From 75dfd75460858d4d0ec0303d40e7290a387c5f1a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 10:27:37 -0700 Subject: [PATCH 001/110] refactor(runtime): owner transports, the creation default and a focus-read ratchet (#27275) * refactor(runtime): owner transports, the creation default and a focus-read ratchet Adds runtimeTargetForOwnerEnvironment, runtimeTargetForOwnerHostId and runtimeTargetForWorkspaceOwner so routed call sites can get a transport from the resource's owner, and defaultCreationHost as the one sanctioned reader of the Active Server setting for creation flows with no source row. The owner-routing ratchet gains a second per-file counter for reads of the setting (member reads, the focus-routing helpers and defaultCreationHost), so swapping a helper for a direct read can no longer lower a count. Baseline rows may carry a trailing note that survives --prune. * chore(lint): pin defaultCreationHost's own read in the focus-read baseline * fix(lint): the focus-read ratchet counts shared focus helpers and destructuring reads * fix(lint): discover functions that read the default host instead of hand-listing them A hand list let a look-alike such as getAutomationListTarget replace getActiveRuntimeTarget and lower both ratchets. Every exported top-level function whose body reads the setting or calls a seed helper now counts as a reader, and nested destructuring reads count too. * fix(lint): readers are found through imports and returned values Readers are keyed by defining file and resolved through imports and re-exports, so a same-named function elsewhere is not mistaken for one, and a function that returns a reader's result (a wrapper of a wrapper) is found too. Components are not readers, and a declaration ends at the next top-level statement. * fix(lint): a reader called through a namespace or dynamic import still counts * fix(lint): renamed, parenthesized and .then() dynamic imports bind readers too --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 648 ++++++++++++++++++ .../scripts/check-owner-routing-ratchet.mjs | 469 +++++++++++-- .../check-owner-routing-ratchet.test.mjs | 183 ++++- .../src/lib/default-creation-host.test.ts | 13 + src/renderer/src/lib/default-creation-host.ts | 13 + src/renderer/src/lib/resolve-owner.test.ts | 31 + src/renderer/src/lib/resolve-owner.ts | 10 + .../src/runtime/runtime-client-target.test.ts | 29 +- .../src/runtime/runtime-client-target.ts | 25 + 9 files changed, 1366 insertions(+), 55 deletions(-) create mode 100644 config/focus-setting-read-baseline.txt create mode 100644 src/renderer/src/lib/default-creation-host.test.ts create mode 100644 src/renderer/src/lib/default-creation-host.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt new file mode 100644 index 000000000000..75fe522ca4f7 --- /dev/null +++ b/config/focus-setting-read-baseline.txt @@ -0,0 +1,648 @@ +# Renderer reads of the Active Server setting (member, element and destructuring reads of +# activeRuntimeEnvironmentId) plus calls of exported functions that read it, per file. +# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read +# the default host, through defaultCreationHost. Everything else routes by the owner. +# Prune after removing reads: pnpm check:owner-routing-ratchet --prune +2 src/renderer/src/App.tsx +3 src/renderer/src/app-shell/use-app-shell-services.ts +1 src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts +1 src/renderer/src/components/Landing.tsx +1 src/renderer/src/components/LinearIssueWorkspace.tsx +1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +1 src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +2 src/renderer/src/components/QuickOpen.tsx +1 src/renderer/src/components/activity/ActivityPrototypePage.tsx +1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx +3 src/renderer/src/components/activity/activity-thread-actions.ts +1 src/renderer/src/components/activity/use-agent-pane-threads.ts +4 src/renderer/src/components/agent/AgentSettingsDialog.tsx +1 src/renderer/src/components/automations/automation-host-client.ts +1 src/renderer/src/components/automations/automation-orca-save.ts +1 src/renderer/src/components/automations/automations-page-fixtures.ts +1 src/renderer/src/components/automations/use-automation-source-availability.ts +1 src/renderer/src/components/automations/use-automation-source-host-availability.ts +2 src/renderer/src/components/automations/use-automations-page-controller.ts +1 src/renderer/src/components/automations/use-automations-page-destination-form.ts +2 src/renderer/src/components/automations/use-automations-page-setup-state.ts +1 src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.tsx +1 src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx +1 src/renderer/src/components/browser-pane/browser-reopen-on-server.ts +1 src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx +1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts +1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts +1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts +2 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts +1 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx +2 src/renderer/src/components/cmd-j/quick-action-context.ts +1 src/renderer/src/components/editor/EditorAutosaveController.tsx +2 src/renderer/src/components/editor/EditorContent.tsx +1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx +1 src/renderer/src/components/editor/EditorEditFileSurface.tsx +1 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx +2 src/renderer/src/components/editor/EditorPanel.tsx +4 src/renderer/src/components/editor/ExternalFileChangeBanner.tsx +3 src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx +1 src/renderer/src/components/editor/MarkdownPreview.tsx +1 src/renderer/src/components/editor/MonacoGutterContextMenu.tsx +4 src/renderer/src/components/editor/RichMarkdownEditor.tsx +1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +4 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts +1 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts +2 src/renderer/src/components/editor/csv/CsvPagedViewer.tsx +1 src/renderer/src/components/editor/csv/csv-file-content.ts +1 src/renderer/src/components/editor/csv/csv-link-routing.ts +2 src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts +7 src/renderer/src/components/editor/editor-dropped-file-open.ts +1 src/renderer/src/components/editor/editor-header-file-rename.ts +1 src/renderer/src/components/editor/editor-model-lifetime-fixture.ts +6 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +1 src/renderer/src/components/editor/editor-save-queue.ts +1 src/renderer/src/components/editor/local-image-src-reader.ts +3 src/renderer/src/components/editor/markdown-document-list-request.ts +2 src/renderer/src/components/editor/markdown-preview-link-actions.ts +1 src/renderer/src/components/editor/markdown-preview-local-images.ts +1 src/renderer/src/components/editor/markdown-preview-system-link-action.ts +1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts +1 src/renderer/src/components/editor/rich-markdown-editor-config.ts +2 src/renderer/src/components/editor/rich-markdown-image-context.ts +4 src/renderer/src/components/editor/rich-markdown-image-insert.ts +1 src/renderer/src/components/editor/rich-markdown-paste-handler.ts +3 src/renderer/src/components/editor/rich-markdown-paste-image.ts +1 src/renderer/src/components/editor/use-markdown-preview-components.tsx +1 src/renderer/src/components/editor/use-markdown-preview-foundation.ts +1 src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts +4 src/renderer/src/components/editor/useEditorPanelContentState.ts +5 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts +7 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +1 src/renderer/src/components/editor/useLocalImagePick.ts +5 src/renderer/src/components/editor/useLocalImageSrc.ts +5 src/renderer/src/components/editor/useMarkdownDocuments.ts +1 src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +1 src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts +2 src/renderer/src/components/feature-wall/ConnectIntegrationsList.tsx +1 src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx +1 src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx +1 src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx +2 src/renderer/src/components/feature-wall/use-feature-wall-task-source-presentation.ts +2 src/renderer/src/components/feature-wall/use-integration-connection-status.ts +1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts +1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts +1 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab.tsx +4 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts +7 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx +2 src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx +5 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx +6 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts +1 src/renderer/src/components/github-project/ProjectCellIssueType.tsx +2 src/renderer/src/components/github-project/ProjectCellRepositoryEditors.tsx +2 src/renderer/src/components/github-project/ProjectPicker.tsx +4 src/renderer/src/components/github-project/project-picker-runtime.ts +1 src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx +1 src/renderer/src/components/github-project/slug-dialog/Comments.tsx +1 src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx +1 src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +2 src/renderer/src/components/github-project/useProjectPickerBrowse.ts +2 src/renderer/src/components/github-project/useProjectViewTable.ts +3 src/renderer/src/components/github/PRAssigneesPanel.tsx +2 src/renderer/src/components/github/PRFilterDropdowns.tsx +3 src/renderer/src/components/github/github-rate-limit-display.tsx +5 src/renderer/src/components/github/github-work-item-edit-mutations.ts +3 src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx +1 src/renderer/src/components/jira-connect-dialog.tsx +1 src/renderer/src/components/landing-preflight-runtime.ts +1 src/renderer/src/components/linear-api-key-dialog.tsx +2 src/renderer/src/components/native-chat-resume-modal.test-support.tsx +1 src/renderer/src/components/native-chat-resume-tree.test-support.ts +2 src/renderer/src/components/native-chat/NativeChatComposer.tsx +1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +1 src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx +1 src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +4 src/renderer/src/components/native-chat/native-chat-file-link-existence.ts +1 src/renderer/src/components/native-chat/native-chat-file-link.ts +1 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts +1 src/renderer/src/components/native-chat/native-chat-observed-send.ts +2 src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts +6 src/renderer/src/components/native-chat/native-chat-runtime-send.ts +2 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +2 src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts +1 src/renderer/src/components/native-chat/use-native-chat-file-link-existence.tsx +6 src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +1 src/renderer/src/components/native-chat/use-native-chat-mention-files.ts +2 src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts +4 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts +2 src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +1 src/renderer/src/components/new-workspace/smart-workspace-repo-slug.ts +1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts +2 src/renderer/src/components/new-workspace/use-linear-source-connection.ts +1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts +1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts +1 src/renderer/src/components/ports/WorkspacePortScanner.tsx +1 src/renderer/src/components/pull-request-page/actions/merge-actions.ts +1 src/renderer/src/components/pull-request-page/actions/panel.tsx +2 src/renderer/src/components/pull-request-page/conversation/tab.tsx +3 src/renderer/src/components/pull-request-page/edit/issue-updates.ts +8 src/renderer/src/components/pull-request-page/edit/section.tsx +4 src/renderer/src/components/pull-request-page/reviewers/panel.tsx +4 src/renderer/src/components/pull-request-page/reviewers/request-actions.ts +2 src/renderer/src/components/quick-open-file-list-test-harness.ts +6 src/renderer/src/components/quick-open-file-list.ts +4 src/renderer/src/components/quick-open-file-navigation.ts +2 src/renderer/src/components/quick-open-recent-validation.ts +3 src/renderer/src/components/repo/CreateFromPicker.tsx +1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx +3 src/renderer/src/components/right-sidebar/FileExplorer.tsx +1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx +1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx +1 src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts +1 src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +3 src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts +1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx +1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx +2 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx +3 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx +3 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx +1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx +2 src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts +1 src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +4 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +1 src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx +1 src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts +5 src/renderer/src/components/right-sidebar/git-status-refresh.ts +1 src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts +2 src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts +5 src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts +1 src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts +2 src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts +1 src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts +7 src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts +1 src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx +2 src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts +2 src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts +1 src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts +1 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts +2 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts +2 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts +1 src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-run-snapshot.ts +1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts +1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts +1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts +2 src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts +1 src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx +1 src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts +1 src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts +2 src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts +2 src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts +2 src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts +1 src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts +1 src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts +2 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts +2 src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts +2 src/renderer/src/components/right-sidebar/use-create-pull-request-field-seeding.ts +1 src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts +1 src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts +2 src/renderer/src/components/right-sidebar/use-file-explorer-node-commands.ts +6 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +3 src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts +3 src/renderer/src/components/right-sidebar/useFileDeletion.ts +1 src/renderer/src/components/right-sidebar/useFileDuplicate.ts +2 src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +2 src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +4 src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +3 src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +1 src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts +2 src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +2 src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +4 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +1 src/renderer/src/components/right-sidebar/useFileSearchScope.ts +2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +3 src/renderer/src/components/settings/BaseRefPicker.tsx +2 src/renderer/src/components/settings/BrowserPane.tsx +1 src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +1 src/renderer/src/components/settings/CommitMessageAiPane.tsx +1 src/renderer/src/components/settings/GeneralPane.tsx +1 src/renderer/src/components/settings/IntegrationsPane.tsx +3 src/renderer/src/components/settings/LinearAgentSkillPane.tsx +1 src/renderer/src/components/settings/McpConfigSection.tsx +1 src/renderer/src/components/settings/RepositoryForkSyncSection.tsx +2 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx +1 src/renderer/src/components/settings/RepositoryHooksSection.tsx +1 src/renderer/src/components/settings/RepositoryIconPicker.tsx +4 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +1 src/renderer/src/components/settings/Settings.tsx +1 src/renderer/src/components/settings/SettingsSetupGuidePane.tsx +1 src/renderer/src/components/settings/ShortcutsPane.tsx +2 src/renderer/src/components/settings/TasksPane.tsx +1 src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +1 src/renderer/src/components/settings/bitbucket-credentials-dialog.tsx +1 src/renderer/src/components/settings/bitbucket-integration-card.tsx +3 src/renderer/src/components/settings/cli-source-control-integration-cards.tsx +3 src/renderer/src/components/settings/jira-integration-card.tsx +1 src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx +1 src/renderer/src/components/settings/mcp-config-inspection.ts +2 src/renderer/src/components/settings/provider-account-scope.ts +1 src/renderer/src/components/settings/settings-persistence-test-fixture.ts +2 src/renderer/src/components/settings/settings-setup-guide-progress.ts +1 src/renderer/src/components/settings/source-control-preflight-card-status.ts +2 src/renderer/src/components/settings/task-tracker-integration-cards.tsx +2 src/renderer/src/components/settings/token-source-control-integration-cards.tsx +2 src/renderer/src/components/settings/use-integration-provider-status-refresh.ts +3 src/renderer/src/components/settings/use-repository-issue-command.ts +1 src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts +2 src/renderer/src/components/settings/use-settings-navigation-model.ts +3 src/renderer/src/components/settings/use-settings-store-model.ts +2 src/renderer/src/components/settings/use-settings-terminal-model.ts +4 src/renderer/src/components/settings/use-task-source-provider-readiness.ts +1 src/renderer/src/components/setup-guide/SetupGuideModal.tsx +1 src/renderer/src/components/setup-guide/SetupGuideTelemetryObserver.tsx +1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts +3 src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +1 src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +3 src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +1 src/renderer/src/components/sidebar/SavedHostScopeWidenGate.tsx +1 src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx +1 src/renderer/src/components/sidebar/SidebarAgentsList.tsx +1 src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +1 src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx +1 src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +2 src/renderer/src/components/sidebar/WorktreeList.tsx +1 src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx +1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts +1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts +2 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +2 src/renderer/src/components/sidebar/sidebar-host-options.ts +1 src/renderer/src/components/sidebar/sidebar-project-drop.ts +2 src/renderer/src/components/sidebar/use-add-repo-host-selection.ts +1 src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts +1 src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts +1 src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +1 src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts +1 src/renderer/src/components/sidebar/use-workspace-reference-details.ts +1 src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +1 src/renderer/src/components/sidebar/useCreateRepo.ts +2 src/renderer/src/components/sidebar/useSidebarProjectDrop.ts +1 src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts +2 src/renderer/src/components/sidebar/visible-worktrees.ts +1 src/renderer/src/components/sidebar/workspace-options-menu-items.tsx +2 src/renderer/src/components/sidebar/workspace-reference-details.ts +1 src/renderer/src/components/sidebar/workspace-reference-refresh-targets.ts +1 src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx +1 src/renderer/src/components/sidebar/worktree-filter-visibility.ts +2 src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts +2 src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts +1 src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +1 src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +1 src/renderer/src/components/skills/SkillsPage.tsx +7 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx +1 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx +1 src/renderer/src/components/status-bar/SshStatusSegment.tsx +1 src/renderer/src/components/status-bar/StatusBarSurface.tsx +2 src/renderer/src/components/status-bar/provider-account-sync-key.ts +1 src/renderer/src/components/status-bar/status-bar-claude-accounts.ts +1 src/renderer/src/components/status-bar/status-bar-codex-accounts.ts +1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts +7 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts +1 src/renderer/src/components/status-bar/use-status-bar-controller.ts +1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts +1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts +1 src/renderer/src/components/tab-bar/EditorFileTab.tsx +1 src/renderer/src/components/tab-bar/TabBar.tsx +2 src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +3 src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts +2 src/renderer/src/components/tab-bar/tab-create-entry-action.ts +3 src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts +1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts +4 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +2 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts +1 src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts +1 src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +3 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts +5 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts +4 src/renderer/src/components/task-page-github-reviewer-actions.ts +2 src/renderer/src/components/task-page/TaskPage.tsx +2 src/renderer/src/components/task-page/github/AssigneesCell.tsx +2 src/renderer/src/components/task-page/github/MergeCell.tsx +2 src/renderer/src/components/task-page/github/ReviewCell.tsx +1 src/renderer/src/components/task-page/github/StatusCell.tsx +2 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +1 src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +1 src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts +2 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts +8 src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts +1 src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts +2 src/renderer/src/components/terminal-pane/terminal-file-link-target.ts +6 src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +3 src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts +1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts +2 src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts +1 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts +2 src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +1 src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +1 src/renderer/src/components/terminal-pane/terminal-link-provider-buffer-fixtures.ts +1 src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts +2 src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-pane-links.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts +3 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts +2 src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts +1 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +1 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts +1 src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts +1 src/renderer/src/components/terminal-pane/use-terminal-pane-paste-listeners.ts +1 src/renderer/src/components/terminal-workspace-model.ts +1 src/renderer/src/components/terminal/pty-running-work-probe.ts +1 src/renderer/src/components/terminal/running-terminal-close-guard.ts +1 src/renderer/src/components/terminal/terminal-tab-create.ts +1 src/renderer/src/components/terminal/window-close-running-work.ts +2 src/renderer/src/components/use-quick-open-interaction.ts +3 src/renderer/src/components/use-task-page-github-issue-draft.ts +2 src/renderer/src/components/use-task-page-source-availability.ts +2 src/renderer/src/components/use-task-page-store-bindings.ts +2 src/renderer/src/components/use-terminal-activation-actions.ts +2 src/renderer/src/components/use-terminal-bulk-close-actions.ts +8 src/renderer/src/components/use-terminal-create-actions.ts +2 src/renderer/src/components/use-worktree-jump-palette-controller.ts +2 src/renderer/src/components/use-worktree-jump-palette-filter.ts +3 src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts +2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts +2 src/renderer/src/hooks/automation-dispatch-handler.ts +1 src/renderer/src/hooks/automation-dispatch-workspace.ts +4 src/renderer/src/hooks/composer-state/attachment-drop-state.ts +1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts +1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts +3 src/renderer/src/hooks/composer-state/composer-source-state.ts +1 src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +2 src/renderer/src/hooks/composer-state/composer-target-state.ts +1 src/renderer/src/hooks/composer-state/github-provider-selection.ts +2 src/renderer/src/hooks/composer-state/github-submit-resolution.ts +1 src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts +1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts +1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts +1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts +1 src/renderer/src/hooks/editor-external-watch-disk-verification.ts +2 src/renderer/src/hooks/editor-external-watch-targets.ts +1 src/renderer/src/hooks/editor-runtime-file-watch.ts +1 src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts +1 src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts +6 src/renderer/src/hooks/ipc-events-test-harness.ts +2 src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +1 src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +1 src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts +1 src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts +2 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts +3 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts +1 src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +2 src/renderer/src/hooks/useEditorExternalWatch.ts +2 src/renderer/src/hooks/useGitHubRepoMetadata.ts +4 src/renderer/src/hooks/useGitHubSlugMetadata.ts +1 src/renderer/src/hooks/useInstalledAgentSkills.ts +1 src/renderer/src/hooks/useIpcEvents.ts +1 src/renderer/src/hooks/useIssueMetadata.ts +1 src/renderer/src/hooks/useLinearProviderConnected.ts +7 src/renderer/src/hooks/useSettingsNavigationMetadata.ts +1 src/renderer/src/hooks/useUnreadDockBadge.ts +2 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts +10 src/renderer/src/lib/activate-ai-vault-structured-session.ts +1 src/renderer/src/lib/active-agent-note-send.ts +1 src/renderer/src/lib/active-agent-note-target.ts +1 src/renderer/src/lib/agent-background-session-launch-host.ts +1 src/renderer/src/lib/agent-draft-paste-content.ts +1 src/renderer/src/lib/agent-draft-readiness.ts +2 src/renderer/src/lib/agent-followup-delivery.ts +1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts +3 src/renderer/src/lib/agent-hibernation-coordinator.ts +5 src/renderer/src/lib/agent-paste-draft.ts +1 src/renderer/src/lib/agent-ready-wait.ts +1 src/renderer/src/lib/automation-session-observer.ts +1 src/renderer/src/lib/client-creation-action-error.ts +3 src/renderer/src/lib/client-creation-action-policy.ts +3 src/renderer/src/lib/codex-pane-selection-lane.ts +2 src/renderer/src/lib/codex-session-restart.ts +1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host +1 src/renderer/src/lib/doc-preview-grants.ts +3 src/renderer/src/lib/ensure-hooks-confirmed.ts +1 src/renderer/src/lib/execute-open-editor-path-move.ts +1 src/renderer/src/lib/execution-host-facts.ts +6 src/renderer/src/lib/file-preview.ts +1 src/renderer/src/lib/file-search-result-owner.ts +2 src/renderer/src/lib/folder-workspace-runtime-owner.ts +1 src/renderer/src/lib/github-pr-start-point.ts +1 src/renderer/src/lib/github-source-runtime-context.ts +2 src/renderer/src/lib/github-work-item-source-lookup.ts +1 src/renderer/src/lib/host-mirrored-pane-liveness.ts +1 src/renderer/src/lib/hosted-review-repo-read.ts +6 src/renderer/src/lib/http-link-destinations.ts +3 src/renderer/src/lib/http-link-routing.ts +2 src/renderer/src/lib/launch-agent-background-session.ts +1 src/renderer/src/lib/launch-agent-execution-context.ts +2 src/renderer/src/lib/launch-agent-in-new-tab.ts +1 src/renderer/src/lib/launch-agent-web-host-tab.ts +2 src/renderer/src/lib/launch-ai-vault-session.ts +1 src/renderer/src/lib/launch-work-item-direct-preflight.ts +2 src/renderer/src/lib/launch-work-item-direct.ts +1 src/renderer/src/lib/launch-worktree-background-terminals.ts +3 src/renderer/src/lib/local-file-access.ts +2 src/renderer/src/lib/local-preflight-context.ts +1 src/renderer/src/lib/markdown-document-templates.ts +1 src/renderer/src/lib/new-workspace.ts +1 src/renderer/src/lib/open-agent-message-sender.ts +2 src/renderer/src/lib/provider-runtime-context.ts +1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts +2 src/renderer/src/lib/rename-file.ts +4 src/renderer/src/lib/repo-runtime-owner.ts +2 src/renderer/src/lib/repo-slug-cache.ts +3 src/renderer/src/lib/repo-slug-index.ts +2 src/renderer/src/lib/resolve-owner.ts +1 src/renderer/src/lib/runtime-session-mirror-owners.ts +1 src/renderer/src/lib/runtime-session-mirror-targets.ts +1 src/renderer/src/lib/single-runtime-legacy-owner.ts +3 src/renderer/src/lib/structured-agent-session-tab-activation.ts +1 src/renderer/src/lib/tab-number-shortcuts.ts +1 src/renderer/src/lib/terminal-tab-owner-activation.ts +4 src/renderer/src/lib/terminal-worktree-route.ts +3 src/renderer/src/lib/unread-badge-count-selector.ts +1 src/renderer/src/lib/user-opened-local-path.ts +2 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts +1 src/renderer/src/lib/workspace-browser-tab-close.ts +4 src/renderer/src/lib/workspace-browser-tab-open.ts +3 src/renderer/src/lib/workspace-file-host-routing.ts +1 src/renderer/src/lib/workspace-tab-palette-activation.ts +1 src/renderer/src/lib/workspace-terminal-host-authority.ts +1 src/renderer/src/lib/worktree-activation-pty-inventory.ts +3 src/renderer/src/lib/worktree-activation.ts +1 src/renderer/src/lib/worktree-creation-agent-seeds.ts +1 src/renderer/src/lib/worktree-creation-flow-execute.ts +1 src/renderer/src/lib/worktree-creation-flow-startup.ts +1 src/renderer/src/lib/worktree-creation-flow.ts +1 src/renderer/src/lib/worktree-creation-hook-preparation.ts +1 src/renderer/src/lib/worktree-operation-route.ts +1 src/renderer/src/lib/worktree-row-open-in-owner.ts +3 src/renderer/src/lib/worktree-runtime-owner.ts +1 src/renderer/src/lib/worktree-sort-order-host-split.ts +1 src/renderer/src/runtime/close-mirrored-editor-tab.ts +3 src/renderer/src/runtime/mobile-markdown-bridge.ts +1 src/renderer/src/runtime/runtime-client-target.ts +2 src/renderer/src/runtime/runtime-file-import-client.ts +3 src/renderer/src/runtime/runtime-file-metadata-client.ts +8 src/renderer/src/runtime/runtime-file-mutation-client.ts +2 src/renderer/src/runtime/runtime-file-range-client.ts +10 src/renderer/src/runtime/runtime-file-read-client.ts +6 src/renderer/src/runtime/runtime-file-routing.ts +5 src/renderer/src/runtime/runtime-file-search-client.ts +1 src/renderer/src/runtime/runtime-file-upload-client.ts +1 src/renderer/src/runtime/runtime-file-watch-client.ts +2 src/renderer/src/runtime/runtime-git-client-context.ts +5 src/renderer/src/runtime/runtime-git-diff-client.ts +5 src/renderer/src/runtime/runtime-git-generation-client.ts +6 src/renderer/src/runtime/runtime-git-status-client.ts +10 src/renderer/src/runtime/runtime-git-sync-client.ts +9 src/renderer/src/runtime/runtime-git-working-tree-client.ts +3 src/renderer/src/runtime/runtime-hooks-client.ts +17 src/renderer/src/runtime/runtime-jira-client.ts +2 src/renderer/src/runtime/runtime-jira-summary-client.ts +1 src/renderer/src/runtime/runtime-jira-target.ts +4 src/renderer/src/runtime/runtime-jira-user-fields-client.ts +8 src/renderer/src/runtime/runtime-linear-client.ts +5 src/renderer/src/runtime/runtime-linear-issue-mutations.ts +12 src/renderer/src/runtime/runtime-linear-project-client.ts +1 src/renderer/src/runtime/runtime-path-existence-batch.ts +3 src/renderer/src/runtime/runtime-repo-client.ts +3 src/renderer/src/runtime/runtime-terminal-inspection.ts +1 src/renderer/src/runtime/runtime-terminal-stream.ts +1 src/renderer/src/runtime/runtime-terminal-verified-input.ts +5 src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts +2 src/renderer/src/runtime/web-runtime-browser-creation.ts +1 src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap-dispatch.ts +1 src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts +1 src/renderer/src/runtime/web-runtime-session-tab-move.ts +2 src/renderer/src/runtime/web-runtime-terminal-actions.ts +1 src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +3 src/renderer/src/runtime/web-runtime-terminal-creation.ts +1 src/renderer/src/runtime/web-runtime-worktree-activation.ts +1 src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +2 src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts +1 src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts +2 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts +2 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts +2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts +5 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts +1 src/renderer/src/store/github/cache-identity.ts +3 src/renderer/src/store/github/project-actions.ts +2 src/renderer/src/store/github/project-field-mutations.ts +2 src/renderer/src/store/github/project-row-actions.ts +1 src/renderer/src/store/github/pull-request-execution.ts +2 src/renderer/src/store/github/refresh-routing-actions.ts +3 src/renderer/src/store/github/repository-routing.ts +2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts +2 src/renderer/src/store/github/work-item-aggregate-actions.ts +3 src/renderer/src/store/github/work-item-fetch-actions.ts +1 src/renderer/src/store/github/work-item-mutation-actions.ts +3 src/renderer/src/store/github/work-item-routing.ts +9 src/renderer/src/store/index.ts +6 src/renderer/src/store/project-groups/nested-repository-operations.ts +2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts +7 src/renderer/src/store/project-groups/project-group-mutations.ts +1 src/renderer/src/store/projects/project-host-routing.ts +1 src/renderer/src/store/projects/project-update.ts +2 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +1 src/renderer/src/store/repos/owner-routing.ts +2 src/renderer/src/store/repos/repo-add-actions.ts +2 src/renderer/src/store/repos/repo-catalog-actions.ts +2 src/renderer/src/store/repos/repo-removal.ts +2 src/renderer/src/store/repos/repo-update.ts +2 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +2 src/renderer/src/store/repos/safe-auto-fork-sync.ts +1 src/renderer/src/store/slices/browser-slice-test-harness.ts +14 src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts +1 src/renderer/src/store/slices/browser/browser-host-actions.ts +7 src/renderer/src/store/slices/browser/browser-host-state.ts +1 src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts +12 src/renderer/src/store/slices/browser/browser-profile-import-actions.ts +11 src/renderer/src/store/slices/browser/browser-profile-list-actions.ts +6 src/renderer/src/store/slices/browser/browser-tab-actions.ts +1 src/renderer/src/store/slices/browser/create-browser-slice.ts +1 src/renderer/src/store/slices/browser/paired-browser-tab-creator.ts +3 src/renderer/src/store/slices/diff-comment-persistence.ts +2 src/renderer/src/store/slices/editor-slice-test-harness.ts +4 src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts +1 src/renderer/src/store/slices/editor/actions/git-remote-status.ts +8 src/renderer/src/store/slices/editor/actions/git-remote-sync.ts +2 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts +1 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts +1 src/renderer/src/store/slices/editor/actions/open-file-action.ts +1 src/renderer/src/store/slices/editor/actions/open-file-apply.ts +2 src/renderer/src/store/slices/editor/create-editor-slice.ts +1 src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts +1 src/renderer/src/store/slices/github-cache-key.ts +2 src/renderer/src/store/slices/github-slice-test-harness.ts +1 src/renderer/src/store/slices/github.ts +1 src/renderer/src/store/slices/hosted-review-cache-identity.ts +4 src/renderer/src/store/slices/hosted-review.ts +1 src/renderer/src/store/slices/jira-collection-read-actions.ts +13 src/renderer/src/store/slices/jira-connection-actions.ts +4 src/renderer/src/store/slices/jira-issue-read-actions.ts +5 src/renderer/src/store/slices/jira-read-coordination.ts +3 src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts +3 src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts +3 src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts +3 src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts +3 src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts +6 src/renderer/src/store/slices/linear/linear-issue-list-actions.ts +3 src/renderer/src/store/slices/linear/linear-project-detail-actions.ts +3 src/renderer/src/store/slices/linear/linear-project-issue-actions.ts +3 src/renderer/src/store/slices/linear/linear-project-list-actions.ts +13 src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts +4 src/renderer/src/store/slices/linear/linear-slice-scope.ts +3 src/renderer/src/store/slices/linear/linear-slice-status-actions.ts +3 src/renderer/src/store/slices/linear/linear-team-actions.ts +2 src/renderer/src/store/slices/preflight.ts +1 src/renderer/src/store/slices/project-group-owner-routing.ts +1 src/renderer/src/store/slices/repo-host-identity.ts +1 src/renderer/src/store/slices/repo-reorder-host-split.ts +10 src/renderer/src/store/slices/repos.ts +1 src/renderer/src/store/slices/runtime-status.ts +1 src/renderer/src/store/slices/settings-owner-hydration-publication.ts +5 src/renderer/src/store/slices/settings.ts +8 src/renderer/src/store/slices/store-test-helpers.ts +2 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts +2 src/renderer/src/store/slices/terminal-tab-retirement.ts +1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts +3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts +3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts +13 src/renderer/src/store/slices/worktrees.ts +2 src/renderer/src/store/slices/worktrees/create/create-worktree.ts +2 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts +2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts +6 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts +4 src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts +2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts +5 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +1 src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts +1 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +2 src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts +2 src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts +4 src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts +4 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts +7 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts +1 src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts +2 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts +1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts +1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts +2 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts +1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts +1 src/renderer/src/store/terminals/terminal-shutdown.ts +1 src/renderer/src/store/terminals/terminal-workspace-routing.ts +3 src/renderer/src/web/preload-api/web-preference-normalization.ts +16 src/renderer/src/web/preload-api/web-preferences-store.ts +6 src/renderer/src/web/preload-api/web-settings-api.ts +1 src/renderer/src/web/web-preload-api.ts diff --git a/config/scripts/check-owner-routing-ratchet.mjs b/config/scripts/check-owner-routing-ratchet.mjs index c3304373fa5c..0c78a91b6c5a 100644 --- a/config/scripts/check-owner-routing-ratchet.mjs +++ b/config/scripts/check-owner-routing-ratchet.mjs @@ -4,33 +4,351 @@ import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' -// Ratchet for renderer code that routes host-owned work by the "Active Server" focus setting -// instead of by the resource's own host. The three helpers are counted together so renaming one -// into another never lowers the count. Per-file counts may only go down. +// Two ratchets for renderer code that routes host-owned work by the "Active Server" setting +// instead of by the resource's own host. Per-file counts may only go down. +// - Owner routing: the three focus-routing helpers, counted together so renaming one into another +// never lowers the count. +// - Focus reads: every read of the setting plus every call of an exported function that reads it +// for the caller (discovered, not hand-listed), so swapping one form for another never lowers it. -const BASELINE_PATH = 'config/owner-routing-baseline.txt' const SCAN_ROOT = 'src/renderer/src' -const HELPERS = '(?:getActiveRuntimeTarget|legacyRouteFromSettings|settingsForRuntimeOwner)' +const HELPER_NAMES = 'getActiveRuntimeTarget|legacyRouteFromSettings|settingsForRuntimeOwner' +const HELPERS = `(?:${HELPER_NAMES})` const IMPORT_EXPORT_LIST = /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\}/g // Calls and value uses (`.map(helper)`); definitions and type queries are not routing. const FOCUS_ROUTING_USE = new RegExp(`(?` are indexed types (`GlobalSettings['…']`), not reads. +const SETTING_MEMBER_READ = + /\??\.\s*activeRuntimeEnvironmentId\b|(?)\s*)\[\s*['"]activeRuntimeEnvironmentId['"]\s*\]/g +// `const { activeRuntimeEnvironmentId } = s`, `{ activeRuntimeEnvironmentId: id }: T = s` or nested +// `{ settings: { activeRuntimeEnvironmentId } } = state`; object literals and `({ … }) =>` +// parameters are not reads of the setting. +const SETTING_DESTRUCTURE_READ = + /\{[^{}]*\bactiveRuntimeEnvironmentId\b[^{}]*\}(?:\s*\})*\s*(?::[^=;{}]*)?=(?![=>])/g +// A top-level declaration: `[export] [async] function name` or `[export] const|let name =`. +const TOP_LEVEL_DECLARATION = + /^(export\s+)?(?:default\s+)?(?:async\s+)?(?:function\s*\*?\s*([\w$]+)|(?:const|let)\s+([\w$]+)\b)/gm +// Any new statement at column 0 ends a declaration; closing brackets and continuations do not. +const TOP_LEVEL_BOUNDARY = /^[^\s)}\]]/gm + +// Member calls only, so dotted string keys (`'auto.hooks.useX.abc'`) are not uses. +const MEMBER_NAME = /\.\s*([\w$]+)\s*(?:\?\.\s*)?\(/g + +function readerUsePattern(names) { + return names.size === 0 + ? null + : new RegExp( + `(? match.index) + return [...sourceText.matchAll(TOP_LEVEL_DECLARATION)].map((match) => { + const end = boundaries.find((index) => index > match.index) ?? sourceText.length + return { + name: match[2] ?? match[3], + exported: Boolean(match[1]), + text: sourceText.slice(match.index, end) + } + }) +} + +// Statements that hand a value on: `return …` or an arrow's expression body, each running to the +// first newline outside brackets. Object-literal results (`=> ({ … })`, `return { … }`) are +// skipped: slice creators and hooks return bags of actions, which pulls in the whole store. +const VALUE_STATEMENT_START = /\breturn\s+(?!\(?\s*\{)|=>\s*(?!\(?\s*\{)/g + +function valueStatements(body) { + const statements = [] + for (const match of body.matchAll(VALUE_STATEMENT_START)) { + let depth = 0 + let end = match.index + match[0].length + for (; end < body.length; end += 1) { + const char = body[end] + if ('([{'.includes(char)) { + depth += 1 + } else if (')]}'.includes(char)) { + if (depth === 0) { + break + } + depth -= 1 + } else if (char === '\n' && depth === 0) { + break + } + } + statements.push(body.slice(match.index, end)) + } + return statements.join('\n') +} + +const IMPORT_STATEMENT = + /\bimport\s+(?:type\s+)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g +const REEXPORT_STATEMENT = /\bexport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g +// `const { reader } = (await import('…'))` and `import('…').then(({ reader }) =>` bind like a +// named import; `{ reader: local }` renames like `as`. +const DYNAMIC_IMPORT_DESTRUCTURE = + /\{([^{}]*)\}\s*=\s*\(?\s*await\s+import\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\))?/g +const DYNAMIC_IMPORT_THEN = + /\bimport\(\s*['"]([^'"]+)['"]\s*\)\s*\.then\(\s*(?:async\s*)?\(\s*\{([^{}]*)\}/g +const STAR_REEXPORT_STATEMENT = /\bexport\s+\*\s+from\s*['"]([^'"]+)['"]/g +const LOCAL_EXPORT_LIST = /\bexport\s+\{([^}]*)\}(?!\s*from)/g + +function specifiers(list) { + return list + .split(',') + .map((part) => part.trim().replace(/^type\s+/, '')) + .filter(Boolean) + .map((part) => { + const [imported, local = imported] = part.split(/\s+as\s+|\s*:\s*/) + return { imported: imported.trim(), local: local.trim() } + }) +} + +function resolveModule(spec, fromRel, files) { + let base + if (spec.startsWith('@/')) { + base = `${SCAN_ROOT}/${spec.slice(2)}` + } else if (spec.startsWith('.')) { + base = path.posix.join(path.posix.dirname(fromRel), spec) + } else { + return null + } + base = base.replace(/\.(?:m?js|tsx?)$/, '') + return ( + ['.ts', '.tsx', '/index.ts', '/index.tsx'].map((ext) => base + ext).find((c) => files.has(c)) ?? + null + ) +} + +function parseModule(rel, text, files) { + const imports = new Map() + for (const [, list, spec] of [ + ...text.matchAll(IMPORT_STATEMENT), + ...text.matchAll(DYNAMIC_IMPORT_DESTRUCTURE), + ...[...text.matchAll(DYNAMIC_IMPORT_THEN)].map(([all, from, names]) => [all, names, from]) + ]) { + const from = resolveModule(spec, rel, files) + for (const { imported, local } of specifiers(list)) { + imports.set(local, { from, name: imported }) + } + } + const reexports = new Map() + for (const [, list, spec] of text.matchAll(REEXPORT_STATEMENT)) { + const from = resolveModule(spec, rel, files) + for (const { imported, local } of specifiers(list)) { + reexports.set(local, { from, name: imported }) + } + } + const starFrom = [...text.matchAll(STAR_REEXPORT_STATEMENT)].map(([, spec]) => + resolveModule(spec, rel, files) + ) + const listed = new Map() + for (const [, list] of text.matchAll(LOCAL_EXPORT_LIST)) { + for (const { imported, local } of specifiers(list)) { + listed.set(imported, local) + } + } + const declarations = topLevelDeclarations(text.replace(IMPORT_EXPORT_LIST, '')).map((decl) => ({ + ...decl, + exportedAs: decl.exported ? decl.name : (listed.get(decl.name) ?? null) + })) + return { imports, reexports, starFrom, declarations } +} + +/** + * Functions that read the setting for their caller, keyed by defining file so a same-named + * function elsewhere (a test harness's `useAppStore`) is not mistaken for one. Seeds, then every + * exported top-level function whose body reads the setting or calls a seed, then, to a fixpoint, + * every one that returns a reader's result. Hand lists miss look-alikes such as a copy of + * `getActiveRuntimeTarget` under another name; discovery does not. Following every call instead + * of returned values pulls in most of the renderer through the store. + */ +export function discoverFocusReaders(sources) { + const files = new Set(sources.keys()) + const modules = new Map([...sources].map(([rel, text]) => [rel, parseModule(rel, text, files)])) + const seeds = new Set(SEED_FOCUS_READERS) + const seedUse = readerUsePattern(seeds) + const readerKeys = new Set() + + const resolveExport = (rel, name, depth = 0) => { + const mod = rel ? modules.get(rel) : null + if (!mod || depth > 8) { + return null + } + if (mod.declarations.some((decl) => decl.exportedAs === name)) { + return `${rel}#${name}` + } + const re = mod.reexports.get(name) + if (re) { + return resolveExport(re.from, re.name, depth + 1) + } + for (const from of mod.starFrom) { + const key = resolveExport(from, name, depth + 1) + if (key) { + return key + } + } + return null + } + const resolveLocal = (rel, name) => { + const mod = modules.get(rel) + if (mod.declarations.some((decl) => decl.exportedAs && decl.name === name)) { + return `${rel}#${mod.declarations.find((decl) => decl.name === name).exportedAs}` + } + const imported = mod.imports.get(name) + return imported ? resolveExport(imported.from, imported.name) : null + } + const isReader = (rel, name) => seeds.has(name) || readerKeys.has(resolveLocal(rel, name)) + + const pending = [] + for (const [rel, mod] of modules) { + for (const { name, exportedAs, text } of mod.declarations) { + // Components render; a `` that reads focus inside is not a read by its parent. + if (!exportedAs || /^[A-Z]/.test(exportedAs)) { + continue + } + seedUse.lastIndex = 0 + if (countSettingReads(text) > 0 || seedUse.test(text)) { + readerKeys.add(`${rel}#${exportedAs}`) + continue + } + const statements = valueStatements(text) + const passed = new Set(statements.match(/[\w$]+/g)) + passed.delete(name) + const members = [...statements.matchAll(MEMBER_NAME)].map(([, member]) => member) + pending.push({ rel, key: `${rel}#${exportedAs}`, passed: [...passed], members }) + } + } + // Exported reader names, for `ns.reader(…)` through a namespace or dynamic import. + const memberNames = new Set(seeds) + const addReader = (key) => { + readerKeys.add(key) + memberNames.add(key.slice(key.indexOf('#') + 1)) + } + for (const key of readerKeys) { + addReader(key) + } + let changed = true + while (changed) { + changed = false + for (let i = pending.length - 1; i >= 0; i -= 1) { + const { rel, key, passed, members } = pending[i] + if ( + passed.some((id) => isReader(rel, id)) || + members.some((member) => memberNames.has(member)) + ) { + addReader(key) + pending.splice(i, 1) + changed = true + } + } + } + + const namesByFile = new Map() + return { + has: (name) => memberNames.has(name), + memberNames, + /** Names that refer to a reader inside `rel`: seeds, its own readers and imported ones. */ + namesFor(rel) { + if (!namesByFile.has(rel)) { + const mod = modules.get(rel) + const local = [ + ...(mod?.declarations.map((decl) => decl.name) ?? []), + ...(mod?.imports.keys() ?? []) + ] + namesByFile.set( + rel, + new Set([...seeds, ...local.filter((name) => mod && isReader(rel, name))]) + ) + } + return namesByFile.get(rel) + } + } +} + +/** + * Reads of the setting (member, element and destructuring reads) plus every use of a name that + * refers to a reader. Object-literal keys are writes and are not counted. + */ +export function countFocusSettingReads( + sourceText, + readerNames = new Set(SEED_FOCUS_READERS), + memberNames = new Set() +) { + const body = sourceText + .replace(IMPORT_EXPORT_LIST, '') + // A placeholder, so a dangling `const` does not read as a declaration of the next call. + .replace(DYNAMIC_IMPORT_DESTRUCTURE, 'dynamicImport') + .replace(DYNAMIC_IMPORT_THEN, 'dynamicImport') + const pattern = readerUsePattern(readerNames) + // `ns.reader(…)` through a namespace or dynamic import; names already counted bare are skipped. + const members = [...body.matchAll(MEMBER_NAME)].filter( + ([, member]) => memberNames.has(member) && !readerNames.has(member) + ).length + return countSettingReads(body) + (pattern ? (body.match(pattern)?.length ?? 0) : 0) + members +} + +/** An alias hides later calls from the name-based helper count, so it is refused outright. */ export function hasFocusRoutingAlias(sourceText) { - return (sourceText.match(IMPORT_EXPORT_LIST) ?? []).some((list) => FOCUS_ROUTING_ALIAS.test(list)) + const alias = new RegExp(`\\b(?:${SEED_FOCUS_READERS.join('|')})\\s+as\\b`) + return (sourceText.match(IMPORT_EXPORT_LIST) ?? []).some((list) => alias.test(list)) } +export const RATCHETS = [ + { + name: 'owner-routing', + baselinePath: 'config/owner-routing-baseline.txt', + count: (text) => countFocusRoutingCalls(text), + header: [ + '# Renderer call sites that route by the Active Server focus setting:', + '# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file.', + '# This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead.', + '# Prune after removing sites: pnpm check:owner-routing-ratchet --prune' + ], + advice: + "Route by the resource's owner (resolveOwner + callHostRoute) instead of the Active Server setting." + }, + { + name: 'focus-setting-read', + baselinePath: 'config/focus-setting-read-baseline.txt', + count: (text, rel, readers) => + countFocusSettingReads(text, readers.namesFor(rel), readers.memberNames), + header: [ + '# Renderer reads of the Active Server setting (member, element and destructuring reads of', + '# activeRuntimeEnvironmentId) plus calls of exported functions that read it, per file.', + '# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read', + '# the default host, through defaultCreationHost. Everything else routes by the owner.', + '# Prune after removing reads: pnpm check:owner-routing-ratchet --prune' + ], + advice: + "Route by the resource's owner. Only a creation flow with no source row may use defaultCreationHost." + } +] + export function isScannedPath(rel) { return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)\.tsx?$/.test(rel) } -/** ` ` lines; `#` comments and blanks ignored. */ +/** ` [# note]` lines; `#` comment lines and blanks ignored. */ export function parseBaseline(text) { const counts = new Map() for (const raw of text.split('\n')) { @@ -44,19 +362,26 @@ export function parseBaseline(text) { return counts } -export function formatBaseline(counts) { - const header = [ - '# Renderer call sites that route by the Active Server focus setting:', - '# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file.', - '# This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead.', - '# Prune after removing sites: pnpm check:owner-routing-ratchet --prune', - '' - ].join('\n') +/** Trailing `# note` per row, so a pruned baseline keeps why an entry is still allowed. */ +export function parseBaselineNotes(text) { + const notes = new Map() + for (const raw of text.split('\n')) { + const match = /^\s*\d+\s+(\S+)\s+#\s*(.+?)\s*$/.exec(raw) + if (match) { + notes.set(match[1], match[2]) + } + } + return notes +} + +export function formatBaseline(counts, header = RATCHETS[0].header, notes = new Map()) { const rows = [...counts] .filter(([, count]) => count > 0) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([file, count]) => `${count} ${file}`) - return `${header}${rows.join('\n')}\n` + .map(([file, count]) => + notes.has(file) ? `${count} ${file} # ${notes.get(file)}` : `${count} ${file}` + ) + return `${[...header, ''].join('\n')}${rows.join('\n')}${rows.length > 0 ? '\n' : ''}` } /** `grown`: above baseline (fails). `shrunk`: below baseline, must be pruned so it cannot regrow. */ @@ -76,82 +401,120 @@ export function diffCounts(current, baseline) { return { grown: grown.sort(byFile), shrunk: shrunk.sort(byFile) } } -export function collectCurrentCounts(root = process.cwd()) { - const tracked = execFileSync('git', ['ls-files', SCAN_ROOT], { +function readTrackedSources(root, scanRoot) { + const tracked = execFileSync('git', ['ls-files', scanRoot], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) .split('\n') .filter((rel) => rel && isScannedPath(rel)) - const counts = new Map() - const aliased = [] + const sources = new Map() for (const rel of tracked) { - let source try { - source = fs.readFileSync(path.join(root, rel), 'utf8') + sources.set(rel, fs.readFileSync(path.join(root, rel), 'utf8')) } catch { + // Deleted in the working tree but still tracked. + } + } + return sources +} + +// One read and one discovery per root per run; `--prune` then `main` reuse them. +const scanCache = new Map() + +function scanSources(root) { + if (!scanCache.has(root)) { + const sources = new Map( + FOCUS_SCAN_ROOTS.flatMap((scanRoot) => [...readTrackedSources(root, scanRoot)]) + ) + scanCache.set(root, { sources, readers: discoverFocusReaders(sources) }) + } + return scanCache.get(root) +} + +export function collectCurrentCounts(root = process.cwd(), count = RATCHETS[0].count) { + const { sources, readers } = scanSources(root) + const counts = new Map() + const aliased = [] + for (const [rel, source] of sources) { + if (!rel.startsWith(`${SCAN_ROOT}/`)) { continue } - const count = countFocusRoutingCalls(source) - if (count > 0) { - counts.set(rel, count) + const found = count(source, rel, readers) + if (found > 0) { + counts.set(rel, found) } if (hasFocusRoutingAlias(source)) { aliased.push(rel) } } - return { counts, aliased } + return { counts, aliased, readers } } function total(counts) { return [...counts.values()].reduce((sum, count) => sum + count, 0) } -export function main(root = process.cwd()) { - const baselineFile = path.join(root, BASELINE_PATH) +function checkRatchet(root, ratchet) { + const baselineFile = path.join(root, ratchet.baselinePath) if (!fs.existsSync(baselineFile)) { - console.error(`::error::Missing ${BASELINE_PATH}.`) - return 1 + console.error(`::error::Missing ${ratchet.baselinePath}.`) + return { ok: false, total: 0 } } const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) - const { counts: current, aliased } = collectCurrentCounts(root) + const { counts: current } = collectCurrentCounts(root, ratchet.count) const { grown, shrunk } = diffCounts(current, baseline) - for (const file of aliased) { + for (const { file, now, allowed } of grown) { console.error( - `::error file=${file}::A focus-routing helper is imported or exported under another name, which hides its calls from this ratchet. Use the original name.` + `::error file=${file}::${ratchet.name}: ${now} use(s), baseline allows ${allowed}. ${ratchet.advice}` ) } - for (const { file, now, allowed } of grown) { + for (const { file, now, allowed } of shrunk) { console.error( - `::error file=${file}::${now} focus-routed call(s), baseline allows ${allowed}. Route by the resource's owner (resolveOwner + callHostRoute) instead of the Active Server setting.` + `::error file=${file}::${ratchet.name}: ${now} use(s), baseline still allows ${allowed}. Run: pnpm check:owner-routing-ratchet --prune` ) } - for (const { file, now, allowed } of shrunk) { + return { ok: grown.length === 0 && shrunk.length === 0, total: total(current) } +} + +export function main(root = process.cwd()) { + const { aliased } = collectCurrentCounts(root) + for (const file of aliased) { console.error( - `::error file=${file}::${now} focus-routed call(s), baseline still allows ${allowed}. Run: pnpm check:owner-routing-ratchet --prune` + `::error file=${file}::A focus-routing helper is imported or exported under another name, which hides its calls from this ratchet. Use the original name.` ) } - if (aliased.length > 0 || grown.length > 0 || shrunk.length > 0) { - return 1 + let ok = aliased.length === 0 + for (const ratchet of RATCHETS) { + const result = checkRatchet(root, ratchet) + ok = ok && result.ok + if (result.ok) { + console.log(`${ratchet.name} ratchet OK — ${result.total} use(s).`) + } } - console.log(`Owner-routing ratchet OK — ${total(current)} focus-routed call site(s).`) - return 0 + return ok ? 0 : 1 } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const root = process.cwd() - if (process.argv[2] === '--prune') { - const baselineFile = path.join(root, BASELINE_PATH) - const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) - const { counts: current } = collectCurrentCounts(root) +export function prune(root = process.cwd()) { + for (const ratchet of RATCHETS) { + const baselineFile = path.join(root, ratchet.baselinePath) + const text = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : '' + const baseline = parseBaseline(text) + const { counts: current } = collectCurrentCounts(root, ratchet.count) // Lowers entries only; growth still has to be fixed in the code. const pruned = new Map( [...baseline].map(([file, allowed]) => [file, Math.min(allowed, current.get(file) ?? 0)]) ) - fs.writeFileSync(baselineFile, formatBaseline(pruned)) - console.log(`Pruned ${BASELINE_PATH} to ${total(pruned)} call site(s).`) - process.exit(main(root)) + fs.writeFileSync(baselineFile, formatBaseline(pruned, ratchet.header, parseBaselineNotes(text))) + console.log(`Pruned ${ratchet.baselinePath} to ${total(pruned)} use(s).`) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const root = process.cwd() + if (process.argv[2] === '--prune') { + prune(root) } process.exit(main(root)) } diff --git a/config/scripts/check-owner-routing-ratchet.test.mjs b/config/scripts/check-owner-routing-ratchet.test.mjs index c33b9304121e..89dc6e2b5c24 100644 --- a/config/scripts/check-owner-routing-ratchet.test.mjs +++ b/config/scripts/check-owner-routing-ratchet.test.mjs @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest' import { countFocusRoutingCalls, + countFocusSettingReads, diffCounts, + discoverFocusReaders, formatBaseline, hasFocusRoutingAlias, isScannedPath, - parseBaseline + parseBaseline, + parseBaselineNotes } from './check-owner-routing-ratchet.mjs' describe('countFocusRoutingCalls', () => { @@ -39,6 +42,172 @@ describe('countFocusRoutingCalls', () => { }) }) +describe('countFocusSettingReads', () => { + it('counts setting reads, the helpers and the creation default together', () => { + const src = [ + "import { defaultCreationHost } from './default-creation-host'", + 'const a = settings?.activeRuntimeEnvironmentId', + 'const b = state.settings.activeRuntimeEnvironmentId', + "const c = settings['activeRuntimeEnvironmentId']", + 'const d = getActiveRuntimeTarget(settings)', + 'const e = defaultCreationHost(settings)', + 'export function defaultCreationHost(settings) {}' + ].join('\n') + expect(countFocusSettingReads(src)).toBe(5) + }) + + it('discovers readers through imports, so a look-alike or a wrapper lowers nothing', () => { + const readers = discoverFocusReaders( + new Map([ + [ + 'src/shared/execution-host.ts', + 'export function getSettingsFocusedExecutionHostId(settings) {\n return settings?.activeRuntimeEnvironmentId\n}\n' + ], + [ + 'src/renderer/src/a.ts', + [ + "import { getSettingsFocusedExecutionHostId } from '../../shared/execution-host'", + 'export function getAutomationListTarget(settings) {', + ' const id = settings?.activeRuntimeEnvironmentId?.trim()', + " return id ? { kind: 'environment', environmentId: id } : { kind: 'local' }", + '}', + 'export const cacheKey = (s) => `k:${getActiveRuntimeTarget(s).kind}`', + 'export function browserHostId(state, override) {', + ' return override ?? getSettingsFocusedExecutionHostId(state.settings)', + '}', + 'export function wrappedTwice(state) {', + ' return browserHostId(state, null)', + '}', + 'export function ownerTarget(row) {', + ' return row.owner', + '}', + 'export function SettingsPane() {', + ' return getAutomationListTarget(null)', + '}' + ].join('\n') + ], + [ + 'src/renderer/src/harness.ts', + 'export const ownerTarget = (s) => s.activeRuntimeEnvironmentId' + ], + [ + 'src/renderer/src/b.ts', + [ + "import { browserHostId, wrappedTwice, ownerTarget, SettingsPane } from './a'", + 'const a = wrappedTwice(state)', + 'const b = ownerTarget(row)', + 'const c = ' + ].join('\n') + ] + ]) + ) + const inA = readers.namesFor('src/renderer/src/a.ts') + for (const name of ['getAutomationListTarget', 'cacheKey', 'browserHostId', 'wrappedTwice']) { + expect(inA.has(name)).toBe(true) + } + expect(inA.has('ownerTarget')).toBe(false) + expect(inA.has('SettingsPane')).toBe(false) + // `ownerTarget` from `./a` is not the harness's same-named reader. + expect( + [...readers.namesFor('src/renderer/src/b.ts')].filter((n) => !n.startsWith('get')) + ).toEqual(expect.arrayContaining(['browserHostId', 'wrappedTwice'])) + expect(readers.namesFor('src/renderer/src/b.ts').has('ownerTarget')).toBe(false) + expect( + countFocusSettingReads( + 'const t = runtimeTargetForOwnerHostId(getSettingsFocusedExecutionHostId(s))', + readers.namesFor('src/renderer/src/a.ts') + ) + ).toBe(1) + expect( + countFocusSettingReads( + "import { wrappedTwice } from './a'\nconst t = wrappedTwice(state)", + readers.namesFor('src/renderer/src/b.ts') + ) + ).toBe(1) + expect(countFocusSettingReads('export const cacheKey = (s) => s', inA)).toBe(0) + }) + + it('counts a reader called through a namespace or dynamic import', () => { + const readers = discoverFocusReaders( + new Map([ + [ + 'src/renderer/src/routing.ts', + 'export function focusedHostId(s) {\n return s.activeRuntimeEnvironmentId\n}\n' + ], + [ + 'src/renderer/src/wrap.ts', + "import * as rr from './routing'\nexport function hostOf(s) {\n return rr.focusedHostId(s)\n}\n" + ], + [ + 'src/renderer/src/use.ts', + "const { focusedHostId } = await import('./routing')\nconst a = focusedHostId(s)\n" + ] + ]) + ) + expect(readers.has('hostOf')).toBe(true) + expect( + countFocusSettingReads( + "import * as rr from './routing'\nconst a = rr.focusedHostId(s)\nt('auto.rr.focusedHostId.x')", + readers.namesFor('src/renderer/src/other.ts'), + readers.memberNames + ) + ).toBe(1) + expect( + countFocusSettingReads( + "const { focusedHostId } = await import('./routing')\nconst a = focusedHostId(s)\n", + readers.namesFor('src/renderer/src/use.ts'), + readers.memberNames + ) + ).toBe(1) + for (const [rel, text] of [ + ['src/renderer/src/r1.ts', "const { focusedHostId: g } = await import('./routing')\ng(s)"], + [ + 'src/renderer/src/r2.ts', + "const { focusedHostId } = (await import('./routing'))\nfocusedHostId(s)" + ], + [ + 'src/renderer/src/r3.ts', + "void import('./routing').then(({ focusedHostId }) => focusedHostId(s))" + ] + ]) { + const withFile = discoverFocusReaders( + new Map([ + [ + 'src/renderer/src/routing.ts', + 'export function focusedHostId(s) {\n return s.activeRuntimeEnvironmentId\n}\n' + ], + [rel, text] + ]) + ) + expect(countFocusSettingReads(text, withFile.namesFor(rel), withFile.memberNames)).toBe(1) + } + }) + + it('counts destructuring reads', () => { + expect(countFocusSettingReads('const { activeRuntimeEnvironmentId } = s')).toBe(1) + expect( + countFocusSettingReads('const { settings: { activeRuntimeEnvironmentId } } = state') + ).toBe(1) + expect( + countFocusSettingReads( + "const { theme, activeRuntimeEnvironmentId: id }: Pick = s" + ) + ).toBe(1) + }) + + it('does not count writes, keys, props or type positions', () => { + const src = [ + 'const owner = { activeRuntimeEnvironmentId: id }', + 'settings = { ...settings, activeRuntimeEnvironmentId: null }', + "type T = GlobalSettings['activeRuntimeEnvironmentId']", + 'updateSettings({ activeRuntimeEnvironmentId: null })', + 'const f = ({ activeRuntimeEnvironmentId }) => activeRuntimeEnvironmentId', + 'if (a === b) { x = { activeRuntimeEnvironmentId } }' + ].join('\n') + expect(countFocusSettingReads(src)).toBe(0) + }) +}) + describe('hasFocusRoutingAlias', () => { it('refuses an aliased import or re-export, which would hide its calls', () => { expect(hasFocusRoutingAlias("import { getActiveRuntimeTarget as route } from './rpc'")).toBe( @@ -47,6 +216,7 @@ describe('hasFocusRoutingAlias', () => { expect( hasFocusRoutingAlias("export {\n settingsForRuntimeOwner as owner\n} from './target'") ).toBe(true) + expect(hasFocusRoutingAlias("import { defaultCreationHost as host } from './d'")).toBe(true) expect(hasFocusRoutingAlias("import { getActiveRuntimeTarget } from './rpc'")).toBe(false) }) }) @@ -75,6 +245,17 @@ describe('baseline', () => { ) }) + it('keeps a row note through a prune', () => { + const text = formatBaseline( + new Map([['src/a.ts', 1]]), + ['# header'], + new Map([['src/a.ts', 'waits for V4b']]) + ) + expect(text).toBe('# header\n1 src/a.ts # waits for V4b\n') + expect(parseBaseline(text)).toEqual(new Map([['src/a.ts', 1]])) + expect(parseBaselineNotes(text)).toEqual(new Map([['src/a.ts', 'waits for V4b']])) + }) + it('fails growth, including a new file, and asks to prune shrinkage', () => { const { grown, shrunk } = diffCounts( new Map([ diff --git a/src/renderer/src/lib/default-creation-host.test.ts b/src/renderer/src/lib/default-creation-host.test.ts new file mode 100644 index 000000000000..05ee35739059 --- /dev/null +++ b/src/renderer/src/lib/default-creation-host.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { defaultCreationHost } from './default-creation-host' + +describe('defaultCreationHost', () => { + it('is this computer unless a server is chosen', () => { + expect(defaultCreationHost(null)).toEqual({ kind: 'local' }) + expect(defaultCreationHost({ activeRuntimeEnvironmentId: null })).toEqual({ kind: 'local' }) + expect(defaultCreationHost({ activeRuntimeEnvironmentId: ' env-a ' })).toEqual({ + kind: 'environment', + environmentId: 'env-a' + }) + }) +}) diff --git a/src/renderer/src/lib/default-creation-host.ts b/src/renderer/src/lib/default-creation-host.ts new file mode 100644 index 000000000000..9fe9e06cff88 --- /dev/null +++ b/src/renderer/src/lib/default-creation-host.ts @@ -0,0 +1,13 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' + +/** + * The "Default host for new projects" setting. Only a creation flow with no source row reads it; + * everything else routes by the resource's owner. + */ +export function defaultCreationHost( + settings: Pick | null | undefined +): RuntimeClientTarget { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } +} diff --git a/src/renderer/src/lib/resolve-owner.test.ts b/src/renderer/src/lib/resolve-owner.test.ts index dab7faced3db..2c20288b1b42 100644 --- a/src/renderer/src/lib/resolve-owner.test.ts +++ b/src/renderer/src/lib/resolve-owner.test.ts @@ -7,6 +7,7 @@ import { resetLegacyFocusFallbackCountForTest, resolveOwner, resolveOwnerWithLegacyFocus, + runtimeTargetForWorkspaceOwner, type WorkspaceOwnerRef } from './resolve-owner' import type { @@ -241,3 +242,33 @@ describe('resolveOwner characterization', () => { }) }) }) + +describe('runtimeTargetForWorkspaceOwner', () => { + const settings = { activeRuntimeEnvironmentId: 'env-other' } + it('dials the owner, not the focused server', () => { + const rows: WorktreeOperationRouteState = { + settings, + repos: [{ id: 'repo-1', executionHostId: 'runtime:env-a' }], + worktreesByRepo: { 'repo-1': [worktree('runtime:env-a', 'env-a')] } + } + expect(runtimeTargetForWorkspaceOwner(rows, { workspaceId: WORKTREE_ID })).toEqual({ + kind: 'environment', + environmentId: 'env-a' + }) + }) + + it('rides this app for local and direct SSH owners', () => { + const sshRows: WorktreeOperationRouteState = { + settings, + repos: [{ id: 'repo-1', connectionId: 'box' }], + worktreesByRepo: { 'repo-1': [worktree('ssh:box')] } + } + expect(runtimeTargetForWorkspaceOwner(sshRows, { workspaceId: WORKTREE_ID })).toEqual({ + kind: 'local' + }) + }) + + it('has no transport when no row names the workspace', () => { + expect(runtimeTargetForWorkspaceOwner({ settings }, { workspaceId: WORKTREE_ID })).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/resolve-owner.ts b/src/renderer/src/lib/resolve-owner.ts index 6462f79ac896..c2753f50980b 100644 --- a/src/renderer/src/lib/resolve-owner.ts +++ b/src/renderer/src/lib/resolve-owner.ts @@ -26,6 +26,7 @@ import { } from './worktree-operation-route' import { getNestedSshTargetIdForFolderWorkspace } from './folder-workspace-runtime-owner' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { hostRouteForAuthority, type RuntimeClientTarget } from '@/runtime/runtime-client-target' /** A worktree id, a `folder:` workspace key, or the floating workspace; `hostId` when the row names one. */ export type WorkspaceOwnerRef = { workspaceId: string; hostId?: ExecutionHostId } @@ -105,6 +106,15 @@ export function resolveOwner( return resolveRoute({ ...state, settings: null }, ref) } +/** Transport to the workspace's owner, or `null` when the rows name none or disagree. */ +export function runtimeTargetForWorkspaceOwner( + state: WorktreeOperationRouteState, + ref: WorkspaceOwnerRef +): RuntimeClientTarget | null { + const match = resolveOwner(state, ref) + return match.kind === 'resolved' ? hostRouteForAuthority(match.owner).target : null +} + let legacyFocusFallbackCount = 0 /** How many answers focus has changed since start; a slice drops its fallback once this stays 0. */ diff --git a/src/renderer/src/runtime/runtime-client-target.test.ts b/src/renderer/src/runtime/runtime-client-target.test.ts index 7519bd898b3d..93101dbdeacc 100644 --- a/src/renderer/src/runtime/runtime-client-target.test.ts +++ b/src/renderer/src/runtime/runtime-client-target.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { UNRESOLVED_OWNER_HOST_ID } from '../../../shared/execution-host' -import { hostRouteForAuthority, runtimeTargetForExecutionHostId } from './runtime-client-target' +import { + hostRouteForAuthority, + runtimeTargetForExecutionHostId, + runtimeTargetForOwnerEnvironment, + runtimeTargetForOwnerHostId +} from './runtime-client-target' describe('runtimeTargetForExecutionHostId', () => { it('keeps the answers for plain host ids', () => { @@ -35,3 +40,25 @@ describe('hostRouteForAuthority', () => { }) }) }) + +describe('owner transports', () => { + it('maps an owner environment id, with null as this app', () => { + expect(runtimeTargetForOwnerEnvironment(null)).toEqual({ kind: 'local' }) + expect(runtimeTargetForOwnerEnvironment(' ')).toEqual({ kind: 'local' }) + expect(runtimeTargetForOwnerEnvironment(' env-a ')).toEqual({ + kind: 'environment', + environmentId: 'env-a' + }) + }) + + it('routes local and direct SSH owners through this app and never dials the sentinel', () => { + expect(runtimeTargetForOwnerHostId('local')).toEqual({ kind: 'local' }) + expect(runtimeTargetForOwnerHostId('ssh:box')).toEqual({ kind: 'local' }) + expect(runtimeTargetForOwnerHostId('runtime:env%2Fa')).toEqual({ + kind: 'environment', + environmentId: 'env/a' + }) + expect(runtimeTargetForOwnerHostId(UNRESOLVED_OWNER_HOST_ID)).toBeNull() + expect(runtimeTargetForOwnerHostId(undefined)).toBeNull() + }) +}) diff --git a/src/renderer/src/runtime/runtime-client-target.ts b/src/renderer/src/runtime/runtime-client-target.ts index 47bc13c150cb..e17f811f10d0 100644 --- a/src/renderer/src/runtime/runtime-client-target.ts +++ b/src/renderer/src/runtime/runtime-client-target.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' +import { parseRoutableExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { parseHostAuthorityKey, type HostAuthority, @@ -14,6 +15,30 @@ export function getActiveRuntimeTarget( return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } } +/** Transport to an owner's runtime environment; `null` is this app (local or direct SSH). */ +export function runtimeTargetForOwnerEnvironment( + environmentId: string | null +): RuntimeClientTarget { + const ownerId = environmentId?.trim() + return ownerId ? { kind: 'environment', environmentId: ownerId } : { kind: 'local' } +} + +/** + * Transport to the host that owns a row: a server's own runtime, or this app for local and direct + * SSH (which ride this app's IPC with a `connectionId`). `null` when the id names no routable host. + */ +export function runtimeTargetForOwnerHostId( + hostId: ExecutionHostId | null | undefined +): RuntimeClientTarget | null { + const parsed = parseRoutableExecutionHostId(hostId) + if (!parsed) { + return null + } + return parsed.kind === 'runtime' + ? { kind: 'environment', environmentId: parsed.environmentId } + : { kind: 'local' } +} + /** * RPC target for a dispatchable host; direct SSH and the unresolved-owner sentinel have none. A * nested authority key routes to the server that owns the SSH target. From 2eb6a920ddfeff5e5f2b9144e0ab0c0f13c74319 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 10:50:17 -0700 Subject: [PATCH 002/110] refactor(forge): forge reads and edits route by the repo's owner or the task source (#27276) GitHub PR and issue dialogs, task-page cells, Project boards, slug metadata, the repo slug index, hosted-review actions, GitLab MR base resolution and the Settings API-budget panels now carry a RuntimeClientTarget derived from the repo row, the row-less task source, the board row's cache key or the Settings host scope, instead of settings-shaped objects resolved through getActiveRuntimeTarget. A repo row with no owner stamp is local; an untracked Project row edits on the host that loaded it. Owner-routing ratchet 209 -> 165; focus-read ratchet 401 -> 352. Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 67 +++---------------- config/owner-routing-baseline.txt | 31 +-------- .../edit-item-fields/gh-edit-section.tsx | 14 ++-- .../land-pull-request/pr-actions-panel.tsx | 9 ++- .../land-pull-request/pr-reviewers-panel.tsx | 16 ++--- .../pr-reviewers-request-actions.ts | 14 ++-- .../components/github-project/ProjectCell.tsx | 12 ++-- .../github-project/ProjectCellIdentity.tsx | 8 +-- .../github-project/ProjectCellIssueType.tsx | 18 ++--- .../ProjectCellRepositoryEditors.tsx | 12 ++-- .../github-project/ProjectItemSlugDialog.tsx | 8 +-- .../github-project/ProjectPicker.tsx | 18 ++--- .../components/github-project/ProjectRow.tsx | 8 +-- .../github-project/ProjectViewList.tsx | 8 +-- .../github-project/ProjectViewToolbar.tsx | 4 +- .../github-project/ProjectViewWrapper.tsx | 18 ++--- .../github-project/project-picker-runtime.ts | 23 ++----- .../slug-dialog/AssigneesEditor.tsx | 8 +-- .../github-project/slug-dialog/Comments.tsx | 44 +++--------- .../slug-dialog/LabelsEditor.tsx | 13 ++-- .../slug-dialog/SlugDialogBody.tsx | 20 +++--- .../github-project/useProjectPickerBrowse.ts | 6 +- .../github-project/useProjectViewTable.ts | 17 ++--- .../components/github/PRAssigneesPanel.tsx | 10 +-- .../components/github/PRFilterDropdowns.tsx | 11 +-- .../github/github-rate-limit-display.tsx | 27 +++++--- .../github/github-work-item-edit-mutations.ts | 53 ++++++++------- .../github-work-item-mutation-target.test.ts | 42 ++++++++++++ .../gitlab/gitlab-rate-limit-display.tsx | 27 +++++--- .../pull-request-page/actions/panel.tsx | 8 +-- .../pull-request-page/conversation/tab.tsx | 8 +-- .../pull-request-page/edit/section.tsx | 14 ++-- .../pull-request-page/reviewers/panel.tsx | 16 ++--- .../reviewers/request-actions.ts | 10 +-- .../settings/GitProviderApiBudgetPane.tsx | 10 ++- .../settings/provider-account-scope.test.ts | 6 +- .../settings/provider-account-scope.ts | 7 +- .../provider-rate-limit-scope-panels.test.tsx | 12 ++-- .../settings/repository-github-account.ts | 8 +-- .../settings/repository-icon-github.ts | 11 ++- .../settings-git-task-section-renderers.tsx | 5 +- .../task-page-github-reviewer-actions.ts | 11 +-- .../task-page/github/AssigneesCell.tsx | 14 ++-- .../components/task-page/github/Filters.tsx | 13 +++- .../components/task-page/github/MergeCell.tsx | 12 ++-- .../task-page/github/ReviewCell.tsx | 10 +-- .../task-page/github/StatusCell.tsx | 12 ++-- .../use-task-page-github-issue-draft.ts | 21 ++---- .../composer-state/composer-source-state.ts | 3 +- .../gitlab-provider-selection.ts | 19 +++--- .../src/hooks/useGitHubRepoMetadata.ts | 9 +-- .../src/hooks/useGitHubSlugMetadata.test.tsx | 23 ++----- .../src/hooks/useGitHubSlugMetadata.ts | 14 ++-- .../src/hooks/useIssueMetadata.test.tsx | 3 +- .../lib/github-source-runtime-context.test.ts | 10 +-- .../src/lib/github-source-runtime-context.ts | 30 ++++----- .../src/lib/hosted-review-repo-read.ts | 8 +-- .../src/lib/repo-runtime-owner.test.ts | 37 ++++++++++ src/renderer/src/lib/repo-runtime-owner.ts | 16 +++++ src/renderer/src/lib/repo-slug-cache.test.ts | 30 ++++++--- src/renderer/src/lib/repo-slug-cache.ts | 32 +++++---- src/renderer/src/lib/repo-slug-index.ts | 34 +++++----- .../lib/task-source-runtime-target.test.ts | 32 +++++++++ .../src/lib/task-source-runtime-target.ts | 16 +++++ .../src/store/github/cache-identity.ts | 17 ++--- .../src/store/github/project-actions.ts | 7 +- .../store/github/project-field-mutations.ts | 8 +-- .../src/store/github/project-row-actions.ts | 34 +++++----- .../provider-request-coalescing.test.ts | 8 ++- src/renderer/src/store/github/slice-types.ts | 6 +- .../github/work-item-mutation-actions.ts | 9 ++- ...ithub-project-request-coordination.test.ts | 40 ++++++----- .../slices/github-project-row-owner.test.ts | 21 +++--- .../store/slices/github-project-row-owner.ts | 30 +++------ .../slices/github-project-view-tables.test.ts | 38 ++++++----- ...ithub-provider-request-concurrency.test.ts | 15 +++-- .../store/slices/hosted-review-cache-state.ts | 20 ++++-- .../src/store/slices/hosted-review.test.ts | 28 ++++++++ .../src/store/slices/hosted-review.ts | 14 ++-- .../session/worktree-terminal-reference.ts | 2 +- 80 files changed, 730 insertions(+), 657 deletions(-) create mode 100644 src/renderer/src/components/github/github-work-item-mutation-target.test.ts create mode 100644 src/renderer/src/lib/task-source-runtime-target.test.ts create mode 100644 src/renderer/src/lib/task-source-runtime-target.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 75fe522ca4f7..a82f0220b48c 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -87,27 +87,6 @@ 2 src/renderer/src/components/feature-wall/use-integration-connection-status.ts 1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts 1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts -1 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab.tsx -4 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts -7 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx -2 src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx -5 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx -6 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts -1 src/renderer/src/components/github-project/ProjectCellIssueType.tsx -2 src/renderer/src/components/github-project/ProjectCellRepositoryEditors.tsx -2 src/renderer/src/components/github-project/ProjectPicker.tsx -4 src/renderer/src/components/github-project/project-picker-runtime.ts -1 src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx -1 src/renderer/src/components/github-project/slug-dialog/Comments.tsx -1 src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx -1 src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx -2 src/renderer/src/components/github-project/useProjectPickerBrowse.ts -2 src/renderer/src/components/github-project/useProjectViewTable.ts -3 src/renderer/src/components/github/PRAssigneesPanel.tsx -2 src/renderer/src/components/github/PRFilterDropdowns.tsx -3 src/renderer/src/components/github/github-rate-limit-display.tsx -5 src/renderer/src/components/github/github-work-item-edit-mutations.ts -3 src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx 1 src/renderer/src/components/jira-connect-dialog.tsx 1 src/renderer/src/components/landing-preflight-runtime.ts 1 src/renderer/src/components/linear-api-key-dialog.tsx @@ -133,20 +112,12 @@ 2 src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts 4 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts 2 src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts -1 src/renderer/src/components/new-workspace/smart-workspace-repo-slug.ts 1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts 2 src/renderer/src/components/new-workspace/use-linear-source-connection.ts 1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts 2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts 1 src/renderer/src/components/ports/WorkspacePortScanner.tsx -1 src/renderer/src/components/pull-request-page/actions/merge-actions.ts -1 src/renderer/src/components/pull-request-page/actions/panel.tsx -2 src/renderer/src/components/pull-request-page/conversation/tab.tsx -3 src/renderer/src/components/pull-request-page/edit/issue-updates.ts -8 src/renderer/src/components/pull-request-page/edit/section.tsx -4 src/renderer/src/components/pull-request-page/reviewers/panel.tsx -4 src/renderer/src/components/pull-request-page/reviewers/request-actions.ts 2 src/renderer/src/components/quick-open-file-list-test-harness.ts 6 src/renderer/src/components/quick-open-file-list.ts 4 src/renderer/src/components/quick-open-file-navigation.ts @@ -243,7 +214,7 @@ 3 src/renderer/src/components/settings/jira-integration-card.tsx 1 src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx 1 src/renderer/src/components/settings/mcp-config-inspection.ts -2 src/renderer/src/components/settings/provider-account-scope.ts +1 src/renderer/src/components/settings/provider-account-scope.ts 1 src/renderer/src/components/settings/settings-persistence-test-fixture.ts 2 src/renderer/src/components/settings/settings-setup-guide-progress.ts 1 src/renderer/src/components/settings/source-control-preflight-card-status.ts @@ -321,12 +292,7 @@ 1 src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx 3 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts 5 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts -4 src/renderer/src/components/task-page-github-reviewer-actions.ts -2 src/renderer/src/components/task-page/TaskPage.tsx -2 src/renderer/src/components/task-page/github/AssigneesCell.tsx -2 src/renderer/src/components/task-page/github/MergeCell.tsx -2 src/renderer/src/components/task-page/github/ReviewCell.tsx -1 src/renderer/src/components/task-page/github/StatusCell.tsx +1 src/renderer/src/components/task-page/TaskPage.tsx 2 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts 1 src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts 1 src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -362,7 +328,6 @@ 1 src/renderer/src/components/terminal/terminal-tab-create.ts 1 src/renderer/src/components/terminal/window-close-running-work.ts 2 src/renderer/src/components/use-quick-open-interaction.ts -3 src/renderer/src/components/use-task-page-github-issue-draft.ts 2 src/renderer/src/components/use-task-page-source-availability.ts 2 src/renderer/src/components/use-task-page-store-bindings.ts 2 src/renderer/src/components/use-terminal-activation-actions.ts @@ -377,12 +342,11 @@ 4 src/renderer/src/hooks/composer-state/attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts -3 src/renderer/src/hooks/composer-state/composer-source-state.ts +2 src/renderer/src/hooks/composer-state/composer-source-state.ts 1 src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts 2 src/renderer/src/hooks/composer-state/composer-target-state.ts 1 src/renderer/src/hooks/composer-state/github-provider-selection.ts 2 src/renderer/src/hooks/composer-state/github-submit-resolution.ts -1 src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts @@ -400,8 +364,6 @@ 3 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts 1 src/renderer/src/hooks/useActiveProjectSkillRuntime.ts 2 src/renderer/src/hooks/useEditorExternalWatch.ts -2 src/renderer/src/hooks/useGitHubRepoMetadata.ts -4 src/renderer/src/hooks/useGitHubSlugMetadata.ts 1 src/renderer/src/hooks/useInstalledAgentSkills.ts 1 src/renderer/src/hooks/useIpcEvents.ts 1 src/renderer/src/hooks/useIssueMetadata.ts @@ -434,10 +396,7 @@ 1 src/renderer/src/lib/file-search-result-owner.ts 2 src/renderer/src/lib/folder-workspace-runtime-owner.ts 1 src/renderer/src/lib/github-pr-start-point.ts -1 src/renderer/src/lib/github-source-runtime-context.ts -2 src/renderer/src/lib/github-work-item-source-lookup.ts 1 src/renderer/src/lib/host-mirrored-pane-liveness.ts -1 src/renderer/src/lib/hosted-review-repo-read.ts 6 src/renderer/src/lib/http-link-destinations.ts 3 src/renderer/src/lib/http-link-routing.ts 2 src/renderer/src/lib/launch-agent-background-session.ts @@ -457,8 +416,6 @@ 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts 2 src/renderer/src/lib/rename-file.ts 4 src/renderer/src/lib/repo-runtime-owner.ts -2 src/renderer/src/lib/repo-slug-cache.ts -3 src/renderer/src/lib/repo-slug-index.ts 2 src/renderer/src/lib/resolve-owner.ts 1 src/renderer/src/lib/runtime-session-mirror-owners.ts 1 src/renderer/src/lib/runtime-session-mirror-targets.ts @@ -534,19 +491,14 @@ 2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts 5 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts 1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts -1 src/renderer/src/store/github/cache-identity.ts -3 src/renderer/src/store/github/project-actions.ts -2 src/renderer/src/store/github/project-field-mutations.ts -2 src/renderer/src/store/github/project-row-actions.ts 1 src/renderer/src/store/github/pull-request-execution.ts 2 src/renderer/src/store/github/refresh-routing-actions.ts 3 src/renderer/src/store/github/repository-routing.ts 2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts 2 src/renderer/src/store/github/work-item-aggregate-actions.ts 3 src/renderer/src/store/github/work-item-fetch-actions.ts -1 src/renderer/src/store/github/work-item-mutation-actions.ts 3 src/renderer/src/store/github/work-item-routing.ts -9 src/renderer/src/store/index.ts +8 src/renderer/src/store/index.ts 6 src/renderer/src/store/project-groups/nested-repository-operations.ts 2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts 7 src/renderer/src/store/project-groups/project-group-mutations.ts @@ -582,10 +534,9 @@ 2 src/renderer/src/store/slices/editor/create-editor-slice.ts 1 src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts 1 src/renderer/src/store/slices/github-cache-key.ts -2 src/renderer/src/store/slices/github-slice-test-harness.ts -1 src/renderer/src/store/slices/github.ts +1 src/renderer/src/store/slices/github-slice-test-harness.ts 1 src/renderer/src/store/slices/hosted-review-cache-identity.ts -4 src/renderer/src/store/slices/hosted-review.ts +1 src/renderer/src/store/slices/hosted-review.ts 1 src/renderer/src/store/slices/jira-collection-read-actions.ts 13 src/renderer/src/store/slices/jira-connection-actions.ts 4 src/renderer/src/store/slices/jira-issue-read-actions.ts @@ -611,13 +562,13 @@ 1 src/renderer/src/store/slices/runtime-status.ts 1 src/renderer/src/store/slices/settings-owner-hydration-publication.ts 5 src/renderer/src/store/slices/settings.ts -8 src/renderer/src/store/slices/store-test-helpers.ts +7 src/renderer/src/store/slices/store-test-helpers.ts 2 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts 2 src/renderer/src/store/slices/terminal-tab-retirement.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts 3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts -13 src/renderer/src/store/slices/worktrees.ts +12 src/renderer/src/store/slices/worktrees.ts 2 src/renderer/src/store/slices/worktrees/create/create-worktree.ts 2 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts 2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts @@ -634,7 +585,7 @@ 3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts 7 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts 1 src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts -2 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts +1 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts 1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts 1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts 2 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 7f4a13042581..cadca974e089 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -15,21 +15,8 @@ 1 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts 1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts 2 src/renderer/src/components/editor/useMarkdownDocuments.ts -1 src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx -1 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx -2 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts -1 src/renderer/src/components/github-project/ProjectCellIssueType.tsx -4 src/renderer/src/components/github-project/project-picker-runtime.ts -1 src/renderer/src/components/github-project/slug-dialog/Comments.tsx -1 src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx -1 src/renderer/src/components/github/github-rate-limit-display.tsx -4 src/renderer/src/components/github/github-work-item-edit-mutations.ts -1 src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx 1 src/renderer/src/components/linear-api-key-dialog.tsx 1 src/renderer/src/components/ports/WorkspacePortScanner.tsx -1 src/renderer/src/components/pull-request-page/actions/panel.tsx -1 src/renderer/src/components/pull-request-page/reviewers/panel.tsx -2 src/renderer/src/components/pull-request-page/reviewers/request-actions.ts 1 src/renderer/src/components/quick-open-file-navigation.ts 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx @@ -40,19 +27,12 @@ 1 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts 1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts 1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts -2 src/renderer/src/components/task-page-github-reviewer-actions.ts -1 src/renderer/src/components/task-page/github/AssigneesCell.tsx -2 src/renderer/src/components/task-page/github/MergeCell.tsx -1 src/renderer/src/components/task-page/github/StatusCell.tsx 1 src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts 1 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts 1 src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts -1 src/renderer/src/components/use-task-page-github-issue-draft.ts -1 src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts -2 src/renderer/src/hooks/useGitHubSlugMetadata.ts 1 src/renderer/src/hooks/useIssueMetadata.ts 1 src/renderer/src/hooks/useSettingsNavigationMetadata.ts 3 src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -61,13 +41,9 @@ 1 src/renderer/src/lib/automation-session-observer.ts 1 src/renderer/src/lib/codex-pane-selection-lane.ts 1 src/renderer/src/lib/github-pr-start-point.ts -1 src/renderer/src/lib/github-source-runtime-context.ts -1 src/renderer/src/lib/hosted-review-repo-read.ts 1 src/renderer/src/lib/launch-agent-background-session.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts 1 src/renderer/src/lib/local-file-access.ts -1 src/renderer/src/lib/repo-slug-cache.ts -1 src/renderer/src/lib/repo-slug-index.ts 1 src/renderer/src/lib/structured-agent-session-tab-activation.ts 1 src/renderer/src/lib/workspace-file-host-routing.ts 1 src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -97,12 +73,7 @@ 2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts 3 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts 1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts -1 src/renderer/src/store/github/cache-identity.ts -1 src/renderer/src/store/github/project-actions.ts -1 src/renderer/src/store/github/project-field-mutations.ts -2 src/renderer/src/store/github/project-row-actions.ts 1 src/renderer/src/store/github/repository-routing.ts -1 src/renderer/src/store/github/work-item-mutation-actions.ts 6 src/renderer/src/store/project-groups/nested-repository-operations.ts 2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts 4 src/renderer/src/store/project-groups/project-group-mutations.ts @@ -115,7 +86,7 @@ 1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 1 src/renderer/src/store/slices/diff-comment-persistence.ts 1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts -4 src/renderer/src/store/slices/hosted-review.ts +1 src/renderer/src/store/slices/hosted-review.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx index 24a3bf218c2a..e1cd1f0a12c1 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx @@ -4,7 +4,7 @@ import { useAppStore } from '@/store' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { getTaskPageGitHubDuplicateCandidates, getTaskPageGitHubDuplicateTargetErrorMessage, @@ -63,8 +63,8 @@ function GHEditSectionItem({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const duplicateIssueCandidates = useGitHubDuplicateIssueCandidates(item, duplicatePickerOpen) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, item.repoId ?? null, sourceContext)) ) const { isPending, run } = useImmediateMutation() // Why: from a Project view, keep projectViewCache in sync too — patchWorkItem only walks workItemsCache, so the table would render stale without this. See docs/design/github-project-view-tasks.md §Dialog editing from Project rows. @@ -80,8 +80,8 @@ function GHEditSectionItem({ const issueRepo = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) const metadataOptions = useMemo( - () => ({ ...sourceSettings, ownerRepo: issueRepo }), - [sourceSettings, issueRepo] + () => ({ target: ownerTarget, ownerRepo: issueRepo }), + [ownerTarget, issueRepo] ) // Project metadata comes from the row repository. const slugOwner = projectOrigin?.owner ?? null @@ -94,7 +94,7 @@ function GHEditSectionItem({ const repoLabelsBySlug = useRepoLabelsBySlug( slugOwner, slugRepo, - sourceSettings, + ownerTarget, projectOrigin?.host ) const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath @@ -108,7 +108,7 @@ function GHEditSectionItem({ slugOwner, slugRepo, assignees, - sourceSettings, + ownerTarget, projectOrigin?.host ) const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx index cece1174e852..fb25c145130a 100644 --- a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx @@ -6,9 +6,9 @@ import { Button } from '@/components/ui/button' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { - getGitHubRepoRoutingSettings, + getGitHubRepoRoutingTarget, getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' @@ -55,10 +55,9 @@ export function PRActionsPanel({ const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)) + const mergeTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, item.repoId ?? repoId ?? null, sourceContext)) ) - const mergeTarget = getActiveRuntimeTarget(sourceSettings) const prRepo = resolvePullRequestRepo(item, projectOrigin) const canMutateWithRepoContext = !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx index 23fcfda0962c..329999b41aa5 100644 --- a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx @@ -4,7 +4,6 @@ import { LoaderCircle, Pencil } from 'lucide-react' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoAssignees } from '@/hooks/useIssueMetadata' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { getGitHubPRReviewerRows } from '@/components/github-pr-reviewer-display' @@ -16,7 +15,7 @@ import type { GitHubAssignableUser } from '../../../../../shared/github/pull-req import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../../../shared/task-source-context' import { translate } from '@/i18n/i18n' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity' import { mergeReviewerSuggestions } from '@/components/github/work-item-state-presentation' import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' @@ -55,8 +54,8 @@ export function PRReviewersPanel({ reviewRequests: item.reviewRequests })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, item.repoId ?? null, sourceContext)) ) const reviewerInputRef = useRef(null) const reviewerInputFocusFrameRef = useRef(null) @@ -134,13 +133,13 @@ export function PRReviewersPanel({ open && reviewRepo ? reviewRepo.owner : null, open && reviewRepo ? reviewRepo.repo : null, reviewerSeedUsers.map((user) => user.login), - sourceSettings, + ownerTarget, reviewRepo?.host ) const reviewerMetadataByPath = useRepoAssignees( open && !reviewRepo ? repoPath : null, open && !reviewRepo ? item.repoId : null, - sourceSettings + { target: ownerTarget } ) const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } @@ -230,14 +229,13 @@ export function PRReviewersPanel({ localReviewRequests.length > 0 || item.reviewRequests !== undefined || item.latestReviews !== undefined - const canRequestReview = - !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' + const canRequestReview = !!repoPath || ownerTarget.kind === 'environment' const actionArgs = { submitting, selectedReviewerLogins, localReviewRequests, - sourceSettings, + ownerTarget, repoPath, sourceContext, item, diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts index 362638038760..c74849ed6cc4 100644 --- a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context' import { normalizeGitHubReviewerLogins, @@ -14,14 +14,14 @@ import type { } from '../../../../../shared/github/pull-request-types' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../../../shared/task-source-context' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { translate } from '@/i18n/i18n' type ReviewerRequestArgs = { submitting: boolean selectedReviewerLogins: Set localReviewRequests: GitHubAssignableUser[] - sourceSettings: Pick + ownerTarget: RuntimeClientTarget repoPath: string | null sourceContext?: TaskSourceContext | null item: GitHubWorkItem @@ -46,7 +46,7 @@ export async function requestPRReviewers({ reviewerInput, selectedReviewerLogins, localReviewRequests, - sourceSettings, + ownerTarget, repoPath, sourceContext, item, @@ -79,7 +79,7 @@ export async function requestPRReviewers({ ) return } - const target = getActiveRuntimeTarget(sourceSettings) + const target = ownerTarget if (target.kind !== 'environment' && !repoPath) { toast.error( translate( @@ -169,7 +169,7 @@ export async function removePRReviewers({ submitting, reviewersToRemove, localReviewRequests, - sourceSettings, + ownerTarget, repoPath, sourceContext, item, @@ -193,7 +193,7 @@ export async function removePRReviewers({ if (logins.length === 0) { return } - const target = getActiveRuntimeTarget(sourceSettings) + const target = ownerTarget if (target.kind !== 'environment' && !repoPath) { toast.error( translate( diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index a2138f74a24b..96d071e3cf5f 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React from 'react' import { TYPE_FIELD_DATA_TYPE } from './columns' import type { @@ -6,7 +7,6 @@ import type { GitHubProjectFieldMutationValue, GitHubProjectRow } from '../../../../shared/github/project-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { ProjectTitleCell, ProjectTypeCell } from './ProjectCellIdentity' import { ProjectAssigneesCell, ProjectLabelsCell } from './ProjectCellRepositoryEditors' @@ -28,7 +28,7 @@ type Props = { onEditIssueType?: (issueType: GitHubIssueType | null) => void onOpenDialog?: () => void sourceHost?: string - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget } export default function ProjectCell({ @@ -41,7 +41,7 @@ export default function ProjectCell({ onEditIssueType, onOpenDialog, sourceHost, - sourceSettings + sourceTarget }: Props): React.JSX.Element { const value = row.fieldValuesByFieldId[field.id] const editableValue = editable && row.itemType !== 'REDACTED' @@ -54,7 +54,7 @@ export default function ProjectCell({ row={row} editable={editableValue && row.itemType === 'ISSUE'} sourceHost={sourceHost} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} onEditIssueType={onEditIssueType} /> ) @@ -65,7 +65,7 @@ export default function ProjectCell({ row={row} editable={editableValue && row.itemType !== 'DRAFT_ISSUE'} sourceHost={sourceHost} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} onEditAssignees={onEditAssignees} /> ) @@ -76,7 +76,7 @@ export default function ProjectCell({ row={row} editable={editableValue && row.itemType !== 'DRAFT_ISSUE'} sourceHost={sourceHost} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} onEditLabels={onEditLabels} /> ) diff --git a/src/renderer/src/components/github-project/ProjectCellIdentity.tsx b/src/renderer/src/components/github-project/ProjectCellIdentity.tsx index d95891d45446..2159cdf85da9 100644 --- a/src/renderer/src/components/github-project/ProjectCellIdentity.tsx +++ b/src/renderer/src/components/github-project/ProjectCellIdentity.tsx @@ -1,8 +1,8 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React from 'react' import { FileText, GitPullRequest, Lock } from 'lucide-react' import { translate } from '@/i18n/i18n' import type { GitHubIssueType, GitHubProjectRow } from '../../../../shared/github/project-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { ProjectIssueTypeCell } from './ProjectCellIssueType' export function ProjectTitleCell({ @@ -51,13 +51,13 @@ export function ProjectTypeCell({ row, editable, sourceHost, - sourceSettings, + sourceTarget, onEditIssueType }: { row: GitHubProjectRow editable: boolean sourceHost?: string - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget onEditIssueType?: (issueType: GitHubIssueType | null) => void }): React.JSX.Element { if (row.itemType === 'ISSUE') { @@ -66,7 +66,7 @@ export function ProjectTypeCell({ row={row} editable={editable} sourceHost={sourceHost} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} onEditIssueType={onEditIssueType} /> ) diff --git a/src/renderer/src/components/github-project/ProjectCellIssueType.tsx b/src/renderer/src/components/github-project/ProjectCellIssueType.tsx index 814db7148770..99f63244cb34 100644 --- a/src/renderer/src/components/github-project/ProjectCellIssueType.tsx +++ b/src/renderer/src/components/github-project/ProjectCellIssueType.tsx @@ -1,28 +1,28 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React, { useEffect, useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { CircleDot } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { useRepoSlugIndex } from '@/lib/repo-slug-index' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import type { GitHubIssueType, GitHubProjectRow } from '../../../../shared/github/project-types' import type { ListIssueTypesBySlugResult } from '../../../../shared/github/project-result-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { chipStyle, colorHex, singleSelectChipColors } from './project-cell-chip-colors' export function ProjectIssueTypeCell({ row, editable, sourceHost, - sourceSettings, + sourceTarget, onEditIssueType }: { row: GitHubProjectRow editable: boolean sourceHost?: string - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget onEditIssueType?: (issueType: GitHubIssueType | null) => void }): React.JSX.Element { const issueType = row.content.issueType @@ -35,8 +35,8 @@ export function ProjectIssueTypeCell({ () => lookupSlug(row.content.repository, sourceHost)[0] ?? null, [lookupSlug, row.content.repository, sourceHost] ) - const ownerSettings = useAppStore( - useShallow((state) => getSettingsForRepoRuntimeOwner(state, matchedRepo?.id ?? null)) + const ownerTarget = useAppStore( + useShallow((state) => (matchedRepo ? runtimeTargetForRepoOwner(state, matchedRepo.id) : null)) ) useEffect(() => { @@ -45,7 +45,7 @@ export function ProjectIssueTypeCell({ } let cancelled = false setLoading(true) - const target = getActiveRuntimeTarget(matchedRepo ? ownerSettings : sourceSettings) + const target = ownerTarget ?? sourceTarget const request = target.kind === 'environment' ? callRuntimeRpc( @@ -73,7 +73,7 @@ export function ProjectIssueTypeCell({ return () => { cancelled = true } - }, [matchedRepo, open, owner, ownerSettings, repo, sourceHost, sourceSettings]) + }, [open, owner, ownerTarget, repo, sourceHost, sourceTarget]) const trigger = ( diff --git a/src/renderer/src/components/github-project/ProjectCellRepositoryEditors.tsx b/src/renderer/src/components/github-project/ProjectCellRepositoryEditors.tsx index b956bff408c9..c61a4c54c60a 100644 --- a/src/renderer/src/components/github-project/ProjectCellRepositoryEditors.tsx +++ b/src/renderer/src/components/github-project/ProjectCellRepositoryEditors.tsx @@ -1,24 +1,24 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React, { useMemo, useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { GitHubProjectRow } from '../../../../shared/github/project-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { EmptyProjectCell, ProjectLabelChip, ProjectUserChip } from './ProjectCellValueEditors' type RepositoryEditorProps = { row: GitHubProjectRow editable: boolean sourceHost?: string - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget } export function ProjectAssigneesCell({ row, editable, sourceHost, - sourceSettings, + sourceTarget, onEditAssignees }: RepositoryEditorProps & { onEditAssignees?: (add: string[], remove: string[]) => void @@ -38,7 +38,7 @@ export function ProjectAssigneesCell({ open ? owner : null, open ? repo : null, seedKey ? seedKey.split(',') : [], - sourceSettings, + sourceTarget, sourceHost ) const content = @@ -104,7 +104,7 @@ export function ProjectLabelsCell({ row, editable, sourceHost, - sourceSettings, + sourceTarget, onEditLabels }: RepositoryEditorProps & { onEditLabels?: (add: string[], remove: string[]) => void @@ -115,7 +115,7 @@ export function ProjectLabelsCell({ const metadata = useRepoLabelsBySlug( open ? owner : null, open ? repo : null, - sourceSettings, + sourceTarget, sourceHost ) const content = diff --git a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx index 06dc94deb4ca..2c37c715c270 100644 --- a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx +++ b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx @@ -7,23 +7,23 @@ // Files, Checks, and review-thread tabs hidden. This component is that // simplified surface; it also routes every write through slug-addressed // mutation helpers and patches the Project table cache on success. +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React from 'react' import * as VisuallyHidden from 'radix-ui/visually-hidden' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' import { SlugDialogBody } from './slug-dialog/SlugDialogBody' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' type Props = { projectOrigin: GitHubItemDialogProjectOrigin | null - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget onClose: () => void } export default function ProjectItemSlugDialog({ projectOrigin, - sourceSettings, + sourceTarget, onClose }: Props): React.JSX.Element { const open = projectOrigin !== null @@ -55,7 +55,7 @@ export default function ProjectItemSlugDialog({ {projectOrigin ? ( ) : null} diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 7b1100fcc3d7..738595cd977c 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { ChevronDown } from 'lucide-react' import { toast } from 'sonner' @@ -27,8 +28,7 @@ import { parseProjectInput } from './project-picker-input' import { getProjectPickerBrowseHost, listProjectViewsForRuntime, - resolveProjectRefForRuntime, - type ProjectSourceSettings + resolveProjectRefForRuntime } from './project-picker-runtime' import type { ProjectPickerChoice, ResolvedProjectSelection } from './project-picker-selection' import { useProjectPickerBrowse } from './useProjectPickerBrowse' @@ -46,7 +46,7 @@ type Props = { title?: string } | null onSelect: (selection: ResolvedProjectSelection) => void - sourceSettings: ProjectSourceSettings + sourceTarget: RuntimeClientTarget } const EMPTY_PROJECT_SETTINGS: GitHubProjectSettings = { @@ -59,7 +59,7 @@ const EMPTY_PROJECT_SETTINGS: GitHubProjectSettings = { export default function ProjectPicker({ activeProject, onSelect, - sourceSettings + sourceTarget }: Props): React.JSX.Element { const settings = useAppStore((state) => state.settings) const updateSettings = useAppStore((state) => state.updateSettings) @@ -77,7 +77,7 @@ export default function ProjectPicker({ const [viewList, setViewList] = useState([]) const [viewLoading, setViewLoading] = useState(false) const browseHost = getProjectPickerBrowseHost(activeProject ?? projectSettings.activeProject) - const browse = useProjectPickerBrowse(sourceSettings, browseHost) + const browse = useProjectPickerBrowse(sourceTarget, browseHost) const { loadBrowse } = browse useEffect(() => { @@ -149,7 +149,7 @@ export default function ProjectPicker({ setViewPickFor(selection) setViewLoading(true) try { - const result = await listProjectViewsForRuntime(sourceSettings, { + const result = await listProjectViewsForRuntime(sourceTarget, { owner: choice.owner, ownerType: choice.ownerType, projectNumber: choice.number, @@ -185,7 +185,7 @@ export default function ProjectPicker({ } } }, - [commitSelection, mountedRef, projectSettings.lastViewByProject, sourceSettings] + [commitSelection, mountedRef, projectSettings.lastViewByProject, sourceTarget] ) const handlePaste = useCallback(async () => { @@ -202,7 +202,7 @@ export default function ProjectPicker({ setPasteError(null) setPasteBusy(true) try { - const result = await resolveProjectRefForRuntime(sourceSettings, input, parsed.host) + const result = await resolveProjectRefForRuntime(sourceTarget, input, parsed.host) if (!mountedRef.current) { return } @@ -224,7 +224,7 @@ export default function ProjectPicker({ setPasteBusy(false) } } - }, [handleChooseProject, mountedRef, pasteInput, sourceSettings]) + }, [handleChooseProject, mountedRef, pasteInput, sourceTarget]) const filteredBrowse = useMemo( () => diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index 57ad63784c3c..cf5e6b070a2d 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React from 'react' import { ExternalLink, Play } from 'lucide-react' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' @@ -12,7 +13,6 @@ import type { GitHubProjectFieldMutationValue, GitHubProjectRow as GitHubProjectRowType } from '../../../../shared/github/project-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' const PROJECT_FROZEN_COLUMN_SURFACE_CLASS = @@ -35,7 +35,7 @@ type Props = { onStartWork?: () => void onOpenInBrowser?: () => void sourceHost?: string - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget } export default function ProjectRow({ @@ -53,7 +53,7 @@ export default function ProjectRow({ onStartWork, onOpenInBrowser, sourceHost, - sourceSettings + sourceTarget }: Props): React.JSX.Element { const disabled = row.itemType === 'REDACTED' // Why: design doc §Row actions — draft-issue rows have no URL or number, so @@ -103,7 +103,7 @@ export default function ProjectRow({ onEditIssueType={onEditIssueType} onOpenDialog={f.dataType === 'TITLE' ? onOpenDialog : undefined} sourceHost={sourceHost} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} /> {next ? ( diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index cdb9e2dc4875..5611188038b1 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -23,7 +23,7 @@ import type { GitHubProjectSortDirection, GitHubProjectTable } from '../../../../shared/github/project-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { translate } from '@/i18n/i18n' type SortOverride = { fieldId: string; direction: GitHubProjectSortDirection } @@ -59,7 +59,7 @@ type Props = { onEditIssueType?: (row: GitHubProjectRow, issueType: GitHubIssueType | null) => void onStartWork?: (row: GitHubProjectRow) => void onOpenInBrowser?: (row: GitHubProjectRow) => void - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget } export default function ProjectViewList({ @@ -71,7 +71,7 @@ export default function ProjectViewList({ onEditIssueType, onStartWork, onOpenInBrowser, - sourceSettings + sourceTarget }: Props): React.JSX.Element { const [collapsed, setCollapsed] = useState>(() => new Set()) // Why: column-header clicks override the view's saved sortByFields locally @@ -253,7 +253,7 @@ export default function ProjectViewList({ onStartWork={() => onStartWork?.(row)} onOpenInBrowser={() => onOpenInBrowser?.(row)} sourceHost={table.project.host} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} /> )) : null} diff --git a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx index c9593082915c..81427ebbc805 100644 --- a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx +++ b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx @@ -20,7 +20,7 @@ export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTabl viewId, doFetch, setAppliedQueryByView, - sourceSettings + sourceTarget } = tableState const selectedViewUrl = table ? `${table.project.url}/views/${table.selectedView.number ?? ''}` @@ -59,7 +59,7 @@ export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTabl : null } onSelect={(selection) => void doFetch(selection, true)} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} /> {currentProjectViewKey ? ( @@ -34,11 +34,11 @@ export default function ProjectViewWrapper({ selectedRepoIds, sourceHostId }: Props): React.JSX.Element { - const sourceSettings = useMemo( - () => getTaskSourceRuntimeSettings({ hostId: sourceHostId }), + const sourceTarget = useMemo( + () => taskSourceRuntimeTarget({ hostId: sourceHostId }), [sourceHostId] ) - const tableState = useProjectViewTable(selectedRepoIds, sourceSettings) + const tableState = useProjectViewTable(selectedRepoIds, sourceTarget) const rowActions = useProjectRowActions({ table: tableState.table, currentCacheKey: tableState.currentCacheKey, @@ -48,8 +48,8 @@ export default function ProjectViewWrapper({ const slugOrigin = rowActions.missingDialogs.slugDialog?.origin ?? null const slugCacheKey = slugOrigin?.cacheKey ?? null // Why: an open dialog keeps writing to the host its row was loaded from, even if the board's source moves. - const slugSourceSettings = useMemo( - () => (slugCacheKey ? settingsForProjectViewCacheKey(null, slugCacheKey) : null), + const slugSourceTarget = useMemo( + () => (slugCacheKey ? projectViewCacheKeyTarget(slugCacheKey) : null), [slugCacheKey] ) @@ -66,7 +66,7 @@ export default function ProjectViewWrapper({ rowActions.setSlugDialog(null)} /> ) if (visibleTable.selectedView.layout === 'ROADMAP_LAYOUT') { diff --git a/src/renderer/src/components/github-project/project-picker-runtime.ts b/src/renderer/src/components/github-project/project-picker-runtime.ts index b3efed48defc..d97576ce65a1 100644 --- a/src/renderer/src/components/github-project/project-picker-runtime.ts +++ b/src/renderer/src/components/github-project/project-picker-runtime.ts @@ -1,5 +1,5 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { githubProjectHost } from '../../../../shared/github/project-identity' import type { GitHubProjectOwnerType } from '../../../../shared/github/project-types' import type { @@ -8,14 +8,8 @@ import type { ResolveProjectRefResult } from '../../../../shared/github/project-result-types' -/** The board's row-less source host, from `getTaskSourceRuntimeSettings`; never the focused server. */ -export type ProjectSourceSettings = Pick - -export function getProjectPickerRuntimeScope( - settings: ProjectSourceSettings, - host: string -): string { - const target = getActiveRuntimeTarget(settings) +/** `target` is the board's row-less source host, never the focused server. */ +export function getProjectPickerRuntimeScope(target: RuntimeClientTarget, host: string): string { const runtimeScope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' return `${runtimeScope}\0${host.toLowerCase()}` } @@ -25,10 +19,9 @@ export function getProjectPickerBrowseHost(activeProject: { host?: string } | nu } export async function listAccessibleProjectsForRuntime( - settings: ProjectSourceSettings, + target: RuntimeClientTarget, host: string ): Promise { - const target = getActiveRuntimeTarget(settings) const args = { host } return target.kind === 'environment' ? callRuntimeRpc(target, 'github.project.listAccessible', args, { @@ -38,7 +31,7 @@ export async function listAccessibleProjectsForRuntime( } export async function listProjectViewsForRuntime( - settings: ProjectSourceSettings, + target: RuntimeClientTarget, args: { owner: string ownerType: GitHubProjectOwnerType @@ -46,7 +39,6 @@ export async function listProjectViewsForRuntime( host?: string } ): Promise { - const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc(target, 'github.project.listViews', args, { timeoutMs: 30_000 @@ -55,11 +47,10 @@ export async function listProjectViewsForRuntime( } export async function resolveProjectRefForRuntime( - settings: ProjectSourceSettings, + target: RuntimeClientTarget, input: string, host?: string ): Promise { - const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, diff --git a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx index cccdefb3f31b..7a4a3fee9de9 100644 --- a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx @@ -1,8 +1,8 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React, { useMemo, useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' export function AssigneesEditor({ @@ -11,7 +11,7 @@ export function AssigneesEditor({ host, selected, disabled, - sourceSettings, + sourceTarget, onChange }: { owner: string @@ -19,7 +19,7 @@ export function AssigneesEditor({ host?: string selected: string[] disabled?: boolean - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget onChange: (add: string[], remove: string[]) => void | Promise }): React.JSX.Element { const [open, setOpen] = useState(false) @@ -31,7 +31,7 @@ export function AssigneesEditor({ open ? owner : null, open ? repo : null, seedKey ? seedKey.split(',') : [], - sourceSettings, + sourceTarget, host ) return ( diff --git a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx index 998ea5dd4447..88f326adc583 100644 --- a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx @@ -1,60 +1,40 @@ -import React, { useMemo, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import React, { useState } from 'react' import { Send } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { useAppStore } from '@/store' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getCommentBodySubmitState, hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' -import { useRepoSlugIndex } from '@/lib/repo-slug-index' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import type { PRComment } from '../../../../../shared/github/comment-types' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { GitHubProjectCommentMutationResult, GitHubProjectMutationResult } from '../../../../../shared/github/project-result-types' import { translate } from '@/i18n/i18n' -function getRuntimeTarget(settings: Parameters[0]) { - const target = getActiveRuntimeTarget(settings) +function getRuntimeTarget(target: RuntimeClientTarget) { return target.kind === 'environment' ? target : null } -function useRuntimeSettingsForSlug(owner: string, repo: string, host?: string) { - const { lookupSlug } = useRepoSlugIndex() - const matchedRepo = useMemo( - () => lookupSlug(`${owner}/${repo}`, host)[0] ?? null, - [lookupSlug, owner, repo, host] - ) - return useAppStore( - useShallow((s) => - matchedRepo ? getSettingsForRepoRuntimeOwner(s, matchedRepo.id) : s.settings - ) - ) -} - export function CommentsList({ owner, repo, host, comments, - sourceSettings, + sourceTarget, onChange }: { owner: string repo: string host?: string comments: PRComment[] - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget onChange: (next: PRComment[]) => void }): React.JSX.Element { - const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo, host) - const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings return (
{comments.length === 0 ? ( @@ -72,7 +52,7 @@ export function CommentsList({ repo={repo} comment={c} onDelete={async () => { - const target = getRuntimeTarget(runtimeSettings) + const target = getRuntimeTarget(sourceTarget) const args = { owner, repo, @@ -94,7 +74,7 @@ export function CommentsList({ onChange(comments.filter((x) => x.id !== c.id)) }} onEdit={async (next) => { - const target = getRuntimeTarget(runtimeSettings) + const target = getRuntimeTarget(sourceTarget) const args = { owner, repo, @@ -194,20 +174,18 @@ export function NewCommentForm({ repo, host, number, - sourceSettings, + sourceTarget, onAdded }: { owner: string repo: string host?: string number: number - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget onAdded: (c: PRComment) => void }): React.JSX.Element { const [draft, setDraft] = useState('') const [submitting, setSubmitting] = useState(false) - const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo, host) - const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings const canSubmitComment = hasBoundedCommentBodyText(draft) return (
@@ -240,7 +218,7 @@ export function NewCommentForm({ } setSubmitting(true) try { - const target = getRuntimeTarget(runtimeSettings) + const target = getRuntimeTarget(sourceTarget) const args = { owner, repo, diff --git a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx index 96d64b7ad2ac..a807fef63629 100644 --- a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx @@ -1,8 +1,8 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React, { useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' export function LabelsEditor({ @@ -11,7 +11,7 @@ export function LabelsEditor({ host, selected, disabled, - sourceSettings, + sourceTarget, onChange }: { owner: string @@ -19,16 +19,11 @@ export function LabelsEditor({ host?: string selected: string[] disabled?: boolean - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget onChange: (add: string[], remove: string[]) => void | Promise }): React.JSX.Element { const [open, setOpen] = useState(false) - const metadata = useRepoLabelsBySlug( - open ? owner : null, - open ? repo : null, - sourceSettings, - host - ) + const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceTarget, host) return ( !disabled && setOpen(o)}> diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index e04c31299588..f3420d1af9fe 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -1,4 +1,5 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: slug dialog details are loaded through GitHub IPC and must clear stale request state before each lookup. */ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { CircleDot, ExternalLink, GitPullRequest, LoaderCircle, X } from 'lucide-react' import { toast } from 'sonner' @@ -6,9 +7,8 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' import { LabelsEditor } from './LabelsEditor' import { AssigneesEditor } from './AssigneesEditor' @@ -17,11 +17,11 @@ import { translate } from '@/i18n/i18n' export function SlugDialogBody({ projectOrigin, - sourceSettings, + sourceTarget, onClose }: { projectOrigin: GitHubItemDialogProjectOrigin - sourceSettings: Pick | null | undefined + sourceTarget: RuntimeClientTarget onClose: () => void }): React.JSX.Element { const { owner, repo, host, number, type, cacheKey } = projectOrigin @@ -56,7 +56,7 @@ export function SlugDialogBody({ setLoading(true) setError(null) setDetails(null) - const target = getActiveRuntimeTarget(sourceSettings) + const target = sourceTarget const request = target.kind === 'environment' ? callRuntimeRpc< @@ -91,7 +91,7 @@ export function SlugDialogBody({ } setLoading(false) }) - }, [owner, repo, host, number, type, sourceSettings]) + }, [owner, repo, host, number, type, sourceTarget]) const title = row?.content.title ?? details?.item.title ?? '' const url = row?.content.url ?? details?.item.url ?? null @@ -224,7 +224,7 @@ export function SlugDialogBody({ host={host} selected={labels} disabled={!row} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} onChange={async (add, remove) => { // Why: bail rather than call the helper with an empty id — // see commitTitle above. Trigger is also disabled when !row. @@ -246,7 +246,7 @@ export function SlugDialogBody({ host={host} selected={assignees} disabled={!row} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} onChange={async (add, remove) => { if (!row) { return @@ -341,7 +341,7 @@ export function SlugDialogBody({ repo={repo} host={host} comments={details.comments} - sourceSettings={sourceSettings} + sourceTarget={sourceTarget} onChange={(next) => setDetails((d) => (d ? { ...d, comments: next } : d))} /> setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))} /> diff --git a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts index 0c49c6370021..4bc6360ce290 100644 --- a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts +++ b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { useMountedRef } from '@/hooks/useMountedRef' import type { GitHubProjectSummary } from '../../../../shared/github/project-types' @@ -9,12 +10,11 @@ import { } from './project-picker-browse-cache' import { getProjectPickerRuntimeScope, - listAccessibleProjectsForRuntime, - type ProjectSourceSettings + listAccessibleProjectsForRuntime } from './project-picker-runtime' export function useProjectPickerBrowse( - settings: ProjectSourceSettings, + settings: RuntimeClientTarget, browseHost: string ): { browseProjects: GitHubProjectSummary[] diff --git a/src/renderer/src/components/github-project/useProjectViewTable.ts b/src/renderer/src/components/github-project/useProjectViewTable.ts index 5fbd9ee5f088..bf2f5f35d8f2 100644 --- a/src/renderer/src/components/github-project/useProjectViewTable.ts +++ b/src/renderer/src/components/github-project/useProjectViewTable.ts @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' @@ -18,7 +19,7 @@ import { githubProjectIdentityKey } from '../../../../shared/github/project-identity' import type { ResolvedProjectSelection } from './project-picker-selection' -import { listProjectViewsForRuntime, type ProjectSourceSettings } from './project-picker-runtime' +import { listProjectViewsForRuntime } from './project-picker-runtime' import { filterProjectTableRowsBySelectedRepos } from './project-row-filtering' import { getNextVisibleProjectTableCache, @@ -30,7 +31,7 @@ import { useRepoSlugIndex } from '@/lib/repo-slug-index' export function useProjectViewTable( selectedRepoIds: ReadonlySet, - sourceSettings: ProjectSourceSettings + sourceTarget: RuntimeClientTarget ) { const settings = useAppStore((state) => state.settings) const projectViewCache = useAppStore((state) => state.projectViewCache) @@ -39,7 +40,7 @@ export function useProjectViewTable( const lastViewByProject = settings?.githubProjects?.lastViewByProject ?? {} const { lookupSlugMatches, ready: slugIndexReady } = useRepoSlugIndex() const mountedRef = useMountedRef() - const sourceScope = projectViewSourceScope(sourceSettings) + const sourceScope = projectViewSourceScope(sourceTarget) const [loading, setLoading] = useState(false) const [error, setError] = useState<{ error: GitHubProjectViewError; totalCount?: number } | null>( null @@ -58,7 +59,7 @@ export function useProjectViewTable( setError(null) try { const result: GetProjectViewTableResult = await fetchProjectViewTable( - sourceSettings, + sourceTarget, { owner: selection.owner, ownerType: selection.ownerType, @@ -81,7 +82,7 @@ export function useProjectViewTable( } } }, - [fetchProjectViewTable, mountedRef, sourceSettings] + [fetchProjectViewTable, mountedRef, sourceTarget] ) const projectIdentity = activeProject ? githubProjectIdentityKey(activeProject) : null @@ -131,7 +132,7 @@ export function useProjectViewTable( return } let cancelled = false - void listProjectViewsForRuntime(sourceSettings, { + void listProjectViewsForRuntime(sourceTarget, { owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, @@ -152,7 +153,7 @@ export function useProjectViewTable( return () => { cancelled = true } - }, [activeProject, sourceSettings, sourceScope, viewListByProject]) + }, [activeProject, sourceTarget, sourceScope, viewListByProject]) const selectedRepoFingerprint = useMemo( () => getSelectedRepoFingerprint(selectedRepoIds), @@ -235,7 +236,7 @@ export function useProjectViewTable( ) return { - sourceSettings, + sourceTarget, activeProject, sourceScope, viewId, diff --git a/src/renderer/src/components/github/PRAssigneesPanel.tsx b/src/renderer/src/components/github/PRAssigneesPanel.tsx index a2744e82d2b2..eae876ed1501 100644 --- a/src/renderer/src/components/github/PRAssigneesPanel.tsx +++ b/src/renderer/src/components/github/PRAssigneesPanel.tsx @@ -8,7 +8,7 @@ import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { useImmediateMutation, useRepoAssignees } from '@/hooks/useIssueMetadata' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { parseOwnerRepoFromItemUrl, resolvePullRequestRepo, @@ -44,8 +44,8 @@ export function PRAssigneesPanel({ })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, item.repoId ?? null, sourceContext)) ) const { isPending, run } = useImmediateMutation() @@ -77,10 +77,10 @@ export function PRAssigneesPanel({ slugOwner, slugRepo, assigneeLogins, - sourceSettings, + ownerTarget, projectOrigin?.host ?? assigneeSlug?.host ) - const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) + const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, { target: ownerTarget }) const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath const canEditAssignees = Boolean(projectOrigin || repoPath) const assigneesByLogin = useMemo( diff --git a/src/renderer/src/components/github/PRFilterDropdowns.tsx b/src/renderer/src/components/github/PRFilterDropdowns.tsx index ddf74929515c..8159f74c6223 100644 --- a/src/renderer/src/components/github/PRFilterDropdowns.tsx +++ b/src/renderer/src/components/github/PRFilterDropdowns.tsx @@ -19,7 +19,7 @@ import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { ParsedTaskQuery } from '../../../../shared/task-query' import { translate } from '@/i18n/i18n' @@ -31,7 +31,8 @@ type Props = { // Cross-repo filters can't enumerate every repo's labels in one popover — // GitHub's own PR list scopes the dropdown to a single repo too. primarySlug: GitHubOwnerRepo | null - settings?: Pick | null + /** Host that owns the repo the primary slug came from. */ + ownerTarget?: RuntimeClientTarget | null onChange: (change: PRFilterChange) => void } @@ -75,7 +76,7 @@ export default function PRFilterDropdowns({ kind, authorLogins, primarySlug, - settings, + ownerTarget, onChange }: Props): React.JSX.Element { const [openSection, setOpenSection] = useState(null) @@ -86,14 +87,14 @@ export default function PRFilterDropdowns({ const labelsState = useRepoLabelsBySlug( popoverOpen ? owner : null, popoverOpen ? repo : null, - settings, + ownerTarget, primarySlug?.host ) const assigneesState = useRepoAssigneesBySlug( popoverOpen ? owner : null, popoverOpen ? repo : null, undefined, - settings, + ownerTarget, primarySlug?.host ) diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx index 17d81a90532e..ae921d39ff34 100644 --- a/src/renderer/src/components/github/github-rate-limit-display.tsx +++ b/src/renderer/src/components/github/github-rate-limit-display.tsx @@ -2,8 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { Gauge, RefreshCw } from 'lucide-react' import { cn } from '@/lib/utils' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GetRateLimitResult, GitHubRateLimitSnapshot @@ -75,7 +75,10 @@ export function toneForGitHubBucket(remaining: number, limit: number): 'ok' | 'w return 'ok' } -export function useGitHubRateLimitSnapshot(options?: { autoRefresh?: boolean }): { +export function useGitHubRateLimitSnapshot( + target: RuntimeClientTarget, + options?: { autoRefresh?: boolean } +): { snapshot: GitHubRateLimitSnapshot | null hasError: boolean isFetching: boolean @@ -84,7 +87,6 @@ export function useGitHubRateLimitSnapshot(options?: { autoRefresh?: boolean }): const [snapshot, setSnapshot] = useState(null) const [hasError, setHasError] = useState(false) const [isFetching, setIsFetching] = useState(false) - const settings = useAppStore((s) => s.settings) const latestToken = useRef(0) const autoRefresh = options?.autoRefresh ?? true @@ -93,7 +95,6 @@ export function useGitHubRateLimitSnapshot(options?: { autoRefresh?: boolean }): const token = ++latestToken.current setIsFetching(true) try { - const target = getActiveRuntimeTarget(settings) const params = force ? { force: true } : undefined const res = target.kind === 'environment' @@ -120,7 +121,7 @@ export function useGitHubRateLimitSnapshot(options?: { autoRefresh?: boolean }): } } }, - [settings] + [target] ) useEffect(() => { @@ -172,10 +173,16 @@ function GitHubRateLimitRows({ ) } -export function GitHubRateLimitPanel({ className }: { className?: string }): React.JSX.Element { - const { snapshot, hasError, isFetching, refresh } = useGitHubRateLimitSnapshot() - const settings = useAppStore((s) => s.settings) - const budgetScope = getProviderRateLimitScope(settings, 'GitHub') +export function GitHubRateLimitPanel({ + className, + target +}: { + className?: string + /** The host chosen at the top of Settings. */ + target: RuntimeClientTarget +}): React.JSX.Element { + const { snapshot, hasError, isFetching, refresh } = useGitHubRateLimitSnapshot(target) + const budgetScope = getProviderRateLimitScope(target, 'GitHub') return (
diff --git a/src/renderer/src/components/github/github-work-item-edit-mutations.ts b/src/renderer/src/components/github/github-work-item-edit-mutations.ts index d79334fbbe3c..0726d309deba 100644 --- a/src/renderer/src/components/github/github-work-item-edit-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-edit-mutations.ts @@ -1,27 +1,35 @@ import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { - getGitHubRepoRoutingSettings, + getGitHubRepoRoutingTarget, getGitHubRuntimeRepoId, getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' +import { taskSourceRuntimeTarget } from '@/lib/task-source-runtime-target' +import { projectViewCacheKeyTarget } from '@/store/github/cache-identity' import type { GitHubWorkItemProjectOrigin } from '@/components/github/github-work-item-identity' import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations' import { githubProjectHost } from '../../../../shared/github/project-identity' -import { - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../../shared/task-source-context' +import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' // Why: for a Project row whose repo differs from the active workspace, mutations must target the row's actual repo via slug-addressed IPCs, else edits silently apply to the workspace's repo. // Why: these edit IPCs return `{ ok, error }`; callers throw on `!ok` so useImmediateMutation (which expects throws on failure) works unchanged. -export function getGitHubMutationSettings(repoId: string | null | undefined) { - const state = useAppStore.getState() +export function getGitHubMutationTarget( + repoId: string | null | undefined, + projectOrigin: GitHubWorkItemProjectOrigin | undefined +): RuntimeClientTarget { // Why: even slug-addressed project-origin mutations must run on the backing repo's owner host when its id is known. - return getSettingsForRepoRuntimeOwner(state, repoId ?? null) + const owner = runtimeTargetForRepoOwner(useAppStore.getState(), repoId) + if (owner) { + return owner + } + // Why: an untracked repo's Project row goes to the host that loaded the row; any other + // slug-addressed edit uses this computer's credentials, never the focused server. + return projectOrigin ? projectViewCacheKeyTarget(projectOrigin.cacheKey) : { kind: 'local' } } export async function runIssueUpdate(args: { @@ -35,11 +43,10 @@ export async function runIssueUpdate(args: { }): Promise { const issueRepo = args.projectOrigin if (issueRepo) { - const targetSettings = + const target = args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.repoId) - const target = getActiveRuntimeTarget(targetSettings) + ? taskSourceRuntimeTarget(args.sourceContext) + : getGitHubMutationTarget(args.repoId, args.projectOrigin) const updateArgs = { owner: issueRepo.owner, repo: issueRepo.repo, @@ -132,11 +139,10 @@ export async function runWorkItemBodyUpdate(args: { if (!targetSlug) { throw new Error('No GitHub repository context available for this pull request.') } - const targetSettings = + const target = args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.item.repoId) - const target = getActiveRuntimeTarget(targetSettings) + ? taskSourceRuntimeTarget(args.sourceContext) + : getGitHubMutationTarget(args.item.repoId, args.projectOrigin) const updateArgs = { owner: targetSlug.owner, repo: targetSlug.repo, @@ -194,11 +200,10 @@ export async function runPullRequestStateUpdate(args: { updates: { state: 'open' | 'closed' } }): Promise { if (args.projectOrigin) { - const targetSettings = + const target = args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.repoId) - const target = getActiveRuntimeTarget(targetSettings) + ? taskSourceRuntimeTarget(args.sourceContext) + : getGitHubMutationTarget(args.repoId, args.projectOrigin) const updateArgs = { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo, @@ -235,9 +240,7 @@ export async function runPullRequestStateUpdate(args: { return } // Why: close/reopen must route by the repo owner host like merge (#6957). - const target = getActiveRuntimeTarget( - getGitHubRepoRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext) - ) + const target = getGitHubRepoRoutingTarget(useAppStore.getState(), args.repoId, args.sourceContext) if (!args.repoPath && target.kind !== 'environment') { throw new Error('No repo context available for this pull request.') } diff --git a/src/renderer/src/components/github/github-work-item-mutation-target.test.ts b/src/renderer/src/components/github/github-work-item-mutation-target.test.ts new file mode 100644 index 000000000000..8d3ca532545b --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-mutation-target.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' + +const store = vi.hoisted(() => ({ + state: { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'tracked', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + } +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store.state } })) + +import { getGitHubMutationTarget } from './github-work-item-edit-mutations' + +const projectOrigin = { + owner: 'acme', + repo: 'widgets', + number: 1, + type: 'issue' as const, + projectId: 'p', + projectItemId: 'i', + cacheKey: 'github-project:runtime:board-runtime:org/acme/1:view' +} + +describe('getGitHubMutationTarget', () => { + it("routes a tracked repo's edit to the repo owner", () => { + expect(getGitHubMutationTarget('tracked', projectOrigin)).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + }) + + it('sends an untracked Project row to the host that loaded it, not the focused server', () => { + expect(getGitHubMutationTarget('untracked', projectOrigin)).toEqual({ + kind: 'environment', + environmentId: 'board-runtime' + }) + }) + + it("uses this computer's credentials for other slug edits with no known repo", () => { + expect(getGitHubMutationTarget(null, undefined)).toEqual({ kind: 'local' }) + }) +}) diff --git a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx index 66e18cc4fca1..41cf6eb07120 100644 --- a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx +++ b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx @@ -3,8 +3,8 @@ import { Gauge, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GetGitLabRateLimitResult, GitLabRateLimitSnapshot @@ -41,7 +41,10 @@ export function toneForGitLabBucket(remaining: number, limit: number): 'ok' | 'w return 'ok' } -export function useGitLabRateLimitSnapshot(options?: { autoRefresh?: boolean }): { +export function useGitLabRateLimitSnapshot( + target: RuntimeClientTarget, + options?: { autoRefresh?: boolean } +): { snapshot: GitLabRateLimitSnapshot | null hasError: boolean isFetching: boolean @@ -50,7 +53,6 @@ export function useGitLabRateLimitSnapshot(options?: { autoRefresh?: boolean }): const [snapshot, setSnapshot] = useState(null) const [hasError, setHasError] = useState(false) const [isFetching, setIsFetching] = useState(false) - const settings = useAppStore((s) => s.settings) const latestToken = useRef(0) const autoRefresh = options?.autoRefresh ?? true @@ -59,7 +61,6 @@ export function useGitLabRateLimitSnapshot(options?: { autoRefresh?: boolean }): const token = ++latestToken.current setIsFetching(true) try { - const target = getActiveRuntimeTarget(settings) const params = force ? { force: true } : undefined const res = target.kind === 'environment' @@ -89,7 +90,7 @@ export function useGitLabRateLimitSnapshot(options?: { autoRefresh?: boolean }): } } }, - [settings] + [target] ) useEffect(() => { @@ -149,10 +150,16 @@ function GitLabRateLimitRows({ ) } -export function GitLabRateLimitPanel({ className }: { className?: string }): React.JSX.Element { - const { snapshot, hasError, isFetching, refresh } = useGitLabRateLimitSnapshot() - const settings = useAppStore((s) => s.settings) - const budgetScope = getProviderRateLimitScope(settings, 'GitLab') +export function GitLabRateLimitPanel({ + className, + target +}: { + className?: string + /** The host chosen at the top of Settings. */ + target: RuntimeClientTarget +}): React.JSX.Element { + const { snapshot, hasError, isFetching, refresh } = useGitLabRateLimitSnapshot(target) + const budgetScope = getProviderRateLimitScope(target, 'GitLab') return (
diff --git a/src/renderer/src/components/pull-request-page/actions/panel.tsx b/src/renderer/src/components/pull-request-page/actions/panel.tsx index c6505c3c3273..173dac2afca3 100644 --- a/src/renderer/src/components/pull-request-page/actions/panel.tsx +++ b/src/renderer/src/components/pull-request-page/actions/panel.tsx @@ -21,8 +21,7 @@ import { import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { resolveGitHubPRMergeMethods } from '../../../../../shared/github/pull-request-merge-methods' import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity' @@ -60,10 +59,9 @@ export function PRActionsPanel({ const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)) + const mergeTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, item.repoId ?? repoId ?? null, sourceContext)) ) - const mergeTarget = getActiveRuntimeTarget(sourceSettings) const prRepo = resolvePullRequestRepo(item, projectOrigin) const canMutateWithRepoContext = !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' diff --git a/src/renderer/src/components/pull-request-page/conversation/tab.tsx b/src/renderer/src/components/pull-request-page/conversation/tab.tsx index 74d82f78df37..7de837420af3 100644 --- a/src/renderer/src/components/pull-request-page/conversation/tab.tsx +++ b/src/renderer/src/components/pull-request-page/conversation/tab.tsx @@ -6,7 +6,7 @@ import { useAppStore } from '@/store' import { useRepoAssignees } from '@/hooks/useIssueMetadata' import { canUseGitHubRepoContext, - getGitHubRepoRoutingSettings + getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { @@ -103,10 +103,10 @@ export function ConversationTab({ const bodyTextareaRef = useRef(null) const bodyTextareaFocusFrameRef = useRef(null) const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, item.repoId ?? repoId ?? null, sourceContext)) ) - const repoAssignees = useRepoAssignees(repoPath, item.repoId, sourceSettings) + const repoAssignees = useRepoAssignees(repoPath, item.repoId, { target: ownerTarget }) const botAuthorOverrides = usePRBotAuthorOverrides() const commentCounts = useMemo( () => getPRCommentAudienceCounts(comments, botAuthorOverrides), diff --git a/src/renderer/src/components/pull-request-page/edit/section.tsx b/src/renderer/src/components/pull-request-page/edit/section.tsx index 2da1ec96bff8..b43f5ff0c929 100644 --- a/src/renderer/src/components/pull-request-page/edit/section.tsx +++ b/src/renderer/src/components/pull-request-page/edit/section.tsx @@ -9,7 +9,7 @@ import { useImmediateMutation, useRepoAssignees, useRepoLabels } from '@/hooks/u import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' import type { TaskSourceContext } from '../../../../../shared/task-source-context' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { getStateLabel } from '@/components/github/work-item-state-presentation' import { translate } from '@/i18n/i18n' import type { PullRequestPageProjectOrigin } from '../page-types' @@ -51,8 +51,8 @@ export function GHEditSection({ const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, item.repoId ?? repoId ?? null, sourceContext)) ) const { isPending, run } = useImmediateMutation() // Why: patchWorkItem only updates workItemsCache; Project-view rows also need projectViewCache patched or the table stays stale. See docs/design/github-project-view-tasks.md. @@ -72,25 +72,25 @@ export function GHEditSection({ const repoLabelsByPath = useRepoLabels( projectOrigin ? null : repoPath, projectOrigin ? null : repoId, - sourceSettings + { target: ownerTarget } ) const repoLabelsBySlug = useRepoLabelsBySlug( slugOwner, slugRepo, - sourceSettings, + ownerTarget, projectOrigin?.host ) const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath const repoAssigneesByPath = useRepoAssignees( projectOrigin ? null : repoPath, projectOrigin ? null : repoId, - sourceSettings + { target: ownerTarget } ) const repoAssigneesBySlug = useRepoAssigneesBySlug( slugOwner, slugRepo, assignees, - sourceSettings, + ownerTarget, projectOrigin?.host ) const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath diff --git a/src/renderer/src/components/pull-request-page/reviewers/panel.tsx b/src/renderer/src/components/pull-request-page/reviewers/panel.tsx index 7862e7112707..81f50429a273 100644 --- a/src/renderer/src/components/pull-request-page/reviewers/panel.tsx +++ b/src/renderer/src/components/pull-request-page/reviewers/panel.tsx @@ -6,8 +6,7 @@ import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import type { TaskSourceContext } from '../../../../../shared/task-source-context' import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity' import { getGitHubPRReviewerRows } from '@/components/github-pr-reviewer-display' import { mergeReviewerSuggestions } from '@/components/github/work-item-state-presentation' @@ -52,8 +51,8 @@ export function PRReviewersPanel({ reviewRequests: item.reviewRequests })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, item.repoId ?? null, sourceContext)) ) const submittingRef = useRef(false) const reviewerInputRef = useRef(null) @@ -132,13 +131,13 @@ export function PRReviewersPanel({ open && reviewRepo ? reviewRepo.owner : null, open && reviewRepo ? reviewRepo.repo : null, reviewerSeedUsers.map((user) => user.login), - sourceSettings, + ownerTarget, reviewRepo?.host ) const reviewerMetadataByPath = useRepoAssignees( open && !reviewRepo ? repoPath : null, open && !reviewRepo ? item.repoId : null, - sourceSettings + { target: ownerTarget } ) const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath const displayItem = { ...item, reviewRequests: localReviewRequests } @@ -228,8 +227,7 @@ export function PRReviewersPanel({ localReviewRequests.length > 0 || item.reviewRequests !== undefined || item.latestReviews !== undefined - const canRequestReview = - !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' + const canRequestReview = !!repoPath || ownerTarget.kind === 'environment' const { handleRequestReview, handleRemoveReviewers, requestReviewer } = createReviewerRequestActions({ @@ -240,7 +238,7 @@ export function PRReviewersPanel({ selectedReviewerLogins, localReviewRequests, setLocalReviewRequests, - sourceSettings, + ownerTarget, repoPath, sourceContext, item, diff --git a/src/renderer/src/components/pull-request-page/reviewers/request-actions.ts b/src/renderer/src/components/pull-request-page/reviewers/request-actions.ts index be1b02716741..1f6f1cfeaba3 100644 --- a/src/renderer/src/components/pull-request-page/reviewers/request-actions.ts +++ b/src/renderer/src/components/pull-request-page/reviewers/request-actions.ts @@ -1,5 +1,5 @@ import { toast } from 'sonner' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { normalizeGitHubReviewerLogins, parseGitHubReviewerInputLogins @@ -14,7 +14,7 @@ import type { } from '../../../../../shared/github/pull-request-types' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../../../shared/task-source-context' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' const MAX_REQUESTED_REVIEWERS = 15 @@ -27,7 +27,7 @@ type ReviewerRequestActionsArgs = { selectedReviewerLogins: Set localReviewRequests: GitHubAssignableUser[] setLocalReviewRequests: (value: GitHubAssignableUser[]) => void - sourceSettings: Pick + ownerTarget: RuntimeClientTarget repoPath: string | null sourceContext?: TaskSourceContext | null item: GitHubWorkItem @@ -70,7 +70,7 @@ export function createReviewerRequestActions(args: ReviewerRequestActionsArgs): ) return } - const target = getActiveRuntimeTarget(args.sourceSettings) + const target = args.ownerTarget if (target.kind !== 'environment' && !args.repoPath) { toast.error( translate( @@ -171,7 +171,7 @@ export function createReviewerRequestActions(args: ReviewerRequestActionsArgs): if (logins.length === 0) { return } - const target = getActiveRuntimeTarget(args.sourceSettings) + const target = args.ownerTarget if (target.kind !== 'environment' && !args.repoPath) { toast.error( translate( diff --git a/src/renderer/src/components/settings/GitProviderApiBudgetPane.tsx b/src/renderer/src/components/settings/GitProviderApiBudgetPane.tsx index 0d9ad86b1d9c..de03b0bfce1e 100644 --- a/src/renderer/src/components/settings/GitProviderApiBudgetPane.tsx +++ b/src/renderer/src/components/settings/GitProviderApiBudgetPane.tsx @@ -4,13 +4,17 @@ import { matchesSettingsSearch } from './settings-search' import { GitHubRateLimitPanel } from '../github/github-rate-limit-display' import { GitLabRateLimitPanel } from '../gitlab/gitlab-rate-limit-display' import { translate } from '@/i18n/i18n' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' type GitProviderApiBudgetPaneProps = { settingsSearchQuery?: string + /** The host chosen at the top of Settings; budgets come from that host's CLI. */ + target: RuntimeClientTarget } export function GitProviderApiBudgetPane({ - settingsSearchQuery + settingsSearchQuery, + target }: GitProviderApiBudgetPaneProps): React.JSX.Element | null { const storeSearchQuery = useAppStore((s) => s.settingsSearchQuery) const searchQuery = settingsSearchQuery ?? storeSearchQuery @@ -40,7 +44,7 @@ export function GitProviderApiBudgetPane({ keywords={['github', 'gh', 'graphql', 'rate limit', 'api budget']} className="space-y-3" > - + ) : null, matchesSettingsSearch(searchQuery, { @@ -66,7 +70,7 @@ export function GitProviderApiBudgetPane({ keywords={['gitlab', 'glab', 'rate limit', 'api budget']} className="space-y-3" > - + ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/provider-account-scope.test.ts b/src/renderer/src/components/settings/provider-account-scope.test.ts index 3ce78fb1b5ab..7c3e1e3fea46 100644 --- a/src/renderer/src/components/settings/provider-account-scope.test.ts +++ b/src/renderer/src/components/settings/provider-account-scope.test.ts @@ -26,12 +26,14 @@ describe('getProviderAccountScope', () => { }) it('describes provider API budgets as host-scoped', () => { - expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: null }, 'GitHub')).toEqual({ + expect(getProviderRateLimitScope({ kind: 'local' }, 'GitHub')).toEqual({ label: LOCAL_HOST_LABEL, description: 'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.' }) - expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: ' env-1 ' }, 'GitLab')).toEqual({ + expect( + getProviderRateLimitScope({ kind: 'environment', environmentId: 'env-1' }, 'GitLab') + ).toEqual({ label: 'Remote server: env-1', description: 'GitLab API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.' diff --git a/src/renderer/src/components/settings/provider-account-scope.ts b/src/renderer/src/components/settings/provider-account-scope.ts index 61fe161aa36b..cba1e1654078 100644 --- a/src/renderer/src/components/settings/provider-account-scope.ts +++ b/src/renderer/src/components/settings/provider-account-scope.ts @@ -1,6 +1,7 @@ import { translate } from '@/i18n/i18n' import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' export type ProviderAccountScope = { label: string @@ -63,11 +64,11 @@ export function getProviderAccountScope( } export function getProviderRateLimitScope( - settings: Pick | null | undefined, + target: RuntimeClientTarget, providerLabel: string ): ProviderRateLimitScope { - const runtimeId = settings?.activeRuntimeEnvironmentId?.trim() - if (runtimeId) { + if (target.kind === 'environment') { + const runtimeId = target.environmentId return { label: translate( 'auto.components.settings.providerAccountScope.remoteServer', diff --git a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx index 389964b4b559..7b1f0a1bcdc7 100644 --- a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx +++ b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx @@ -27,14 +27,14 @@ vi.mock('@/store', () => ({ })) describe('provider rate-limit panels account scope', () => { - it('shows the local host scope for GitHub API budget', () => { + it('shows the Settings host, not the focused server, for GitHub API budget', () => { mocks.store.current = { - settings: { activeRuntimeEnvironmentId: null }, + settings: { activeRuntimeEnvironmentId: 'runtime-focused' }, openSettingsPage: vi.fn(), openSettingsTarget: vi.fn() } - const markup = renderToStaticMarkup() + const markup = renderToStaticMarkup() expect(markup).toContain(`Budget scope: ${LOCAL_HOST_LABEL}`) expect(markup).toContain( @@ -45,12 +45,14 @@ describe('provider rate-limit panels account scope', () => { it('shows the remote server scope for GitLab API budget', () => { mocks.store.current = { - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + settings: { activeRuntimeEnvironmentId: null }, openSettingsPage: vi.fn(), openSettingsTarget: vi.fn() } - const markup = renderToStaticMarkup() + const markup = renderToStaticMarkup( + + ) expect(markup).toContain('Budget scope: Remote server: runtime-1') expect(markup).toContain( diff --git a/src/renderer/src/components/settings/repository-github-account.ts b/src/renderer/src/components/settings/repository-github-account.ts index ba1b923f49e6..3dbdb3d67ba3 100644 --- a/src/renderer/src/components/settings/repository-github-account.ts +++ b/src/renderer/src/components/settings/repository-github-account.ts @@ -5,9 +5,7 @@ import type { GhAccountBindingValidationResult } from '../../../../shared/github/auth-types' import { ghAccountBindingsEqual } from '../../../../shared/github/account-binding' -import { callRuntimeRpc, type getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' - -type RuntimeTarget = ReturnType +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' const LOCAL_GH_ACCOUNT_IPC_TIMEOUT_MS = 30_000 @@ -31,7 +29,7 @@ function withTimeout(promise: Promise, timeoutMs: number, label: string): /** Routes through RPC for remote environments and IPC locally; both read the repo's execution host. */ export function listRepositoryGhBindableAccounts( - runtimeTarget: RuntimeTarget, + runtimeTarget: RuntimeClientTarget, repo: Pick, options: { refreshCapability?: boolean } = {} ): Promise { @@ -55,7 +53,7 @@ export function listRepositoryGhBindableAccounts( } export function validateRepositoryGhAccountBinding( - runtimeTarget: RuntimeTarget, + runtimeTarget: RuntimeClientTarget, repo: Repo, binding: GhAccountBinding ): Promise { diff --git a/src/renderer/src/components/settings/repository-icon-github.ts b/src/renderer/src/components/settings/repository-icon-github.ts index f3dbee71fe7b..8cd881bf7b37 100644 --- a/src/renderer/src/components/settings/repository-icon-github.ts +++ b/src/renderer/src/components/settings/repository-icon-github.ts @@ -2,9 +2,8 @@ import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-re import type { Repo } from '../../../../shared/repo-types' import { githubAvatarIcon, githubAvatarSlug, type RepoIcon } from '../../../../shared/repo-icon' import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' -import { callRuntimeRpc, type getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -type RuntimeTarget = ReturnType type ResolveRepositoryGitHubAvatarOptions = { forceLive?: boolean } @@ -15,7 +14,7 @@ export type RepositoryGitHubAvatarResolution = { } function resolveRepositoryIdentityLive( - runtimeTarget: RuntimeTarget, + runtimeTarget: RuntimeClientTarget, repo: Repo, method: 'github.repoUpstream' | 'github.repoSlug', localCall: (args: { @@ -34,7 +33,7 @@ function resolveRepositoryIdentityLive( } export function resolveRepositoryUpstreamLive( - runtimeTarget: RuntimeTarget, + runtimeTarget: RuntimeClientTarget, repo: Repo ): Promise { return resolveRepositoryIdentityLive(runtimeTarget, repo, 'github.repoUpstream', (args) => @@ -43,7 +42,7 @@ export function resolveRepositoryUpstreamLive( } function resolveRepositorySlugLive( - runtimeTarget: RuntimeTarget, + runtimeTarget: RuntimeClientTarget, repo: Repo ): Promise { return resolveRepositoryIdentityLive(runtimeTarget, repo, 'github.repoSlug', (args) => @@ -52,7 +51,7 @@ function resolveRepositorySlugLive( } export async function resolveRepositoryGitHubAvatar( - runtimeTarget: RuntimeTarget, + runtimeTarget: RuntimeClientTarget, repo: Repo, options: ResolveRepositoryGitHubAvatarOptions = {} ): Promise { diff --git a/src/renderer/src/components/settings/settings-git-task-section-renderers.tsx b/src/renderer/src/components/settings/settings-git-task-section-renderers.tsx index ad935931e49f..29de49c21435 100644 --- a/src/renderer/src/components/settings/settings-git-task-section-renderers.tsx +++ b/src/renderer/src/components/settings/settings-git-task-section-renderers.tsx @@ -39,7 +39,10 @@ export function renderGitSettingsSection(context: SettingsRenderContext): React. customPromptDiscardSignal={model.sourceControlAiPromptDiscardSignal} settingsSearchQuery={model.settingsSearchQuery} /> - + ) : null} diff --git a/src/renderer/src/components/task-page-github-reviewer-actions.ts b/src/renderer/src/components/task-page-github-reviewer-actions.ts index b42557428aab..11c2cdcee924 100644 --- a/src/renderer/src/components/task-page-github-reviewer-actions.ts +++ b/src/renderer/src/components/task-page-github-reviewer-actions.ts @@ -1,7 +1,8 @@ import type { Dispatch, SetStateAction } from 'react' import { translate } from '@/i18n/i18n' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { TaskPageGitHubMutationIntent } from '@/components/task-page-github-work-item-mutation-patches' import { normalizeGitHubReviewerLogins, @@ -32,7 +33,7 @@ type ReviewerActionsInput = { setReviewerInput: Dispatch> setSubmitting: Dispatch> sourceContext?: TaskSourceContext | null - sourceSettings: Parameters[0] + ownerTarget: RuntimeClientTarget submitting: boolean workItemMutation: TaskPageGitHubWorkItemMutationRunner } @@ -50,7 +51,7 @@ export function createTaskPageGitHubReviewerActions({ setReviewerInput, setSubmitting, sourceContext, - sourceSettings, + ownerTarget, submitting, workItemMutation }: ReviewerActionsInput): { @@ -102,7 +103,7 @@ export function createTaskPageGitHubReviewerActions({ successToast: translate('auto.components.TaskPage.8f06dbb9e5', 'Reviewer requested'), errorToast: translate('auto.components.TaskPage.dc67f69962', 'Failed to request reviewer'), mutate: async () => { - const target = getActiveRuntimeTarget(sourceSettings) + const target = ownerTarget const runtimeRepoId = sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id return target.kind === 'environment' @@ -182,7 +183,7 @@ export function createTaskPageGitHubReviewerActions({ : translate('auto.components.TaskPage.837bb901ec', 'Reviewers removed'), errorToast: translate('auto.components.TaskPage.ed1daeb49a', 'Failed to remove reviewer'), mutate: async () => { - const target = getActiveRuntimeTarget(sourceSettings) + const target = ownerTarget const runtimeRepoId = sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id return target.kind === 'environment' diff --git a/src/renderer/src/components/task-page/github/AssigneesCell.tsx b/src/renderer/src/components/task-page/github/AssigneesCell.tsx index 0073d9d83929..bd4e6f9e406a 100644 --- a/src/renderer/src/components/task-page/github/AssigneesCell.tsx +++ b/src/renderer/src/components/task-page/github/AssigneesCell.tsx @@ -5,11 +5,11 @@ import type { TaskSourceContext } from '../../../../../shared/task-source-contex import React, { useMemo, useState, useCallback } from 'react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { parseGitHubIssueOrPRLink } from '@/lib/github-links' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { translate } from '@/i18n/i18n' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { githubProjectHost } from '../../../../../shared/github/project-identity' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/utils' @@ -28,8 +28,8 @@ export function GHAssigneesCell({ sourceContext?: TaskSourceContext | null workItemMutation: TaskPageGitHubWorkItemMutationRunner }): React.JSX.Element { - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, repo?.id ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, repo?.id ?? null, sourceContext)) ) const [open, setOpen] = useState(false) const [pendingLogin, setPendingLogin] = useState(null) @@ -49,7 +49,7 @@ export function GHAssigneesCell({ open ? owner : null, open ? repoName : null, seedLogins, - sourceSettings, + ownerTarget, parsed?.slug.host ) const toggleAssignee = useCallback( @@ -92,7 +92,7 @@ export function GHAssigneesCell({ : { addAssignees: [user.login] } - const target = getActiveRuntimeTarget(sourceSettings) + const target = ownerTarget if (owner && repoName) { const args = { owner, @@ -161,7 +161,7 @@ export function GHAssigneesCell({ repo, repoName, sourceContext, - sourceSettings, + ownerTarget, workItemMutation ] ) diff --git a/src/renderer/src/components/task-page/github/Filters.tsx b/src/renderer/src/components/task-page/github/Filters.tsx index a021c0411004..3733a073cd74 100644 --- a/src/renderer/src/components/task-page/github/Filters.tsx +++ b/src/renderer/src/components/task-page/github/Filters.tsx @@ -9,6 +9,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip import { Button } from '@/components/ui/button' import { resolveNewIssueOpenSeed } from '@/components/task-page-new-issue-draft' import { useAppStore } from '@/store' +import { useShallow } from 'zustand/react/shallow' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { hasUpstreamCandidateDivergence, hasDivergentSources } from '../../task-page-draft-storage' import IssueSourceIndicator from '@/components/github/IssueSourceIndicator' import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' @@ -19,7 +21,6 @@ export function TaskPageGitHubFilters({ model: TaskPageComposerActionsModel }): React.JSX.Element | null { const { - settings, setTaskResumeState, setIssueSourcePreference, selectedRepos, @@ -51,6 +52,14 @@ export function TaskPageGitHubFilters({ handleTaskSearchKeyDown, githubTasksBusy } = model + // Why: filter metadata for the primary slug is read from the host that owns the repo it came from. + const primaryGithubFilterRepoId = + perRepoSourceState.find((state) => + activeGithubTaskKind === 'prs' ? state.sources?.prs : state.sources?.issues + )?.repoId ?? null + const primaryGithubFilterTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, primaryGithubFilterRepoId, null)) + ) return ( // Why: top of the joined GitHub list card — pairs with the // table shell below (rounded-t-none border-t-0) as one surface. @@ -98,7 +107,7 @@ export function TaskPageGitHubFilters({ kind={activeGithubTaskKind} authorLogins={loadedGitHubAuthorLogins} primarySlug={primaryGithubFilterSlug} - settings={settings} + ownerTarget={primaryGithubFilterTarget} onChange={(change) => applyPRFilterChange(change)} />
diff --git a/src/renderer/src/components/task-page/github/MergeCell.tsx b/src/renderer/src/components/task-page/github/MergeCell.tsx index 745f674004f5..167da2cd07cb 100644 --- a/src/renderer/src/components/task-page/github/MergeCell.tsx +++ b/src/renderer/src/components/task-page/github/MergeCell.tsx @@ -6,14 +6,14 @@ import React, { useState } from 'react' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { translate } from '@/i18n/i18n' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { resolveGitHubPRMergeMethods, GITHUB_PR_MERGE_METHOD_LABELS } from '../../../../../shared/github/pull-request-merge-methods' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { DropdownMenu, DropdownMenuTrigger, @@ -39,8 +39,8 @@ export function PRMergeCell({ }): React.JSX.Element { const [merging, setMerging] = useState(false) const confirm = useConfirmationDialog() - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, repo?.id ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, repo?.id ?? null, sourceContext)) ) if (item.type !== 'pr') { return ( @@ -104,7 +104,7 @@ export function PRMergeCell({ 'Failed to merge pull request' ), mutate: async () => { - const target = getActiveRuntimeTarget(sourceSettings) + const target = ownerTarget const runtimeRepoId = sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id return target.kind === 'environment' @@ -159,7 +159,7 @@ export function PRMergeCell({ ? translate('auto.components.TaskPage.a3318684bc', 'Failed to enable auto-merge') : translate('auto.components.TaskPage.1a9ea003dc', 'Failed to disable auto-merge'), mutate: async () => { - const target = getActiveRuntimeTarget(sourceSettings) + const target = ownerTarget const runtimeRepoId = sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id return target.kind === 'environment' diff --git a/src/renderer/src/components/task-page/github/ReviewCell.tsx b/src/renderer/src/components/task-page/github/ReviewCell.tsx index d795cb920281..30df50d97ef5 100644 --- a/src/renderer/src/components/task-page/github/ReviewCell.tsx +++ b/src/renderer/src/components/task-page/github/ReviewCell.tsx @@ -5,7 +5,7 @@ import type { TaskSourceContext } from '../../../../../shared/task-source-contex import React, { useState, useMemo, useRef, useCallback } from 'react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { getGitHubPRReviewerQueryState, @@ -56,8 +56,8 @@ export function PRReviewCell({ index: 0 }) const [submitting, setSubmitting] = useState(false) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, repo?.id ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, repo?.id ?? null, sourceContext)) ) const reviewerInputRef = useRef(null) const reviewerTriggerRef = useRef(null) @@ -125,7 +125,7 @@ export function PRReviewCell({ open && reviewRepo ? reviewRepo.owner : null, open && reviewRepo ? reviewRepo.repo : null, reviewerSeedUsers.map((user) => user.login), - sourceSettings, + ownerTarget, reviewRepo?.host ) const authorLogin = item.author?.toLowerCase() ?? null @@ -241,7 +241,7 @@ export function PRReviewCell({ setReviewerInput, setSubmitting, sourceContext, - sourceSettings, + ownerTarget, submitting, workItemMutation }) diff --git a/src/renderer/src/components/task-page/github/StatusCell.tsx b/src/renderer/src/components/task-page/github/StatusCell.tsx index 4daf932ad7a3..81de90e9299c 100644 --- a/src/renderer/src/components/task-page/github/StatusCell.tsx +++ b/src/renderer/src/components/task-page/github/StatusCell.tsx @@ -10,7 +10,7 @@ import { } from '@/components/task-page-github-status-state' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { parseGitHubIssueOrPRLink } from '@/lib/github-links' import { getTaskPageGitHubDuplicateCandidates, @@ -20,7 +20,7 @@ import { getTaskPageGitHubDuplicateTargetErrorMessage } from '@/components/task-page-github-status-actions' import { translate } from '@/i18n/i18n' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { githubProjectHost } from '../../../../../shared/github/project-identity' import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' @@ -50,8 +50,8 @@ export function GHStatusCell({ const [duplicateSearch, setDuplicateSearch] = useState('') const [duplicateError, setDuplicateError] = useState(null) const duplicateIssueCandidates = useGitHubDuplicateIssueCandidates(item, duplicatePickerOpen) - const sourceSettings = useAppStore( - useShallow((s) => getGitHubRepoRoutingSettings(s, repo?.id ?? null, sourceContext)) + const ownerTarget = useAppStore( + useShallow((s) => getGitHubRepoRoutingTarget(s, repo?.id ?? null, sourceContext)) ) const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) const filteredDuplicateCandidates = useMemo( @@ -132,7 +132,7 @@ export function GHStatusCell({ sourceContext, errorToast: translate('auto.components.TaskPage.1c893195ac', 'Failed to update state'), mutate: async () => { - const target = getActiveRuntimeTarget(sourceSettings) + const target = ownerTarget // Why: issue rows can be sourced by owner/repo URL instead of the local // repo context; slug-aware writes preserve close reasons and duplicates. if (parsedOwnerRepo) { @@ -208,7 +208,7 @@ export function GHStatusCell({ parsedIssueLink, repo, sourceContext, - sourceSettings, + ownerTarget, stateMutationPending, statusUpdating, updateLocalState, diff --git a/src/renderer/src/components/use-task-page-github-issue-draft.ts b/src/renderer/src/components/use-task-page-github-issue-draft.ts index 9c869aeb1b06..3748e19d58ae 100644 --- a/src/renderer/src/components/use-task-page-github-issue-draft.ts +++ b/src/renderer/src/components/use-task-page-github-issue-draft.ts @@ -2,8 +2,7 @@ import type { TaskPageGitHubCacheReconciliationModel } from './use-task-page-git import { useState, useMemo, useEffect } from 'react' import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import { useAppStore } from '@/store' -import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getGitHubRepoRoutingTarget } from '@/lib/github-source-runtime-context' import { useRepoLabels, useRepoAssignees } from '@/hooks/useIssueMetadata' import { resolveVanishedNewIssueRepoReset, @@ -36,12 +35,10 @@ export function useTaskPageGitHubIssueDraft(model: TaskPageGitHubCacheReconcilia if (!newIssueTargetRepo?.id) { return null } - const target = getActiveRuntimeTarget( - getGitHubRepoRoutingSettings( - { repos: [newIssueTargetRepo], settings }, - newIssueTargetRepo.id, - newIssueSourceContext - ) + const target = getGitHubRepoRoutingTarget( + { repos: [newIssueTargetRepo], settings }, + newIssueTargetRepo.id, + newIssueSourceContext ) if (target.kind !== 'environment') { return null @@ -51,16 +48,12 @@ export function useTaskPageGitHubIssueDraft(model: TaskPageGitHubCacheReconcilia const newIssueRepoLabels = useRepoLabels( newIssueOpen ? (newIssueTargetRepo?.path ?? null) : null, newIssueOpen ? (newIssueTargetRepo?.id ?? null) : null, - { - runtimeEnvironmentId: newIssueOpen ? (newIssueRuntimeTarget?.environmentId ?? null) : null - } + { target: newIssueOpen ? newIssueRuntimeTarget : null } ) const newIssueRepoAssignees = useRepoAssignees( newIssueOpen ? (newIssueTargetRepo?.path ?? null) : null, newIssueOpen ? (newIssueTargetRepo?.id ?? null) : null, - { - runtimeEnvironmentId: newIssueOpen ? (newIssueRuntimeTarget?.environmentId ?? null) : null - } + { target: newIssueOpen ? newIssueRuntimeTarget : null } ) // Why: only handles the "chosen repo vanished" case; a reactive clear keyed on target id can't tell a restore from a user switch and would wipe the recovery draft. diff --git a/src/renderer/src/hooks/composer-state/composer-source-state.ts b/src/renderer/src/hooks/composer-state/composer-source-state.ts index 5199f2d4efbf..cb5052336d6a 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-state.ts @@ -204,8 +204,7 @@ export function useComposerSourceState( setLinkedWorkItem: target.sourceContextState.setLinkedWorkItem, setName: target.sourceContextState.setName, setPushTarget: target.workspaceIdentityState.setPushTarget, - setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint, - settings: target.composerTargetStore.settings + setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint }) const workItemSourceActions = useWorkItemSourceActions({ branchAutoNameRef: target.asyncComposerState.branchAutoNameRef, diff --git a/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts b/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts index 1ca1cdef0709..e322d84156c4 100644 --- a/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts +++ b/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts @@ -24,7 +24,6 @@ type GitLabProviderSelectionInput = Pick< | 'setName' | 'setPushTarget' | 'setStartFromResetHint' - | 'settings' > import { useCallback } from 'react' @@ -34,8 +33,8 @@ import { getLinkedItemDisplayName } from '@/components/sidebar/folder-workspace-composer-helpers' import { shouldApplyWorkspaceSourceAutoName } from '../../../../shared/new-workspace/workspace-source' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { GitPushTarget } from '../../../../shared/worktree/types' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -63,8 +62,7 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) setLinkedWorkItem, setName, setPushTarget, - setStartFromResetHint, - settings + setStartFromResetHint } = input // Why: GitLab parallel of handleSmartGitHubItemSelect — resolves MR base via worktrees:resolveMrBase (refs/merge-requests//head); issues short-circuit. @@ -104,11 +102,11 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) return } setCompareBaseRef(undefined) - const itemRepoSettings = getSettingsForRepoRuntimeOwner( - { repos: [runRepo], settings }, - runRepo.id - ) - const target = getActiveRuntimeTarget(itemRepoSettings) + const target = runtimeTargetForRepoOwner({ repos: [runRepo] }, runRepo.id) + if (!target) { + setCompareBaseRef(undefined) + return + } const resolveMrBase = target.kind === 'local' ? window.api.worktrees.resolveMrBase({ @@ -172,7 +170,6 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) isProjectGroupTarget, name, selectedRepo, - settings, branchAutoNameRef, lastAutoNameRef, setBaseBranch, diff --git a/src/renderer/src/hooks/useGitHubRepoMetadata.ts b/src/renderer/src/hooks/useGitHubRepoMetadata.ts index 8cdb20376bcc..cb08e0a198d7 100644 --- a/src/renderer/src/hooks/useGitHubRepoMetadata.ts +++ b/src/renderer/src/hooks/useGitHubRepoMetadata.ts @@ -1,4 +1,5 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GitHubAssignableUser, GitHubOwnerRepo @@ -8,8 +9,8 @@ import { createMetadataRequestStore } from './metadata-request-cache' import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest' type GitHubMetadataOptions = { - runtimeEnvironmentId?: string | null - activeRuntimeEnvironmentId?: string | null + /** The repo owner's transport; absent reads through this app. */ + target?: RuntimeClientTarget | null ownerRepo?: GitHubOwnerRepo | null } @@ -22,7 +23,7 @@ export function useRepoLabels( options?: GitHubMetadataOptions ): MetadataListState { const runtimeEnvironmentId = - options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null + options?.target?.kind === 'environment' ? options.target.environmentId : null const repoSelector = repoId ?? repoPath ?? '' const ownerRepo = runtimeEnvironmentId ? null : options?.ownerRepo const repositoryKey = ownerRepo @@ -61,7 +62,7 @@ export function useRepoAssignees( options?: GitHubMetadataOptions ): MetadataListState { const runtimeEnvironmentId = - options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null + options?.target?.kind === 'environment' ? options.target.environmentId : null const repoSelector = repoId ?? repoPath ?? '' const ownerRepo = runtimeEnvironmentId ? null : options?.ownerRepo const repositoryKey = ownerRepo diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx index 620fe0d88d0c..2f0be9a5e69f 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx @@ -76,9 +76,7 @@ describe('useGitHubSlugMetadata', () => { function LabelsProbe(): null { renders += 1 - const metadata = useRepoLabelsBySlug('stablyai', 'orca', { - activeRuntimeEnvironmentId: null - }) + const metadata = useRepoLabelsBySlug('stablyai', 'orca', { kind: 'local' }) labels = metadata.data return null } @@ -105,9 +103,7 @@ describe('useGitHubSlugMetadata', () => { function AssigneesProbe(): null { renders += 1 - const metadata = useRepoAssigneesBySlug('stablyai', 'orca', ['jinwoo'], { - activeRuntimeEnvironmentId: null - }) + const metadata = useRepoAssigneesBySlug('stablyai', 'orca', ['jinwoo'], { kind: 'local' }) assigneeLogins = metadata.data.map((user) => user.login) return null } @@ -135,9 +131,7 @@ describe('useGitHubSlugMetadata', () => { function FailingLabelsProbe(): null { renders += 1 - const metadata = useRepoLabelsBySlug('stablyai', 'orca', { - activeRuntimeEnvironmentId: null - }) + const metadata = useRepoLabelsBySlug('stablyai', 'orca', { kind: 'local' }) error = metadata.error return null } @@ -157,12 +151,7 @@ describe('useGitHubSlugMetadata', () => { apiMocks.listLabelsBySlug.mockResolvedValue({ ok: true, labels: ['enterprise'] }) function LabelsProbe(): null { - useRepoLabelsBySlug( - 'stablyai', - 'orca', - { activeRuntimeEnvironmentId: null }, - 'ghe.example.com' - ) + useRepoLabelsBySlug('stablyai', 'orca', { kind: 'local' }, 'ghe.example.com') return null } @@ -183,9 +172,7 @@ describe('useGitHubSlugMetadata', () => { function FailingAssigneesProbe(): null { renders += 1 - const metadata = useRepoAssigneesBySlug('stablyai', 'orca', ['jinwoo'], { - activeRuntimeEnvironmentId: null - }) + const metadata = useRepoAssigneesBySlug('stablyai', 'orca', ['jinwoo'], { kind: 'local' }) error = metadata.error return null } diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index 91cd10ce4557..84649cee1365 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -1,6 +1,6 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { ListAssignableUsersBySlugResult, ListLabelsBySlugResult @@ -21,11 +21,10 @@ export function clearGitHubSlugMetadataCache(): void { export function useRepoLabelsBySlug( owner: string | null, repo: string | null, - settings?: Pick | null, + ownerTarget?: RuntimeClientTarget | null, host?: string ): MetadataListState { - const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId ?? null - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) + const target: RuntimeClientTarget = ownerTarget ?? { kind: 'local' } const selectedOwner = owner ?? '' const selectedRepo = repo ?? '' const repositoryKey = owner && repo ? githubRepoIdentityKey({ owner, repo, host }) : null @@ -64,12 +63,11 @@ export function useRepoAssigneesBySlug( owner: string | null, repo: string | null, seedLogins?: string[], - settings?: Pick | null, + ownerTarget?: RuntimeClientTarget | null, host?: string ): MetadataListState { const seedKey = (seedLogins ?? []).slice().sort().join(',') - const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId ?? null - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) + const target: RuntimeClientTarget = ownerTarget ?? { kind: 'local' } const selectedOwner = owner ?? '' const selectedRepo = repo ?? '' const repositoryKey = owner && repo ? githubRepoIdentityKey({ owner, repo, host }) : null diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index f586e03fada4..e040ee78cb5a 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -174,8 +174,7 @@ describe('useIssueMetadata hooks', () => { function LabelsProbe(): null { labels = useRepoLabels('/local/repo', 'remote-repo-id', { - runtimeEnvironmentId: ' env-explicit ', - activeRuntimeEnvironmentId: 'env-active' + target: { kind: 'environment', environmentId: 'env-explicit' } }).data return null } diff --git a/src/renderer/src/lib/github-source-runtime-context.test.ts b/src/renderer/src/lib/github-source-runtime-context.test.ts index dc690948430d..8759da60e6f2 100644 --- a/src/renderer/src/lib/github-source-runtime-context.test.ts +++ b/src/renderer/src/lib/github-source-runtime-context.test.ts @@ -2,10 +2,10 @@ import { readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import type { TaskSourceContext } from '../../../shared/task-source-context' -import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../runtime/runtime-client-target' import { canUseGitHubRepoContext, - getGitHubRepoRoutingSettings, + getGitHubRepoRoutingTarget, getGitHubRuntimeRepoId, getGitHubSourceRuntimeHost, getGitHubSourceRuntimeTarget @@ -58,7 +58,7 @@ describe('GitHub source runtime context', () => { }) }) -describe('getGitHubRepoRoutingSettings', () => { +describe('getGitHubRepoRoutingTarget', () => { const runtimeOwnedRepo: RepoRuntimeOwnerState = { settings: { activeRuntimeEnvironmentId: null }, repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] @@ -68,8 +68,8 @@ describe('getGitHubRepoRoutingSettings', () => { state: RepoRuntimeOwnerState, repoId: string | null, sourceContext: TaskSourceContext | null - ): ReturnType { - return getActiveRuntimeTarget(getGitHubRepoRoutingSettings(state, repoId, sourceContext)) + ): RuntimeClientTarget { + return getGitHubRepoRoutingTarget(state, repoId, sourceContext) } it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => { diff --git a/src/renderer/src/lib/github-source-runtime-context.ts b/src/renderer/src/lib/github-source-runtime-context.ts index 86d314231866..94430f1124b5 100644 --- a/src/renderer/src/lib/github-source-runtime-context.ts +++ b/src/renderer/src/lib/github-source-runtime-context.ts @@ -1,10 +1,11 @@ import type { ParsedExecutionHost } from '../../../shared/execution-host' import { parseExecutionHostId } from '../../../shared/execution-host' import type { TaskSourceContext } from '../../../shared/task-source-context' -import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' -import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' +import { taskSourceRuntimeTarget } from './task-source-runtime-target' import { getExplicitRuntimeOwnerEnvironmentId, getHostlessSourceRepoOwnerEnvironmentId, @@ -26,30 +27,27 @@ export function getGitHubSourceRuntimeHost( export function getGitHubSourceRuntimeTarget( sourceContext: TaskSourceContext | null | undefined ): RuntimeClientTarget { - return getActiveRuntimeTarget( - getTaskSourceRuntimeSettings(sourceContext?.provider === 'github' ? sourceContext : null) - ) + return taskSourceRuntimeTarget(sourceContext?.provider === 'github' ? sourceContext : null) } // Why: PR reads and actions run on the repo's explicit owner host (#6957, #7623); // a local or absent source never downgrades a runtime-owned repo to local IPC, // a runtime source still overrides, and the globally focused runtime is never // used as a fallback — a repo without an explicit owner is a local repo. -export function getGitHubRepoRoutingSettings( +export function getGitHubRepoRoutingTarget( state: RepoRuntimeOwnerState, repoId: string | null | undefined, sourceContext: TaskSourceContext | null | undefined -): Pick { +): RuntimeClientTarget { const sourceHost = getGitHubSourceRuntimeHost(sourceContext) if (sourceHost) { - return { activeRuntimeEnvironmentId: sourceHost.environmentId } - } - return { - activeRuntimeEnvironmentId: - sourceContext?.provider === 'github' - ? getHostlessSourceRepoOwnerEnvironmentId(state.repos, repoId) - : getExplicitRuntimeOwnerEnvironmentId(state, repoId) + return runtimeTargetForOwnerEnvironment(sourceHost.environmentId) } + return runtimeTargetForOwnerEnvironment( + sourceContext?.provider === 'github' + ? getHostlessSourceRepoOwnerEnvironmentId(state.repos, repoId) + : getExplicitRuntimeOwnerEnvironmentId(state, repoId) + ) } export function canUseGitHubRepoContext( diff --git a/src/renderer/src/lib/hosted-review-repo-read.ts b/src/renderer/src/lib/hosted-review-repo-read.ts index 710b61a04d4f..7367f5d527ea 100644 --- a/src/renderer/src/lib/hosted-review-repo-read.ts +++ b/src/renderer/src/lib/hosted-review-repo-read.ts @@ -1,17 +1,15 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { settingsForHostedReviewRepoOwner } from '@/store/slices/hosted-review-cache-state' -import type { AppState } from '@/store/types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { hostedReviewRepoOwnerTarget } from '@/store/slices/hosted-review-cache-state' import { getRepoExecutionHostId } from '../../../shared/execution-host' import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../../../shared/hosted-review' import type { Repo } from '../../../shared/repo-types' export function readHostedReviewForRepo( repo: Repo, - settings: AppState['settings'], query: Omit ): Promise { const args = { ...query, repoPath: repo.path, repoId: repo.id } - const target = getActiveRuntimeTarget(settingsForHostedReviewRepoOwner(settings, repo)) + const target = hostedReviewRepoOwnerTarget(repo) return target.kind === 'environment' ? callRuntimeRpc( target, diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index b341dbdbd6ab..6f7e61282ac2 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -6,6 +6,7 @@ import { getRepoOwnerRoutedSettings, getRuntimeEnvironmentIdForRepo, getSettingsForRepoRuntimeOwner, + runtimeTargetForRepoOwner, releaseRepoRuntimeOwnerSettingsCache } from './repo-runtime-owner' @@ -329,3 +330,39 @@ describe('getSettingsForRepoRuntimeOwner identity', () => { expect(getSettingsForRepoRuntimeOwner(state, 'repo-evictable')).not.toBe(first) }) }) + +describe('runtimeTargetForRepoOwner', () => { + const focused = { activeRuntimeEnvironmentId: 'focused-runtime' } + + it('routes by the row, never by the focused server', () => { + expect( + runtimeTargetForRepoOwner( + { + settings: focused, + repos: [{ id: 'r', connectionId: null, executionHostId: 'runtime:owner' }] + }, + 'r' + ) + ).toEqual({ kind: 'environment', environmentId: 'owner' }) + expect( + runtimeTargetForRepoOwner( + { settings: focused, repos: [{ id: 'r', connectionId: 'box', executionHostId: null }] }, + 'r' + ) + ).toEqual({ kind: 'local' }) + }) + + it('treats a row with no owner stamp as local instead of the focused server (#6957)', () => { + expect( + runtimeTargetForRepoOwner( + { settings: focused, repos: [{ id: 'r', connectionId: null, executionHostId: null }] }, + 'r' + ) + ).toEqual({ kind: 'local' }) + }) + + it('has no transport for an unknown repo', () => { + expect(runtimeTargetForRepoOwner({ settings: focused, repos: [] }, 'r')).toBeNull() + expect(runtimeTargetForRepoOwner({ settings: focused, repos: [] }, null)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 49e9e7239146..d947a686cfcb 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -5,6 +5,10 @@ import { } from '../../../shared/execution-host' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { Repo } from '../../../shared/repo-types' +import { + runtimeTargetForOwnerHostId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' export type RepoRuntimeOwnerState = { repos?: readonly Pick[] @@ -31,6 +35,18 @@ function findRepoOwner( return focusedMatches.length === 1 ? focusedMatches[0] : null } +/** + * Transport to a repo row's owner; a row with no owner stamp is local. `null` when no row, or no + * single row among duplicates, names the repo. + */ +export function runtimeTargetForRepoOwner( + state: RepoRuntimeOwnerState, + repoId: string | null | undefined +): RuntimeClientTarget | null { + const repo = repoId ? findRepoOwner(state, repoId) : null + return repo ? runtimeTargetForOwnerHostId(getRepoExecutionHostId(repo)) : null +} + export function getRuntimeEnvironmentIdForRepo( state: RepoRuntimeOwnerState, repoId: string | null | undefined diff --git a/src/renderer/src/lib/repo-slug-cache.test.ts b/src/renderer/src/lib/repo-slug-cache.test.ts index 16cba1b82ed0..82fa79b272cb 100644 --- a/src/renderer/src/lib/repo-slug-cache.test.ts +++ b/src/renderer/src/lib/repo-slug-cache.test.ts @@ -8,7 +8,7 @@ import { readRepoSlugCache, rememberRepoSlug, lookupReposBySlugFromCache, - settingsForRepoOwner, + repoSlugCacheKey, slugByRepoId, slugCacheKey } from './repo-slug-cache' @@ -35,27 +35,27 @@ describe('repo slug cache host identity', () => { [enterprise, 'ghe.example:8443'] ] as const) { slugByRepoId.set( - slugCacheKey(candidate.id, settingsForRepoOwner(candidate, null)), + slugCacheKey(candidate.id, { kind: 'local' }), githubRepoIdentityKey({ owner: 'acme', repo: 'widgets', host }) ) } - expect(lookupReposBySlugFromCache([dotCom, enterprise], null, 'acme/widgets')).toEqual([dotCom]) + expect(lookupReposBySlugFromCache([dotCom, enterprise], 'acme/widgets')).toEqual([dotCom]) expect( - lookupReposBySlugFromCache([dotCom, enterprise], null, 'acme/widgets', 'ghe.example:8443') + lookupReposBySlugFromCache([dotCom, enterprise], 'acme/widgets', 'ghe.example:8443') ).toEqual([enterprise]) }) it('drops the fork alias while its own origin is unresolved', () => { const fork = { ...repo('fork'), upstream: { owner: 'acme', repo: 'widgets' } } - expect(lookupReposBySlugFromCache([fork], null, 'acme/widgets')).toEqual([]) - slugByRepoId.set(slugCacheKey(fork.id, settingsForRepoOwner(fork, null)), null) - expect(lookupReposBySlugFromCache([fork], null, 'acme/widgets')).toEqual([]) + expect(lookupReposBySlugFromCache([fork], 'acme/widgets')).toEqual([]) + slugByRepoId.set(slugCacheKey(fork.id, { kind: 'local' }), null) + expect(lookupReposBySlugFromCache([fork], 'acme/widgets')).toEqual([]) }) it('expires negative slug resolutions so an external GHES login can recover', () => { - const key = slugCacheKey('enterprise', null) + const key = slugCacheKey('enterprise', { kind: 'local' }) rememberRepoSlug(key, null, 1_000) expect(readRepoSlugCache(key, 1_000)).toEqual({ hit: true, value: null }) @@ -63,3 +63,17 @@ describe('repo slug cache host identity', () => { expect(readRepoSlugCache(key, 1_000 + REPO_SLUG_FAILURE_TTL_MS)).toEqual({ hit: false }) }) }) + +describe('repo slug cache owner', () => { + beforeEach(() => clearRepoSlugCacheValues()) + + it('keys a repo by the host that owns it, never by a focused server', () => { + expect(repoSlugCacheKey(repo('local-repo'))).toBe('local:local-repo') + expect(repoSlugCacheKey({ ...repo('server-repo'), executionHostId: 'runtime:env-a' })).toBe( + 'runtime:env-a:server-repo' + ) + expect( + repoSlugCacheKey({ ...repo('ssh-repo'), executionHostId: undefined, connectionId: 'box' }) + ).toBe('local:ssh-repo') + }) +}) diff --git a/src/renderer/src/lib/repo-slug-cache.ts b/src/renderer/src/lib/repo-slug-cache.ts index 7c73372d5ee6..d8a5554bfda5 100644 --- a/src/renderer/src/lib/repo-slug-cache.ts +++ b/src/renderer/src/lib/repo-slug-cache.ts @@ -1,10 +1,9 @@ // Why: the slug → Repo cache and its synchronous lookup live here (separate from // repo-slug-index.ts) so store slices can import the sync lookup without pulling // in repo-slug-index's `@/store` dependency, which would form an import cycle. -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { Repo } from '../../../shared/repo-types' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getSettingsForRepoRuntimeOwner } from './repo-runtime-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { runtimeTargetForRepoOwner } from './repo-runtime-owner' import { githubHostFromIdentityKey, githubRepoIdentityKey @@ -99,29 +98,28 @@ export function nextRepoSlugFailureRetryDelay( return Number.isFinite(earliestExpiry) ? Math.max(0, earliestExpiry - now) : null } -export function slugCacheKey( - repoId: string, - settings: Pick | null | undefined -): string { - const target = getActiveRuntimeTarget(settings) +export function slugCacheKey(repoId: string, target: RuntimeClientTarget): string { return `${target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'}:${repoId}` } -export function settingsForRepoOwner( - repo: Repo, - settings: Pick | null | undefined -): Pick { - return getSettingsForRepoRuntimeOwner({ repos: [repo], settings }, repo.id) +/** A repo's remote metadata is read from the host that owns the row; `null` when no host does. */ +export function repoSlugOwnerTarget(repo: Repo): RuntimeClientTarget | null { + return runtimeTargetForRepoOwner({ repos: [repo] }, repo.id) +} + +/** The slug cache key for a repo on its owner, or `null` when the row names no routable host. */ +export function repoSlugCacheKey(repo: Repo): string | null { + const target = repoSlugOwnerTarget(repo) + return target ? slugCacheKey(repo.id, target) : null } /** Synchronous slug → Repo lookup against the already-resolved module cache. * Used by store slices (which can't run the async hook-based index) to route * project-row mutations to the matched repo's owner host; callers fall back to - * focused settings when nothing matches. Origin matches win over upstream ones + * the row's own source host when nothing matches. Origin matches win over upstream ones * so a clone of the upstream repo itself is never shadowed by someone's fork. */ export function lookupReposBySlugFromCache( repos: readonly Repo[], - settings: Pick | null | undefined, slug: string | null | undefined, host?: string ): Repo[] { @@ -133,8 +131,8 @@ export function lookupReposBySlugFromCache( const matched: Repo[] = [] const upstreamMatched: Repo[] = [] for (const repo of repos) { - const cacheKey = slugCacheKey(repo.id, settingsForRepoOwner(repo, settings)) - const originKey = slugByRepoId.get(cacheKey) + const cacheKey = repoSlugCacheKey(repo) + const originKey = cacheKey ? slugByRepoId.get(cacheKey) : undefined if (originKey === target) { matched.push(repo) } else if (repoUpstreamIdentityKey(repo, originKey) === target) { diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 752e2fdfe181..66899d96ea86 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -15,16 +15,16 @@ // process (`repoSlug` reads `.git/config`). import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { Repo } from '../../../shared/repo-types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { deleteRepoSlugCacheKey, nextRepoSlugFailureRetryDelay, readRepoSlugCache, rememberRepoSlug, + repoSlugCacheKey, + repoSlugOwnerTarget, repoUpstreamIdentityKey, - settingsForRepoOwner, slugByRepoId, slugCacheKey, type RepoSlugMatches, @@ -72,11 +72,14 @@ export function clearRepoSlugCacheEntry(repoId: string): void { } } -async function resolveRepoSlug( - repo: Repo, - settings: Pick | null | undefined -): Promise { - const cacheKey = slugCacheKey(repo.id, settings) +async function resolveRepoSlug(repo: Repo): Promise { + // Why: the project slug index spans repos from multiple hosts; each repo's remote metadata + // must be read from its owner. + const target = repoSlugOwnerTarget(repo) + if (!target) { + return null + } + const cacheKey = slugCacheKey(repo.id, target) const cached = readRepoSlugCache(cacheKey) if (cached.hit) { return cached.value @@ -99,7 +102,6 @@ async function resolveRepoSlug( return value } try { - const target = getActiveRuntimeTarget(settings) const result = target.kind === 'environment' ? await callRuntimeRpc<{ owner: string; repo: string; host?: string } | null>( @@ -131,14 +133,13 @@ async function resolveRepoSlug( } async function buildIndex( - repos: readonly Repo[], - settings: Pick | null | undefined + repos: readonly Repo[] ): Promise<{ index: SlugIndex; upstreamIndex: SlugIndex; retryDelayMs: number | null }> { // Why: evict cached entries for repos that no longer exist in state so // the cache cannot grow unbounded across long sessions where users add // and remove repos. Without this, every removed repo's id (and its // negative-cached null) lingers forever. - const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settingsForRepoOwner(r, settings)))) + const liveKeys = new Set(repos.flatMap((r) => repoSlugCacheKey(r) ?? [])) for (const key of slugByRepoId.keys()) { if (!liveKeys.has(key)) { deleteRepoSlugCacheKey(key) @@ -150,9 +151,7 @@ async function buildIndex( const results = await Promise.all( repos.map(async (r) => ({ repo: r, - // Why: the project slug index spans repos from multiple hosts; each - // repo's remote metadata must be read from its owner. - slug: await resolveRepoSlug(r, settingsForRepoOwner(r, settings)) + slug: await resolveRepoSlug(r) })) ) for (const { repo, slug } of results) { @@ -187,7 +186,6 @@ export type RepoSlugIndexState = { * deep trees can treat it as referentially equal inside a single render cycle. */ export function useRepoSlugIndex(): RepoSlugIndexState { const repos = useAppStore((s) => s.repos) - const settings = useAppStore((s) => s.settings) const [index, setIndex] = useState(() => new Map()) const [upstreamIndex, setUpstreamIndex] = useState(() => new Map()) const [ready, setReady] = useState(false) @@ -204,7 +202,7 @@ export function useRepoSlugIndex(): RepoSlugIndexState { const gen = ++generationRef.current setReady(false) setRetryDelayMs(null) - void buildIndex(repos, settings).then( + void buildIndex(repos).then( ({ index: next, upstreamIndex: nextUpstream, retryDelayMs: nextRetryDelayMs }) => { if (gen !== generationRef.current) { return @@ -218,7 +216,7 @@ export function useRepoSlugIndex(): RepoSlugIndexState { return () => { generationRef.current += 1 } - }, [repos, retryGeneration, settings]) + }, [repos, retryGeneration]) useEffect(() => { if (retryDelayMs === null) { diff --git a/src/renderer/src/lib/task-source-runtime-target.test.ts b/src/renderer/src/lib/task-source-runtime-target.test.ts new file mode 100644 index 000000000000..6d88d20fcdda --- /dev/null +++ b/src/renderer/src/lib/task-source-runtime-target.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { UNRESOLVED_OWNER_HOST_ID } from '../../../shared/execution-host' +import { taskSourceRuntimeTarget } from './task-source-runtime-target' +import { projectViewCacheKeyTarget } from '@/store/github/cache-identity' + +describe('row-less source transports', () => { + it('reads a task source from its own host', () => { + expect(taskSourceRuntimeTarget({ hostId: 'runtime:env-a' })).toEqual({ + kind: 'environment', + environmentId: 'env-a' + }) + expect(taskSourceRuntimeTarget({ hostId: 'local' })).toEqual({ kind: 'local' }) + expect(taskSourceRuntimeTarget(null)).toEqual({ kind: 'local' }) + }) + + it('keeps an unresolved source host remote so the call fails instead of running locally', () => { + expect(taskSourceRuntimeTarget({ hostId: UNRESOLVED_OWNER_HOST_ID })).toEqual({ + kind: 'environment', + environmentId: 'unresolved-owner' + }) + }) + + it('reads a board row back from the host written into its cache key', () => { + expect(projectViewCacheKeyTarget('github-project:runtime:env-b:org/acme/1:view')).toEqual({ + kind: 'environment', + environmentId: 'env-b' + }) + expect(projectViewCacheKeyTarget('github-project:local:org/acme/1:view')).toEqual({ + kind: 'local' + }) + }) +}) diff --git a/src/renderer/src/lib/task-source-runtime-target.ts b/src/renderer/src/lib/task-source-runtime-target.ts new file mode 100644 index 000000000000..de56ee53782b --- /dev/null +++ b/src/renderer/src/lib/task-source-runtime-target.ts @@ -0,0 +1,16 @@ +import { parseExecutionHostId } from '../../../shared/execution-host' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' + +/** + * Transport to a row-less source's host; a source that names no server runs on this app. The + * unresolved-owner sentinel stays a server target so the call fails instead of running locally. + */ +export function taskSourceRuntimeTarget( + context: Pick | null | undefined +): RuntimeClientTarget { + const parsed = parseExecutionHostId(context?.hostId) + return parsed?.kind === 'runtime' + ? { kind: 'environment', environmentId: parsed.environmentId } + : { kind: 'local' } +} diff --git a/src/renderer/src/store/github/cache-identity.ts b/src/renderer/src/store/github/cache-identity.ts index d5c2664fc080..a186f81bd117 100644 --- a/src/renderer/src/store/github/cache-identity.ts +++ b/src/renderer/src/store/github/cache-identity.ts @@ -12,7 +12,7 @@ import { } from '../../../../shared/execution-host' import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '../slices/github-cache-key' import type { AppState } from '../types' -import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../../runtime/runtime-client-target' import type { CacheEntry } from './cache-model' function queryOverrideKeyPart(queryOverride: string | undefined): string { @@ -49,21 +49,14 @@ export function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope return `${sourceScope}:${projectKey}:${selector}${queryOverrideKeyPart(args.queryOverride)}` } -export function projectViewSourceScope( - settings: Pick | null -): string { - const target = getActiveRuntimeTarget(settings) +export function projectViewSourceScope(target: RuntimeClientTarget): string { return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' } -export function settingsForProjectViewCacheKey( - settings: AppState['settings'], - cacheKey: string -): Pick, 'activeRuntimeEnvironmentId'> { +/** The host a board row was loaded from, read back from the scope written into its cache key. */ +export function projectViewCacheKeyTarget(cacheKey: string): RuntimeClientTarget { const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey) - return runtimeMatch - ? { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] } - : { ...settings, activeRuntimeEnvironmentId: null } + return runtimeMatch ? { kind: 'environment', environmentId: runtimeMatch[1] } : { kind: 'local' } } export function workItemsCacheKey( diff --git a/src/renderer/src/store/github/project-actions.ts b/src/renderer/src/store/github/project-actions.ts index f6943566b4e9..8da4cda29041 100644 --- a/src/renderer/src/store/github/project-actions.ts +++ b/src/renderer/src/store/github/project-actions.ts @@ -2,7 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' import type { GetProjectViewTableResult } from '../../../../shared/github/project-result-types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { projectViewCacheKey, projectViewRequestKey, @@ -25,9 +25,8 @@ export const createProjectActions = ( GitHubSlice, 'fetchProjectViewTable' | 'updateProjectFieldValue' | 'clearProjectFieldValue' > => ({ - fetchProjectViewTable: async (sourceSettings, args, options) => { - const target = getActiveRuntimeTarget(sourceSettings) - const sourceScope = projectViewSourceScope(sourceSettings) + fetchProjectViewTable: async (target, args, options) => { + const sourceScope = projectViewSourceScope(target) const requestKey = projectViewRequestKey(args, sourceScope) // Fast path: a caller-supplied `viewId` gives the resolved cache key up front, so serve a fresh entry directly. diff --git a/src/renderer/src/store/github/project-field-mutations.ts b/src/renderer/src/store/github/project-field-mutations.ts index 1391d703f617..d26207b176b4 100644 --- a/src/renderer/src/store/github/project-field-mutations.ts +++ b/src/renderer/src/store/github/project-field-mutations.ts @@ -8,8 +8,8 @@ import type { } from '../../../../shared/github/project-types' import type { GitHubProjectMutationResult } from '../../../../shared/github/project-result-types' import { translate } from '@/i18n/i18n' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' -import { projectViewSourceScope, settingsForProjectViewCacheKey } from './cache-identity' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { projectViewCacheKeyTarget, projectViewSourceScope } from './cache-identity' import { applyRowPatch, optimisticFieldValueFromMutation } from './project-cache' import { githubProjectHost } from '../../../../shared/github/project-identity' @@ -57,7 +57,7 @@ export function createProjectFieldActions( delete fields[fieldId] } applyRowPatch(set, cacheKey, rowId, { ...row, fieldValuesByFieldId: fields }) - const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) + const target = projectViewCacheKeyTarget(cacheKey) const args = { projectId: table.project.id, host: table.project.host, itemId: rowId, fieldId } let result: GitHubProjectMutationResult try { @@ -123,7 +123,7 @@ export function createProjectFieldActions( ): Promise => { const table = get().projectViewCache[cacheKey]?.data const key = JSON.stringify([ - projectViewSourceScope(settingsForProjectViewCacheKey(get().settings, cacheKey)), + projectViewSourceScope(projectViewCacheKeyTarget(cacheKey)), githubProjectHost(table?.project.host).toLowerCase(), table?.project.id ?? cacheKey, rowId, diff --git a/src/renderer/src/store/github/project-row-actions.ts b/src/renderer/src/store/github/project-row-actions.ts index 63fd0d18ee37..53ca84638818 100644 --- a/src/renderer/src/store/github/project-row-actions.ts +++ b/src/renderer/src/store/github/project-row-actions.ts @@ -4,9 +4,9 @@ import type { GitHubSlice } from './slice-types' import type { GitHubProjectRow } from '../../../../shared/github/project-types' import type { GitHubProjectMutationResult } from '../../../../shared/github/project-result-types' import { translate } from '@/i18n/i18n' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' -import { settingsForProjectRowOwner } from '../slices/github-project-row-owner' -import { settingsForProjectViewCacheKey } from './cache-identity' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForProjectRowOwner } from '../slices/github-project-row-owner' +import { projectViewCacheKeyTarget } from './cache-identity' import { applyRowPatch, parseSlugAndNumber, rollbackRowIfPresent } from './project-cache' export const createProjectRowActions = ( @@ -91,14 +91,12 @@ export const createProjectRowActions = ( // Why: labels/assignees go through the issue endpoint for both (GitHub PRs are issues for those); title/body split PR→updatePullRequestBySlug vs issue→updateIssueBySlug. let envelope: GitHubProjectMutationResult = { ok: true } // Why: slug-only Project rows have no registered Orca repo, so fall back to the view source in the cache key, not the focused host. - const target = getActiveRuntimeTarget( - settingsForProjectRowOwner( - get(), - owner, - repo, - table.project.host, - settingsForProjectViewCacheKey(get().settings, cacheKey) - ) + const target = runtimeTargetForProjectRowOwner( + get(), + owner, + repo, + table.project.host, + projectViewCacheKeyTarget(cacheKey) ) if ( previousRow.itemType === 'PULL_REQUEST' && @@ -221,14 +219,12 @@ export const createProjectRowActions = ( } applyRowPatch(set, cacheKey, rowId, optimistic) // Why: slug-only Project rows belong to the host that loaded the view, which may differ from the now-focused host. - const target = getActiveRuntimeTarget( - settingsForProjectRowOwner( - get(), - owner, - repo, - table.project.host, - settingsForProjectViewCacheKey(get().settings, cacheKey) - ) + const target = runtimeTargetForProjectRowOwner( + get(), + owner, + repo, + table.project.host, + projectViewCacheKeyTarget(cacheKey) ) const args = { owner, diff --git a/src/renderer/src/store/github/provider-request-coalescing.test.ts b/src/renderer/src/store/github/provider-request-coalescing.test.ts index 5367fb1989d5..ef9b524d6674 100644 --- a/src/renderer/src/store/github/provider-request-coalescing.test.ts +++ b/src/renderer/src/store/github/provider-request-coalescing.test.ts @@ -308,10 +308,10 @@ describe('GitHub provider request upgrade coalescing', () => { .mockReturnValueOnce(replacementWeak.promise) .mockReturnValue(forcedResult.promise) - const first = store.getState().fetchProjectViewTable(null, projectViewRequest) + const first = store.getState().fetchProjectViewTable({ kind: 'local' }, projectViewRequest) const forcedFetch = store .getState() - .fetchProjectViewTable(null, projectViewRequest, { force: true }) + .fetchProjectViewTable({ kind: 'local' }, projectViewRequest, { force: true }) // Why: only a non-forced entry can be superseded mid-flight, and it has to appear in the // microtask after the first request clears the key — before the forced waiter re-checks it. const pending = inflightProjectViewRequests.get( @@ -320,7 +320,9 @@ describe('GitHub provider request upgrade coalescing', () => { expect(pending).toBeDefined() let replacementFetch: Promise | undefined void pending?.promise.then(() => { - replacementFetch = store.getState().fetchProjectViewTable(null, projectViewRequest) + replacementFetch = store + .getState() + .fetchProjectViewTable({ kind: 'local' }, projectViewRequest) }) const staleError = { type: 'network_error' as const, message: 'first attempt offline' } diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts index 6943d07ab5d1..d321c83e25da 100644 --- a/src/renderer/src/store/github/slice-types.ts +++ b/src/renderer/src/store/github/slice-types.ts @@ -26,7 +26,7 @@ import type { } from '../../../../shared/github/project-result-types' import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { CacheEntry, FetchOptions, @@ -254,9 +254,9 @@ export type GitHubSlice = { evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void // ── ProjectV2 view cache ───────────────────────────────────────────── projectViewCache: Record> - /** `sourceSettings` names the board's row-less source host; the focused server is never read. */ + /** `sourceTarget` names the board's row-less source host; the focused server is never read. */ fetchProjectViewTable: ( - sourceSettings: Pick | null, + sourceTarget: RuntimeClientTarget, args: GetProjectViewTableArgs, options?: FetchOptions ) => Promise diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts index fde46aef6068..a6883927c047 100644 --- a/src/renderer/src/store/github/work-item-mutation-actions.ts +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -7,8 +7,8 @@ import { parseGitHubIssueOrPRLink } from '../../../../shared/github/links' import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' import { translate } from '@/i18n/i18n' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import type { CacheEntry } from './cache-model' import { evictRepoCacheEntries, repoCacheKeyPrefixes } from './cache-identity' import { ERROR_TOAST_DURATION } from './cache-policy' @@ -78,7 +78,10 @@ export const createWorkItemMutationActions = ( // Why: map 'auto' to undefined so persistence drops the key entirely (see main/persistence.ts#updateRepo). const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } // Why: route to the repo's owner host (like updateRepo) so the write lands where the repo lives, not the focused runtime. - const target = getActiveRuntimeTarget(getSettingsForRepoRuntimeOwner(get(), repoId)) + const target = runtimeTargetForRepoOwner(get(), repoId) + if (!target) { + throw new Error(`No single host owns project ${repoId}.`) + } await (target.kind === 'local' ? window.api.repos.update({ repoId, updates }) : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) diff --git a/src/renderer/src/store/slices/github-project-request-coordination.test.ts b/src/renderer/src/store/slices/github-project-request-coordination.test.ts index 8b77d5a02a2e..3c818c088419 100644 --- a/src/renderer/src/store/slices/github-project-request-coordination.test.ts +++ b/src/renderer/src/store/slices/github-project-request-coordination.test.ts @@ -50,7 +50,9 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { const table = makeTable() store.setState({ projectViewCache: { [cacheKey]: { data: table, fetchedAt: Date.now() } } }) - await expect(store.getState().fetchProjectViewTable(null, request)).resolves.toEqual({ + await expect( + store.getState().fetchProjectViewTable({ kind: 'local' }, request) + ).resolves.toEqual({ ok: true, data: table }) @@ -62,8 +64,8 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { const pending = Promise.withResolvers() mockApi.gh.getProjectViewTable.mockReturnValueOnce(pending.promise) - const first = store.getState().fetchProjectViewTable(null, request) - const duplicate = store.getState().fetchProjectViewTable(null, request) + const first = store.getState().fetchProjectViewTable({ kind: 'local' }, request) + const duplicate = store.getState().fetchProjectViewTable({ kind: 'local' }, request) pending.resolve({ ok: true, data: makeTable() }) await expect(Promise.all([first, duplicate])).resolves.toEqual([ @@ -80,9 +82,9 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { .mockReturnValueOnce(weak.promise) .mockResolvedValue({ ok: true, data: makeTable('forced') }) - const first = store.getState().fetchProjectViewTable(null, request) + const first = store.getState().fetchProjectViewTable({ kind: 'local' }, request) const forced = Array.from({ length: 20 }, () => - store.getState().fetchProjectViewTable(null, request, { force: true }) + store.getState().fetchProjectViewTable({ kind: 'local' }, request, { force: true }) ) weak.resolve({ ok: true, data: makeTable('weak') }) @@ -101,7 +103,7 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) await expect( - store.getState().fetchProjectViewTable(null, request, { force: true }) + store.getState().fetchProjectViewTable({ kind: 'local' }, request, { force: true }) ).resolves.toEqual({ ok: false, error @@ -110,11 +112,14 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) await expect( - store.getState().fetchProjectViewTable(null, { - owner: 'acme', - ownerType: 'organization', - projectNumber: 1 - }) + store.getState().fetchProjectViewTable( + { kind: 'local' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + } + ) ).resolves.toEqual({ ok: false, error }) expect(Object.keys(store.getState().projectViewCache)).toEqual([cacheKey]) }) @@ -123,11 +128,14 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { mockApi.gh.getProjectViewTable.mockRejectedValueOnce(new Error('transport offline')) await expect( - store.getState().fetchProjectViewTable(null, { - owner: 'acme', - ownerType: 'organization', - projectNumber: 1 - }) + store.getState().fetchProjectViewTable( + { kind: 'local' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + } + ) ).resolves.toEqual({ ok: false, error: { type: 'unknown', message: 'transport offline' } diff --git a/src/renderer/src/store/slices/github-project-row-owner.test.ts b/src/renderer/src/store/slices/github-project-row-owner.test.ts index 89b10e295b65..c6fe489099a1 100644 --- a/src/renderer/src/store/slices/github-project-row-owner.test.ts +++ b/src/renderer/src/store/slices/github-project-row-owner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import type { Repo } from '../../../../shared/repo-types' -import { settingsForProjectRowOwner } from './github-project-row-owner' +import { runtimeTargetForProjectRowOwner } from './github-project-row-owner' import { lookupReposBySlugFromCache } from '@/lib/repo-slug-cache' vi.mock('@/lib/repo-slug-cache', () => ({ @@ -13,7 +13,7 @@ function repo(id: string, executionHostId: string | null): Repo { return { id, executionHostId, connectionId: null } as unknown as Repo } -describe('settingsForProjectRowOwner', () => { +describe('runtimeTargetForProjectRowOwner', () => { beforeEach(() => { mockedLookup.mockReset() }) @@ -24,19 +24,22 @@ describe('settingsForProjectRowOwner', () => { repos: [repo('repo-1', 'runtime:owner-env')], settings: { activeRuntimeEnvironmentId: 'focused-env' } } - expect(settingsForProjectRowOwner(state, 'acme', 'widgets')).toEqual({ - activeRuntimeEnvironmentId: 'owner-env' - }) + expect( + runtimeTargetForProjectRowOwner(state, 'acme', 'widgets', undefined, { kind: 'local' }) + ).toEqual({ kind: 'environment', environmentId: 'owner-env' }) }) - it('falls back to focused settings when no repo matches the slug', () => { + it('falls back to the host the row was loaded from, not focus, when no repo matches', () => { mockedLookup.mockReturnValue([]) const state = { repos: [repo('repo-1', 'runtime:owner-env')], settings: { activeRuntimeEnvironmentId: 'focused-env' } } - expect(settingsForProjectRowOwner(state, 'acme', 'widgets')).toEqual({ - activeRuntimeEnvironmentId: 'focused-env' - }) + expect( + runtimeTargetForProjectRowOwner(state, 'acme', 'widgets', undefined, { + kind: 'environment', + environmentId: 'board-env' + }) + ).toEqual({ kind: 'environment', environmentId: 'board-env' }) }) }) diff --git a/src/renderer/src/store/slices/github-project-row-owner.ts b/src/renderer/src/store/slices/github-project-row-owner.ts index 9af67dc4d505..fd3c3a46d3e0 100644 --- a/src/renderer/src/store/slices/github-project-row-owner.ts +++ b/src/renderer/src/store/slices/github-project-row-owner.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' import { lookupReposBySlugFromCache } from '@/lib/repo-slug-cache' type RepoOwnerState = { @@ -8,25 +9,16 @@ type RepoOwnerState = { settings: Pick | null | undefined } -/** Resolve the runtime settings to route a GitHub Project row mutation through. - * When the row's `owner/repo` slug matches a known repo, route by that repo's - * owner host; otherwise fall back to the focused settings (the row may belong - * to a repo Orca doesn't track). */ -export function settingsForProjectRowOwner( +/** Transport for a GitHub Project row mutation. A row whose `owner/repo` slug matches a known + * repo goes to that repo's owner; otherwise (Orca may not track the repo) it goes to + * `rowSource`, the host the row was loaded from. */ +export function runtimeTargetForProjectRowOwner( state: RepoOwnerState, owner: string, repo: string, - host?: string, - fallbackSettings: - | Pick - | null - | undefined = state.settings -): Pick | null | undefined { - const matchedRepo = lookupReposBySlugFromCache( - state.repos, - state.settings, - `${owner}/${repo}`, - host - )[0] - return matchedRepo ? getSettingsForRepoRuntimeOwner(state, matchedRepo.id) : fallbackSettings + host: string | undefined, + rowSource: RuntimeClientTarget +): RuntimeClientTarget { + const matchedRepo = lookupReposBySlugFromCache(state.repos, `${owner}/${repo}`, host)[0] + return (matchedRepo ? runtimeTargetForRepoOwner(state, matchedRepo.id) : null) ?? rowSource } diff --git a/src/renderer/src/store/slices/github-project-view-tables.test.ts b/src/renderer/src/store/slices/github-project-view-tables.test.ts index 43e51bf0dce0..830ca770d2b6 100644 --- a/src/renderer/src/store/slices/github-project-view-tables.test.ts +++ b/src/renderer/src/store/slices/github-project-view-tables.test.ts @@ -61,7 +61,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) const result = await store.getState().fetchProjectViewTable( - { activeRuntimeEnvironmentId: 'env-board' }, + { kind: 'environment', environmentId: 'env-board' }, { owner: 'acme', ownerType: 'organization', @@ -123,7 +123,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) await store.getState().fetchProjectViewTable( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, { owner: 'acme', ownerType: 'organization', @@ -159,12 +159,15 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { } }) - const localResult = await store.getState().fetchProjectViewTable(null, { - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1' - }) + const localResult = await store.getState().fetchProjectViewTable( + { kind: 'local' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + } + ) expect(localResult.ok).toBe(true) expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(1) @@ -210,13 +213,16 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { .mockResolvedValueOnce({ ok: true, data: makeTable('ghe.example', 'enterprise-project') }) for (const host of ['github.com', 'ghe.example']) { - await store.getState().fetchProjectViewTable(null, { - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1', - host - }) + await store.getState().fetchProjectViewTable( + { kind: 'local' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1', + host + } + ) } expect(mockApi.gh.getProjectViewTable).toHaveBeenNthCalledWith( @@ -517,7 +523,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { for (let i = 0; i <= 500; i++) { vi.setSystemTime(1_000 + i) await store.getState().fetchProjectViewTable( - null, + { kind: 'local' }, { owner: 'acme', ownerType: 'organization', diff --git a/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts index 64a51027d590..e4ee1b59d36a 100644 --- a/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts +++ b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts @@ -30,12 +30,15 @@ describe('GitHub provider request concurrency', () => { .fetchWorkItems(`repo-${index}`, `/repo/${index}`, 20, '') .catch(() => []) ) - const queuedProject = store.getState().fetchProjectViewTable(null, { - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1' - }) + const queuedProject = store.getState().fetchProjectViewTable( + { kind: 'local' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + } + ) const queuedWork = store .getState() .fetchWorkItems('repo-queued', '/repo/queued', 20, '') diff --git a/src/renderer/src/store/slices/hosted-review-cache-state.ts b/src/renderer/src/store/slices/hosted-review-cache-state.ts index f0afe39d3516..36d392cf7c51 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-state.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-state.ts @@ -8,6 +8,10 @@ import type { import type { Repo } from '../../../../shared/repo-types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import type { AppState } from '../types' +import { + runtimeTargetForOwnerHostId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' export type HostedReviewCacheEntry = { data: T | null @@ -229,14 +233,16 @@ export function settingsForHostedReviewRepoOwner( : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) } -export function settingsForHostedReviewActionOwner( - settings: AppState['settings'], +/** + * Transport for a review on a known repo: a server's repo goes to that server; local and SSH repos + * run on this app's IPC. An unknown repo also stays on this app; focus never names an owner. + */ +export function hostedReviewRepoOwnerTarget( repo: Pick | undefined -): AppState['settings'] { - if (!repo?.executionHostId && !repo?.connectionId) { - return settings - } - return settingsForHostedReviewRepoOwner(settings, repo) +): RuntimeClientTarget { + return ( + (repo ? runtimeTargetForOwnerHostId(getRepoExecutionHostId(repo)) : null) ?? { kind: 'local' } + ) } export function hostedReviewBranchLookupArgs( diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 6ada2a4b0d11..e1f3d07db2fe 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -53,6 +53,12 @@ function makeStore(settings: AppState['settings'] = null) { })) } +function stampRepoOwner(store: ReturnType, executionHostId: `runtime:${string}`) { + store.setState((state) => ({ + repos: state.repos.map((repo) => ({ ...repo, executionHostId })) + })) +} + const review: HostedReviewInfo = { provider: 'gitlab', number: 5, @@ -496,6 +502,7 @@ describe('hosted review slice', () => { const store = makeStore({ activeRuntimeEnvironmentId: 'env-win' } as AppState['settings']) + stampRepoOwner(store, 'runtime:env-win') await store.getState().createHostedReview('/repo', { provider: 'github', @@ -531,6 +538,7 @@ describe('hosted review slice', () => { const store = makeStore({ activeRuntimeEnvironmentId: 'env-win' } as AppState['settings']) + stampRepoOwner(store, 'runtime:env-win') await store.getState().createStackedHostedReview('/repo', { provider: 'github', @@ -553,6 +561,25 @@ describe('hosted review slice', () => { ) }) + it('creates a review for an unstamped local repo on this computer, not the focused server', async () => { + mockApi.hostedReview.create.mockResolvedValueOnce({ ok: true, number: 1, url: 'u' }) + const focused = { activeRuntimeEnvironmentId: 'env-win' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the slice reads only the focus field of settings. + const store = makeStore(focused as AppState['settings']) + + await store.getState().createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR' + }) + + expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled() + expect(mockApi.hostedReview.create).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/repo', repoId: 'repo-1' }) + ) + }) + it('uses the selected worktree selector for runtime pull request creation eligibility', async () => { runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({ provider: 'github', @@ -564,6 +591,7 @@ describe('hosted review slice', () => { const store = makeStore({ activeRuntimeEnvironmentId: 'env-win' } as AppState['settings']) + stampRepoOwner(store, 'runtime:env-win') await store.getState().getHostedReviewCreationEligibility({ repoPath: '/repo', diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 255b29879ac7..fa8d50a6eef2 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -22,7 +22,7 @@ import { hostedReviewBranchLookupArgs, isFreshHostedReview, isStaleMergedGitHubReviewForHead, - settingsForHostedReviewActionOwner, + hostedReviewRepoOwnerTarget, settingsForHostedReviewRepoOwner, shouldRefetchForLinkedHint, shouldRefetchGitHubScopedResultForNoHint, @@ -69,10 +69,8 @@ export const createHostedReviewSlice: StateCreator { - const settings = get().settings const repo = findHostedReviewRepoByPath(get().repos, args.repoPath, args.repoId) - const ownerSettings = settingsForHostedReviewActionOwner(settings, repo) - const target = getActiveRuntimeTarget(ownerSettings) + const target = hostedReviewRepoOwnerTarget(repo) if (target.kind === 'environment') { const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args void _repoPath @@ -97,10 +95,8 @@ export const createHostedReviewSlice: StateCreator { - const settings = get().settings const repo = findHostedReviewRepoByPath(get().repos, repoPath, input.repoId) - const ownerSettings = settingsForHostedReviewActionOwner(settings, repo) - const target = getActiveRuntimeTarget(ownerSettings) + const target = hostedReviewRepoOwnerTarget(repo) const { repoId: inputRepoId, ...hostedReviewInput } = input if (target.kind === 'environment') { const { worktreePath, ...runtimeInput } = hostedReviewInput @@ -124,10 +120,8 @@ export const createHostedReviewSlice: StateCreator { - const settings = get().settings const repo = findHostedReviewRepoByPath(get().repos, repoPath, input.repoId) - const ownerSettings = settingsForHostedReviewActionOwner(settings, repo) - const target = getActiveRuntimeTarget(ownerSettings) + const target = hostedReviewRepoOwnerTarget(repo) const { repoId: inputRepoId, ...hostedReviewInput } = input if (target.kind === 'environment') { const { worktreePath, ...runtimeInput } = hostedReviewInput diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts b/src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts index 9c38a82f44d4..4178db92470e 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts @@ -222,7 +222,7 @@ export function createObserveTerminalGitHubPullRequestLink( } } // Older hosts may omit exact head evidence; retain their branch-confirmation path. - const pr = await readHostedReviewForRepo(repo, get().settings, { + const pr = await readHostedReviewForRepo(repo, { branch, force: true, active: true, From 7de3710dbed7466ebf0ff7edc84738ce9906c4f7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 11:23:18 -0700 Subject: [PATCH 003/110] refactor(files): file reads and edits route by the file's owner (#27278) * refactor(files): file reads and edits route by the file's owner The runtime file clients took a settings object and turned its Active Server field into a connection, so any caller that passed raw settings silently read or wrote on the focused server. They now take `target: RuntimeClientTarget`, and every caller derives it from the owner: the tab's stamped owner, else its worktree's owner (getRuntimeTargetForFileOwner / getRuntimeTargetForWorktreeOwner), the file-explorer operation owner, or an already captured owner id. Behavior: single-host (local-only) users see no change. An editor tab opened without an owner stamp, a terminal link with no PTY owner, an OS file drop and a notebook link now go to the workspace's owner instead of whichever server is focused when the read happens. Removes getEditorFileDropSettingsForWorktree and the native-chat settings clone cache; settingsForWorktreeOperationRoute loses its editor caller. * fix(files): file owner targets come from the rows, never the focused server getRuntimeTargetForWorktreeOwner/getRuntimeTargetForFileOwner move to lib/file-owner-runtime-target.ts and resolve through the focus-free resolveOwner. A workspace with no row yet stays on this app (tabs and drops stamp a server owner when one exists); rows that disagree return null, and callers with an error path refuse via requireRuntimeTargetForFileOwner. * test: keep the editor content-state fixture within the line limit * fix(files): an unresolved document owner blocks local image reads A null owner target became runtimeContext undefined, which image readers take as a plain local file, so a server-owned document's images were read from this machine. It is now runtimeContext null, the readers' do-not-load sentinel, in the rich editor and the preview, and prewarm skips it. * fix(files): read the image node view's storage through a named type --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 118 ++---------------- config/owner-routing-baseline.txt | 27 ---- .../navigate/navigate-browser-page-url.ts | 3 +- .../doc-preview-document-actions.test.ts | 20 +++ .../src/components/editor/EditorPanel.tsx | 17 ++- .../editor/ExternalFileChangeBanner.tsx | 8 +- .../ExternalFileChangeCompareDialog.tsx | 16 ++- .../MarkdownPreview.image-access.test.tsx | 21 +++- ...nPreview.link-routing.interaction.test.tsx | 21 +++- ...rkdownPreview.toc-visibility-gate.test.tsx | 21 +++- .../components/editor/RichMarkdownEditor.tsx | 12 +- .../components/editor/csv/CsvPagedViewer.tsx | 5 +- .../editor/csv/csv-paged-preview.test.ts | 2 +- .../editor/csv/useCsvPagedPreview.test.tsx | 4 +- ...or-dropped-file-open.escaping-link.test.ts | 17 ++- .../editor/editor-dropped-file-open.test.ts | 26 ++-- .../editor/editor-dropped-file-open.ts | 38 ++---- .../editor-restored-tab-conflict-scan.ts | 16 ++- .../markdown-document-list-request.test.ts | 6 +- .../editor/markdown-document-list-request.ts | 2 +- .../editor/markdown-preview-link-actions.ts | 7 +- .../markdown-preview-local-images.test.ts | 4 +- .../editor/markdown-preview-local-images.ts | 8 +- ...ed-editor-workspace-runtime-owner.test.tsx | 2 +- .../rich-markdown-async-image-paste.test.ts | 9 +- .../rich-markdown-editor-click-routing.ts | 3 - .../rich-markdown-editor-config.test.ts | 2 +- .../editor/rich-markdown-editor-config.ts | 12 +- .../editor/rich-markdown-extensions.ts | 9 +- .../rich-markdown-image-context.test.ts | 58 +++++++++ .../editor/rich-markdown-image-context.ts | 62 ++++++--- .../editor/rich-markdown-image-insert.ts | 7 +- ...arkdown-large-text-paste.selection.test.ts | 2 +- .../editor/rich-markdown-paste-image.ts | 7 +- ...itor-group-file-drop-owner.remote.test.tsx | 4 +- .../use-editor-group-file-drop-owner.test.tsx | 17 ++- .../use-markdown-preview-source-foundation.ts | 31 +++-- .../useEditorPanelContentState.test.tsx | 16 ++- .../editor/useEditorPanelFileContentLoader.ts | 29 +++-- ...torPanelRemoteSiblingContentState.test.tsx | 19 +++ .../editor/useLocalImageSrc.test.ts | 14 +-- .../src/components/editor/useLocalImageSrc.ts | 6 +- .../useMarkdownDocuments.navigation.test.ts | 19 ++- .../useMarkdownDocuments.ownership.test.tsx | 9 +- .../components/editor/useMarkdownDocuments.ts | 21 +++- .../editor/useMarkdownDocuments.watch.test.ts | 21 +++- .../useRichMarkdownEditorInstance.test.tsx | 2 +- .../useRichMarkdownProgrammaticSync.test.ts | 3 +- .../editor/useRichMarkdownProgrammaticSync.ts | 16 +-- .../useRichMarkdownReconcileRoundTrip.ts | 7 +- .../FloatingTerminalPanel.bounds.test.tsx | 2 +- .../use-floating-terminal-create-actions.ts | 5 +- .../NativeChatAgentMessageRow.test.tsx | 4 +- .../NativeChatImageAttachmentPreview.test.tsx | 2 +- .../NativeChatImageAttachmentPreview.tsx | 3 +- .../NativeChatTranscriptChrome.test.tsx | 2 +- .../native-chat-file-link-existence.test.ts | 7 +- .../native-chat-file-link-existence.ts | 5 +- .../native-chat-image-runtime-context.test.ts | 6 +- .../native-chat-image-runtime-context.ts | 48 +++---- ...en-file-list-remote-catalog.react.test.tsx | 5 +- .../quick-open-file-list.react.test.tsx | 6 +- .../src/components/quick-open-file-list.ts | 6 +- .../quick-open-file-navigation.test.ts | 14 +-- .../components/quick-open-file-navigation.ts | 6 +- .../quick-open-recent-validation.test.ts | 2 +- .../FileExplorerRow.actions.test.tsx | 4 +- .../file-explorer-deletion-editors.ts | 4 +- .../file-explorer-directory-listing.ts | 2 +- .../file-explorer-instant-dir-toggle.test.ts | 2 +- ...file-explorer-operation-generation.test.ts | 2 +- .../file-explorer-operation-owner.ts | 16 +-- .../use-file-explorer-tree-pane-state.ts | 11 +- .../right-sidebar/useFileDeletion.ts | 8 +- .../right-sidebar/useFileDuplicate.ts | 2 +- .../right-sidebar/useFileExplorerHandlers.ts | 3 +- .../useFileExplorerImport.remote.test.tsx | 4 +- .../right-sidebar/useFileExplorerImport.ts | 2 +- .../useFileExplorerInlineInput.ts | 14 +-- .../right-sidebar/useFileExplorerMoveDrop.ts | 2 +- .../right-sidebar/useFileExplorerTree.ts | 2 +- .../useFileSearchPanel.lifecycle.test.tsx | 2 +- .../useFileSearchRunner.test.tsx | 6 +- .../right-sidebar/useFileSearchRunner.ts | 9 +- .../McpConfigSection.host-owner.test.tsx | 6 +- .../components/settings/McpConfigSection.tsx | 3 +- .../settings/mcp-config-inspection.ts | 2 +- .../tab-bar/tab-create-entry-action.test.ts | 2 +- .../tab-bar/tab-create-entry-local-path.ts | 10 +- .../terminal-drop-handler.test.ts | 6 +- ...erminal-drop-runtime-catalog-owner.test.ts | 2 +- .../terminal-file-link-target.test.ts | 2 +- .../terminal-file-open-routing.ts | 9 +- .../terminal-file-path-mapping.ts | 22 +++- .../terminal-host-workspace-file.ts | 12 +- ...minal-link-remote-runtime-ssh-open.test.ts | 8 +- .../terminal-native-file-drop-destination.ts | 2 - .../terminal-native-file-drop.ts | 8 +- .../terminal-pane-element-file-drop.test.tsx | 2 +- .../terminal-path-existence-batch.test.ts | 12 +- .../terminal-path-existence-batch.ts | 5 +- .../terminal-worktree-path-link.test.ts | 12 +- .../terminal-worktree-path-link.ts | 5 +- .../composer-state/attachment-drop-state.ts | 11 +- ...editor-external-watch-disk-verification.ts | 5 +- .../hooks/editor-runtime-file-watch.test.ts | 4 +- .../src/hooks/editor-runtime-file-watch.ts | 3 +- .../src/lib/create-untitled-markdown.test.ts | 20 +-- .../src/lib/create-untitled-markdown.ts | 16 +-- .../lib/editor-file-operation-owner.test.ts | 2 +- .../src/lib/editor-file-operation-owner.ts | 9 +- .../lib/execute-open-editor-path-move.test.ts | 2 +- .../src/lib/file-owner-runtime-target.test.ts | 106 ++++++++++++++++ .../src/lib/file-owner-runtime-target.ts | 58 +++++++++ .../src/lib/file-search-result-owner.test.ts | 10 +- .../src/lib/file-search-result-owner.ts | 6 +- .../lib/floating-workspace-tab-creation.ts | 2 +- ...loating-workspace-terminal-actions.test.ts | 4 +- src/renderer/src/lib/local-file-access.ts | 13 +- .../lib/markdown-document-templates.test.ts | 6 +- .../src/lib/markdown-document-templates.ts | 6 +- src/renderer/src/lib/rename-file.ts | 2 +- .../src/lib/user-opened-local-path.test.ts | 2 +- .../src/lib/workspace-file-host-routing.ts | 9 +- .../src/runtime/mobile-markdown-bridge.ts | 4 +- .../remote-host-file-open-repro.test.ts | 17 ++- .../src/runtime/runtime-client-target.ts | 5 + .../runtime-file-client-download.test.ts | 12 +- ...untime-file-client-external-import.test.ts | 12 +- ...ime-file-client-mutation-ownership.test.ts | 22 ++-- ...runtime-file-client-search-listing.test.ts | 48 +++---- .../src/runtime/runtime-file-client-types.ts | 8 +- .../runtime/runtime-file-client-watch.test.ts | 20 +-- .../src/runtime/runtime-file-client.test.ts | 52 ++++---- .../src/runtime/runtime-file-import-client.ts | 3 +- ...ntime-file-import-pairing-revision.test.ts | 4 +- .../runtime/runtime-file-metadata-client.ts | 4 +- .../runtime/runtime-file-mutation-client.ts | 4 +- .../runtime/runtime-file-range-client.test.ts | 14 ++- .../src/runtime/runtime-file-range-client.ts | 6 +- .../src/runtime/runtime-file-read-client.ts | 13 +- .../src/runtime/runtime-file-routing.ts | 21 ++-- .../src/runtime/runtime-file-search-client.ts | 10 +- .../src/runtime/runtime-file-watch-client.ts | 8 +- .../runtime-legacy-recent-freshness.test.ts | 2 +- .../runtime-path-existence-batch.test.ts | 2 +- ...untime-path-existence-error-parity.test.ts | 4 +- .../runtime-path-existence-pairing.test.ts | 2 +- ...ntime-path-existence-queue-pairing.test.ts | 4 +- ...e-quick-open-version-compatibility.test.ts | 20 +-- .../runtime/use-worktree-runtime-target.ts | 26 +++- .../editor/actions/markdown-link-action.ts | 5 +- .../actions/markdown-preview-actions.ts | 2 +- 153 files changed, 1090 insertions(+), 734 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-image-context.test.ts create mode 100644 src/renderer/src/lib/file-owner-runtime-target.test.ts create mode 100644 src/renderer/src/lib/file-owner-runtime-target.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index a82f0220b48c..b377f24e894b 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -10,7 +10,6 @@ 1 src/renderer/src/components/LinearIssueWorkspace.tsx 1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx 1 src/renderer/src/components/NativeChatResumeOnRestartModal.tsx -2 src/renderer/src/components/QuickOpen.tsx 1 src/renderer/src/components/activity/ActivityPrototypePage.tsx 1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx 3 src/renderer/src/components/activity/activity-thread-actions.ts @@ -31,54 +30,23 @@ 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts -2 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts +1 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts 1 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx 2 src/renderer/src/components/cmd-j/quick-action-context.ts -1 src/renderer/src/components/editor/EditorAutosaveController.tsx -2 src/renderer/src/components/editor/EditorContent.tsx -1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx -1 src/renderer/src/components/editor/EditorEditFileSurface.tsx -1 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx -2 src/renderer/src/components/editor/EditorPanel.tsx -4 src/renderer/src/components/editor/ExternalFileChangeBanner.tsx -3 src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx -1 src/renderer/src/components/editor/MarkdownPreview.tsx 1 src/renderer/src/components/editor/MonacoGutterContextMenu.tsx -4 src/renderer/src/components/editor/RichMarkdownEditor.tsx 1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx 4 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts 1 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts -2 src/renderer/src/components/editor/csv/CsvPagedViewer.tsx -1 src/renderer/src/components/editor/csv/csv-file-content.ts 1 src/renderer/src/components/editor/csv/csv-link-routing.ts 2 src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts -7 src/renderer/src/components/editor/editor-dropped-file-open.ts -1 src/renderer/src/components/editor/editor-header-file-rename.ts 1 src/renderer/src/components/editor/editor-model-lifetime-fixture.ts -6 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts -1 src/renderer/src/components/editor/editor-save-queue.ts -1 src/renderer/src/components/editor/local-image-src-reader.ts -3 src/renderer/src/components/editor/markdown-document-list-request.ts -2 src/renderer/src/components/editor/markdown-preview-link-actions.ts -1 src/renderer/src/components/editor/markdown-preview-local-images.ts +1 src/renderer/src/components/editor/markdown-preview-link-actions.ts 1 src/renderer/src/components/editor/markdown-preview-system-link-action.ts 1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts -1 src/renderer/src/components/editor/rich-markdown-editor-config.ts -2 src/renderer/src/components/editor/rich-markdown-image-context.ts -4 src/renderer/src/components/editor/rich-markdown-image-insert.ts -1 src/renderer/src/components/editor/rich-markdown-paste-handler.ts -3 src/renderer/src/components/editor/rich-markdown-paste-image.ts -1 src/renderer/src/components/editor/use-markdown-preview-components.tsx -1 src/renderer/src/components/editor/use-markdown-preview-foundation.ts -1 src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts 4 src/renderer/src/components/editor/useEditorPanelContentState.ts 5 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts -7 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts -1 src/renderer/src/components/editor/useLocalImagePick.ts -5 src/renderer/src/components/editor/useLocalImageSrc.ts -5 src/renderer/src/components/editor/useMarkdownDocuments.ts -1 src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts -1 src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts +1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +1 src/renderer/src/components/editor/useMarkdownDocuments.ts 2 src/renderer/src/components/feature-wall/ConnectIntegrationsList.tsx 1 src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx 1 src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx @@ -94,10 +62,8 @@ 1 src/renderer/src/components/native-chat-resume-tree.test-support.ts 2 src/renderer/src/components/native-chat/NativeChatComposer.tsx 1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx -1 src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx 1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx 1 src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts -4 src/renderer/src/components/native-chat/native-chat-file-link-existence.ts 1 src/renderer/src/components/native-chat/native-chat-file-link.ts 1 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts 1 src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -105,10 +71,7 @@ 2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts 6 src/renderer/src/components/native-chat/native-chat-runtime-send.ts 2 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts -2 src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts -1 src/renderer/src/components/native-chat/use-native-chat-file-link-existence.tsx 6 src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts -1 src/renderer/src/components/native-chat/use-native-chat-mention-files.ts 2 src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts 4 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts 2 src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -118,13 +81,10 @@ 2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts 1 src/renderer/src/components/ports/WorkspacePortScanner.tsx -2 src/renderer/src/components/quick-open-file-list-test-harness.ts -6 src/renderer/src/components/quick-open-file-list.ts -4 src/renderer/src/components/quick-open-file-navigation.ts -2 src/renderer/src/components/quick-open-recent-validation.ts +1 src/renderer/src/components/quick-open-file-list-test-harness.ts 3 src/renderer/src/components/repo/CreateFromPicker.tsx 1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx -3 src/renderer/src/components/right-sidebar/FileExplorer.tsx +2 src/renderer/src/components/right-sidebar/FileExplorer.tsx 1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx 1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx 1 src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts @@ -136,9 +96,7 @@ 3 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx 3 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx -2 src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts -1 src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts -4 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +1 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts 1 src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx 1 src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts 5 src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -173,21 +131,10 @@ 2 src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts 2 src/renderer/src/components/right-sidebar/use-create-pull-request-field-seeding.ts 1 src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts -1 src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts -2 src/renderer/src/components/right-sidebar/use-file-explorer-node-commands.ts -6 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +2 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts 3 src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts -3 src/renderer/src/components/right-sidebar/useFileDeletion.ts -1 src/renderer/src/components/right-sidebar/useFileDuplicate.ts -2 src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts -2 src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts -4 src/renderer/src/components/right-sidebar/useFileExplorerImport.ts -3 src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts -1 src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts -2 src/renderer/src/components/right-sidebar/useFileExplorerTree.ts -2 src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts 4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts -4 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +1 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts 1 src/renderer/src/components/right-sidebar/useFileSearchScope.ts 2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts 3 src/renderer/src/components/settings/BaseRefPicker.tsx @@ -197,7 +144,6 @@ 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/IntegrationsPane.tsx 3 src/renderer/src/components/settings/LinearAgentSkillPane.tsx -1 src/renderer/src/components/settings/McpConfigSection.tsx 1 src/renderer/src/components/settings/RepositoryForkSyncSection.tsx 2 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -213,7 +159,6 @@ 3 src/renderer/src/components/settings/cli-source-control-integration-cards.tsx 3 src/renderer/src/components/settings/jira-integration-card.tsx 1 src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx -1 src/renderer/src/components/settings/mcp-config-inspection.ts 1 src/renderer/src/components/settings/provider-account-scope.ts 1 src/renderer/src/components/settings/settings-persistence-test-fixture.ts 2 src/renderer/src/components/settings/settings-setup-guide-progress.ts @@ -279,12 +224,8 @@ 1 src/renderer/src/components/status-bar/use-status-bar-controller.ts 1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts 1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts -1 src/renderer/src/components/tab-bar/EditorFileTab.tsx 1 src/renderer/src/components/tab-bar/TabBar.tsx -2 src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx 3 src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts -2 src/renderer/src/components/tab-bar/tab-create-entry-action.ts -3 src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts 1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts 4 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts 2 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts @@ -298,26 +239,14 @@ 1 src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts 1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts 2 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts -8 src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts -1 src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts -2 src/renderer/src/components/terminal-pane/terminal-file-link-target.ts -6 src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts -3 src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts 1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts -2 src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts 1 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts -2 src/renderer/src/components/terminal-pane/terminal-link-handlers.ts 1 src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts -1 src/renderer/src/components/terminal-pane/terminal-link-provider-buffer-fixtures.ts -1 src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts 2 src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts -1 src/renderer/src/components/terminal-pane/terminal-pane-pane-links.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts -3 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts -2 src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts @@ -327,7 +256,6 @@ 1 src/renderer/src/components/terminal/running-terminal-close-guard.ts 1 src/renderer/src/components/terminal/terminal-tab-create.ts 1 src/renderer/src/components/terminal/window-close-running-work.ts -2 src/renderer/src/components/use-quick-open-interaction.ts 2 src/renderer/src/components/use-task-page-source-availability.ts 2 src/renderer/src/components/use-task-page-store-bindings.ts 2 src/renderer/src/components/use-terminal-activation-actions.ts @@ -339,7 +267,7 @@ 2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts 2 src/renderer/src/hooks/automation-dispatch-handler.ts 1 src/renderer/src/hooks/automation-dispatch-workspace.ts -4 src/renderer/src/hooks/composer-state/attachment-drop-state.ts +1 src/renderer/src/hooks/composer-state/attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts 2 src/renderer/src/hooks/composer-state/composer-source-state.ts @@ -350,9 +278,7 @@ 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts -1 src/renderer/src/hooks/editor-external-watch-disk-verification.ts 2 src/renderer/src/hooks/editor-external-watch-targets.ts -1 src/renderer/src/hooks/editor-runtime-file-watch.ts 1 src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts 1 src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts 6 src/renderer/src/hooks/ipc-events-test-harness.ts @@ -363,7 +289,7 @@ 2 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts 3 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts 1 src/renderer/src/hooks/useActiveProjectSkillRuntime.ts -2 src/renderer/src/hooks/useEditorExternalWatch.ts +1 src/renderer/src/hooks/useEditorExternalWatch.ts 1 src/renderer/src/hooks/useInstalledAgentSkills.ts 1 src/renderer/src/hooks/useIpcEvents.ts 1 src/renderer/src/hooks/useIssueMetadata.ts @@ -393,7 +319,6 @@ 1 src/renderer/src/lib/execute-open-editor-path-move.ts 1 src/renderer/src/lib/execution-host-facts.ts 6 src/renderer/src/lib/file-preview.ts -1 src/renderer/src/lib/file-search-result-owner.ts 2 src/renderer/src/lib/folder-workspace-runtime-owner.ts 1 src/renderer/src/lib/github-pr-start-point.ts 1 src/renderer/src/lib/host-mirrored-pane-liveness.ts @@ -407,14 +332,11 @@ 1 src/renderer/src/lib/launch-work-item-direct-preflight.ts 2 src/renderer/src/lib/launch-work-item-direct.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts -3 src/renderer/src/lib/local-file-access.ts 2 src/renderer/src/lib/local-preflight-context.ts -1 src/renderer/src/lib/markdown-document-templates.ts 1 src/renderer/src/lib/new-workspace.ts 1 src/renderer/src/lib/open-agent-message-sender.ts 2 src/renderer/src/lib/provider-runtime-context.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts -2 src/renderer/src/lib/rename-file.ts 4 src/renderer/src/lib/repo-runtime-owner.ts 2 src/renderer/src/lib/resolve-owner.ts 1 src/renderer/src/lib/runtime-session-mirror-owners.ts @@ -425,11 +347,9 @@ 1 src/renderer/src/lib/terminal-tab-owner-activation.ts 4 src/renderer/src/lib/terminal-worktree-route.ts 3 src/renderer/src/lib/unread-badge-count-selector.ts -1 src/renderer/src/lib/user-opened-local-path.ts 2 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts 1 src/renderer/src/lib/workspace-browser-tab-close.ts 4 src/renderer/src/lib/workspace-browser-tab-open.ts -3 src/renderer/src/lib/workspace-file-host-routing.ts 1 src/renderer/src/lib/workspace-tab-palette-activation.ts 1 src/renderer/src/lib/workspace-terminal-host-authority.ts 1 src/renderer/src/lib/worktree-activation-pty-inventory.ts @@ -444,17 +364,7 @@ 3 src/renderer/src/lib/worktree-runtime-owner.ts 1 src/renderer/src/lib/worktree-sort-order-host-split.ts 1 src/renderer/src/runtime/close-mirrored-editor-tab.ts -3 src/renderer/src/runtime/mobile-markdown-bridge.ts 1 src/renderer/src/runtime/runtime-client-target.ts -2 src/renderer/src/runtime/runtime-file-import-client.ts -3 src/renderer/src/runtime/runtime-file-metadata-client.ts -8 src/renderer/src/runtime/runtime-file-mutation-client.ts -2 src/renderer/src/runtime/runtime-file-range-client.ts -10 src/renderer/src/runtime/runtime-file-read-client.ts -6 src/renderer/src/runtime/runtime-file-routing.ts -5 src/renderer/src/runtime/runtime-file-search-client.ts -1 src/renderer/src/runtime/runtime-file-upload-client.ts -1 src/renderer/src/runtime/runtime-file-watch-client.ts 2 src/renderer/src/runtime/runtime-git-client-context.ts 5 src/renderer/src/runtime/runtime-git-diff-client.ts 5 src/renderer/src/runtime/runtime-git-generation-client.ts @@ -469,7 +379,6 @@ 8 src/renderer/src/runtime/runtime-linear-client.ts 5 src/renderer/src/runtime/runtime-linear-issue-mutations.ts 12 src/renderer/src/runtime/runtime-linear-project-client.ts -1 src/renderer/src/runtime/runtime-path-existence-batch.ts 3 src/renderer/src/runtime/runtime-repo-client.ts 3 src/renderer/src/runtime/runtime-terminal-inspection.ts 1 src/renderer/src/runtime/runtime-terminal-stream.ts @@ -527,12 +436,11 @@ 4 src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts 1 src/renderer/src/store/slices/editor/actions/git-remote-status.ts 8 src/renderer/src/store/slices/editor/actions/git-remote-sync.ts -2 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts +1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts 1 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts 1 src/renderer/src/store/slices/editor/actions/open-file-action.ts 1 src/renderer/src/store/slices/editor/actions/open-file-apply.ts -2 src/renderer/src/store/slices/editor/create-editor-slice.ts -1 src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts +1 src/renderer/src/store/slices/editor/create-editor-slice.ts 1 src/renderer/src/store/slices/github-cache-key.ts 1 src/renderer/src/store/slices/github-slice-test-harness.ts 1 src/renderer/src/store/slices/hosted-review-cache-identity.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index cadca974e089..9cb19d92b224 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -2,22 +2,10 @@ # getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file. # This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead. # Prune after removing sites: pnpm check:owner-routing-ratchet --prune -1 src/renderer/src/components/editor/EditorPanel.tsx -1 src/renderer/src/components/editor/ExternalFileChangeBanner.tsx -1 src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx 1 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts -2 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts -1 src/renderer/src/components/editor/markdown-preview-link-actions.ts -1 src/renderer/src/components/editor/rich-markdown-image-context.ts -1 src/renderer/src/components/editor/rich-markdown-image-insert.ts -1 src/renderer/src/components/editor/rich-markdown-paste-image.ts -1 src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts 1 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts -1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts -2 src/renderer/src/components/editor/useMarkdownDocuments.ts 1 src/renderer/src/components/linear-api-key-dialog.tsx 1 src/renderer/src/components/ports/WorkspacePortScanner.tsx -1 src/renderer/src/components/quick-open-file-navigation.ts 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -27,9 +15,6 @@ 1 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts 1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts 1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts -1 src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts -1 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts -1 src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts @@ -43,19 +28,8 @@ 1 src/renderer/src/lib/github-pr-start-point.ts 1 src/renderer/src/lib/launch-agent-background-session.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts -1 src/renderer/src/lib/local-file-access.ts 1 src/renderer/src/lib/structured-agent-session-tab-activation.ts -1 src/renderer/src/lib/workspace-file-host-routing.ts 1 src/renderer/src/lib/worktree-creation-flow-startup.ts -1 src/renderer/src/runtime/mobile-markdown-bridge.ts -1 src/renderer/src/runtime/runtime-file-import-client.ts -1 src/renderer/src/runtime/runtime-file-metadata-client.ts -1 src/renderer/src/runtime/runtime-file-mutation-client.ts -1 src/renderer/src/runtime/runtime-file-range-client.ts -2 src/renderer/src/runtime/runtime-file-read-client.ts -4 src/renderer/src/runtime/runtime-file-routing.ts -4 src/renderer/src/runtime/runtime-file-search-client.ts -1 src/renderer/src/runtime/runtime-file-watch-client.ts 1 src/renderer/src/runtime/runtime-git-client-context.ts 5 src/renderer/src/runtime/runtime-git-diff-client.ts 5 src/renderer/src/runtime/runtime-git-generation-client.ts @@ -85,7 +59,6 @@ 1 src/renderer/src/store/repos/repo-update.ts 1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 1 src/renderer/src/store/slices/diff-comment-persistence.ts -1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts 1 src/renderer/src/store/slices/hosted-review.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts index a3ba85c5a353..fdf57e706c50 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -22,6 +22,7 @@ import type { } from '../describe-page/browser-page-types' import type { MutableRefObject } from 'react' import { statUserOpenedPath } from '@/lib/user-opened-local-path' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' export type NavigateBrowserPageToUrlArgs = { url: string @@ -100,7 +101,7 @@ export function navigateBrowserPageToUrl({ // Why getKnownWorktreeById: folder and floating workspaces are absent from allWorktrees(). const activeWorktree = store.getKnownWorktreeById(worktreeId) const fileContext: RuntimeFileOperationArgs = { - settings: store.settings, + target: requireRuntimeTargetForFileOwner(store, worktreeId, undefined), worktreeId, worktreePath: activeWorktree?.path, connectionId: undefined diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts index 3842d9d95456..485595bcb9f8 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts @@ -27,6 +27,26 @@ vi.mock('@/lib/connection-owner-resolution', () => ({ vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => mocks.worktreeRuntimeOwnerId })) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined + ) => { + const owner = + runtimeEnvironmentId === undefined ? mocks.worktreeRuntimeOwnerId : runtimeEnvironmentId + return owner + ? { kind: 'environment' as const, environmentId: owner } + : { kind: 'local' as const } + } + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => mocks.connectionIdForWorkspace, getConnectionIdForFile: () => mocks.connectionIdForFile diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 118b14a42bb6..938c13868066 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -4,7 +4,7 @@ import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' import { canShowWorkspaceFileBrowserAction, openFilePreviewToSide } from '@/lib/file-preview' import { getEditorHeaderCopyState } from './editor-header' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { exportActiveMarkdownToPdf } from './export-active-markdown' import type { EditorToggleValue } from './EditorViewToggle' import { EditorPanelShell } from './EditorPanelShell' @@ -142,6 +142,16 @@ function EditorPanelInner({ closeRenameDialog, handleRenameConfirm } = useUntitledFileRename({ openFiles, clearUntitled }) + // Why not local: an unresolved owner must not offer this machine's folder picker either. + const renameDialogFileOffClient = useAppStore( + (s) => + !!renameDialogFile && + getRuntimeTargetForFileOwner( + s, + renameDialogFile.worktreeId, + renameDialogFile.runtimeEnvironmentId + )?.kind !== 'local' + ) useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview }) @@ -279,10 +289,7 @@ function EditorPanelInner({ ) } const disableRenameBrowse = Boolean( - settingsForRuntimeOwner( - settings, - renameDialogFile?.runtimeEnvironmentId - )?.activeRuntimeEnvironmentId?.trim() || + renameDialogFileOffClient || (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) ) const markdownDocumentStateFileId = diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx index 71a11eaa7757..be943fb5c8ef 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button' import { getConnectionIdForFile } from '@/lib/connection-context' import { editorTabFileAccess } from '@/lib/local-file-access' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import type { OpenFile } from '@/store/slices/editor' @@ -87,8 +87,12 @@ export function keepTabEditsOverExternalChange(file: OpenFile): void { // the dismissed conflict from the stale signature on every launch. // Best-effort: a failed read leaves the old signature, which can only // re-surface the banner — never lose data. + const target = getRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId) + if (!target) { + return + } void readRuntimeFileContent({ - settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + target, filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, diff --git a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx index 26cd9fbe95f7..c3a506e351e6 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx @@ -14,7 +14,10 @@ import { getConnectionIdForFile } from '@/lib/connection-context' import { editorTabFileAccess } from '@/lib/local-file-access' import { detectLanguage } from '@/lib/language-detect' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + FILE_OWNER_UNRESOLVED_MESSAGE, + getRuntimeTargetForFileOwner +} from '@/lib/file-owner-runtime-target' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { translate } from '@/i18n/i18n' @@ -57,8 +60,17 @@ export function ExternalFileChangeCompareDialog({ setDiskState({ kind: 'loading' }) // Why: read at open time — the banner can be minutes old and the agent // may have written again since; the comparison must show current disk. + const target = getRuntimeTargetForFileOwner( + useAppStore.getState(), + file.worktreeId, + file.runtimeEnvironmentId + ) + if (!target) { + setDiskState({ kind: 'error', message: FILE_OWNER_UNRESOLVED_MESSAGE }) + return + } void readRuntimeFileContent({ - settings: settingsForRuntimeOwner(useAppStore.getState().settings, file.runtimeEnvironmentId), + target, filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, diff --git a/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx index 7e815d20f43f..a8a579c29b3d 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx @@ -39,8 +39,25 @@ vi.mock('@/store', () => { return { useAppStore } }) vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (settings: unknown) => settings +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) +vi.mock('@/runtime/use-worktree-runtime-target', () => ({ + useFileOwnerRuntimeTarget: (_worktreeId: unknown, runtimeEnvironmentId?: string | null) => + runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) diff --git a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx index e244a50865a3..d22e47bcdf8a 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx @@ -59,8 +59,25 @@ vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: (_worktrees: unknown, id: string) => worktreeLookup.value.find((worktree) => worktree.id === id) ?? null })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (settings: unknown) => settings +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) +vi.mock('@/runtime/use-worktree-runtime-target', () => ({ + useFileOwnerRuntimeTarget: (_worktreeId: unknown, runtimeEnvironmentId?: string | null) => + runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: statRuntimePathMock diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx index 645d1f556d60..f6fb46d38ce1 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx @@ -45,8 +45,25 @@ vi.mock('@/store', () => { return { useAppStore } }) vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (settings: unknown) => settings +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) +vi.mock('@/runtime/use-worktree-runtime-target', () => ({ + useFileOwnerRuntimeTarget: (_worktreeId: unknown, runtimeEnvironmentId?: string | null) => + runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 6678cda35699..86624380c3b6 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useFileOwnerRuntimeTarget } from '@/runtime/use-worktree-runtime-target' import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment } from '../../../../shared/diff-comment-types' import { useAppStore } from '@/store' @@ -56,8 +57,10 @@ export default function RichMarkdownEditor({ headerSlot }: RichMarkdownEditorProps): React.JSX.Element { const rootRef = useRef(null) - const settings = useAppStore((s) => s.settings) - const richMarkdownSpellcheckEnabled = settings?.richMarkdownSpellcheckEnabled ?? true + const richMarkdownSpellcheckEnabled = useAppStore( + (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true + ) + const runtimeTarget = useFileOwnerRuntimeTarget(worktreeId, runtimeEnvironmentId) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const addDiffComment = useAppStore((s) => s.addDiffComment) @@ -200,7 +203,7 @@ export default function RichMarkdownEditor({ runtimeEnvironmentId, isMac, richMarkdownSpellcheckEnabled, - settings, + runtimeTarget, activateMarkdownLink, rootRef, editorRef, @@ -296,8 +299,7 @@ export default function RichMarkdownEditor({ baseCanonicalRef, markdownDocuments, rootRef, - runtimeEnvironmentId, - settings, + runtimeTarget, slashMenuSetter: menu.setSlashMenu, worktreeId, worktreeRoot diff --git a/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx b/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx index 968a2420e280..65b558ad3ce9 100644 --- a/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx +++ b/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx @@ -11,6 +11,7 @@ import { EditorFileLoadErrorView } from '../EditorFileLoadErrorView' import { useCsvColumnWidths } from './useCsvColumnWidths' import { buildFileViewPreferenceKey } from '../file-view-preference-storage' import { openCsvHttpLink } from './csv-link-routing' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' export default function CsvPagedViewer({ file, @@ -97,7 +98,7 @@ function CsvPagedTable({ preferenceKey ?? buildFileViewPreferenceKey({ worktreeId: file.readArgs.worktreeId ?? '', - runtimeEnvironmentId: file.readArgs.settings?.activeRuntimeEnvironmentId, + runtimeEnvironmentId: runtimeTargetEnvironmentId(file.readArgs.target), externalSshTargetId: file.readArgs.connectionId, filePath: file.readArgs.filePath }), @@ -124,7 +125,7 @@ function CsvPagedTable({ openCsvHttpLink(url, event, { filePath: file.readArgs.filePath, worktreeId: file.readArgs.worktreeId, - runtimeEnvironmentId: file.readArgs.settings?.activeRuntimeEnvironmentId, + runtimeEnvironmentId: runtimeTargetEnvironmentId(file.readArgs.target), connectionId: file.readArgs.connectionId ?? null }) } diff --git a/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts b/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts index 8e873a8a79a6..0787baf66099 100644 --- a/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts +++ b/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts @@ -15,7 +15,7 @@ vi.mock('./csv-preview-worker-client', () => ({ })) const snapshot = { size: 1024 * 1024, mtime: 1, isDirectory: false } -const file = { readArgs: { settings: null, filePath: '/repo/a.csv' }, snapshot } +const file = { readArgs: { target: { kind: 'local' as const }, filePath: '/repo/a.csv' }, snapshot } beforeEach(() => { vi.resetAllMocks() mocks.stat.mockResolvedValue(snapshot) diff --git a/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx b/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx index 8f9cf3349ab5..fceac36d0940 100644 --- a/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx +++ b/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx @@ -39,7 +39,7 @@ it('keeps the new viewport loader alive when a canceled delimiter scan rejects l ]) ) const file = { - readArgs: { settings: null, filePath: '/repo/a.csv' }, + readArgs: { target: { kind: 'local' as const }, filePath: '/repo/a.csv' }, snapshot: { size: 20, mtime: 1, isDirectory: false } } const { result, rerender } = renderHook(({ delimiter }) => useCsvPagedPreview(file, delimiter), { @@ -79,7 +79,7 @@ it('coalesces rapid scrolls and releases rows outside the latest viewport', asyn ) ) const file = { - readArgs: { settings: null, filePath: '/repo/a.csv' }, + readArgs: { target: { kind: 'local' as const }, filePath: '/repo/a.csv' }, snapshot: { size: 80, mtime: 1, isDirectory: false } } const { result } = renderHook(() => useCsvPagedPreview(file, ',')) diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts b/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts index 02198a89201a..6f04f5e23ab4 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts @@ -11,9 +11,20 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/lib/ssh-mutation-expectation', () => ({ captureWorktreeSshMutationExpectation: () => ({}) })) -vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: () => null -})) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + _runtimeEnvironmentId: string | null | undefined + ) => ({ kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) vi.mock('@/lib/user-opened-local-path', () => ({ statUserOpenedPath: mocks.statUserOpenedPath })) vi.mock('@/store', () => ({ useAppStore: { diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.test.ts b/src/renderer/src/components/editor/editor-dropped-file-open.test.ts index 5409651b6669..452e6096982d 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.test.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.test.ts @@ -1,30 +1,28 @@ import { describe, expect, it } from 'vitest' import { getEditorFileDropOperationContext, - getEditorFileDropSettingsForWorktree, shouldUploadRemoteEditorFileDrop } from './editor-dropped-file-open' +import { getRuntimeTargetForWorktreeOwner } from '@/lib/file-owner-runtime-target' describe('shouldUploadRemoteEditorFileDrop', () => { it('does not upload editor drops for local workspaces', () => { - expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, null)).toBe(false) + expect(shouldUploadRemoteEditorFileDrop({ kind: 'local' }, null)).toBe(false) }) it('uploads editor drops while a runtime environment is active', () => { - expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: 'env-1' }, null)).toBe( - true - ) + expect( + shouldUploadRemoteEditorFileDrop({ kind: 'environment', environmentId: 'env-1' }, null) + ).toBe(true) }) it('uploads editor drops for SSH workspaces', () => { - expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe( - true - ) + expect(shouldUploadRemoteEditorFileDrop({ kind: 'local' }, 'ssh-1')).toBe(true) }) it('uses the worktree owner runtime instead of the focused runtime', () => { expect( - getEditorFileDropSettingsForWorktree( + getRuntimeTargetForWorktreeOwner( { settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }], @@ -32,12 +30,12 @@ describe('shouldUploadRemoteEditorFileDrop', () => { }, 'wt-1' ) - ).toEqual({ activeRuntimeEnvironmentId: 'owner-runtime' }) + ).toEqual({ kind: 'environment', environmentId: 'owner-runtime' }) }) it('keeps explicit local worktree editor drops local while a runtime is focused', () => { expect( - getEditorFileDropSettingsForWorktree( + getRuntimeTargetForWorktreeOwner( { settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], @@ -45,7 +43,7 @@ describe('shouldUploadRemoteEditorFileDrop', () => { }, 'wt-1' ) - ).toEqual({ activeRuntimeEnvironmentId: null }) + ).toEqual({ kind: 'local' }) }) it('builds file operation context from the worktree owner instead of global focus', () => { @@ -61,7 +59,7 @@ describe('shouldUploadRemoteEditorFileDrop', () => { undefined ) ).toEqual({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repos/repo-1', connectionId: undefined @@ -81,7 +79,7 @@ describe('shouldUploadRemoteEditorFileDrop', () => { 'ssh-1' ) ).toEqual({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/home/orca/repo-1', connectionId: 'ssh-1' diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.ts b/src/renderer/src/components/editor/editor-dropped-file-open.ts index a07b57b658c0..1d253dbf0eee 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.ts @@ -4,39 +4,27 @@ import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-lin import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { joinPath } from '@/lib/path' -import { - getRuntimeEnvironmentIdForWorktree, - type WorktreeRuntimeOwnerState -} from '@/lib/worktree-runtime-owner' +import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner' import { importExternalPathsToRuntime, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { + runtimeTargetEnvironmentId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { translate } from '@/i18n/i18n' import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { statUserOpenedPath } from '@/lib/user-opened-local-path' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace' - -export function getEditorFileDropSettingsForWorktree( - store: WorktreeRuntimeOwnerState, - worktreeId: string -): Pick { - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) - // Why: OS drops target the selected worktree. Use that worktree's host owner - // so a focused runtime cannot hijack local/SSH editor drops. - return { - ...store.settings, - activeRuntimeEnvironmentId: runtimeEnvironmentId - } -} +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' export function shouldUploadRemoteEditorFileDrop( - settings: Pick | null | undefined, + target: RuntimeClientTarget, connectionId: string | null | undefined ): boolean { - return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim()) + return target.kind === 'environment' || Boolean(connectionId?.trim()) } export function getEditorFileDropOperationContext( @@ -46,7 +34,8 @@ export function getEditorFileDropOperationContext( connectionId: string | undefined ): RuntimeFileOperationArgs { return { - settings: getEditorFileDropSettingsForWorktree(store, worktreeId), + // Why: OS drops target the selected worktree's owner, never the focused server. + target: requireRuntimeTargetForFileOwner(store, worktreeId, undefined), worktreeId, worktreePath, connectionId @@ -89,7 +78,7 @@ function sameEditorFileDropOwner(a: EditorFileDropContext, b: EditorFileDropCont return ( a.worktreePath === b.worktreePath && a.connectionId === b.connectionId && - left.settings?.activeRuntimeEnvironmentId === right.settings?.activeRuntimeEnvironmentId && + runtimeTargetEnvironmentId(left.target) === runtimeTargetEnvironmentId(right.target) && left.expectedExecutionHostId === right.expectedExecutionHostId && left.expectedSshTargetId === right.expectedSshTargetId && left.expectedSshConnectionGeneration === right.expectedSshConnectionGeneration @@ -149,9 +138,8 @@ async function openEditorFileDropPaths( const store = useAppStore.getState() const { worktreeId, groupId } = destination const groupOptions = groupId ? { targetGroupId: groupId } : null - const dropSettings = fileContext.settings - const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId ?? null - if (shouldUploadRemoteEditorFileDrop(dropSettings, connectionId)) { + const runtimeEnvironmentId = runtimeTargetEnvironmentId(fileContext.target) + if (shouldUploadRemoteEditorFileDrop(fileContext.target, connectionId)) { if (!worktreePath) { toast.error( translate( diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index 72d44520981e..2caf51e322ed 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -5,7 +5,10 @@ import type { OpenFile } from '@/store/slices/editor' import { getConnectionIdForFile } from '@/lib/connection-context' import { editorTabFileAccess } from '@/lib/local-file-access' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner +} from '@/lib/file-owner-runtime-target' import { canAutoSaveOpenFile } from './editor-autosave' import { getDiskBaselineSignature } from './diff-content-signature' import { markFileChangedOnDisk } from './editor-changed-on-disk-mark' @@ -40,8 +43,13 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { // Only local/SSH paths can be probed: for runtime-owned files window.api.fs would stat the client path and misreport it as gone. const probeFileMissing = async (file: OpenFile): Promise => { - const settings = settingsForRuntimeOwner(store.getState().settings, file.runtimeEnvironmentId) - if (settings?.activeRuntimeEnvironmentId?.trim()) { + const owner = getRuntimeTargetForFileOwner( + store.getState(), + file.worktreeId, + file.runtimeEnvironmentId + ) + // Why `!owner`: with no single owner, a client stat cannot prove the file gone. + if (owner?.kind !== 'local') { return false } try { @@ -65,7 +73,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { try { const state = store.getState() const result = await readRuntimeFileContent({ - settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + target: requireRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId), filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts index d9a56114ea06..062aec9ed984 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.test.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.ts @@ -8,7 +8,7 @@ import { function context(overrides: Partial = {}): RuntimeFileOperationArgs { return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'worktree-1', worktreePath: '/repo', ...overrides @@ -171,7 +171,9 @@ describe('shared Markdown document list requests', () => { it('keeps runtime routes isolated and encodes unusual paths losslessly', () => { const local = context() const ssh = context({ connectionId: 'ssh-1' }) - const runtime = context({ settings: { activeRuntimeEnvironmentId: ' runtime-1 ' } }) + const runtime = context({ + target: { kind: 'environment' as const, environmentId: ' runtime-1 ' } + }) const unusualRoot = '/repo\nwith-newline' const windowsRoot = 'C:\\repo\\docs' const uncRoot = '\\\\server\\share\\repo' diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts index f88406704c88..dc311fc327b2 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -27,7 +27,7 @@ export function getMarkdownDocumentListRequestKey( rootPath: string ): string { return JSON.stringify([ - context.settings?.activeRuntimeEnvironmentId?.trim() ?? '', + context.target.kind === 'environment' ? context.target.environmentId : '', context.connectionId ?? '', context.worktreeId ?? '', context.worktreePath ?? '', diff --git a/src/renderer/src/components/editor/markdown-preview-link-actions.ts b/src/renderer/src/components/editor/markdown-preview-link-actions.ts index f966a02bd885..7e1fd5a6254c 100644 --- a/src/renderer/src/components/editor/markdown-preview-link-actions.ts +++ b/src/renderer/src/components/editor/markdown-preview-link-actions.ts @@ -6,7 +6,7 @@ import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/loc import { getLinkSourceLocalOpenOwner } from '@/lib/link-source-local-open-owner' import { openHttpLink } from '@/lib/http-link-routing' import { translate } from '@/i18n/i18n' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { statRuntimePath } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' @@ -171,8 +171,9 @@ export async function handleMarkdownPreviewLinkClick({ try { const stats = await statRuntimePath( { - settings: settingsForRuntimeOwner( - useAppStore.getState().settings, + target: requireRuntimeTargetForFileOwner( + useAppStore.getState(), + targetWorktree.id, resolvedSourceRuntimeEnvironmentId ), worktreeId: targetWorktree.id, diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.test.ts b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts index 5c5e54b21c6a..b8bcb8ec90f0 100644 --- a/src/renderer/src/components/editor/markdown-preview-local-images.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts @@ -130,7 +130,7 @@ describe('extractMarkdownPreviewLocalImageCandidates', () => { '/repo/docs/readme.md', { runtimeContext: { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' } @@ -141,7 +141,7 @@ describe('extractMarkdownPreviewLocalImageCandidates', () => { '/repo/docs/readme.md', { runtimeContext: { - settings: { activeRuntimeEnvironmentId: 'env-2' }, + target: { kind: 'environment' as const, environmentId: 'env-2' }, worktreeId: 'wt-1', worktreePath: '/repo' } diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.ts b/src/renderer/src/components/editor/markdown-preview-local-images.ts index c366c3bc76a2..51d6631dcc4e 100644 --- a/src/renderer/src/components/editor/markdown-preview-local-images.ts +++ b/src/renderer/src/components/editor/markdown-preview-local-images.ts @@ -31,7 +31,8 @@ type MarkdownImageAstNode = { type ExtractLocalImageCandidatesOptions = { connectionId?: string | null limit?: number - runtimeContext?: MarkdownImageRuntimeContext + /** `null`: the owner is unresolved, so nothing is read. */ + runtimeContext?: MarkdownImageRuntimeContext | null } type PrewarmMarkdownPreviewLocalImagesOptions = ExtractLocalImageCandidatesOptions & { @@ -70,7 +71,7 @@ export function extractMarkdownPreviewLocalImageCandidates( options: ExtractLocalImageCandidatesOptions = {} ): MarkdownPreviewLocalImageCandidate[] { const limit = Math.max(0, options.limit ?? MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT) - if (limit === 0 || !markdown.includes('!')) { + if (limit === 0 || options.runtimeContext === null || !markdown.includes('!')) { return [] } @@ -98,7 +99,8 @@ export function extractMarkdownPreviewLocalImageCandidates( const cacheKey = getLocalImageCacheKey( absolutePath, options.connectionId, - options.runtimeContext, + // Null returned early above. + options.runtimeContext ?? undefined, documentResourceAccess(filePath) ) if (seenCacheKeys.has(cacheKey)) { diff --git a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx index a410a99492ab..661608108a65 100644 --- a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx +++ b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx @@ -125,7 +125,7 @@ describe('a restored tab whose workspace moved to its managed server', () => { await act(async () => load!(FILE_PATH, file.id, WORKTREE, 'README.md')) expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) expect(mocks.readRuntimeFileContent.mock.calls[0]?.[0]).toMatchObject({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: WORKTREE }) expect(fileContents[file.id]).toMatchObject({ content: '# repo' }) diff --git a/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts b/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts index aba0e4b9d26e..74c4e599b98b 100644 --- a/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts @@ -18,7 +18,7 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionId: vi.fn(() => null) vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn(() => ({ - settings: null, + target: { kind: 'local' as const }, folderWorkspaces: [], worktreesByRepo: {}, openFiles: [] @@ -460,7 +460,7 @@ describe('rich Markdown asynchronous image paste', () => { for (const phase of ['saving', 'importing']) { const editor = richEditor() const runtimeContext: RichMarkdownImageRuntimeContext = { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: null @@ -491,7 +491,10 @@ describe('rich Markdown asynchronous image paste', () => { } else if (field === 'root') { nextContext.runtimeContext.worktreePath = '/other-repo' } else if (field === 'runtime') { - nextContext.runtimeContext.settings = { activeRuntimeEnvironmentId: 'other-runtime' } + nextContext.runtimeContext.target = { + kind: 'environment', + environmentId: 'other-runtime' + } } else if (field === 'connection') { nextContext.runtimeContext.connectionId = 'other-connection' } else { diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts index b009be2cd90b..16a2a6824581 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts @@ -11,7 +11,6 @@ import { } from '@/lib/workspace-browser-tab-open' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { getLinkSourceLocalOpenOwner } from '@/lib/link-source-local-open-owner' -import type { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { absolutePathToFileUri as toFileUrlForOsEscape, resolveMarkdownLinkTarget @@ -40,8 +39,6 @@ export type ActivateMarkdownLink = ( } ) => void | Promise -export type RichMarkdownRuntimeSettings = Parameters[0] - type RichMarkdownEditorClickRoutingOptions = { activateMarkdownLink: ActivateMarkdownLink editorRef: MutableRefObject diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts index 8f03d9cf5b27..c71e11f137fb 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -51,7 +51,7 @@ function createConfigParams(overrides: Partial = {}): Editor runtimeEnvironmentId: null, isMac: false, richMarkdownSpellcheckEnabled: true, - settings: { activeRuntimeEnvironmentId: null }, + runtimeTarget: { kind: 'local' }, activateMarkdownLink: vi.fn(), rootRef: ref(null), editorRef: ref(null), diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 606e85557548..c96102fc7f2c 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -19,8 +19,7 @@ import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuat import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { handleRichMarkdownEditorClick, - type ActivateMarkdownLink, - type RichMarkdownRuntimeSettings + type ActivateMarkdownLink } from './rich-markdown-editor-click-routing' import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler' import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' @@ -28,6 +27,7 @@ import { createRichMarkdownImageResolverContext, setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' import type { MutableRefObject, Dispatch, SetStateAction } from 'react' import type { DiffComment } from '../../../../shared/diff-comment-types' @@ -49,7 +49,8 @@ export type EditorConfigParams = { runtimeEnvironmentId?: string | null isMac: boolean richMarkdownSpellcheckEnabled: boolean - settings: RichMarkdownRuntimeSettings + /** The document owner's transport, for image reads. */ + runtimeTarget: RuntimeClientTarget | null activateMarkdownLink: ActivateMarkdownLink rootRef: MutableRefObject editorRef: MutableRefObject @@ -103,7 +104,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE runtimeEnvironmentId, isMac, richMarkdownSpellcheckEnabled, - settings, + runtimeTarget, activateMarkdownLink, rootRef, editorRef, @@ -247,8 +248,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot }) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index e317ee0d0e85..890017c889fd 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -44,7 +44,10 @@ import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight' import { RichMarkdownTaskList } from './rich-markdown-task-list' import { createCachedLowlight } from './rich-markdown-lowlight-cache' import { documentResourceAccess } from '@/lib/local-file-access' -import { resolveRichMarkdownImageUrl } from './rich-markdown-image-context' +import { + readRichMarkdownImageRuntimeContext, + resolveRichMarkdownImageUrl +} from './rich-markdown-image-context' const lowlight = createCachedLowlight(createLowlight(common)) @@ -143,9 +146,7 @@ export function createRichMarkdownExtensions({ releaseImageLease?.() releaseImageLease = undefined const fp = this.storage.filePath as string - const runtimeContext = this.storage.runtimeContext as - | RuntimeFileOperationArgs - | undefined + const runtimeContext = readRichMarkdownImageRuntimeContext(this.storage) const contextVersionAtLoad = getImageContextVersion(this.storage) if (src && fp) { const access = documentResourceAccess(fp) diff --git a/src/renderer/src/components/editor/rich-markdown-image-context.test.ts b/src/renderer/src/components/editor/rich-markdown-image-context.test.ts new file mode 100644 index 000000000000..0459a0fb1c2a --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-image-context.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createRichMarkdownImageResolverContext, + readRichMarkdownImageRuntimeContext +} from './rich-markdown-image-context' +import { prewarmMarkdownPreviewLocalImages } from './markdown-preview-local-images' +import { loadLocalImageSrc, resetLocalImageSrcStateForTests } from './useLocalImageSrc' + +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) + +describe('image reads for a document whose owner is unresolved', () => { + afterEach(() => { + resetLocalImageSrcStateForTests() + vi.unstubAllGlobals() + }) + + it('never reads the image from this machine', async () => { + const readFile = vi.fn(async () => ({ content: '', isBinary: true })) + vi.stubGlobal('window', { ...window, api: { fs: { readFile } } }) + const context = createRichMarkdownImageResolverContext({ + filePath: '/srv/repo/doc.md', + runtimeTarget: null, + worktreeId: 'repo::/srv/repo', + worktreeRoot: '/srv/repo' + }) + // `undefined` would mean a plain local file; an unresolved owner must block instead. + expect(context.runtimeContext).toBeNull() + // The editor's image node view reads the stored context back; null must survive that. + expect( + readRichMarkdownImageRuntimeContext({ runtimeContext: context.runtimeContext }) + ).toBeNull() + + await expect( + loadLocalImageSrc('./shot.png', '/srv/repo/doc.md', undefined, context.runtimeContext) + ).resolves.toBeNull() + const loadImage = vi.fn(async () => null) + prewarmMarkdownPreviewLocalImages('![shot](./shot.png)', '/srv/repo/doc.md', { + runtimeContext: null, + loadImage + }) + await Promise.resolve() + expect(loadImage).not.toHaveBeenCalled() + expect(readFile).not.toHaveBeenCalled() + }) + + it('keeps no workspace distinct from an unresolved owner', () => { + expect( + createRichMarkdownImageResolverContext({ + filePath: '/notes/doc.md', + runtimeTarget: { kind: 'local' }, + worktreeId: '', + worktreeRoot: null + }).runtimeContext + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-image-context.ts b/src/renderer/src/components/editor/rich-markdown-image-context.ts index b01d2236b453..ff84266e819c 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-context.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-context.ts @@ -1,6 +1,6 @@ import type { Editor } from '@tiptap/core' import { getConnectionId } from '@/lib/connection-context' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' export type RichMarkdownImageRuntimeContext = Omit & { @@ -10,11 +10,10 @@ export type RichMarkdownImageRuntimeContext = Omit - runtimeContext?: RichMarkdownImageRuntimeContext + /** `null`: the owner is unresolved, so local images must not load; `undefined`: no workspace. */ + runtimeContext?: RichMarkdownImageRuntimeContext | null } -export type RichMarkdownImageResolverSettings = Parameters[0] - type RichMarkdownImageUrls = Record function isRichMarkdownImageUrls(value: unknown): value is RichMarkdownImageUrls { @@ -40,10 +39,30 @@ type RichMarkdownImageStorage = { filePath: string imageUrls?: Record reloadListeners?: Set<() => void> - runtimeContext?: RichMarkdownImageRuntimeContext + runtimeContext?: RichMarkdownImageRuntimeContext | null } } +/** The image node view's storage; its context is validated on read. */ +export type RichMarkdownImageNodeStorage = { runtimeContext?: unknown } + +/** The stored image context; `null` (unresolved owner) is kept so readers refuse instead of going local. */ +export function readRichMarkdownImageRuntimeContext( + storage: RichMarkdownImageNodeStorage +): RichMarkdownImageRuntimeContext | null | undefined { + const value = storage.runtimeContext + if (value === null) { + return null + } + return isRichMarkdownImageRuntimeContext(value) ? value : undefined +} + +function isRichMarkdownImageRuntimeContext( + value: unknown +): value is RichMarkdownImageRuntimeContext { + return typeof value === 'object' && value !== null && 'target' in value && 'worktreeId' in value +} + export function getRichMarkdownImageResolverContextVersion(editor: Editor): number { const image: unknown = 'image' in editor.storage ? editor.storage.image : null if (!image || typeof image !== 'object') { @@ -56,29 +75,30 @@ export function getRichMarkdownImageResolverContextVersion(editor: Editor): numb export function createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot }: { filePath: string externalSshTargetId?: string - runtimeEnvironmentId?: string | null - settings: RichMarkdownImageResolverSettings + /** The document owner's transport; `null` while unresolved, which blocks local image reads. */ + runtimeTarget: RuntimeClientTarget | null worktreeId: string worktreeRoot: string | null }): RichMarkdownImageResolverContext { return { filePath, - runtimeContext: worktreeRoot - ? { - settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), - worktreeId, - worktreePath: worktreeRoot, - connectionId: getConnectionId(worktreeId), - expectedExternalSshTargetId: externalSshTargetId - } - : undefined + runtimeContext: !worktreeRoot + ? undefined + : runtimeTarget + ? { + target: runtimeTarget, + worktreeId, + worktreePath: worktreeRoot, + connectionId: getConnectionId(worktreeId), + expectedExternalSshTargetId: externalSshTargetId + } + : null } } @@ -117,7 +137,11 @@ function getRichMarkdownImageContextSignature(context: RichMarkdownImageResolver return [ context.filePath, JSON.stringify(context.imageUrls ?? {}), - context.runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client', + context.runtimeContext === null + ? 'unresolved' + : context.runtimeContext?.target.kind === 'environment' + ? context.runtimeContext.target.environmentId + : 'client', context.runtimeContext?.connectionId ?? 'local', context.runtimeContext?.expectedExternalSshTargetId ?? '', context.runtimeContext?.worktreeId ?? 'unknown-worktree', diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.ts index 1f6f020dfdb2..a040bb9f86bc 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.ts @@ -6,7 +6,7 @@ import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' import { editorTabDocumentFolderAccess } from '@/lib/local-file-access' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { translate } from '@/i18n/i18n' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -46,7 +46,7 @@ export async function insertRichMarkdownImageFromPath({ worktreeId && parsedWorkspace?.type !== 'folder' ? getEditorFileOperationContext(state, { worktreeId, runtimeEnvironmentId }, worktreePath) : { - settings: settingsForRuntimeOwner(state.settings, runtimeEnvironmentId), + target: requireRuntimeTargetForFileOwner(state, worktreeId, runtimeEnvironmentId), worktreeId, worktreePath, connectionId, @@ -57,8 +57,7 @@ export async function insertRichMarkdownImageFromPath({ ? captureDirectSshMutationExpectation(state, connectionId, runtimeEnvironmentId) : {}) } - const settings = fileContext.settings - if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) { + if (fileContext.target.kind === 'environment' && !worktreePath) { toast.error( translate( 'auto.components.editor.useLocalImagePick.91d835dc88', diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts index e871e7e02ed6..839c625d56db 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts @@ -185,7 +185,7 @@ describe('large Markdown paste selection during yields', () => { ...context, runtimeContext: { connectionId: 'another-target', - settings: null, + target: { kind: 'local' as const }, worktreeId: 'workspace', worktreePath: '/repo' } diff --git a/src/renderer/src/components/editor/rich-markdown-paste-image.ts b/src/renderer/src/components/editor/rich-markdown-paste-image.ts index 676014b14be5..09607bf3e909 100644 --- a/src/renderer/src/components/editor/rich-markdown-paste-image.ts +++ b/src/renderer/src/components/editor/rich-markdown-paste-image.ts @@ -2,7 +2,7 @@ import type { Editor } from '@tiptap/react' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { useAppStore } from '@/store' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { extractIpcErrorMessage } from './rich-markdown-ipc-error-message' import { insertRichMarkdownImageFromPath } from './rich-markdown-image-insert' import { captureRichMarkdownImageInsertionTarget } from './rich-markdown-image-insertion-target' @@ -73,8 +73,9 @@ async function saveClipboardImageForMarkdownPaste( worktreeId: string | null, runtimeEnvironmentId?: string | null ): Promise { - const settings = settingsForRuntimeOwner(useAppStore.getState().settings, runtimeEnvironmentId) - const hasRuntimeOwner = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) + const hasRuntimeOwner = + requireRuntimeTargetForFileOwner(useAppStore.getState(), worktreeId, runtimeEnvironmentId) + .kind === 'environment' // Why: runtime-owned notes use runtime-side clipboard import; routing this // temp save through SSH would put the source file on the wrong machine. const connectionId = hasRuntimeOwner ? undefined : (getConnectionId(worktreeId) ?? undefined) diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx index 9d13093bb61a..92c5988e146c 100644 --- a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx @@ -77,7 +77,7 @@ describe('editor group OS file drops on remote workspaces', () => { worktreeId: 'wt-ssh', worktreePath: SSH_PATH, connectionId: 'ssh-1', - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, expectedExecutionHostId: 'ssh:ssh-1', expectedSshTargetId: 'ssh-1', expectedSshConnectionGeneration: 3 @@ -99,7 +99,7 @@ describe('editor group OS file drops on remote workspaces', () => { await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) expect(mocks.importPaths.mock.calls[0][0]).toMatchObject({ worktreeId: 'wt-runtime', - settings: { activeRuntimeEnvironmentId: 'owner-runtime' } + target: { kind: 'environment', environmentId: 'owner-runtime' } }) expect(mocks.importPaths.mock.calls[0][2]).toBe(joinPath(RUNTIME_PATH, '.orca/drops')) expect(mocks.openFile).toHaveBeenCalledWith( diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx index a33abd776bfe..55fa5d0b2d3e 100644 --- a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx @@ -34,9 +34,20 @@ vi.mock('@/lib/ssh-mutation-expectation', () => ({ return { expectedExecutionHostId: 'local' } } })) -vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: () => null -})) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + _runtimeEnvironmentId: string | null | undefined + ) => ({ kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) vi.mock('@/lib/user-opened-local-path', () => ({ statUserOpenedPath: mocks.stat })) vi.mock('@/store', () => ({ useAppStore: { diff --git a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts index 433bd6c4a31e..480c78b4930f 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts @@ -3,7 +3,7 @@ import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolu import { computeEditorFontSize } from '@/lib/editor-font-zoom' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' import { isMarkdownComment } from '@/lib/diff-comment-compat' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { useFileOwnerRuntimeTarget } from '@/runtime/use-worktree-runtime-target' import { useAppStore } from '@/store' import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' import { prewarmMarkdownPreviewLocalImages } from './markdown-preview-local-images' @@ -124,23 +124,28 @@ export function useMarkdownPreviewSourceFoundation({ ), [allDiffComments, sourceRelativePath] ) - const settings = useAppStore((s) => s.settings) + const sourceRuntimeTarget = useFileOwnerRuntimeTarget( + sourceRoutingWorktreeId, + resolvedSourceRuntimeEnvironmentId + ) const imageRuntimeContext = useMemo( () => - sourceRoutingWorktreeId && worktreeRoot - ? { - settings: settingsForRuntimeOwner(settings, resolvedSourceRuntimeEnvironmentId), - worktreeId: sourceRoutingWorktreeId, - worktreePath: worktreeRoot, - connectionId: sourceConnectionId, - expectedExternalSshTargetId: sourceOpenFile?.externalSshTargetId - } - : undefined, + !sourceRoutingWorktreeId || !worktreeRoot + ? undefined + : sourceRuntimeTarget + ? { + target: sourceRuntimeTarget, + worktreeId: sourceRoutingWorktreeId, + worktreePath: worktreeRoot, + connectionId: sourceConnectionId, + expectedExternalSshTargetId: sourceOpenFile?.externalSshTargetId + } + : // Why null, not undefined: undefined reads as a plain local file and would read here. + null, [ - settings, + sourceRuntimeTarget, sourceConnectionId, sourceOpenFile?.externalSshTargetId, - resolvedSourceRuntimeEnvironmentId, sourceRoutingWorktreeId, worktreeRoot ] diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 3ce87d778fc8..6c4b955ba13e 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -20,9 +20,9 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/runtime/runtime-file-client', () => ({ getRuntimeFileReadScope: vi.fn( ( - settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined, + target: { kind: 'local' } | { kind: 'environment'; environmentId: string }, connectionId?: string - ) => connectionId ?? settings?.activeRuntimeEnvironmentId ?? null + ) => connectionId ?? (target.kind === 'environment' ? target.environmentId : null) ), readRuntimeFileContent: mocks.readRuntimeFileContent, subscribeRuntimeFileChanges: vi.fn() @@ -47,6 +47,18 @@ vi.mock('@/lib/runtime-workspace-file-route', () => ({ findWorkspaceFileRoute: vi.fn(() => null) })) +// Fixture owners: `repo-runtime::` workspaces live on runtime-1, everything else here is local. +vi.mock('@/lib/file-owner-runtime-target', () => { + const ownerTarget = (_s: unknown, worktreeId?: string, owner?: string | null) => { + const id = owner === undefined && worktreeId?.startsWith('repo-runtime::') ? 'runtime-1' : owner + return id ? { kind: 'environment', environmentId: id } : { kind: 'local' } + } + return { + getRuntimeTargetForFileOwner: ownerTarget, + requireRuntimeTargetForFileOwner: ownerTarget + } +}) + vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index a5389a91c1a2..a6df92031753 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -5,7 +5,9 @@ import { useAppStore } from '@/store' import { getDiskBaselineSignature } from './diff-content-signature' import { getRuntimeFileReadScope } from '@/runtime/runtime-file-client' import { readEditorCsvFileContent } from './csv/csv-file-content' -import { RuntimeRpcCallError, settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route' import { selectWorktreeHostConnectionPhase } from '@/lib/worktree-host-connection-phase' import { @@ -92,11 +94,6 @@ export function useEditorPanelFileContentLoader({ const resolvedConnectionId = getConnectionIdForFile(worktreeId ?? null, filePath) const connectionId = resolvedConnectionId ?? undefined const restoredOpenFile = openFilesRef.current.find((file) => file.id === id) - const activeSettings = useAppStore.getState().settings - const readSettings = settingsForRuntimeOwner( - activeSettings, - restoredOpenFile?.runtimeEnvironmentId - ) const isLiveTailLogTab = restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true // Why: these tabs name a file on this client, so the worktree's SSH or runtime owner must @@ -104,6 +101,16 @@ export function useEditorPanelFileContentLoader({ const isClientLocalTab = restoredOpenFile ? isClientLocalReadOnlyTab(restoredOpenFile) : false + const readTarget: RuntimeClientTarget = + isClientLocalTab && restoredOpenFile?.runtimeEnvironmentId === undefined + ? { kind: 'local' } + : requireRuntimeTargetForFileOwner( + useAppStore.getState(), + worktreeId, + restoredOpenFile?.runtimeEnvironmentId + ) + const readOwnerEnvironmentId = + readTarget.kind === 'environment' ? readTarget.environmentId : undefined readConnectionId = connectionId let readWorktreeId = worktreeId let readRelativePath = restoredOpenFile?.relativePath ?? relativePath @@ -145,7 +152,7 @@ export function useEditorPanelFileContentLoader({ if ( !isClientLocalTab && resolvedConnectionId === undefined && - !readSettings?.activeRuntimeEnvironmentId?.trim() && + !readOwnerEnvironmentId && !isWorktreeConnectionResolved(worktreeId ?? null) ) { // Why: the backing repo hasn't hydrated yet (SSH still connecting), so @@ -160,9 +167,7 @@ export function useEditorPanelFileContentLoader({ const externalSshOwnerId = restoredOpenFile.externalSshTargetId?.trim() || (isClientLocalTab ? undefined : connectionId) - const runtimeEnvironmentId = isClientLocalTab - ? undefined - : readSettings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = isClientLocalTab ? undefined : readOwnerEnvironmentId if (isClientLocalTab) { readConnectionId = undefined } else { @@ -190,7 +195,7 @@ export function useEditorPanelFileContentLoader({ } } } - const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) + const readScope = getRuntimeFileReadScope(readTarget, readConnectionId) const access = restoredOpenFile ? editorTabFileAccess(useAppStore.getState(), restoredOpenFile) : undefined @@ -213,7 +218,7 @@ export function useEditorPanelFileContentLoader({ if (!pending) { const promise = readEditorCsvFileContent( { - settings: readSettings, + target: readTarget, filePath, relativePath: readRelativePath, worktreeId: readWorktreeId, diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx index 2627969dacb4..0dad110e8ecf 100644 --- a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -52,6 +52,25 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ getExplicitRuntimeEnvironmentIdForWorktree: vi.fn(() => null) })) +// These fixtures' workspaces live on runtime-1, so an unstamped tab resolves there. +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined + ) => + runtimeEnvironmentId === null + ? { kind: 'local' as const } + : { kind: 'environment' as const, environmentId: runtimeEnvironmentId ?? 'runtime-1' } + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) + vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) vi.mock('./useEditorPanelExternalContentEvents', () => ({ diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index d4b0cdcb1732..6dd8c92d0a5f 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -105,17 +105,17 @@ afterEach(() => { describe('getLocalImageCacheKey', () => { it('scopes local markdown image cache entries by runtime owner', () => { const localKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }) const remoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }) const otherRemoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { - settings: { activeRuntimeEnvironmentId: 'env-2' }, + target: { kind: 'environment' as const, environmentId: 'env-2' }, worktreeId: 'wt-1', worktreePath: '/repo' }) @@ -365,7 +365,7 @@ describe('loadLocalImageSrc', () => { await expect( loadLocalImageSrc('diagram.png', '/repo/docs/readme.md', null, { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -373,7 +373,7 @@ describe('loadLocalImageSrc', () => { ).resolves.toBe('blob:runtime-one') await expect( loadLocalImageSrc('diagram.png', '/repo/docs/readme.md', null, { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-2', worktreePath: '/repo', connectionId: 'ssh-2' @@ -388,7 +388,7 @@ describe('loadLocalImageSrc', () => { await expect( loadLocalImageSrc('diagram.png', '/tmp/readme.md', null, { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-2', @@ -454,7 +454,7 @@ describe('useLocalImageSrc runtime owner', () => { function sshOwner(overrides: Partial = {}): RuntimeFileOperationArgs { return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index f256b1735524..f82ac8cfac0e 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -17,6 +17,7 @@ import { subscribeToLocalImageCacheInvalidation, unpinLocalImageCache } from './local-image-src-cache' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' export function getLocalImageCacheKey( absolutePath: string, @@ -26,8 +27,9 @@ export function getLocalImageCacheKey( }, access?: LocalFileAccess ): string { - const runtimeEnvironmentId = - runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client' + const runtimeEnvironmentId = runtimeContext + ? (runtimeTargetEnvironmentId(runtimeContext.target) ?? 'client') + : 'client' return [ runtimeEnvironmentId, runtimeContext?.connectionId ?? connectionId ?? 'local', diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts b/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts index d2d328c57775..e99dc37f8236 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts @@ -33,9 +33,20 @@ vi.mock('@/store', () => ({ })) vi.mock('@/lib/connection-context', () => ({ getConnectionIdFromState: () => runtimeConnectionId })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: runtime.stat })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (_settings: unknown, owner: string | null | undefined) => ({ owner }) -})) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) vi.mock('./markdown-document-list-request', () => ({ requestSharedMarkdownDocumentList: runtime.list })) @@ -225,7 +236,7 @@ describe('Markdown document navigation', () => { expect(runtime.stat).toHaveBeenCalledWith( { - settings: { owner: 'runtime-owner' }, + target: { kind: 'environment', environmentId: 'runtime-owner' }, worktreeId: 'wt', worktreePath: '/repo', connectionId: 'ssh-owner' diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx b/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx index 854d3ab925b9..a086e9062e27 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx @@ -85,7 +85,10 @@ it('waits for the retained editor to take its managed workspace owner before lis await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) expect(mocks.list).toHaveBeenCalledTimes(1) expect(mocks.list).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'env-1' }, worktreeId }), + expect.objectContaining({ + target: { kind: 'environment', environmentId: 'env-1' }, + worktreeId + }), '/remote/repo', { requireFresh: false } ) @@ -122,7 +125,7 @@ it('uses an explicit server owner even before its desktop repository catalog is await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) expect(mocks.list).toHaveBeenCalledTimes(1) expect(mocks.list).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'env-1' } }), + expect.objectContaining({ target: { kind: 'environment', environmentId: 'env-1' } }), '/remote/repo', { requireFresh: false } ) @@ -173,7 +176,7 @@ it('waits for ownership migration in a non-git folder workspace too', async () = await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) expect(mocks.list).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'folder:notes' }), '/notes', diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 3e5b287d396e..259a7166ffab 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -5,7 +5,10 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { getConnectionIdFromState } from '@/lib/connection-context' import { statRuntimePath } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner +} from '@/lib/file-owner-runtime-target' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { createMarkdownDocumentIndex, @@ -81,9 +84,14 @@ export function useMarkdownDocuments( return } const state = useAppStore.getState() - const settings = settingsForRuntimeOwner(state.settings, activeFile.runtimeEnvironmentId) + const target = getRuntimeTargetForFileOwner( + state, + worktreeId, + activeFile.runtimeEnvironmentId + ) // The content loader reowns retained tabs before metadata may use their new host. if ( + !target || findRestoredEditorWorkspaceRuntimeOwner( state, { @@ -95,7 +103,7 @@ export function useMarkdownDocuments( }, worktreeId ) || - (connectionId === undefined && !settings?.activeRuntimeEnvironmentId?.trim()) + (connectionId === undefined && target.kind === 'local') ) { return } @@ -105,7 +113,7 @@ export function useMarkdownDocuments( try { const documents = await requestSharedMarkdownDocumentList( { - settings, + target, worktreeId, worktreePath, connectionId: connectionId ?? undefined @@ -161,8 +169,9 @@ export function useMarkdownDocuments( try { const stats = await statRuntimePath( { - settings: settingsForRuntimeOwner( - useAppStore.getState().settings, + target: requireRuntimeTargetForFileOwner( + useAppStore.getState(), + worktreeId, activeFile.runtimeEnvironmentId ), worktreeId, diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts b/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts index 107504763d7e..4058b8ea315e 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts @@ -24,11 +24,20 @@ vi.mock('@/runtime/runtime-file-client', () => ({ listRuntimeMarkdownDocuments: runtime.list, statRuntimePath: runtime.stat })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (_settings: unknown, owner: string | null | undefined) => ({ - activeRuntimeEnvironmentId: owner - }) -})) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) const toastError = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ toast: { error: toastError } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -147,7 +156,7 @@ describe('Markdown metadata from the existing worktree watcher', () => { await settle() expect(controllers[0].markdownDocuments).toEqual([target]) expect(runtime.list).toHaveBeenLastCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null } }), + expect.objectContaining({ target: { kind: 'local' } }), '/repo' ) }) diff --git a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx index 967facaedbb0..900dda7e80e9 100644 --- a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx +++ b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx @@ -33,7 +33,7 @@ function createParams(content = ''): EditorConfigParams { worktreeRoot: '/repo', isMac: false, richMarkdownSpellcheckEnabled: true, - settings: {} as EditorConfigParams['settings'], + runtimeTarget: { kind: 'local' }, activateMarkdownLink: vi.fn(), rootRef: { current: null }, editorRef: { current: null }, diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts index c136095b8b06..744a9b66dddd 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts @@ -70,8 +70,7 @@ describe('useRichMarkdownProgrammaticSync external-reload baseline adoption (#60 baseCanonicalRef, markdownDocuments: undefined, rootRef: { current: null }, - runtimeEnvironmentId: null, - settings: null, + runtimeTarget: { kind: 'local' }, slashMenuSetter: vi.fn(), worktreeId: 'w1', worktreeRoot: null diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index 6094570d39ee..1719183d297b 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -8,9 +8,9 @@ import { normalizeEmptyListItems } from './rich-markdown-normalize' import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands' import { createRichMarkdownImageResolverContext, - setRichMarkdownImageResolverContext, - type RichMarkdownImageResolverSettings + setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' type RichMarkdownProgrammaticSyncOptions = { @@ -27,8 +27,7 @@ type RichMarkdownProgrammaticSyncOptions = { baseCanonicalRef: MutableRefObject markdownDocuments?: MarkdownDocument[] rootRef: MutableRefObject - runtimeEnvironmentId?: string | null - settings: RichMarkdownImageResolverSettings + runtimeTarget: RuntimeClientTarget | null slashMenuSetter: Dispatch> worktreeId: string worktreeRoot: string | null @@ -54,8 +53,7 @@ export function useRichMarkdownProgrammaticSync({ baseCanonicalRef, markdownDocuments, rootRef, - runtimeEnvironmentId, - settings, + runtimeTarget, slashMenuSetter, worktreeId, worktreeRoot @@ -71,8 +69,7 @@ export function useRichMarkdownProgrammaticSync({ createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot }) @@ -85,8 +82,7 @@ export function useRichMarkdownProgrammaticSync({ externalSshTargetId, filePath, isApplyingProgrammaticUpdateRef, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot ]) diff --git a/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts index 26a9844d84e0..b024cc89bec2 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts @@ -1,8 +1,8 @@ import { useRef, type MutableRefObject } from 'react' -import { useAppStore } from '@/store' import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer' import { createRichMarkdownImageResolverContext } from './rich-markdown-image-context' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' +import { useFileOwnerRuntimeTarget } from '@/runtime/use-worktree-runtime-target' type ReconcileRoundTripParams = { htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext @@ -27,7 +27,7 @@ export function useRichMarkdownReconcileRoundTrip({ worktreeId, worktreeRoot }: ReconcileRoundTripParams): MutableRefObject<(markdown: string) => string | null> { - const settings = useAppStore((s) => s.settings) + const runtimeTarget = useFileOwnerRuntimeTarget(worktreeId, runtimeEnvironmentId) const ref = useRef<(markdown: string) => string | null>(() => null) ref.current = (markdown) => serializeRichMarkdownForReconcile(markdown, { @@ -35,8 +35,7 @@ export function useRichMarkdownReconcileRoundTrip({ imageResolverContext: createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx index bb1ba9561bca..ef28d09f407b 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx @@ -690,7 +690,7 @@ describe('FloatingTerminalPanel markdown editor', () => { '/tmp/orca/floating-notes', FLOATING_TERMINAL_WORKTREE_ID, undefined, - { activeRuntimeEnvironmentId: null } + { kind: 'local' } ) expect(mocks.openFile).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/orca/floating-notes/untitled.md' }), diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 9a29a47e95e9..51e9ece56755 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -13,8 +13,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' -const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } type FloatingTerminalCreateActionsInput = Pick< FloatingTerminalPanelStoreState, @@ -97,7 +98,7 @@ export function useFloatingTerminalCreateActions({ markdownCwd, FLOATING_TERMINAL_WORKTREE_ID, getConnectionId(FLOATING_TERMINAL_WORKTREE_ID) ?? undefined, - LOCAL_RUNTIME_SETTINGS + LOCAL_TARGET ) if (!fileInfo) { return diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx index 4e8a13fae3b0..e44f2892ae3d 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -44,7 +44,9 @@ function renderUserMessage(source?: AgentMessageSource, worktreeId: string | nul expandSignal={false} onScrollMessageToTop={vi.fn()} runtimeContext={ - worktreeId ? { settings: null, worktreeId, worktreePath: '/repo' } : undefined + worktreeId + ? { target: { kind: 'local' as const }, worktreeId, worktreePath: '/repo' } + : undefined } /> ) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index b8d9400235e8..f023b17f9c40 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -82,7 +82,7 @@ describe('NativeChatImageAttachmentPreview', () => { for (const call of mocks.useLocalImageSrc.mock.calls) { expect(call[3]).toEqual({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: null, worktreePath: null }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 80c4ea7a4df5..e011cfebef3f 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -13,6 +13,7 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerFiel import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { chatImageAccess } from '@/lib/local-file-access' import { LoadingSpinner } from '@/components/LoadingSpinner' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' type Props = { attachment: NativeChatComposerImageAttachment @@ -151,7 +152,7 @@ function NativeChatImageThumbnail({ () => readEnvironmentId ? { - settings: { activeRuntimeEnvironmentId: readEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(readEnvironmentId), worktreeId: null, worktreePath: null } diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index e8ea88d317d1..593ee255d898 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx @@ -17,7 +17,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true function runtimeContext(worktreeId: string): RuntimeFileOperationArgs { return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId, worktreePath: `/repo/${worktreeId}`, expectedExecutionHostId: 'local' diff --git a/src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts index 51a2ec04aced..0cee65f9cffd 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts @@ -24,7 +24,7 @@ vi.mock('@/components/terminal-pane/terminal-file-link-target', async (importOri absolutePath: /^[\\/]{2}/.test(link.pathText) ? link.pathText : `/repo/${link.pathText}`, line: null, column: null, - fileContext: { sourceHostResolved: connection.sourceHostResolved }, + fileContext: { target: { kind: 'local' }, sourceHostResolved: connection.sourceHostResolved }, isRemoteRuntimePath: false, cacheKey: link.pathText, isKnownWorktreeRoot: link.pathText === 'ROOT' @@ -33,7 +33,10 @@ vi.mock('@/components/terminal-pane/terminal-file-link-target', async (importOri vi.mock('@/components/terminal-pane/terminal-file-path-mapping', async (importOriginal) => ({ ...(await importOriginal()), - getTerminalFileContext: () => ({ sourceHostResolved: connection.currentOwnerResolved }) + getTerminalFileContext: () => ({ + target: { kind: 'local' }, + sourceHostResolved: connection.currentOwnerResolved + }) })) const host = { cwd: '/repo', worktreeId: 'wt-1', worktreePath: '/repo', connectionId: null } diff --git a/src/renderer/src/components/native-chat/native-chat-file-link-existence.ts b/src/renderer/src/components/native-chat/native-chat-file-link-existence.ts index c95b58783682..fa37b9a30b08 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link-existence.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link-existence.ts @@ -15,6 +15,7 @@ import { type FileLinkTarget } from '@/components/terminal-pane/terminal-file-link-target' import { getTerminalFileContext } from '@/components/terminal-pane/terminal-file-path-mapping' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' // Why: a host that could not answer is retried a few times, then waits for the next recheck. export const UNVERIFIABLE_RETRY_DELAYS_MS = [2_000, 10_000, 30_000] as const @@ -131,8 +132,8 @@ export function createNativeChatFileLinkExistence( if ( !current.sourceHostResolved || current.connectionId !== target.fileContext.connectionId || - current.settings?.activeRuntimeEnvironmentId !== - target.fileContext.settings?.activeRuntimeEnvironmentId + runtimeTargetEnvironmentId(current.target) !== + runtimeTargetEnvironmentId(target.fileContext.target) ) { throw new Error('The workspace host changed or could not be determined') } diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts index d91663efea7c..3dde97611624 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts @@ -64,7 +64,7 @@ describe('resolveNativeChatImageRuntimeContext', () => { const second = resolveNativeChatImageRuntimeContext(storeState, 'tab-1') expect(first).not.toBeNull() - expect(second?.settings).toBe(first?.settings) + expect(second?.target).toBe(first?.target) expect(shallow(second, first)).toBe(true) }) @@ -91,7 +91,7 @@ describe('resolveNativeChatImageRuntimeContext', () => { worktreeId: 'wt-1', worktreePath: '/repo/worktree', expectedExecutionHostId: 'local', - settings: { activeRuntimeEnvironmentId: 'owner-a' } + target: { kind: 'environment' as const, environmentId: 'owner-a' } }) }) @@ -136,7 +136,7 @@ describe('resolveNativeChatImageRuntimeContext', () => { worktreeId: FLOATING_TERMINAL_WORKTREE_ID, worktreePath: '/home/me/pinned', expectedExecutionHostId: 'local', - settings: { activeRuntimeEnvironmentId: null } + target: { kind: 'local' as const } }) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts index b6ca7df8ec91..72b215962321 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts @@ -2,7 +2,10 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { - settingsForWorktreeOperationRoute, + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' +import { resolveWorktreeOperationRouteResult, type WorktreeOperationRouteResolution } from '@/lib/worktree-operation-route' @@ -70,38 +73,21 @@ const FLOATING_WORKSPACE_ROUTE: WorktreeOperationRouteResolution = { route: { executionHostId: 'local', runtimeEnvironmentId: null } } -// Route settings are cloned for the runtime operation contract. Reuse that -// clone while the store's source settings and selected runtime are unchanged so -// consumers do not treat an unrelated store update as a new image owner. -const settingsBySource = new WeakMap>() +// Why stable: consumers compare by identity, so an unrelated store update must not read as a +// new image owner. +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } +const environmentTargets = new Map() -function stableSettingsForRoute( - settings: AppState['settings'], - runtimeEnvironmentId: string | null -): AppState['settings'] { - if (!settings) { - return settingsForWorktreeOperationRoute(settings, { - executionHostId: null, - runtimeEnvironmentId - }) - } - const source = settings as object - let byRuntime = settingsBySource.get(source) - if (!byRuntime) { - byRuntime = new Map() - settingsBySource.set(source, byRuntime) +function stableTargetForRoute(runtimeEnvironmentId: string | null): RuntimeClientTarget { + if (!runtimeEnvironmentId) { + return LOCAL_TARGET } - const cacheKey = runtimeEnvironmentId ?? '' - const cached = byRuntime.get(cacheKey) - if (cached) { - return cached + let target = environmentTargets.get(runtimeEnvironmentId) + if (!target) { + target = runtimeTargetForOwnerEnvironment(runtimeEnvironmentId) + environmentTargets.set(runtimeEnvironmentId, target) } - const resolved = settingsForWorktreeOperationRoute(settings, { - executionHostId: null, - runtimeEnvironmentId - }) - byRuntime.set(cacheKey, resolved) - return resolved + return target } export function resolveNativeChatImageRuntimeContext( @@ -139,7 +125,7 @@ export function resolveNativeChatImageRuntimeContext( return null } const context: RuntimeFileOperationArgs = { - settings: stableSettingsForRoute(state.settings, route.runtimeEnvironmentId), + target: stableTargetForRoute(route.runtimeEnvironmentId), worktreeId: linkContext.worktreeId, worktreePath, expectedExecutionHostId: host.kind === 'ssh' ? host.id : 'local' diff --git a/src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx b/src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx index 95e46d2faeac..cd44734578b7 100644 --- a/src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx @@ -57,7 +57,10 @@ describe.each(['paired-detected', 'legacy-ssh'] as const)('%s file catalog', (ow expect.objectContaining({ worktreeId: WORKTREE_ID, worktreePath: '/srv/worktree', - settings: { activeRuntimeEnvironmentId: owner === 'paired-detected' ? 'hub' : null }, + target: + owner === 'paired-detected' + ? { kind: 'environment', environmentId: 'hub' } + : { kind: 'local' }, connectionId: owner === 'legacy-ssh' ? 'box' : undefined }), expect.objectContaining({ rootPath: '/srv/worktree' }) diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index b7e9070cef52..716b425ad937 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -98,7 +98,7 @@ describe('useRuntimeFileListForWorktree', () => { worktreeId: workspaceKey, worktreePath: '/srv/platform', connectionId: 'ssh-1', - settings: expect.objectContaining({ activeRuntimeEnvironmentId: null }) + target: { kind: 'local' } }), { includeIgnored: true, @@ -170,7 +170,7 @@ describe('useRuntimeFileListForWorktree', () => { expect(searchRuntimeFilePathsMock).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: workspaceKey, worktreePath: '/srv/platform' }), @@ -300,7 +300,7 @@ describe('useRuntimeFileListForWorktree', () => { expect(searchRuntimeFilePathsMock).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'wt-remote', worktreePath: '/srv/remote' }), diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 4217b51f40cb..ffc880c1b4b5 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -38,6 +38,7 @@ import { getFileExplorerOwnerUnresolvedMessage, getFileExplorerOperationRoute } from '@/components/right-sidebar/file-explorer-operation-owner' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export type RuntimeFileListState = { files: string[] @@ -116,7 +117,8 @@ export function useRuntimeFileListForWorktree({ const operationRoute = getFileExplorerOperationRoute(operationOwner) const operationRouteAvailable = operationRoute !== null const connectionId = operationRoute?.connectionId - const runtimeEnvironmentId = operationRoute?.settings.activeRuntimeEnvironmentId ?? null + const runtimeEnvironmentId = + operationOwner.kind === 'runtime' ? operationOwner.environmentId : null const activeTargetStatus = useAppStore((state) => connectionId ? state.sshConnectionStates.get(connectionId)?.status : undefined ) @@ -189,7 +191,7 @@ export function useRuntimeFileListForWorktree({ const requestAbortController = new AbortController() const requestOperationOwner = operationOwnerRef.current const requestContext = { - settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), worktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/quick-open-file-navigation.test.ts b/src/renderer/src/components/quick-open-file-navigation.test.ts index 8e671882079b..47d7deb55149 100644 --- a/src/renderer/src/components/quick-open-file-navigation.test.ts +++ b/src/renderer/src/components/quick-open-file-navigation.test.ts @@ -2,10 +2,10 @@ import { beforeEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const route: { current: { - settings: { activeRuntimeEnvironmentId: string | null } + target: { kind: 'local' } | { kind: 'environment'; environmentId: string } connectionId: string | undefined } - } = { current: { settings: { activeRuntimeEnvironmentId: null }, connectionId: undefined } } + } = { current: { target: { kind: 'local' }, connectionId: undefined } } return { stat: vi.fn(), authorize: vi.fn(), @@ -40,10 +40,6 @@ vi.mock('./right-sidebar/file-explorer-operation-owner', () => ({ }) })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: (settings: { activeRuntimeEnvironmentId: string | null }) => - settings.activeRuntimeEnvironmentId - ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } - : { kind: 'local' }, callRuntimeRpc: mocks.rpc })) vi.mock('@/store/slices/editor/focus/editor-focus-reveal', () => ({ @@ -53,7 +49,7 @@ import { openQuickOpenFile } from './quick-open-file-navigation' beforeEach(() => { vi.clearAllMocks() - mocks.route.current = { settings: { activeRuntimeEnvironmentId: null }, connectionId: undefined } + mocks.route.current = { target: { kind: 'local' }, connectionId: undefined } mocks.stat.mockResolvedValue({ isDirectory: false }) mocks.assertCurrent.mockReset() vi.stubGlobal('window', { api: { fs: { authorizeExternalPath: mocks.authorize } } }) @@ -129,7 +125,7 @@ it('probes SSH paths only on their owning host', async () => { }) it('resolves paired paths on the host and never falls back to client filesystem access', async () => { - mocks.route.current.settings.activeRuntimeEnvironmentId = 'env-a' + mocks.route.current.target = { kind: 'environment', environmentId: 'env-a' } mocks.rpc.mockResolvedValue({ exists: true, isDirectory: false, @@ -189,7 +185,7 @@ it.each(['C:\\repo\\file.ts', '\\\\server\\share\\file.ts'])( ) it('resolves literal colon names on a paired host before treating the suffix as navigation', async () => { - mocks.route.current.settings.activeRuntimeEnvironmentId = 'env-a' + mocks.route.current.target = { kind: 'environment', environmentId: 'env-a' } mocks.rpc.mockResolvedValueOnce({ exists: true, isDirectory: false, diff --git a/src/renderer/src/components/quick-open-file-navigation.ts b/src/renderer/src/components/quick-open-file-navigation.ts index 6610987db660..96fe801d7df8 100644 --- a/src/renderer/src/components/quick-open-file-navigation.ts +++ b/src/renderer/src/components/quick-open-file-navigation.ts @@ -4,7 +4,7 @@ import { detectLanguage } from '@/lib/language-detect' import { joinPath, getRelativePathInsideRoot } from '@/lib/path' import { useAppStore } from '@/store' import { isMissingRuntimePathError } from '@/runtime/runtime-file-client' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import type { RuntimeTerminalPathResolution } from '../../../shared/runtime-file-contracts' import { resolveWslRepoWorktreeBasePath } from '../../../shared/wsl-paths' @@ -36,7 +36,7 @@ export async function openQuickOpenFile( } assertCurrent() const route = guard.route - const target = getActiveRuntimeTarget(route.settings) + const target = route.target const resolvePastedPath = (path: string): string => target.kind !== 'environment' && !route.connectionId && @@ -125,7 +125,7 @@ export async function openQuickOpenFile( filePath, relativePath, worktreeId, - runtimeEnvironmentId: route.settings.activeRuntimeEnvironmentId, + runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null, ...(route.connectionId && getRelativePathInsideRoot(filePath, root) === null ? { externalSshTargetId: route.connectionId } : {}), diff --git a/src/renderer/src/components/quick-open-recent-validation.test.ts b/src/renderer/src/components/quick-open-recent-validation.test.ts index 3606cb3d28f5..1486189efd88 100644 --- a/src/renderer/src/components/quick-open-recent-validation.test.ts +++ b/src/renderer/src/components/quick-open-recent-validation.test.ts @@ -20,7 +20,7 @@ beforeEach(() => { vi.clearAllMocks() }) const context = { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt', worktreePath: '/repo' } diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx index 3d419c45227f..08eab39b9e6f 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx @@ -89,7 +89,7 @@ describe('FileExplorerRow collapse folder action', () => { it('shows remote download only for desktop SSH rows and file-like Remote Host rows', () => { const runtimeContext = { - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + target: { kind: 'environment' as const, environmentId: 'runtime-1' }, worktreeId: 'wt-1', worktreePath: '/repo' } @@ -290,7 +290,7 @@ describe('FileExplorerRow collapse folder action', () => { it('downloads Remote Host rows through the runtime download path', async () => { const runtimeContext = { - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + target: { kind: 'environment' as const, environmentId: 'runtime-1' }, worktreeId: 'wt-1', worktreePath: '/repo' } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts b/src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts index f35c2e8c2dea..94ea2e823b35 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts @@ -3,6 +3,7 @@ import type { OpenFile } from '@/store/slices/editor' import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' import type { FileExplorerOperationRoute } from './file-explorer-operation-owner' import { isPathEqualOrDescendant } from './file-explorer-paths' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' export type FileExplorerDeletionEditor = Pick< OpenFile, @@ -29,8 +30,7 @@ export function getFileExplorerDeletionEditors( const owner = getEditorFileOperationContext(state, file, null) // Identical paths on different hosts must not trigger another editor's save or close. return ( - (owner.settings?.activeRuntimeEnvironmentId?.trim() || null) === - (route.settings.activeRuntimeEnvironmentId?.trim() || null) && + runtimeTargetEnvironmentId(owner.target) === runtimeTargetEnvironmentId(route.target) && owner.expectedExecutionHostId === route.expectedExecutionHostId && owner.connectionId === route.connectionId ) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts index f42108ff4301..e8b04d367bcc 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -49,7 +49,7 @@ export async function readFileExplorerDirectory( } const entries = await readRuntimeDirectory( { - settings: route.settings, + target: route.target, worktreeId: activeWorktreeId, worktreePath, connectionId: route.connectionId diff --git a/src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts index 3ba85c87e282..f4b2a802943c 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts @@ -11,7 +11,7 @@ import type { TreeNode } from './file-explorer-types' vi.mock('./file-explorer-operation-owner', () => ({ getFileExplorerOwnerUnresolvedMessage: () => 'unresolved', - requireMatchingFileExplorerOperationRoute: () => ({ settings: {} }) + requireMatchingFileExplorerOperationRoute: () => ({ target: { kind: 'local' } }) })) vi.mock('@/store', () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts index 75b82277e66e..88b41e66524e 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts @@ -41,7 +41,7 @@ describe('file explorer operation generations', () => { expect( getFileExplorerOperationOwnerFromState(useAppStore.getState(), FLOATING_TERMINAL_WORKTREE_ID) ).toEqual(owner) - expect(guard.route.settings.activeRuntimeEnvironmentId).toBeNull() + expect(guard.route.target).toEqual({ kind: 'local' }) expect(guard.route.expectedExecutionHostId).toBe('local') expect(() => guard.assertCurrent()).not.toThrow() }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts index 2c52ad628955..9c6358c9b624 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -11,8 +11,9 @@ import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { translate } from '@/i18n/i18n' import { getExplicitRuntimeEnvironmentIdForWorktree, - getSettingsForWorktreeRuntimeOwner + getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { FileExplorerOperationOwner } from './file-explorer-types' import { getFloatingWorkspaceOperationRoute, @@ -24,7 +25,7 @@ import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operatio const ownerUnresolved = (): Error => new Error(getFileExplorerOwnerUnresolvedMessage()) export type FileExplorerOperationRoute = { - settings: { activeRuntimeEnvironmentId: string | null } + target: RuntimeClientTarget connectionId?: string expectedExecutionHostId?: 'local' | `ssh:${string}` expectedSshTargetId?: string @@ -82,13 +83,12 @@ export function getFileExplorerOperationOwnerFromState( if (connectionId === undefined && explicitRuntimeEnvironmentId === null) { return { kind: 'unresolved' } } - const settings = getSettingsForWorktreeRuntimeOwner(state, worktreeId) - // Why: inferred SSH ownership outranks global runtime focus, but an explicit + // Why: inferred SSH ownership outranks a projected runtime owner, but an explicit // workspace runtime still owns its files. const runtimeEnvironmentId = connectionId && explicitRuntimeEnvironmentId === null ? null - : settings.activeRuntimeEnvironmentId?.trim() + : getRuntimeEnvironmentIdForWorktree(state, worktreeId)?.trim() if (runtimeEnvironmentId) { return { kind: 'runtime', @@ -114,19 +114,19 @@ export function getFileExplorerOperationRoute( switch (owner.kind) { case 'local': return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, expectedExecutionHostId: 'local' } case 'ssh': return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, connectionId: owner.connectionId, expectedExecutionHostId: toSshExecutionHostId(owner.connectionId) } case 'runtime': { const host = parseExecutionHostId(owner.executionHostId) return { - settings: { activeRuntimeEnvironmentId: owner.environmentId }, + target: { kind: 'environment', environmentId: owner.environmentId }, ...(host?.kind === 'ssh' ? { expectedExecutionHostId: host.id } : { expectedExecutionHostId: 'local' as const }) diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts index 2a50566fc84b..ae251541dbd0 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts @@ -20,6 +20,7 @@ import { useFileExplorerTreeLoadEffects } from './use-file-explorer-tree-load-ef import { useFileExplorerWatch } from './useFileExplorerWatch' import type { useFileExplorerSelection } from './useFileExplorerSelection' import type { useFileExplorerTree } from './useFileExplorerTree' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' type UseFileExplorerTreePaneStateParams = { onRevealOutsideRoot?: () => void @@ -127,7 +128,7 @@ export function useFileExplorerTreePaneState({ ? s.sshConnectionStates.get(connectionId)?.supportsFolderDownload === true : false }) - const activeRuntimeEnvironmentId = useAppStore((s) => + const ownerRuntimeEnvironmentId = useAppStore((s) => getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId) ) const toggleDir = useAppStore((s) => s.toggleDir) @@ -138,15 +139,15 @@ export function useFileExplorerTreePaneState({ const runtimeDownloadContext = useMemo( () => - activeRuntimeEnvironmentId && activeWorktreeId && worktreePath + ownerRuntimeEnvironmentId && activeWorktreeId && worktreePath ? { - settings: { activeRuntimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(ownerRuntimeEnvironmentId), worktreeId: activeWorktreeId, worktreePath, connectionId: activeRepo?.connectionId ?? undefined } : null, - [activeRepo?.connectionId, activeRuntimeEnvironmentId, activeWorktreeId, worktreePath] + [activeRepo?.connectionId, ownerRuntimeEnvironmentId, activeWorktreeId, worktreePath] ) const isWindows = useMemo(() => navigator.userAgent.includes('Windows'), []) @@ -249,7 +250,7 @@ export function useFileExplorerTreePaneState({ const handlers = useFileExplorerHandlers({ activeWorktreeId, - runtimeEnvironmentId: activeRuntimeEnvironmentId, + runtimeEnvironmentId: ownerRuntimeEnvironmentId, openFile, makePreviewFilePermanent, toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir, diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 47f895aaecd0..05e786561924 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -130,7 +130,7 @@ export function useFileDeletion({ const state = useAppStore.getState() const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null const fileContext = { - settings: operationRoute.settings, + target: operationRoute.target, worktreeId: activeWorktreeId, worktreePath: worktree?.path ?? null, connectionId: operationRoute.connectionId, @@ -147,7 +147,7 @@ export function useFileDeletion({ if (!node.isDirectory) { try { const rf = await readRuntimeFileContent({ - settings: fileContext.settings, + target: fileContext.target, filePath: node.path, relativePath: node.relativePath, worktreeId: activeWorktreeId ?? undefined, @@ -172,7 +172,7 @@ export function useFileDeletion({ await writeRuntimeFile( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, node.path, @@ -185,7 +185,7 @@ export function useFileDeletion({ await deleteRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, node.path, diff --git a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts index 1b7a19f9ca62..63d53366cb75 100644 --- a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts +++ b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts @@ -49,7 +49,7 @@ export function useFileDuplicate({ return } const context = { - settings: operationGuard.route.settings, + target: operationGuard.route.target, worktreeId: activeWorktreeId, worktreePath, connectionId: operationGuard.route.connectionId, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts index 3464763cf556..5ae97cf12489 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -11,6 +11,7 @@ import { getFileExplorerOwnerUnresolvedMessage, requireMatchingFileExplorerOperationRoute } from './file-explorer-operation-owner' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' type UseFileExplorerHandlersParams = { activeWorktreeId: string | null @@ -133,7 +134,7 @@ export async function activateFileExplorerNode(args: { let fileRuntimeEnvironmentId: string | null try { const route = requireMatchingFileExplorerOperationRoute(activeWorktreeId, node.operationOwner) - fileRuntimeEnvironmentId = route.settings.activeRuntimeEnvironmentId?.trim() || null + fileRuntimeEnvironmentId = runtimeTargetEnvironmentId(route.target) } catch { toast.error(getFileExplorerOwnerUnresolvedMessage()) return diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx index fd1b68d8a2f1..11a76e8fd31d 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx @@ -89,7 +89,7 @@ describe('file explorer OS file drops on remote workspaces', () => { worktreeId: 'wt-ssh', worktreePath: SSH_ROOT, connectionId: 'ssh-1', - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, expectedExecutionHostId: 'ssh:ssh-1', expectedSshTargetId: 'ssh-1', expectedSshConnectionGeneration: 3 @@ -104,7 +104,7 @@ describe('file explorer OS file drops on remote workspaces', () => { await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) expect(mocks.importPaths.mock.calls[0][0]).toMatchObject({ worktreeId: 'wt-runtime', - settings: { activeRuntimeEnvironmentId: 'owner-runtime' } + target: { kind: 'environment', environmentId: 'owner-runtime' } }) expect(mocks.importPaths.mock.calls[0][2]).toBe(joinPath(RUNTIME_ROOT, 'src')) }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts index 537647ac635b..165a2ecdfedf 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts @@ -102,7 +102,7 @@ export function useFileExplorerImport({ guard.assertCurrent() const { results } = await importExternalPathsToRuntime( { - settings: guard.route.settings, + target: guard.route.target, worktreeId: destination.worktreeId, worktreePath: destination.worktreePath, connectionId: guard.route.connectionId, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index ed1978715797..61726c47563c 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -14,6 +14,7 @@ import { captureFileExplorerOperationGuard, getFileExplorerOperationOwner } from './file-explorer-operation-owner' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' type UseFileExplorerInlineInputParams = { activeWorktreeId: string | null @@ -138,7 +139,7 @@ export function useFileExplorerInlineInput({ ) const operationRoute = operationGuard.route const fileContext = { - settings: operationRoute.settings, + target: operationRoute.target, worktreeId: activeWorktreeId, worktreePath, connectionId: operationRoute.connectionId, @@ -160,7 +161,7 @@ export function useFileExplorerInlineInput({ await deleteRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, fullPath, @@ -173,7 +174,7 @@ export function useFileExplorerInlineInput({ await createRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, fullPath, @@ -189,7 +190,7 @@ export function useFileExplorerInlineInput({ await deleteRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, fullPath @@ -201,7 +202,7 @@ export function useFileExplorerInlineInput({ await createRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, fullPath, @@ -213,8 +214,7 @@ export function useFileExplorerInlineInput({ } await refreshDir(inlineInput.parentPath) if (inlineInput.type === 'file') { - const runtimeEnvironmentId = - fileContext.settings.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = runtimeTargetEnvironmentId(fileContext.target) openFile( { filePath: fullPath, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts index b917353240b6..6cde5043b4e0 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts @@ -58,7 +58,7 @@ export function useFileExplorerMoveDrop({ const operationGuard = captureFileExplorerOperationGuard(activeWorktreeId, operationOwner) const operationRoute = operationGuard.route const fileContext = { - settings: operationRoute.settings, + target: operationRoute.target, worktreeId: activeWorktreeId, worktreePath, connectionId: operationRoute.connectionId, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index 0bea31f44c6a..b6b8605e2b0e 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -195,7 +195,7 @@ export function useFileExplorerTree( } return statUserOpenedPath( { - settings: route.settings, + target: route.target, worktreeId: activeWorktreeId, worktreePath, connectionId: route.connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx index 7c02e1a8e696..c7d9bd65d3e6 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx @@ -121,7 +121,7 @@ it('invalidates completed results and errors when the execution owner changes at ) await act(async () => vi.advanceTimersByTimeAsync(300)) expect(search).toHaveBeenCalledTimes(2) - expect(search.mock.calls[1][0].settings.activeRuntimeEnvironmentId).toBe('remote-a') + expect(search.mock.calls[1][0].target).toEqual({ kind: 'environment', environmentId: 'remote-a' }) expect( useAppStore.getState().fileSearchStateByWorktree['folder:a'].resultOwner?.executionHostId ).toBe('runtime:remote-a') diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx index 8bee124361cd..b183f717d8eb 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx @@ -84,7 +84,7 @@ describe('useFileSearchRunner result ownership', () => { expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'search-runtime-a' }, + target: { kind: 'environment', environmentId: 'search-runtime-a' }, worktreeId }), expect.any(Object), @@ -116,7 +116,7 @@ describe('useFileSearchRunner result ownership', () => { await finishSearch(hook.result.current.executeSearch) expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null }, worktreeId }), + expect.objectContaining({ target: { kind: 'local' }, worktreeId }), expect.any(Object), expect.any(AbortSignal) ) @@ -148,7 +148,7 @@ describe('useFileSearchRunner result ownership', () => { expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, worktreeId, connectionId: 'ssh-target' }), diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts index 23571567d985..c4c6069f185e 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts @@ -17,6 +17,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { SearchResult } from '../../../../shared/code-search-types' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' const SEARCH_DEBOUNCE_MS = 300 const SEARCH_MAX_RESULTS = 2000 @@ -95,10 +96,9 @@ export function useFileSearchRunner({ excludePattern: currentSearchState?.excludePattern || undefined }) ) { - const runtimeSettings = { activeRuntimeEnvironmentId: runtimeEnvironmentId } updateActiveSearchState({ results: createEmptyRuntimeFileSearchResult(), - resultOwner: createFileSearchResultOwner(activeWorktreeId, runtimeSettings, { + resultOwner: createFileSearchResultOwner(activeWorktreeId, runtimeEnvironmentId, { rootPath: worktreePath, executionHostId }), @@ -116,8 +116,7 @@ export function useFileSearchRunner({ searchTimerRef.current = setTimeout(async () => { searchTimerRef.current = null // Why: results can outlive the selected worktree; clicks must reuse the route that produced them. - const runtimeSettings = { activeRuntimeEnvironmentId: runtimeEnvironmentId } - const resultOwner = createFileSearchResultOwner(activeWorktreeId, runtimeSettings, { + const resultOwner = createFileSearchResultOwner(activeWorktreeId, runtimeEnvironmentId, { rootPath: worktreePath, executionHostId }) @@ -145,7 +144,7 @@ export function useFileSearchRunner({ } const results = await searchRuntimeFiles( { - settings: runtimeSettings, + target: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx b/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx index d09d7dd0a12e..d67aeb81def7 100644 --- a/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx @@ -75,7 +75,7 @@ it('inspects and creates a managed-host repo config on that host, never the desk await waitFor(() => expect(runtimeFiles.readRuntimeDirectory).toHaveBeenCalled()) expect(runtimeFiles.readRuntimeDirectory).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'host-a' }, + target: { kind: 'environment', environmentId: 'host-a' }, worktreeId, worktreePath: root }), @@ -89,7 +89,7 @@ it('inspects and creates a managed-host repo config on that host, never the desk await waitFor(() => expect(runtimeFiles.writeRuntimeFile).toHaveBeenCalled()) expect(runtimeFiles.writeRuntimeFile).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'host-a' } }), + expect.objectContaining({ target: { kind: 'environment', environmentId: 'host-a' } }), `${root}/.mcp.json`, expect.any(String) ) @@ -109,7 +109,7 @@ it('keeps a desktop repo on the desktop while a managed host is focused', async await waitFor(() => expect(runtimeFiles.readRuntimeDirectory).toHaveBeenCalled()) expect(runtimeFiles.readRuntimeDirectory).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null } }), + expect.objectContaining({ target: { kind: 'local' } }), root ) }) diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 162a780c426d..be54a2b6be3d 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -33,6 +33,7 @@ import { loadMcpConfigInspections } from './mcp-config-inspection' import { translate } from '@/i18n/i18n' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { writeRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' type McpConfigSectionProps = { repo: Repo @@ -100,7 +101,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele const targetHostId = getWorktreeExecutionHostId(targetWorktree, repo) const fileContext = useMemo( (): RuntimeFileOperationArgs => ({ - settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), worktreeId: targetWorktreeId, worktreePath: targetRootPath, connectionId diff --git a/src/renderer/src/components/settings/mcp-config-inspection.ts b/src/renderer/src/components/settings/mcp-config-inspection.ts index ff917414466f..3e5e1baced3c 100644 --- a/src/renderer/src/components/settings/mcp-config-inspection.ts +++ b/src/renderer/src/components/settings/mcp-config-inspection.ts @@ -77,7 +77,7 @@ export async function loadMcpConfigInspections( try { const result = await readRuntimeFileContent({ - settings: context.settings, + target: context.target, worktreeId: context.worktreeId ?? undefined, filePath: absolutePath, relativePath: candidate.relativePath, diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index 93d007adf31d..221208bc6556 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -26,7 +26,7 @@ describe('openTabEntryWithOperations', () => { groupId: 'group-1', worktreePath: '/repo', runtimeContext: { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts index be2dddee268a..a66d9ea13391 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts @@ -5,6 +5,7 @@ import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-e import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import type { useAppStore } from '@/store' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' type TabEntryAbsolutePathOwnerState = Pick< ReturnType, @@ -23,12 +24,11 @@ type TabEntryAbsolutePathOwnerState = Pick< > export function isTabEntryAbsolutePathAllowed( - context: Pick + context: Pick ): boolean { - // Why: file-operation contexts pin `settings` to the route owner, never to the focused server. return !isLocalPathOpenBlocked( getLocalPathOpenOwnerForRoute({ - runtimeEnvironmentId: context.settings?.activeRuntimeEnvironmentId, + runtimeEnvironmentId: runtimeTargetEnvironmentId(context.target), connectionId: context.connectionId }) ) @@ -69,9 +69,7 @@ export function getTabEntryFileOperationContext( getFolderWorkspaceConnectionId(state, workspaceKey.folderWorkspaceId) === null ) { return { - settings: state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: null } - : { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, worktreeId, worktreePath, expectedExecutionHostId: 'local' diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index d0c416bf27b2..2602ef07df29 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -159,7 +159,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo', expectedExecutionHostId: 'local', @@ -262,7 +262,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '//server/share/repo', expectedExecutionHostId: 'local', @@ -326,7 +326,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + target: { kind: 'environment', environmentId: 'owner-runtime' }, worktreeId: 'wt-1', worktreePath: '/remote/repo', expectedExecutionHostId: 'local', diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts index df4ace370dae..d468f9c69f46 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts @@ -167,7 +167,7 @@ describe.each(['native', 'internal'])('%s runtime terminal catalog ownership', ( if (lane === 'native') { expect(mocks.importPaths).toHaveBeenCalledExactlyOnceWith( { - settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + target: { kind: 'environment', environmentId: 'owner-runtime' }, worktreeId: workspaceId, worktreePath: '/owner/workspace', expectedExecutionHostId: executionHostId, diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts index 6c1d1a3d038c..15d6d5f8bca7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts @@ -15,7 +15,7 @@ const routing = vi.hoisted(() => { vi.mock('./terminal-file-path-mapping', async (importOriginal) => ({ ...(await importOriginal()), getTerminalFileContext: (worktreeId: string, worktreePath: string) => ({ - settings: null, + target: { kind: 'local' }, worktreeId, worktreePath, connectionId: routing.connectionId diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 582fc02a864e..3596fd9eeea9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -23,7 +23,7 @@ import { } from '../../../../shared/execution-host' import { statUserOpenedPath } from '@/lib/user-opened-local-path' import { - CLIENT_LOCAL_FILE_SETTINGS, + CLIENT_LOCAL_FILE_TARGET, resolveHostWorkspaceFile, type HostWorkspaceFile } from './terminal-host-workspace-file' @@ -36,6 +36,7 @@ export { terminalPathWslDistro, type TerminalFileContext } from './terminal-file-path-mapping' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' export type FileOpenFailure = { /** `missing` is a verified absence; `unverifiable` means the host could not answer (dropped SSH, timeout, denied path). */ @@ -164,7 +165,7 @@ export function openDetectedFilePath( clientLocal = true fileContext = { ...fileContext, - settings: CLIENT_LOCAL_FILE_SETTINGS, + target: CLIENT_LOCAL_FILE_TARGET, connectionId: undefined } statResult = { isDirectory: linkOwner.isDirectory, escapesWorktree: false } @@ -254,7 +255,7 @@ export function openDetectedFilePath( ) ) { // Why: early resolution is only needed to avoid an existing sibling-tab collision. - const runtimeOwnerId = fileContext.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target) const executionHostId = runtimeOwnerId ? toRuntimeExecutionHostId(runtimeOwnerId) : getConnectionExecutionHostId(fileContext.connectionId) @@ -289,7 +290,7 @@ export function openDetectedFilePath( // Why: absolute SSH paths outside the worktree otherwise look identical // to client-local external files when the editor reloads or restores. ...(relativePath === filePath && - !fileContext.settings?.activeRuntimeEnvironmentId?.trim() && + fileContext.target.kind === 'local' && fileContext.connectionId ? { externalSshTargetId: fileContext.connectionId } : {}) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts b/src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts index 56a73ce0be2d..0d03dc73a0cd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts @@ -2,8 +2,8 @@ import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' import { buildWorkspaceFileContext } from '@/lib/workspace-file-host-routing' +import { getRuntimeTargetForWorktreeOwner } from '@/lib/file-owner-runtime-target' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { parseExecutionHostId } from '../../../../shared/execution-host' import { parseWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' import { terminalFileSourceHost } from './terminal-worktree-path-link' @@ -19,8 +19,21 @@ export function getTerminalFileContext( worktreePath: string, runtimeEnvironmentId?: string | null ): TerminalFileContext { - const context = buildWorkspaceFileContext(worktreeId, worktreePath, runtimeEnvironmentId) const state = useAppStore.getState() + if ( + runtimeEnvironmentId === undefined && + worktreeId && + !getRuntimeTargetForWorktreeOwner(state, worktreeId) + ) { + // Why: rows disagree on the owner; the local target is a placeholder every caller refuses. + return { + target: { kind: 'local' }, + worktreeId, + worktreePath, + sourceHostResolved: false + } + } + const context = buildWorkspaceFileContext(worktreeId, worktreePath, runtimeEnvironmentId) const sourceHost = terminalFileSourceHost(state, context) if (!sourceHost) { // Why: the connection lookup ignores detected rows, so its local `null` must not override an @@ -35,10 +48,7 @@ export function getTerminalFileContext( } // Why: same-id rows on several hosts leave connectionId unset, which reads downstream as local; // the resolved owner names the direct SSH host. A paired runtime keeps its own transport. - const sshHost = - getActiveRuntimeTarget(context.settings).kind === 'environment' - ? null - : parseExecutionHostId(sourceHost) + const sshHost = context.target.kind === 'environment' ? null : parseExecutionHostId(sourceHost) return sshHost?.kind === 'ssh' && !context.connectionId ? { ...context, connectionId: sshHost.targetId, sourceHostResolved: true } : { ...context, sourceHostResolved: true } diff --git a/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts index a1f2233fbc3f..9306f2b56b90 100644 --- a/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts +++ b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts @@ -1,5 +1,9 @@ import { callHostRoute } from '@/runtime/host-route-call' -import { hostRouteForAuthority } from '@/runtime/runtime-client-target' +import { + hostRouteForAuthority, + runtimeTargetEnvironmentId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { statRuntimePath } from '@/runtime/runtime-file-client' import { isRemoteRuntimeFileOperation } from '@/runtime/runtime-file-routing' @@ -26,7 +30,7 @@ export type HostWorkspaceFile = { /** A path the host disowned that this computer has; the user's click here is the only authority to read it. */ export type ClientLocalFile = { kind: 'client'; isDirectory: boolean } -export const CLIENT_LOCAL_FILE_SETTINGS = { activeRuntimeEnvironmentId: null } +export const CLIENT_LOCAL_FILE_TARGET: RuntimeClientTarget = { kind: 'local' } /** Strips the host's relative suffix; separators are swapped 1:1, so the slice stays byte-exact. */ function workspaceRootOf(absolutePath: string, relativePath: string): string | null { @@ -56,7 +60,7 @@ async function statClientLocalFile( } try { const stat = await statRuntimePath( - { settings: CLIENT_LOCAL_FILE_SETTINGS, worktreeId: null, worktreePath: null }, + { target: CLIENT_LOCAL_FILE_TARGET, worktreeId: null, worktreePath: null }, absolutePath, userNamedFileAccess() ) @@ -77,7 +81,7 @@ export async function resolveHostWorkspaceFile( context: RuntimeFileOperationArgs, absolutePath: string ): Promise { - const environmentId = context.settings?.activeRuntimeEnvironmentId?.trim() + const environmentId = runtimeTargetEnvironmentId(context.target) if ( !environmentId || !context.worktreeId || diff --git a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts index 2df62eb3e998..524f04b119e6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts @@ -53,9 +53,13 @@ vi.mock('@/lib/file-preview', () => ({ installTerminalLinkTestEnvironment(doubles) describe('handleOscLink', () => { - it('stats remote-runtime file links through the active runtime environment', async () => { + it("stats remote-runtime file links through the workspace owner's runtime, not the focused one", async () => { setPlatform('Macintosh') - storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + // Before: the focused server (env-2) answered for a link the workspace owner (env-1) holds. + storeState.settings = { activeRuntimeEnvironmentId: 'env-2' } + storeState.worktreesByRepo = { + 'repo-1': [{ id: 'wt-1', path: '/tmp', repoId: 'repo-1', hostId: 'runtime:env-1' }] + } runtimeEnvironmentCallMock.mockResolvedValueOnce({ id: 'rpc-1', ok: true, diff --git a/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts b/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts index c29668543b68..8a99b9ada71d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts +++ b/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts @@ -48,7 +48,6 @@ function captureNativeTerminalFileDropDestination( } const dropTarget = captureTerminalDropTarget(pane, transport) const state = useAppStore.getState() - const settings = state.settings const owner = captureTerminalDropTransportOwner(transport) const worktreePath = resolveTerminalDropWorktreePath( worktreeId, @@ -80,7 +79,6 @@ function captureNativeTerminalFileDropDestination( pane, dataPaths: paths, dropTarget, - settings, owner, worktreePath, localWslDrop diff --git a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts index 9bd32dbe3cc2..bf739d2ebddb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts +++ b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts @@ -2,7 +2,6 @@ import { toast } from 'sonner' import { extractIpcErrorMessage } from '@/lib/ipc-error' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' -import type { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { PtyTransport } from './pty-transport' @@ -23,10 +22,10 @@ import { showTerminalDropWriteFailure } from './terminal-drop-write-failure' import { joinRuntimeTerminalDropDir } from './terminal-drop-worktree-path' import type { captureTerminalDropTransportOwner } from './terminal-drop-transport-owner' import { toLocalWslDropPath } from './terminal-drop-local-wsl' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export async function deliverNativeTerminalFileDrop( args: NativeDropFlowArgs & { - settings: ReturnType['settings'] owner: ReturnType worktreeId: string localWslDrop: boolean @@ -40,7 +39,6 @@ export async function deliverNativeTerminalFileDrop( pane, dataPaths, dropTarget, - settings, owner, worktreePath, localWslDrop @@ -52,7 +50,6 @@ export async function deliverNativeTerminalFileDrop( manager, paneTransports, pane, - settings, tabId, worktreeId, worktreePath, @@ -127,7 +124,6 @@ export type NativeDropFlowArgs = { async function uploadRuntimeDropPaths( args: NativeDropFlowArgs & { runtimeEnvironmentId: string - settings: ReturnType['settings'] worktreeId: string } ): Promise { @@ -145,7 +141,7 @@ async function uploadRuntimeDropPaths( { // Why: drops into existing worktrees must follow the worktree owner, // not the currently focused host in the sidebar. - settings: { ...args.settings, activeRuntimeEnvironmentId: args.runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(args.runtimeEnvironmentId), worktreeId: args.worktreeId, worktreePath: args.worktreePath, expectedExecutionHostId: args.expectedExecutionHostId, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx index ca79f2e1d755..dc75f2320a3d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx @@ -459,7 +459,7 @@ describe('terminal element file drops', () => { await settle() expect(mocks.importPaths).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + target: { kind: 'environment', environmentId: 'owner-runtime' }, worktreePath: '/owner/workspace', expectedExecutionHostId: 'local' }), diff --git a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts index 2945a8c25005..f1650f49c6c6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts @@ -3,7 +3,11 @@ import { createTerminalPathExistenceBatch } from '@/components/terminal-pane/ter import { requirePathExistenceResults } from '../../../../shared/path-existence-batch' const remote = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/runtime-path-existence-batch', () => ({ runtimePathsExist: remote })) -const context = { settings: null, worktreeId: 'folder:/work', worktreePath: '/work' } +const context = { + target: { kind: 'local' as const }, + worktreeId: 'folder:/work', + worktreePath: '/work' +} afterEach(() => { vi.unstubAllGlobals() remote.mockReset() @@ -26,11 +30,11 @@ it('isolates identical paths by runtime, worktree, connection, and hover turn', ) const enqueue = createTerminalPathExistenceBatch() const contexts = [ - { ...context, settings: { activeRuntimeEnvironmentId: 'a' } }, - { ...context, settings: { activeRuntimeEnvironmentId: 'b' } }, + { ...context, target: { kind: 'environment' as const, environmentId: 'a' } }, + { ...context, target: { kind: 'environment' as const, environmentId: 'b' } }, { ...context, - settings: { activeRuntimeEnvironmentId: 'a' }, + target: { kind: 'environment' as const, environmentId: 'a' }, worktreeId: 'folder:/other', worktreePath: '/other' }, diff --git a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts index 010fb36bf826..019153fab122 100644 --- a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts @@ -4,7 +4,6 @@ import { } from '../../../../shared/path-existence-batch' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' -import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' import { captureRuntimeEnvironmentRequestRevision } from '@/runtime/runtime-environment-revision' type PendingPath = { @@ -29,13 +28,13 @@ export function createTerminalPathExistenceBatch(): ( const groups = new Map() let queued = false return (context, path, remote) => { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target const pairingRevision = target.kind === 'environment' ? captureRuntimeEnvironmentRequestRevision(target.environmentId) : undefined const key = JSON.stringify([ - context.settings?.activeRuntimeEnvironmentId, + target.kind === 'environment' ? target.environmentId : null, context.worktreeId, context.worktreePath, context.connectionId, diff --git a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts index d29cca04ecd9..7408dfd6c73e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts @@ -120,7 +120,7 @@ describe('resolveKnownWorktreeRootPathLink', () => { }) const remoteContext = (environmentId: string, worktreeId = 'source') => ({ - settings: { activeRuntimeEnvironmentId: environmentId }, + target: { kind: 'environment' as const, environmentId: environmentId }, worktreeId, worktreePath: '/source' }) @@ -166,7 +166,7 @@ describe('terminal root link ownership', () => { ] }) const context = { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'source', worktreePath: '/source', connectionId: 'alpha' @@ -259,7 +259,7 @@ describe('terminal root link ownership', () => { activeWorkspaceExecutionHostId: activeHost }) const sshContext = (state: Parameters[0]) => ({ - settings: null, + target: { kind: 'local' as const }, worktreeId: 'repo::/home/u/proj', worktreePath: '/home/u/proj', connectionId: getConnectionIdFromState(state, 'repo::/home/u/proj') ?? undefined @@ -288,7 +288,11 @@ describe('terminal root link ownership', () => { runtimeEnvironments: [], runtimeEnvironmentCatalogHydrated: false } - const context = { settings: null, worktreeId: 'restoring::/x', worktreePath: '/x' } + const context = { + target: { kind: 'local' as const }, + worktreeId: 'restoring::/x', + worktreePath: '/x' + } expect(resolveKnownWorktreeRootPathLink('/target', state, context)).toBeNull() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts index 07a7110682aa..1c21f8a0f43e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts +++ b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts @@ -8,7 +8,6 @@ import { type WorktreeOperationRoute, type WorktreeOperationRouteState } from '@/lib/worktree-operation-route' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { toRuntimeExecutionHostId, @@ -107,8 +106,8 @@ export function terminalFileSourceHost( state: WorktreeOperationRouteState, context: RuntimeFileOperationArgs | undefined ): ExecutionHostId | null { - const target = getActiveRuntimeTarget(context?.settings) - if (target.kind === 'environment') { + const target = context?.target + if (target?.kind === 'environment') { const sourceRoute = context?.worktreeId ? resolveWorktreeOperationRouteResult(state, context.worktreeId) : null diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts index 796cd6b2b48a..599f6f579ea1 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -33,6 +33,7 @@ import { import { applyComposerNativeFileDrop } from '../composer-native-file-drop' import { useMountedRef } from '../useMountedRef' import { userNamedFileAccess } from '@/lib/local-file-access' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' // Local drops bypass the runtime importer's skip classification. function localDropFailure(detail: string | undefined): ComposerDropFailure { @@ -131,7 +132,9 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { targetWorktreeId: string | null | undefined = selectedWorktreeId, canReportFailure: () => boolean = () => true ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => { - if (!targetSettings?.activeRuntimeEnvironmentId?.trim() && !targetConnectionId) { + // Repo-owner settings: the selected repo's host, not the focused server. + const targetRuntimeEnvironmentId = targetSettings?.activeRuntimeEnvironmentId?.trim() || null + if (!targetRuntimeEnvironmentId && !targetConnectionId) { return null } if (!targetRepoPath) { @@ -150,7 +153,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { ? captureDirectSshMutationExpectation( useAppStore.getState(), targetConnectionId, - targetSettings?.activeRuntimeEnvironmentId + targetRuntimeEnvironmentId ) : { expectedExecutionHostId: 'local' as const, @@ -162,7 +165,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { const current = captureDirectSshMutationExpectation( useAppStore.getState(), targetConnectionId, - targetSettings?.activeRuntimeEnvironmentId + targetRuntimeEnvironmentId ) if ( current.expectedSshTargetId !== sshExpectation.expectedSshTargetId || @@ -175,7 +178,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { : undefined const { results } = await importExternalPathsToRuntime( { - settings: targetSettings, + target: runtimeTargetForOwnerEnvironment(targetRuntimeEnvironmentId), worktreeId: targetWorktreeId ?? targetRepoPath, worktreePath: targetRepoPath, connectionId: targetConnectionId ?? undefined, diff --git a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts index 96caaf8c84a1..e45444cac481 100644 --- a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts +++ b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts @@ -14,6 +14,7 @@ import { } from '@/components/editor/editor-self-write-registry' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' import type { EditorExternalWatchTarget } from './editor-external-watch-targets' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export type EditorExternalWatchNotification = { worktreeId: string @@ -65,9 +66,7 @@ function readFileForEchoVerification(args: { let pending = inFlightEchoVerificationReads.get(key) if (!pending) { pending = readRuntimeFileContent({ - settings: args.runtimeEnvironmentId - ? { activeRuntimeEnvironmentId: args.runtimeEnvironmentId } - : null, + target: runtimeTargetForOwnerEnvironment(args.runtimeEnvironmentId ?? null), filePath: args.filePath, relativePath: args.relativePath, worktreeId: args.worktreeId ?? undefined, diff --git a/src/renderer/src/hooks/editor-runtime-file-watch.test.ts b/src/renderer/src/hooks/editor-runtime-file-watch.test.ts index 13ed3a5a6b23..ab299d7538a6 100644 --- a/src/renderer/src/hooks/editor-runtime-file-watch.test.ts +++ b/src/renderer/src/hooks/editor-runtime-file-watch.test.ts @@ -71,7 +71,7 @@ describe('editor runtime file watch recovery', () => { await settle() expect(runtime.subscribe).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: 'host-a' }, + target: { kind: 'environment', environmentId: 'host-a' }, worktreeId: 'wt', worktreePath: '/repo', connectionId: undefined @@ -99,7 +99,7 @@ describe('editor runtime file watch recovery', () => { await settle() expect(runtime.subscribe).toHaveBeenLastCalledWith( { - settings: { activeRuntimeEnvironmentId: 'host-a' }, + target: { kind: 'environment', environmentId: 'host-a' }, worktreeId: folder.worktreeId, worktreePath: '/notes', connectionId: undefined diff --git a/src/renderer/src/hooks/editor-runtime-file-watch.ts b/src/renderer/src/hooks/editor-runtime-file-watch.ts index d88e425c3699..29a4012427b0 100644 --- a/src/renderer/src/hooks/editor-runtime-file-watch.ts +++ b/src/renderer/src/hooks/editor-runtime-file-watch.ts @@ -3,6 +3,7 @@ import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' import { onRuntimeEnvironmentsRetired } from '@/runtime/runtime-environment-revision' import type { EditorExternalWatchTarget } from './editor-external-watch-targets' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export function subscribeEditorRuntimeFileWatch( target: EditorExternalWatchTarget & { runtimeEnvironmentId: string }, @@ -23,7 +24,7 @@ export function subscribeEditorRuntimeFileWatch( const isCurrent = (): boolean => !disposed && attempt === currentAttempt void subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(target.runtimeEnvironmentId), worktreeId: target.worktreeId, worktreePath: target.worktreePath, connectionId: target.connectionId diff --git a/src/renderer/src/lib/create-untitled-markdown.test.ts b/src/renderer/src/lib/create-untitled-markdown.test.ts index 403df38192e7..c888e0483895 100644 --- a/src/renderer/src/lib/create-untitled-markdown.test.ts +++ b/src/renderer/src/lib/create-untitled-markdown.test.ts @@ -9,6 +9,9 @@ import { type RuntimeEnvironmentCallRequest } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' + +const LOCAL: RuntimeClientTarget = { kind: 'local' } describe('createUntitledMarkdownFile', () => { afterEach(() => { @@ -17,7 +20,7 @@ describe('createUntitledMarkdownFile', () => { it('rejects captured ownership without a current-generation assertion', async () => { await expect( - createUntitledMarkdownFile('/repo', 'wt-1', undefined, undefined, { + createUntitledMarkdownFile('/repo', 'wt-1', undefined, LOCAL, { operationProvenance: {} as never }) ).rejects.toThrow("Couldn't verify which host owns this file") @@ -47,7 +50,7 @@ describe('createUntitledMarkdownFile', () => { } }) - await expect(createUntitledMarkdownFile('/repo', 'wt-1')).resolves.toEqual({ + await expect(createUntitledMarkdownFile('/repo', 'wt-1', undefined, LOCAL)).resolves.toEqual({ filePath: '/repo/untitled-3.md', relativePath: 'untitled-3.md', worktreeId: 'wt-1', @@ -81,7 +84,7 @@ describe('createUntitledMarkdownFile', () => { } }) - await expect(createUntitledMarkdownFile('/repo', 'wt-1')).rejects.toThrow( + await expect(createUntitledMarkdownFile('/repo', 'wt-1', undefined, LOCAL)).rejects.toThrow( 'Unable to create untitled markdown file after 100 attempts.' ) @@ -101,7 +104,9 @@ describe('createUntitledMarkdownFile', () => { } }) - await expect(createUntitledMarkdownFile('/repo', 'wt-1', 'conn-1')).resolves.toMatchObject({ + await expect( + createUntitledMarkdownFile('/repo', 'wt-1', 'conn-1', LOCAL) + ).resolves.toMatchObject({ filePath: '/repo/untitled.md' }) @@ -136,7 +141,7 @@ describe('createUntitledMarkdownFile', () => { }) await expect( - createUntitledMarkdownFile('/repo', 'wt-1', undefined, undefined, { + createUntitledMarkdownFile('/repo', 'wt-1', undefined, LOCAL, { now: new Date(2026, 4, 29, 7, 5), template: { id: '.orca/templates/daily.md', @@ -206,7 +211,7 @@ describe('createUntitledMarkdownFile', () => { try { await expect( - createUntitledMarkdownFileWithTemplateSelection('/repo', 'wt-1') + createUntitledMarkdownFileWithTemplateSelection('/repo', 'wt-1', undefined, LOCAL) ).resolves.toMatchObject({ filePath: '/repo/untitled.md', relativePath: 'untitled.md', @@ -256,7 +261,8 @@ describe('createUntitledMarkdownFile', () => { await expect( createUntitledMarkdownFile('/remote/repo', 'wt-1', undefined, { - activeRuntimeEnvironmentId: 'env-1' + kind: 'environment', + environmentId: 'env-1' }) ).resolves.toMatchObject({ filePath: '/remote/repo/untitled.md', diff --git a/src/renderer/src/lib/create-untitled-markdown.ts b/src/renderer/src/lib/create-untitled-markdown.ts index 01c4b7f9a285..571ce8d59066 100644 --- a/src/renderer/src/lib/create-untitled-markdown.ts +++ b/src/renderer/src/lib/create-untitled-markdown.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { createRuntimePath, deleteRuntimePath, @@ -48,15 +48,15 @@ type CreateUntitledMarkdownOptions = { export async function createUntitledMarkdownFile( worktreePath: string, worktreeId: string, - connectionId?: string, - settings?: Pick | null, + connectionId: string | undefined, + target: RuntimeClientTarget, options: CreateUntitledMarkdownOptions = {} ): Promise { const baseName = 'untitled' const ext = '.md' const MAX_ATTEMPTS = 100 const context = { - settings, + target, worktreeId, worktreePath, connectionId, @@ -138,8 +138,8 @@ export async function createUntitledMarkdownFile( export async function createUntitledMarkdownFileWithTemplateSelection( worktreePath: string, worktreeId: string, - connectionId?: string, - settings?: Pick | null, + connectionId: string | undefined, + target: RuntimeClientTarget, operationProvenance?: EditorFileOperationProvenance, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, @@ -150,7 +150,7 @@ export async function createUntitledMarkdownFileWithTemplateSelection( requireOperationAssertion(assertOperationCurrent)() } const context = { - settings, + target, worktreeId, worktreePath, connectionId, @@ -165,7 +165,7 @@ export async function createUntitledMarkdownFileWithTemplateSelection( return null } - return createUntitledMarkdownFile(worktreePath, worktreeId, connectionId, settings, { + return createUntitledMarkdownFile(worktreePath, worktreeId, connectionId, target, { template: selection.type === 'template' ? selection.template : undefined, operationProvenance, expectedSshTargetId, diff --git a/src/renderer/src/lib/editor-file-operation-owner.test.ts b/src/renderer/src/lib/editor-file-operation-owner.test.ts index 43e257419db4..e579bdd9b3df 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -90,7 +90,7 @@ describe('editor file operation owner', () => { '/remote/repo' ) - expect(context.settings?.activeRuntimeEnvironmentId).toBe('hub-a') + expect(context.target).toEqual({ kind: 'environment', environmentId: 'hub-a' }) }) it('rejects an open tab after the same environment id is re-paired', () => { diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index b05d7dd6a8f3..ecf2fa6f859d 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -2,6 +2,10 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/exec import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '@/store/types' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' import { assertWorktreeOperationGenerationSnapshotCurrent, @@ -12,7 +16,6 @@ import { getFloatingWorkspaceOperationRoute, resolveExplicitWorktreeOperationRouteResult, resolveWorktreeOperationRoute, - settingsForWorktreeOperationRoute, type WorktreeOperationRoute } from './worktree-operation-route' @@ -168,7 +171,7 @@ export function getEditorFileOperationContext( }, worktreePath: string | null ): { - settings: AppState['settings'] + target: RuntimeClientTarget worktreeId: string worktreePath: string | null connectionId?: string @@ -213,7 +216,7 @@ export function getEditorFileOperationContext( throw new Error(OWNER_CHANGED_MESSAGE) } return { - settings: settingsForWorktreeOperationRoute(state.settings, route), + target: runtimeTargetForOwnerEnvironment(route.runtimeEnvironmentId), worktreeId: file.worktreeId, worktreePath: resolvedWorktreePath, expectedExecutionHostId: host.kind === 'ssh' ? host.id : 'local', diff --git a/src/renderer/src/lib/execute-open-editor-path-move.test.ts b/src/renderer/src/lib/execute-open-editor-path-move.test.ts index 3798a305445e..7c483efe32d2 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.test.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.test.ts @@ -19,7 +19,7 @@ import { getDiskBaselineSignature } from '@/components/editor/diff-content-signa import { buildEditorExternalWatchEventHandler as createExternalWatchEventHandler } from '@/hooks/editor-external-watch-event-reconciliation' const CONTEXT = { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: undefined diff --git a/src/renderer/src/lib/file-owner-runtime-target.test.ts b/src/renderer/src/lib/file-owner-runtime-target.test.ts new file mode 100644 index 000000000000..e5e8e86116b2 --- /dev/null +++ b/src/renderer/src/lib/file-owner-runtime-target.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner, + requireRuntimeTargetForFileOwner +} from './file-owner-runtime-target' +import type { WorktreeOperationRouteState } from './worktree-operation-route' + +const settings = { activeRuntimeEnvironmentId: 'focused-env' } + +const state: WorktreeOperationRouteState = { + settings, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:owner-env' }, + { id: 'ssh-repo', connectionId: 'box', executionHostId: 'ssh:box' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo', hostId: 'local' }], + 'runtime-repo': [ + { + id: 'runtime-repo::wt-b', + repoId: 'runtime-repo', + hostId: 'runtime:owner-env', + runtimeOwnerEnvironmentId: 'owner-env' + } + ], + 'ssh-repo': [{ id: 'ssh-repo::wt-c', repoId: 'ssh-repo', hostId: 'ssh:box' }] + } +} + +describe('getRuntimeTargetForWorktreeOwner', () => { + it('dials the owner while another server is focused', () => { + expect(getRuntimeTargetForWorktreeOwner(state, 'runtime-repo::wt-b')).toEqual({ + kind: 'environment', + environmentId: 'owner-env' + }) + expect(getRuntimeTargetForWorktreeOwner(state, 'local-repo::wt-a')).toEqual({ kind: 'local' }) + expect(getRuntimeTargetForWorktreeOwner(state, 'ssh-repo::wt-c')).toEqual({ kind: 'local' }) + expect(getRuntimeTargetForWorktreeOwner(state, FLOATING_TERMINAL_WORKTREE_ID)).toEqual({ + kind: 'local' + }) + }) + + it('reads an unstamped legacy row as this app, not the focused server', () => { + // Before: a single saved, focused server claimed rows that predate owner stamping. + const legacy: WorktreeOperationRouteState = { + settings, + runtimeEnvironments: [{ id: 'focused-env' }], + runtimeEnvironmentCatalogHydrated: true, + repos: [{ id: 'legacy-repo' }], + worktreesByRepo: { 'legacy-repo': [{ id: 'legacy-repo::wt', repoId: 'legacy-repo' }] } + } + expect(getRuntimeTargetForWorktreeOwner(legacy, 'legacy-repo::wt')).toEqual({ kind: 'local' }) + }) + + it('stays on this app for no workspace or a workspace with no row yet', () => { + expect(getRuntimeTargetForWorktreeOwner(state, null)).toEqual({ kind: 'local' }) + expect(getRuntimeTargetForWorktreeOwner(state, 'unknown::wt')).toEqual({ kind: 'local' }) + }) + + it('has no transport when rows disagree, and a caller with an error path refuses', () => { + const ambiguous: WorktreeOperationRouteState = { + settings, + worktreesByRepo: { + a: [ + { + id: 'dup::wt', + repoId: 'a', + hostId: 'runtime:env-a', + runtimeOwnerEnvironmentId: 'env-a' + } + ], + b: [ + { + id: 'dup::wt', + repoId: 'b', + hostId: 'runtime:env-b', + runtimeOwnerEnvironmentId: 'env-b' + } + ] + } + } + expect(getRuntimeTargetForWorktreeOwner(ambiguous, 'dup::wt')).toBeNull() + expect(() => requireRuntimeTargetForFileOwner(ambiguous, 'dup::wt', undefined)).toThrow( + "Couldn't verify which host owns this file" + ) + }) +}) + +describe('getRuntimeTargetForFileOwner', () => { + it("keeps the tab's stamped owner, else the worktree's owner", () => { + expect(getRuntimeTargetForFileOwner(state, 'local-repo::wt-a', 'stamped-env')).toEqual({ + kind: 'environment', + environmentId: 'stamped-env' + }) + expect(getRuntimeTargetForFileOwner(state, 'runtime-repo::wt-b', null)).toEqual({ + kind: 'local' + }) + expect(getRuntimeTargetForFileOwner(state, 'runtime-repo::wt-b', undefined)).toEqual({ + kind: 'environment', + environmentId: 'owner-env' + }) + }) +}) diff --git a/src/renderer/src/lib/file-owner-runtime-target.ts b/src/renderer/src/lib/file-owner-runtime-target.ts new file mode 100644 index 000000000000..e39ba815517f --- /dev/null +++ b/src/renderer/src/lib/file-owner-runtime-target.ts @@ -0,0 +1,58 @@ +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' +import { runtimeTargetForWorkspaceOwner, resolveOwner } from './resolve-owner' +import type { WorktreeOperationRouteState } from './worktree-operation-route' + +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } + +export const FILE_OWNER_UNRESOLVED_MESSAGE = + "Couldn't verify which host owns this file. Reopen the file and try again." + +/** + * Transport to the worktree's owner, from its rows alone. `null` when rows disagree, so the caller + * refuses instead of guessing. + */ +export function getRuntimeTargetForWorktreeOwner( + state: WorktreeOperationRouteState, + worktreeId: string | null | undefined +): RuntimeClientTarget | null { + if (!worktreeId) { + return LOCAL_TARGET + } + const target = runtimeTargetForWorkspaceOwner(state, { workspaceId: worktreeId }) + if (target) { + return target + } + // Why local for a missing row: tabs and drops stamp a server owner when one exists, so a + // workspace with no row yet is this app's — the same answer the read had before focus routing. + return resolveOwner(state, { workspaceId: worktreeId }).kind === 'missing' ? LOCAL_TARGET : null +} + +/** + * Transport to an open file's owner: the owner stamped on its tab (`null` is this app), else its + * worktree's owner. Never the focused server, which can change after the tab opened. + */ +export function getRuntimeTargetForFileOwner( + state: WorktreeOperationRouteState, + worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined +): RuntimeClientTarget | null { + return runtimeEnvironmentId === undefined + ? getRuntimeTargetForWorktreeOwner(state, worktreeId) + : runtimeTargetForOwnerEnvironment(runtimeEnvironmentId) +} + +/** {@link getRuntimeTargetForFileOwner} for a caller with an error path. */ +export function requireRuntimeTargetForFileOwner( + state: WorktreeOperationRouteState, + worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined +): RuntimeClientTarget { + const target = getRuntimeTargetForFileOwner(state, worktreeId, runtimeEnvironmentId) + if (!target) { + throw new Error(FILE_OWNER_UNRESOLVED_MESSAGE) + } + return target +} diff --git a/src/renderer/src/lib/file-search-result-owner.test.ts b/src/renderer/src/lib/file-search-result-owner.test.ts index 561e14a20c86..d62dfaad402e 100644 --- a/src/renderer/src/lib/file-search-result-owner.test.ts +++ b/src/renderer/src/lib/file-search-result-owner.test.ts @@ -3,9 +3,7 @@ import { createFileSearchResultOwner } from './file-search-result-owner' describe('createFileSearchResultOwner', () => { it('captures the exact runtime used by the completed search', () => { - const settings = { activeRuntimeEnvironmentId: ' runtime-owner-a ' } - const owner = createFileSearchResultOwner('worktree-a', settings) - settings.activeRuntimeEnvironmentId = 'runtime-owner-b' + const owner = createFileSearchResultOwner('worktree-a', ' runtime-owner-a ') expect(owner).toEqual({ worktreeId: 'worktree-a', @@ -14,11 +12,7 @@ describe('createFileSearchResultOwner', () => { }) it.each([null, '', ' '])('records %j as an explicit non-runtime owner', (environmentId) => { - expect( - createFileSearchResultOwner('local-or-ssh-worktree', { - activeRuntimeEnvironmentId: environmentId - }) - ).toEqual({ + expect(createFileSearchResultOwner('local-or-ssh-worktree', environmentId)).toEqual({ worktreeId: 'local-or-ssh-worktree', runtimeEnvironmentId: null }) diff --git a/src/renderer/src/lib/file-search-result-owner.ts b/src/renderer/src/lib/file-search-result-owner.ts index ed68c0127969..13c96c9058cd 100644 --- a/src/renderer/src/lib/file-search-result-owner.ts +++ b/src/renderer/src/lib/file-search-result-owner.ts @@ -1,5 +1,3 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' - export type FileSearchResultOwner = { worktreeId: string runtimeEnvironmentId: string | null @@ -9,13 +7,13 @@ export type FileSearchResultOwner = { export function createFileSearchResultOwner( worktreeId: string, - settings: Pick, + runtimeEnvironmentId: string | null, identity?: { rootPath: string; executionHostId: string } ): FileSearchResultOwner { return { ...identity, worktreeId, - runtimeEnvironmentId: settings.activeRuntimeEnvironmentId?.trim() || null + runtimeEnvironmentId: runtimeEnvironmentId?.trim() || null } } diff --git a/src/renderer/src/lib/floating-workspace-tab-creation.ts b/src/renderer/src/lib/floating-workspace-tab-creation.ts index 50f31ed48c15..677215ae824e 100644 --- a/src/renderer/src/lib/floating-workspace-tab-creation.ts +++ b/src/renderer/src/lib/floating-workspace-tab-creation.ts @@ -68,7 +68,7 @@ export async function createFloatingWorkspaceMarkdownTab( floatingMarkdownDirectory, FLOATING_TERMINAL_WORKTREE_ID, getConnectionId(FLOATING_TERMINAL_WORKTREE_ID) ?? undefined, - { activeRuntimeEnvironmentId: null } + { kind: 'local' } ) if (!fileInfo) { return diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts index 0e1cf8e22f4b..18f72470420e 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts @@ -548,7 +548,7 @@ describe('createFloatingWorkspaceMarkdownTab', () => { '/tmp/orca/floating-workspace', FLOATING_TERMINAL_WORKTREE_ID, undefined, - { activeRuntimeEnvironmentId: null } + { kind: 'local' } ) expect(store.openFile).toHaveBeenCalledWith(fileInfo, { preview: false, @@ -570,7 +570,7 @@ describe('createFloatingWorkspaceMarkdownTab', () => { '/tmp/orca/floating-workspace', FLOATING_TERMINAL_WORKTREE_ID, undefined, - { activeRuntimeEnvironmentId: null } + { kind: 'local' } ) expect(store.openFile).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index b3480ecb26b9..f6e2741a61e7 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -1,9 +1,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { LocalFileAccess } from '../../../shared/local-file-access' -import { settingsForRuntimeOwner } from '@/runtime/runtime-client-target' import type { OpenFile } from '@/store/slices/editor' -import type { AppState } from '@/store/types' import { getConnectionIdForFile } from './connection-context' +import { getRuntimeTargetForFileOwner } from './file-owner-runtime-target' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' } const CHAT_IMAGE_ACCESS: LocalFileAccess = { kind: 'chat-image' } @@ -58,15 +58,16 @@ export function isClientLocalReadOnlyTab( * the same before and after a restart; every other tab stays inside its project root. */ export function editorTabFileAccess( - state: Pick, + state: WorktreeRuntimeOwnerState, file: EditorTabAccessFile ): LocalFileAccess | undefined { // Why: these are client-local files the user opened, whatever the worktree's host. if (isClientLocalReadOnlyTab(file)) { return USER_FILE_ACCESS } - const runtimeOwner = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) - if (file.externalSshTargetId?.trim() || runtimeOwner?.activeRuntimeEnvironmentId?.trim()) { + const owner = getRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId) + // Why `!owner`: rows that disagree may put the path on another host. + if (file.externalSshTargetId?.trim() || owner?.kind !== 'local') { return undefined } const outsideOwnerRoot = @@ -83,7 +84,7 @@ export function editorTabFileAccess( * user-named, else none, so project tabs keep their project checks. */ export function editorTabDocumentFolderAccess( - state: Pick, + state: WorktreeRuntimeOwnerState, file: EditorTabAccessFile ): LocalFileAccess | undefined { // Why: a read-only tab (an AI Vault log, possibly in an SSH workspace) is never written. diff --git a/src/renderer/src/lib/markdown-document-templates.test.ts b/src/renderer/src/lib/markdown-document-templates.test.ts index ab17687fcc26..107ffde0f30b 100644 --- a/src/renderer/src/lib/markdown-document-templates.test.ts +++ b/src/renderer/src/lib/markdown-document-templates.test.ts @@ -92,7 +92,7 @@ describe('markdown document templates', () => { await expect( listMarkdownDocumentTemplates( { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'conn-1' @@ -137,7 +137,7 @@ describe('markdown document templates', () => { await expect( listMarkdownDocumentTemplates( - { settings: null, worktreeId: 'wt-1', worktreePath: '/repo' }, + { target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, '/repo' ) ).resolves.toEqual([]) @@ -150,7 +150,7 @@ describe('markdown document templates', () => { await expect( listMarkdownDocumentTemplates( - { settings: null, worktreeId: 'wt-1', worktreePath: 'C:\\repo' }, + { target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: 'C:\\repo' }, 'C:\\repo' ) ).resolves.toEqual([ diff --git a/src/renderer/src/lib/markdown-document-templates.ts b/src/renderer/src/lib/markdown-document-templates.ts index b4149389c02d..166e6d3502a0 100644 --- a/src/renderer/src/lib/markdown-document-templates.ts +++ b/src/renderer/src/lib/markdown-document-templates.ts @@ -1,5 +1,5 @@ import type { DirEntry } from '../../../shared/filesystem-entry-types' -import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { readRuntimeDirectory, @@ -211,14 +211,14 @@ export async function listMarkdownDocumentTemplates( export async function readMarkdownDocumentTemplateContent( context: { - settings: Pick | null | undefined + target: RuntimeClientTarget worktreeId: string connectionId?: string }, template: MarkdownDocumentTemplate ): Promise { const result = await readRuntimeFileContent({ - settings: context.settings, + target: context.target, filePath: template.filePath, relativePath: template.relativePath, worktreeId: context.worktreeId, diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index bfadcc41fd9f..c84d361dc4e4 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -62,7 +62,7 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise { ) const operationRoute = operationGuard.route const fileContext = { - settings: operationRoute.settings, + target: operationRoute.target, worktreeId, worktreePath, connectionId: operationRoute.connectionId, diff --git a/src/renderer/src/lib/user-opened-local-path.test.ts b/src/renderer/src/lib/user-opened-local-path.test.ts index a00a0e37a94b..8d08967e84e2 100644 --- a/src/renderer/src/lib/user-opened-local-path.test.ts +++ b/src/renderer/src/lib/user-opened-local-path.test.ts @@ -12,7 +12,7 @@ vi.mock('@/runtime/runtime-file-client', () => ({ import { statUserOpenedPath } from './user-opened-local-path' const project: RuntimeFileOperationArgs = { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'repo::/repo', worktreePath: '/repo' } diff --git a/src/renderer/src/lib/workspace-file-host-routing.ts b/src/renderer/src/lib/workspace-file-host-routing.ts index 16350e4880c9..1120e2e364dc 100644 --- a/src/renderer/src/lib/workspace-file-host-routing.ts +++ b/src/renderer/src/lib/workspace-file-host-routing.ts @@ -1,7 +1,7 @@ import { getConnectionId, getConnectionIdForFile } from '@/lib/connection-context' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { hasRemoteRuntimeOwner } from '@/runtime/runtime-file-routing' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { useAppStore } from '@/store' /** Which host owns a workspace file, resolved the same way for every surface that opens one. */ @@ -10,9 +10,12 @@ export function buildWorkspaceFileContext( worktreePath: string, runtimeEnvironmentId?: string | null ): RuntimeFileOperationArgs { - const settings = useAppStore.getState().settings return { - settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), + target: requireRuntimeTargetForFileOwner( + useAppStore.getState(), + worktreeId, + runtimeEnvironmentId + ), worktreeId: worktreeId || null, worktreePath, connectionId: getConnectionId(worktreeId || null) ?? undefined diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index ef26415f9b10..6d168becd5e2 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -11,7 +11,7 @@ import { editorTabFileAccess } from '@/lib/local-file-access' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { readRuntimeFileContent } from './runtime-file-client' -import { settingsForRuntimeOwner } from './runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { hashMarkdownContent, isMarkdownContentByteLengthOverLimit, @@ -245,7 +245,7 @@ async function readFileContent(file: OpenFile): Promise { const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined const state = useAppStore.getState() const result: FileContent = await readRuntimeFileContent({ - settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + target: requireRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId), filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, diff --git a/src/renderer/src/runtime/remote-host-file-open-repro.test.ts b/src/renderer/src/runtime/remote-host-file-open-repro.test.ts index 1c35465d5e4e..1d4b8a22819e 100644 --- a/src/renderer/src/runtime/remote-host-file-open-repro.test.ts +++ b/src/renderer/src/runtime/remote-host-file-open-repro.test.ts @@ -31,6 +31,7 @@ import { } from '@/components/editor/editor-panel-content-types' import { shouldRetryFileLoadError } from '@/components/editor/useEditorPanelFileLoadRetry' import { readRuntimeFileContent } from './runtime-file-client' +import type { RuntimeClientTarget } from './runtime-client-target' // Mirrors src/main/ipc/filesystem-auth.ts PATH_ACCESS_DENIED_MESSAGE. const PATH_ACCESS_DENIED_MESSAGE = @@ -97,18 +98,16 @@ afterEach(() => { async function openRemoteFileInEditor() { const resolvedConnectionId = getConnectionIdForFile(REMOTE_WORKTREE_ID, REMOTE_FILE_PATH) const connectionId = resolvedConnectionId ?? undefined - const readSettings: { activeRuntimeEnvironmentId: string | null } = { - activeRuntimeEnvironmentId: null - } + const readTarget: RuntimeClientTarget = { kind: 'local' } if ( resolvedConnectionId === undefined && - !readSettings.activeRuntimeEnvironmentId?.trim() && + readTarget.kind === 'local' && !isWorktreeConnectionResolved(REMOTE_WORKTREE_ID) ) { throw new Error(WORKTREE_OWNER_NOT_READY_ERROR) } return readRuntimeFileContent({ - settings: readSettings, + target: readTarget, filePath: REMOTE_FILE_PATH, relativePath: 'src/index.ts', worktreeId: REMOTE_WORKTREE_ID, @@ -122,18 +121,16 @@ async function openFloatingWorkspaceFileInEditor() { FLOATING_FILE_PATH ) const connectionId = resolvedConnectionId ?? undefined - const readSettings: { activeRuntimeEnvironmentId: string | null } = { - activeRuntimeEnvironmentId: null - } + const readTarget: RuntimeClientTarget = { kind: 'local' } if ( resolvedConnectionId === undefined && - !readSettings.activeRuntimeEnvironmentId?.trim() && + readTarget.kind === 'local' && !isWorktreeConnectionResolved(FLOATING_TERMINAL_WORKTREE_ID) ) { throw new Error(WORKTREE_OWNER_NOT_READY_ERROR) } return readRuntimeFileContent({ - settings: readSettings, + target: readTarget, filePath: FLOATING_FILE_PATH, relativePath: 'notes.md', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, diff --git a/src/renderer/src/runtime/runtime-client-target.ts b/src/renderer/src/runtime/runtime-client-target.ts index e17f811f10d0..a7d21a05ac9d 100644 --- a/src/renderer/src/runtime/runtime-client-target.ts +++ b/src/renderer/src/runtime/runtime-client-target.ts @@ -23,6 +23,11 @@ export function runtimeTargetForOwnerEnvironment( return ownerId ? { kind: 'environment', environmentId: ownerId } : { kind: 'local' } } +/** The server a transport dials, or `null` for this app. */ +export function runtimeTargetEnvironmentId(target: RuntimeClientTarget): string | null { + return target.kind === 'environment' ? target.environmentId : null +} + /** * Transport to the host that owns a row: a server's own runtime, or this app for local and direct * SSH (which ride this app's IPC with a `connectionId`). `null` when the id names no routable host. diff --git a/src/renderer/src/runtime/runtime-file-client-download.test.ts b/src/renderer/src/runtime/runtime-file-client-download.test.ts index b71b23d08be9..19a80af0cfcd 100644 --- a/src/renderer/src/runtime/runtime-file-client-download.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-download.test.ts @@ -47,7 +47,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -101,7 +101,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -139,7 +139,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -188,7 +188,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -226,7 +226,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -264,7 +264,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, diff --git a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts index 52f0bb83d09a..cba08809e6ad 100644 --- a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts @@ -95,7 +95,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -216,7 +216,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -282,7 +282,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -328,7 +328,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -388,7 +388,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -430,7 +430,7 @@ describe('runtime file client', () => { await importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', diff --git a/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts b/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts index 688e2e574171..4634dcc220d9 100644 --- a/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts @@ -41,7 +41,7 @@ describe('runtime file client', () => { _meta: { runtimeId: 'remote-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo', expectedSshTargetId: 'ssh-1', @@ -130,7 +130,7 @@ describe('runtime file client', () => { await expect( writeRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-old-hub' }, + target: { kind: 'environment' as const, environmentId: 'env-old-hub' }, worktreeId: 'wt-hub-local', worktreePath: '/hub/repo', expectedExecutionHostId: 'local' @@ -169,7 +169,7 @@ describe('runtime file client', () => { await expect( renameRuntimePath( { - settings: { activeRuntimeEnvironmentId: 'env-old-hub' }, + target: { kind: 'environment' as const, environmentId: 'env-old-hub' }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', connectionId: 'hub-ssh-1', @@ -217,7 +217,7 @@ describe('runtime file client', () => { await expect( readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-old-hub' }, + target: { kind: 'environment' as const, environmentId: 'env-old-hub' }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', connectionId: 'hub-ssh-1' @@ -255,7 +255,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-old-hub' }, + target: { kind: 'environment' as const, environmentId: 'env-old-hub' }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', expectedExecutionHostId: 'ssh:hub-ssh-1', @@ -297,7 +297,7 @@ describe('runtime file client', () => { _meta: { runtimeId: 'new-hub-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-downgraded' }, + target: { kind: 'environment' as const, environmentId: 'env-downgraded' }, worktreeId: 'wt-hub-local', worktreePath: '/hub/repo', expectedExecutionHostId: 'local' as const @@ -356,7 +356,7 @@ describe('runtime file client', () => { await expect( deleteRuntimePath( { - settings: { activeRuntimeEnvironmentId: 'env-repaired' }, + target: { kind: 'environment' as const, environmentId: 'env-repaired' }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', expectedExecutionHostId: 'ssh:hub-ssh-1', @@ -394,7 +394,7 @@ describe('runtime file client', () => { it('does not fall back to client-local mutations for remote-owned paths outside the worktree', async () => { const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' } @@ -421,7 +421,7 @@ describe('runtime file client', () => { it('does not fall back to client-local mutations when a remote Windows path escapes the worktree', async () => { const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: 'C:\\repo' } @@ -440,7 +440,7 @@ describe('runtime file client', () => { it('keeps copy operations on local filesystem IPC when no runtime is active', async () => { await copyRuntimePath( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -460,7 +460,7 @@ describe('runtime file client', () => { it('preserves the SSH connection for copy operations when no runtime is active', async () => { await copyRuntimePath( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', diff --git a/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts b/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts index d8f75a0f163a..c179e180f599 100644 --- a/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts @@ -34,7 +34,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -61,7 +61,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -76,7 +76,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -105,7 +105,7 @@ describe('runtime file client', () => { await expect( listRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -145,7 +145,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -177,7 +177,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-1' @@ -237,7 +237,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -252,7 +252,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -268,7 +268,7 @@ describe('runtime file client', () => { replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 2 }]) await searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -299,7 +299,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-legacy' }, + target: { kind: 'environment' as const, environmentId: 'env-legacy' }, worktreeId: 'wt-legacy', worktreePath: '/remote/repo' }, @@ -350,7 +350,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -390,7 +390,7 @@ describe('runtime file client', () => { const pending = searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -410,7 +410,7 @@ describe('runtime file client', () => { const retryController = new AbortController() const retry = searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -462,7 +462,7 @@ describe('runtime file client', () => { }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'folder-1' } await expect( @@ -486,7 +486,7 @@ describe('runtime file client', () => { await expect( listRuntimeFiles( { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-1' @@ -508,7 +508,7 @@ describe('runtime file client', () => { it('sends the Explorer name filter to local listings only', async () => { fsListFiles.mockResolvedValue([]) - const local = { settings: {}, worktreeId: 'wt-1', worktreePath: '/repo' } + const local = { target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' } await listRuntimeFiles(local, { rootPath: '/repo', nameFilter: 'AppDelegate' }) await listRuntimeFiles( @@ -523,7 +523,7 @@ describe('runtime file client', () => { it('cancelRuntimeFileList aborts the IPC listing but not environment listings (#7721)', () => { cancelRuntimeFileList( { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-1' @@ -535,7 +535,7 @@ describe('runtime file client', () => { fsCancelListFiles.mockClear() cancelRuntimeFileList( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -552,7 +552,7 @@ describe('runtime file client', () => { _meta: { runtimeId: 'remote-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' } @@ -580,7 +580,7 @@ describe('runtime file client', () => { await expect( runtimePathExists( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -600,7 +600,7 @@ describe('runtime file client', () => { await expect( statRuntimePath( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -611,7 +611,7 @@ describe('runtime file client', () => { await expect( statRuntimePath( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: 'C:\\repo' }, @@ -634,7 +634,7 @@ it.each([{ includeIgnored: false }, { followSymlinks: true }])( _meta: { runtimeId: 'remote-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'options-workspace', worktreePath: '/remote/repo' } @@ -665,7 +665,7 @@ it('forwards both listing options only to the selected current host', async () = await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'options-current', worktreePath: '/remote/repo' }, diff --git a/src/renderer/src/runtime/runtime-file-client-types.ts b/src/renderer/src/runtime/runtime-file-client-types.ts index 87f84d9aa220..e9df1c00d209 100644 --- a/src/renderer/src/runtime/runtime-file-client-types.ts +++ b/src/renderer/src/runtime/runtime-file-client-types.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { RuntimeClientTarget } from './runtime-client-target' import type { LocalFileAccess } from '../../../shared/local-file-access' export type RuntimeReadableFileContent = { @@ -11,7 +11,8 @@ export type RuntimeReadableFileContent = { } export type RuntimeFileReadArgs = { - settings: Pick | null | undefined + /** The file owner's transport; never the focused server. */ + target: RuntimeClientTarget filePath: string relativePath?: string worktreeId?: string @@ -23,7 +24,8 @@ export type RuntimeFileReadArgs = { } export type RuntimeFileOperationArgs = { - settings: Pick | null | undefined + /** The worktree owner's transport; never the focused server. */ + target: RuntimeClientTarget worktreeId: string | null | undefined worktreePath: string | null | undefined connectionId?: string diff --git a/src/renderer/src/runtime/runtime-file-client-watch.test.ts b/src/renderer/src/runtime/runtime-file-client-watch.test.ts index cd2366968232..ab855b186569 100644 --- a/src/renderer/src/runtime/runtime-file-client-watch.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-watch.test.ts @@ -18,7 +18,7 @@ describe('runtime file client', () => { await expect( subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -41,7 +41,7 @@ describe('runtime file client', () => { const stop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -89,7 +89,7 @@ describe('runtime file client', () => { await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -116,7 +116,7 @@ describe('runtime file client', () => { await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -144,7 +144,7 @@ describe('runtime file client', () => { const retryPayload = vi.fn() let retryPromise: Promise<() => void> | undefined const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' } @@ -200,7 +200,7 @@ describe('runtime file client', () => { const retryPayload = vi.fn() let retryPromise: Promise<() => void> | undefined const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' } @@ -255,7 +255,7 @@ describe('runtime file client', () => { const firstStop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -263,7 +263,7 @@ describe('runtime file client', () => { ) const secondStop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -324,7 +324,7 @@ describe('runtime file client', () => { const stop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -362,7 +362,7 @@ describe('runtime file client', () => { const stop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 7f2cd70ca5cd..e5bf22e82920 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -27,7 +27,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, filePath: '/repo/readme.md', relativePath: 'readme.md', worktreeId: 'wt-1', @@ -45,7 +45,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, filePath: '/tmp/external.md', relativePath: '/tmp/external.md', worktreeId: 'wt-1', @@ -64,7 +64,7 @@ describe('runtime file client', () => { it('rejects an external SSH file read after the target changes', async () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, filePath: '/tmp/external.md', relativePath: '/tmp/external.md', worktreeId: 'wt-1', @@ -79,7 +79,7 @@ describe('runtime file client', () => { it('rejects an external SSH file read through a runtime environment', async () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/tmp/external.md', relativePath: '/tmp/external.md', worktreeId: 'wt-1', @@ -94,7 +94,7 @@ describe('runtime file client', () => { it('binds direct SSH mutations to the captured target and generation', async () => { const context = { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', @@ -149,7 +149,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/src/index.ts', relativePath: 'src/index.ts', worktreeId: 'wt-1' @@ -171,7 +171,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/Users/me/scratch.md', relativePath: '/Users/me/scratch.md' }) @@ -187,7 +187,7 @@ describe('runtime file client', () => { it('rejects remote-owned text reads that are not worktree-relative', async () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/tmp/scratch.md', relativePath: '/tmp/scratch.md', worktreeId: 'wt-1' @@ -196,7 +196,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/unknown.md', worktreeId: 'wt-1' }) @@ -221,7 +221,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/large.log', relativePath: 'large.log', worktreeId: 'wt-1' @@ -254,7 +254,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/doc.pdf', relativePath: 'doc.pdf', worktreeId: 'wt-1' @@ -295,7 +295,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/secret.txt', relativePath: 'secret.txt', worktreeId: 'wt-1' @@ -327,7 +327,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/huge.pdf', relativePath: 'huge.pdf', worktreeId: 'wt-1' @@ -352,7 +352,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/doc.pdf', relativePath: 'doc.pdf', worktreeId: 'wt-1' @@ -364,11 +364,11 @@ describe('runtime file client', () => { ) }) - it('uses the active runtime id as the dedupe scope', () => { - expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: 'env-1' }, 'ssh-1')).toBe( + it('uses the owner runtime id as the dedupe scope', () => { + expect(getRuntimeFileReadScope({ kind: 'environment', environmentId: 'env-1' }, 'ssh-1')).toBe( 'runtime:env-1' ) - expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe('ssh-1') + expect(getRuntimeFileReadScope({ kind: 'local' }, 'ssh-1')).toBe('ssh-1') }) it('routes directory reads through the selected runtime environment', async () => { @@ -382,7 +382,7 @@ describe('runtime file client', () => { await expect( readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -408,7 +408,7 @@ describe('runtime file client', () => { await readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: 'C:\\Repo' }, @@ -433,7 +433,7 @@ describe('runtime file client', () => { await readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '//Server/Share/Repo' }, @@ -459,7 +459,7 @@ describe('runtime file client', () => { await expect( readRuntimeFilePreview( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -484,7 +484,7 @@ describe('runtime file client', () => { await expect( readRuntimeFilePreview( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-2', @@ -501,7 +501,7 @@ describe('runtime file client', () => { await expect( readRuntimeFilePreview( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -528,7 +528,7 @@ describe('runtime file client', () => { await expect( readRuntimeFilePreview( - { settings: { activeRuntimeEnvironmentId: 'env-1' }, ...owner }, + { target: { kind: 'environment' as const, environmentId: 'env-1' }, ...owner }, storedPath ) ).resolves.toMatchObject({ content: 'base64', mimeType: 'image/png' }) @@ -552,7 +552,7 @@ describe('runtime file client', () => { await readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -571,7 +571,7 @@ describe('runtime file client', () => { await expect( readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-1' diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 98b289611110..9275d06b8325 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -20,7 +20,6 @@ import { ensureRuntimeDirectory, uploadRuntimeFileWithoutClobber } from './runtime-file-upload-client' -import { getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import type { LocalFileAccess } from '../../../shared/local-file-access' import { localAccess } from './runtime-file-read-client' @@ -36,7 +35,7 @@ export async function importExternalPathsToRuntime( access?: LocalFileAccess } ): Promise<{ results: ImportItemResult[] }> { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { return window.api.fs.importExternalPaths( withSshMutationExpectation(context, { diff --git a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts index eb8dad53970a..0c4a287b3d04 100644 --- a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts +++ b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts @@ -36,7 +36,7 @@ const uploadExternalFileToRuntime = vi.fn<(args: Record) => unk const importExternalPaths = vi.fn() const nestedSshContext = { - settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + target: { kind: 'environment' as const, environmentId: ENVIRONMENT_ID }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', connectionId: 'hub-ssh-1', @@ -46,7 +46,7 @@ const nestedSshContext = { } const hubLocalContext = { - settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + target: { kind: 'environment' as const, environmentId: ENVIRONMENT_ID }, worktreeId: 'wt-hub-local', worktreePath: '/hub/repo', expectedExecutionHostId: 'local' as const diff --git a/src/renderer/src/runtime/runtime-file-metadata-client.ts b/src/renderer/src/runtime/runtime-file-metadata-client.ts index e89c2c1e299f..ff026cb11734 100644 --- a/src/renderer/src/runtime/runtime-file-metadata-client.ts +++ b/src/renderer/src/runtime/runtime-file-metadata-client.ts @@ -1,7 +1,7 @@ import type { MarkdownDocument } from '../../../shared/filesystem-entry-types' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { assertLocalFilesystemFallbackAllowed, getRemoteFileArgs } from './runtime-file-routing' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import type { LocalFileAccess } from '../../../shared/local-file-access' import { localAccess } from './runtime-file-read-client' @@ -10,7 +10,7 @@ export async function listRuntimeMarkdownDocuments( context: RuntimeFileOperationArgs, rootPath: string ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { return window.api.fs.listMarkdownDocuments({ rootPath, diff --git a/src/renderer/src/runtime/runtime-file-mutation-client.ts b/src/renderer/src/runtime/runtime-file-mutation-client.ts index 0314ea7b2606..e3a106442275 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-client.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-client.ts @@ -1,6 +1,6 @@ import type { DirEntry } from '../../../shared/filesystem-entry-types' import { normalizeRelativePath } from '@/lib/path' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { assertLocalFilesystemFallbackAllowed, @@ -190,7 +190,7 @@ export async function deleteRuntimeRelativePath( relativePath: string, recursive?: boolean ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if ( target.kind !== 'environment' || !context.worktreeId || diff --git a/src/renderer/src/runtime/runtime-file-range-client.test.ts b/src/renderer/src/runtime/runtime-file-range-client.test.ts index 7b8c9aa937d0..de38ea733b33 100644 --- a/src/renderer/src/runtime/runtime-file-range-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-range-client.test.ts @@ -14,7 +14,7 @@ import { installRuntimeFileClientEnvironment() const localArgs = { - settings: null, + target: { kind: 'local' as const }, filePath: '/repo/large.csv', relativePath: 'large.csv', worktreeId: 'folder:workspace' @@ -65,7 +65,7 @@ describe('CSV range routing', () => { result: { contentBase64: 'YWJj', bytesRead: 3, eof: false } }) await readRuntimeFileRange( - { ...localArgs, settings: { activeRuntimeEnvironmentId: 'env-1' } }, + { ...localArgs, target: { kind: 'environment' as const, environmentId: 'env-1' } }, 10, 3 ) @@ -86,7 +86,11 @@ describe('CSV range routing', () => { it('does not substitute a local file for an unavailable or changed remote owner', async () => { await expect( readRuntimeFileRange( - { ...localArgs, settings: { activeRuntimeEnvironmentId: 'env-1' }, worktreeId: undefined }, + { + ...localArgs, + target: { kind: 'environment' as const, environmentId: 'env-1' }, + worktreeId: undefined + }, 0, 3 ) @@ -101,7 +105,7 @@ describe('CSV range routing', () => { await expect( statRuntimeReadTarget({ ...localArgs, - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, relativePath: '/other/large.csv' }) ).rejects.toThrow('outside') @@ -116,7 +120,7 @@ describe('CSV range routing', () => { }) await expect( readRuntimeFileRange( - { ...localArgs, settings: { activeRuntimeEnvironmentId: 'env-1' } }, + { ...localArgs, target: { kind: 'environment' as const, environmentId: 'env-1' } }, 0, 3 ) diff --git a/src/renderer/src/runtime/runtime-file-range-client.ts b/src/renderer/src/runtime/runtime-file-range-client.ts index bc04820d45a1..08c2dd9bf9ed 100644 --- a/src/renderer/src/runtime/runtime-file-range-client.ts +++ b/src/renderer/src/runtime/runtime-file-range-client.ts @@ -2,12 +2,12 @@ import type { RuntimeFileReadChunkResult } from '../../../shared/runtime-types' import { validateFileRangeRequest } from '../../../shared/file-range-read' import type { RuntimeFileReadArgs } from './runtime-file-client-types' import { assertExternalSshReadOwnership, canReadRelativeRuntimeFile } from './runtime-file-routing' -import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' function rangeReadTarget(args: RuntimeFileReadArgs) { - assertExternalSshReadOwnership(args.settings, args.connectionId, args.expectedExternalSshTargetId) - const target = getActiveRuntimeTarget(args.settings) + assertExternalSshReadOwnership(args.target, args.connectionId, args.expectedExternalSshTargetId) + const target = args.target if (target.kind !== 'environment') { return null } diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index 3cd9943fa40e..ce99fd5bda28 100644 --- a/src/renderer/src/runtime/runtime-file-read-client.ts +++ b/src/renderer/src/runtime/runtime-file-read-client.ts @@ -3,7 +3,7 @@ import type { RuntimeFileReadChunkResult, RuntimeFileReadResult } from '../../../shared/runtime-types' -import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client' import type { RuntimeFileDownloadResult, RuntimeFileOperationArgs, @@ -35,7 +35,7 @@ export function localAccess( } export async function readRuntimeFileContent({ - settings, + target, filePath, relativePath, worktreeId, @@ -44,8 +44,7 @@ export async function readRuntimeFileContent({ includeLocalLogMetadata, access }: RuntimeFileReadArgs): Promise { - assertExternalSshReadOwnership(settings, connectionId, expectedExternalSshTargetId) - const target = getActiveRuntimeTarget(settings) + assertExternalSshReadOwnership(target, connectionId, expectedExternalSshTargetId) if (target.kind !== 'environment' || !worktreeId) { return window.api.fs.readFile({ filePath, @@ -95,12 +94,12 @@ export async function readRuntimeFilePreview( access?: LocalFileAccess ): Promise { assertExternalSshReadOwnership( - context.settings, + context.target, context.connectionId, context.expectedExternalSshTargetId ) const remoteArgs = getRemoteFileArgs(context, filePath) - const runtimeTarget = getActiveRuntimeTarget(context.settings) + const runtimeTarget = context.target // A chat attachment lives in the paired server's store, outside every worktree; that server // reads it back itself (an older one answers method_not_found, and the preview falls back). if ( @@ -139,7 +138,7 @@ export async function downloadRuntimeFile( suggestedName: string ): Promise { assertExternalSshReadOwnership( - context.settings, + context.target, context.connectionId, context.expectedExternalSshTargetId ) diff --git a/src/renderer/src/runtime/runtime-file-routing.ts b/src/renderer/src/runtime/runtime-file-routing.ts index e08b7f493208..57260254a9aa 100644 --- a/src/renderer/src/runtime/runtime-file-routing.ts +++ b/src/renderer/src/runtime/runtime-file-routing.ts @@ -1,23 +1,19 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import { isWindowsAbsolutePathLike, relativePathInsideRoot } from '../../../shared/cross-platform-path' import { normalizeRelativePath } from '@/lib/path' -import { getActiveRuntimeTarget } from './runtime-rpc-client' +import type { RuntimeClientTarget } from './runtime-client-target' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' export function assertExternalSshReadOwnership( - settings: Pick | null | undefined, + target: RuntimeClientTarget, connectionId: string | undefined, expectedExternalSshTargetId: string | undefined ): void { const expectedTargetId = expectedExternalSshTargetId?.trim() - if ( - expectedTargetId && - (getActiveRuntimeTarget(settings).kind === 'environment' || connectionId !== expectedTargetId) - ) { + if (expectedTargetId && (target.kind === 'environment' || connectionId !== expectedTargetId)) { throw new Error('External SSH files are not available after the workspace host changes.') } } @@ -46,10 +42,9 @@ export function withSshMutationExpectation( } export function getRuntimeFileReadScope( - settings: Pick | null | undefined, + target: RuntimeClientTarget, connectionId: string | undefined ): string | undefined { - const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId } @@ -74,12 +69,12 @@ export function getRemoteFileArgs( context: RuntimeFileOperationArgs, absolutePath: string ): { - target: ReturnType & { kind: 'environment' } + target: RuntimeClientTarget & { kind: 'environment' } worktreeId: string worktreeSelector: string relativePath: string } | null { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { return null } @@ -96,9 +91,7 @@ export function getRemoteFileArgs( } export function hasRemoteRuntimeOwner(context: RuntimeFileOperationArgs): boolean { - return ( - getActiveRuntimeTarget(context.settings).kind === 'environment' && Boolean(context.worktreeId) - ) + return context.target.kind === 'environment' && Boolean(context.worktreeId) } export function assertLocalFilesystemFallbackAllowed(context: RuntimeFileOperationArgs): void { diff --git a/src/renderer/src/runtime/runtime-file-search-client.ts b/src/renderer/src/runtime/runtime-file-search-client.ts index eff31f106dfd..153a9f476a9a 100644 --- a/src/renderer/src/runtime/runtime-file-search-client.ts +++ b/src/renderer/src/runtime/runtime-file-search-client.ts @@ -16,7 +16,7 @@ import { searchLegacyQuickOpenInventory, validateLegacyQuickOpenRecentCandidates } from './runtime-legacy-quick-open-inventory' -import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' const QUICK_OPEN_REMOTE_UPDATE_REQUIRED_MESSAGE = @@ -31,7 +31,7 @@ export async function searchRuntimeFiles( if (getRuntimeFileSearchRejectedField(options)) { return createEmptyRuntimeFileSearchResult() } - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { const requestToken = createBrowserUuid() const cancel = (): void => { @@ -80,7 +80,7 @@ export async function listRuntimeFiles( signal?: AbortSignal } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { return window.api.fs.listFiles({ ...(args.includeIgnored === undefined ? {} : { includeIgnored: args.includeIgnored }), @@ -170,7 +170,7 @@ export async function searchRuntimeFilePaths( signal?: AbortSignal } ): Promise<{ files: string[]; truncated: boolean }> { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment') { if (!context.connectionId || !context.worktreePath) { return { files: [], truncated: false } @@ -280,7 +280,7 @@ export function cancelRuntimeFileList( context: RuntimeFileOperationArgs, requestToken: string ): void { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { void window.api.fs.cancelListFiles({ requestToken }).catch(() => { /* cancellation is advisory; the request path has its own timeouts */ diff --git a/src/renderer/src/runtime/runtime-file-watch-client.ts b/src/renderer/src/runtime/runtime-file-watch-client.ts index 8809a8f0d493..9a0737b3a4e4 100644 --- a/src/renderer/src/runtime/runtime-file-watch-client.ts +++ b/src/renderer/src/runtime/runtime-file-watch-client.ts @@ -1,11 +1,7 @@ import type { FsChangedPayload } from '../../../shared/filesystem-entry-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - unwrapRuntimeRpcResult -} from './runtime-rpc-client' +import { callRuntimeRpc, unwrapRuntimeRpcResult } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' type RuntimeFileWatchEvent = @@ -46,7 +42,7 @@ export async function subscribeRuntimeFileChanges( onPayload: (payload: FsChangedPayload) => void, onError?: (error: Error) => void ): Promise<() => void> { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { return window.api.fs.onFsChanged(onPayload) } diff --git a/src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts b/src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts index 8ce5cc97744e..d363af19259a 100644 --- a/src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts +++ b/src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts @@ -26,7 +26,7 @@ for (const version of [1, 2]) { }) ) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: `freshness-${version}`, worktreePath: '/host/repo' } diff --git a/src/renderer/src/runtime/runtime-path-existence-batch.test.ts b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts index 877e5a0f9863..fff0c170fad5 100644 --- a/src/renderer/src/runtime/runtime-path-existence-batch.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts @@ -12,7 +12,7 @@ import { } from '../../../shared/protocol-version' installRuntimeFileClientEnvironment() const context = { - settings: { activeRuntimeEnvironmentId: 'owner-a' }, + target: { kind: 'environment' as const, environmentId: 'owner-a' }, worktreeId: 'folder-1', worktreePath: '/folder' } diff --git a/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts b/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts index 9cb469c4871b..17be77ef4c48 100644 --- a/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts @@ -33,7 +33,7 @@ for (const mode of ['scalar', 'batch'] as const) { error: { code: 'not_found', message: 'Worktree not found: id:folder-1' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } @@ -67,7 +67,7 @@ for (const stage of ['status', 'operation'] as const) { ) runtimeEnvironmentCall.mockImplementation(async () => failure()) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } diff --git a/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts b/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts index 6cdc453ec454..5bb570ec1882 100644 --- a/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts @@ -45,7 +45,7 @@ for (const mode of ['scalar', 'batch', 'legacy', 'missing-method']) { return { id: 'result', ok: true, result: [{ exists: true }] } }) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } diff --git a/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts b/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts index f972b51eb360..ac4ab7159307 100644 --- a/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts @@ -43,7 +43,7 @@ for (const mode of ['scalar', 'queue', 'legacy', 'missing-method'] as const) { return { id: 'result', ok: true, result: [{ exists: true }] } }) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } @@ -77,7 +77,7 @@ it('keeps two queued hovers on distinct revisions of the same environment', asyn ) runtimeEnvironmentCall.mockResolvedValue({ id: 'result', ok: true, result: [{ exists: true }] }) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } diff --git a/src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts b/src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts index 6c15b329bc4f..99dbd09ea985 100644 --- a/src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts +++ b/src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts @@ -21,7 +21,7 @@ it('accepts a future compatible search version rather than falling back on exact await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'future-host', worktreePath: '/host/repo' }, @@ -52,7 +52,7 @@ it('validates default-policy recent paths on a version-two host through complete await expect( listRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'recent-legacy', worktreePath: '/host/repo' }, @@ -81,7 +81,7 @@ it('sends bounded candidates only after a compatible host advertises their seman await expect( listRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'recent-new', worktreePath: '/host/repo' }, @@ -115,7 +115,7 @@ it('refuses to infer recent eligibility from a truncated old-host inventory', as await expect( listRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'recent-truncated', worktreePath: '/host/repo' }, @@ -129,7 +129,7 @@ it.each(['missing-search', 'old-search'] as const)( 'translates only unavailable inventory errors after %s', async (route) => { const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'fallback-errors', worktreePath: '/host/repo' } @@ -161,7 +161,7 @@ it.each(['missing-search', 'old-search'] as const)( it('preserves raw errors when joining a cached pending legacy inventory', async () => { const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'cached-errors', worktreePath: '/host/repo' } @@ -221,7 +221,7 @@ it.each(['method_not_found', 'file_inventory_capacity', 'remote_runtime_unavaila await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: `punctuation-${code}`, worktreePath: '/host/repo' }, @@ -249,7 +249,7 @@ it.each([{ includeIgnored: false }, { followSymlinks: true }])( await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'v2-options', worktreePath: '/repo' }, @@ -277,7 +277,7 @@ it('keeps inherited ignored-file visibility from disabling an older paired host' }) ) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'legacy-ignored', worktreePath: '/repo' } @@ -317,7 +317,7 @@ it('does not reuse unfiltered recent inventory after enabling supported ignore f }) ) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'cached-v2-options', worktreePath: '/repo' } diff --git a/src/renderer/src/runtime/use-worktree-runtime-target.ts b/src/renderer/src/runtime/use-worktree-runtime-target.ts index 22ef4ed5c4b2..503f112d77bc 100644 --- a/src/renderer/src/runtime/use-worktree-runtime-target.ts +++ b/src/renderer/src/runtime/use-worktree-runtime-target.ts @@ -1,7 +1,12 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { runtimeTargetForExecutionHostId, type RuntimeClientTarget } from './runtime-client-target' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' +import { + runtimeTargetForExecutionHostId, + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from './runtime-client-target' /** * Runtime target that owns `worktreeId`, which is not always the globally @@ -14,3 +19,22 @@ export function useWorktreeRuntimeTarget( const executionHostId = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId)) return useMemo(() => runtimeTargetForExecutionHostId(executionHostId), [executionHostId]) } + +/** + * Transport to an open file's owner (see {@link getRuntimeTargetForFileOwner}), stable across + * renders; `null` while rows disagree. + */ +export function useFileOwnerRuntimeTarget( + worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined +): RuntimeClientTarget | null { + // Why a string: a fresh target object per store write would re-render on every change. + const ownerKey = useAppStore((state) => { + const target = getRuntimeTargetForFileOwner(state, worktreeId, runtimeEnvironmentId) + return target ? (target.kind === 'environment' ? target.environmentId : '') : null + }) + return useMemo( + () => (ownerKey === null ? null : runtimeTargetForOwnerEnvironment(ownerKey || null)), + [ownerKey] + ) +} diff --git a/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts b/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts index fdfb2e4f2cca..dfb1e15207ed 100644 --- a/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts +++ b/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts @@ -8,7 +8,7 @@ import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resoluti import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { getLinkSourceLocalOpenOwner } from '@/lib/link-source-local-open-owner' import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal-links' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { statRuntimePath } from '@/runtime/runtime-file-client' import { getOpenedEditFileIdAfterOpen } from '../file-ids/editor-file-ids' import { scheduleEditorLineReveal } from '../focus/editor-focus-reveal' @@ -44,7 +44,6 @@ export function createMarkdownLinkAction( ? ctx.runtimeEnvironmentId : inferredRuntimeEnvironmentId const runtimeOwnerId = sourceRuntimeEnvironmentId?.trim() || null - const sourceSettings = settingsForRuntimeOwner(initialState.settings, runtimeOwnerId) const resolvedConnectionId = ctx.sourceOwner || runtimeOwnerId ? undefined @@ -63,7 +62,7 @@ export function createMarkdownLinkAction( } const sourceConnectionId = sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined const fileContext = { - settings: sourceSettings, + target: runtimeTargetForOwnerEnvironment(runtimeOwnerId), worktreeId: ctx.worktreeId, worktreePath: ctx.worktreeRoot, connectionId: sourceConnectionId diff --git a/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts b/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts index c31880a13eba..da52fb766ca4 100644 --- a/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts @@ -50,7 +50,7 @@ export function createMarkdownPreviewActions( worktree.path, worktreeId, operationContext.connectionId, - operationContext.settings, + operationContext.target, operationProvenance, operationContext.expectedSshConnectionGeneration, operationContext.expectedSshTargetId, From 314993c200f6f3e77be8f1e979520f2bdf7bff78 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 11:43:05 -0700 Subject: [PATCH 004/110] refactor(terminals): terminal input and inspection route by the PTY's owner (#27279) * refactor(terminals): terminal input and inspection route by the PTY's owner A remote PTY id already names the server that owns it. inspect, confirm, send, verified send and the data subscription took settings only to fall back to the focused server for an id without an owner segment, so an old ownerless id could reach a server that does not own it. They now route by the id alone: an ownerless remote id is never dialed and never confirmed locally. Dropping the parameter removes the settings plumbing the native chat, agent draft paste and follow-up paths carried only for this, including the raw focused-settings default in sendBracketedPasteToAgent, and deletes getSettingsForAgentTabRuntimeOwner. * test(e2e): native chat SSH delivery and remote completion follow the narrower terminal signatures --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 28 +-- config/owner-routing-baseline.txt | 3 - .../NativeChatComposer.paste-remount.test.tsx | 1 - .../native-chat/NativeChatComposer.test.tsx | 15 +- .../native-chat/NativeChatComposer.tsx | 8 +- ...ResolvedView.prompt-card-composer.test.tsx | 12 +- .../native-chat/NativeChatResolvedView.tsx | 2 +- .../claude-model-switch-confirmation.test.ts | 7 - .../claude-model-switch-confirmation.ts | 7 +- ...ative-chat-composer-prompt-recall.test.tsx | 3 - .../native-chat-composer-target.ts | 2 - .../native-chat/native-chat-input-clear.ts | 21 +- .../native-chat-observed-send.test.ts | 24 +-- .../native-chat/native-chat-observed-send.ts | 8 +- .../native-chat-queue-send-confirm.test.tsx | 3 - .../native-chat-runtime-image-send.ts | 20 +- ...ive-chat-runtime-send-launch-draft.test.ts | 33 ++-- .../native-chat-runtime-send.test.ts | 184 ++++++------------ .../native-chat/native-chat-runtime-send.ts | 38 ++-- ...structured-send-composition-clear.test.tsx | 3 - ...-native-chat-composer-attachments.test.tsx | 3 +- .../use-native-chat-composer-interrupt.ts | 2 +- .../use-native-chat-interactive-send.test.tsx | 97 +++------ .../use-native-chat-interactive-send.ts | 42 ++-- ...tive-chat-picker-command-dispatch.test.tsx | 4 +- ...use-native-chat-picker-command-dispatch.ts | 4 +- ...use-native-chat-pty-composer-send.test.tsx | 6 +- .../use-native-chat-pty-composer-send.ts | 9 +- ...ative-chat-session-option-command.test.tsx | 8 +- .../use-native-chat-session-option-command.ts | 15 +- ...ion-coordinator-attention-dispatch.test.ts | 8 - ...oordinator-completion-replay-guard.test.ts | 8 - ...ompletion-coordinator-dispose-leak.test.ts | 1 - ...coordinator-hook-done-quiet-window.test.ts | 7 - ...-coordinator-hook-title-precedence.test.ts | 10 - ...on-coordinator-monitoring-turn-end.test.ts | 1 - ...ordinator-pending-title-inspection.test.ts | 8 - ...letion-coordinator-process-cadence.test.ts | 13 -- ...dinator-process-exit-turn-boundary.test.ts | 2 - ...dinator-queued-inspection-disposal.test.ts | 3 - ...-coordinator-stamped-turn-boundary.test.ts | 13 -- ...on-coordinator-stamped-turn-replay.test.ts | 12 -- .../agent-completion-coordinator-types.ts | 3 - ...ent-completion-no-evidence-cadence.test.ts | 1 - .../agent-completion-process-monitor.ts | 4 +- ...-completion-stale-evidence-backoff.test.ts | 1 - ...ent-completion-steady-state-opt-in.test.ts | 5 +- ...ane-foreground-process-exit-retire.test.ts | 1 - .../pending-pane-close-confirmation.test.ts | 3 +- .../pty-connection/pane-agent-identity.ts | 4 +- .../pty-connection/pane-serializer-settle.ts | 36 +--- .../pty-connection/terminal-keydown-fit.ts | 1 - .../terminal-paste-operation-order.test.ts | 2 - .../use-terminal-pane-close-actions.ts | 2 +- ...-running-work-probe-child-evidence.test.ts | 16 +- .../terminal/pty-running-work-probe.ts | 6 +- .../running-terminal-close-guard.test.ts | 6 +- .../terminal/running-terminal-close-guard.ts | 2 +- .../window-close-running-work.test.ts | 2 +- .../terminal/window-close-running-work.ts | 2 +- .../agent-hook-completion-notifications.ts | 1 - .../src/lib/agent-draft-paste-content.ts | 20 +- .../src/lib/agent-draft-readiness.test.ts | 8 +- src/renderer/src/lib/agent-draft-readiness.ts | 11 +- .../src/lib/agent-followup-delivery.test.ts | 16 +- .../src/lib/agent-followup-delivery.ts | 18 +- ...agent-paste-draft-readiness-budget.test.ts | 22 +-- .../agent-paste-draft-submit-retry.test.ts | 12 +- .../src/lib/agent-paste-draft.test.ts | 163 ++++------------ src/renderer/src/lib/agent-paste-draft.ts | 66 ++----- src/renderer/src/lib/agent-ready-wait.ts | 2 +- .../codex-session-restart-shell-flap.test.ts | 2 +- .../src/lib/codex-session-restart.test.ts | 8 +- src/renderer/src/lib/codex-session-restart.ts | 12 +- .../lib/launch-agent-background-session.ts | 1 - ...agent-new-tab-host-route-readiness.test.ts | 2 +- src/renderer/src/lib/new-workspace.test.ts | 6 - src/renderer/src/lib/new-workspace.ts | 12 +- .../src/lib/startup-draft-input-kind.test.ts | 1 - .../runtime-terminal-inspection.test.ts | 183 ++++------------- .../runtime/runtime-terminal-inspection.ts | 45 ++--- .../runtime/runtime-terminal-stream.test.ts | 13 +- .../src/runtime/runtime-terminal-stream.ts | 29 ++- .../runtime-terminal-verified-input.test.ts | 35 +++- .../runtime-terminal-verified-input.ts | 18 +- ...abs-agent-completion-notifications.test.ts | 4 - .../e2e/__mocks__/native-chat-ssh-delivery.ts | 7 +- .../e2e/native-chat-ssh-delivery.unit.test.ts | 35 ++-- ...te-agent-completion-authority.unit.test.ts | 7 - 89 files changed, 432 insertions(+), 1122 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index b377f24e894b..cabbe4f13aac 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -60,21 +60,12 @@ 1 src/renderer/src/components/linear-api-key-dialog.tsx 2 src/renderer/src/components/native-chat-resume-modal.test-support.tsx 1 src/renderer/src/components/native-chat-resume-tree.test-support.ts -2 src/renderer/src/components/native-chat/NativeChatComposer.tsx 1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx 1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx -1 src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts 1 src/renderer/src/components/native-chat/native-chat-file-link.ts 1 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts -1 src/renderer/src/components/native-chat/native-chat-observed-send.ts -2 src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts 2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts -6 src/renderer/src/components/native-chat/native-chat-runtime-send.ts 2 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts -6 src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts -2 src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts -4 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts -2 src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts 1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts 2 src/renderer/src/components/new-workspace/use-linear-source-connection.ts 1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts @@ -234,9 +225,6 @@ 3 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts 5 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts 1 src/renderer/src/components/task-page/TaskPage.tsx -2 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts -1 src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts -1 src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts 1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts 2 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts 1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts @@ -247,15 +235,11 @@ 1 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts -1 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-paste-listeners.ts 1 src/renderer/src/components/terminal-workspace-model.ts -1 src/renderer/src/components/terminal/pty-running-work-probe.ts -1 src/renderer/src/components/terminal/running-terminal-close-guard.ts 1 src/renderer/src/components/terminal/terminal-tab-create.ts -1 src/renderer/src/components/terminal/window-close-running-work.ts 2 src/renderer/src/components/use-task-page-source-availability.ts 2 src/renderer/src/components/use-task-page-store-bindings.ts 2 src/renderer/src/components/use-terminal-activation-actions.ts @@ -301,18 +285,12 @@ 1 src/renderer/src/lib/active-agent-note-send.ts 1 src/renderer/src/lib/active-agent-note-target.ts 1 src/renderer/src/lib/agent-background-session-launch-host.ts -1 src/renderer/src/lib/agent-draft-paste-content.ts -1 src/renderer/src/lib/agent-draft-readiness.ts -2 src/renderer/src/lib/agent-followup-delivery.ts 1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts 3 src/renderer/src/lib/agent-hibernation-coordinator.ts -5 src/renderer/src/lib/agent-paste-draft.ts -1 src/renderer/src/lib/agent-ready-wait.ts 1 src/renderer/src/lib/automation-session-observer.ts 1 src/renderer/src/lib/client-creation-action-error.ts 3 src/renderer/src/lib/client-creation-action-policy.ts 3 src/renderer/src/lib/codex-pane-selection-lane.ts -2 src/renderer/src/lib/codex-session-restart.ts 1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host 1 src/renderer/src/lib/doc-preview-grants.ts 3 src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -324,7 +302,7 @@ 1 src/renderer/src/lib/host-mirrored-pane-liveness.ts 6 src/renderer/src/lib/http-link-destinations.ts 3 src/renderer/src/lib/http-link-routing.ts -2 src/renderer/src/lib/launch-agent-background-session.ts +1 src/renderer/src/lib/launch-agent-background-session.ts 1 src/renderer/src/lib/launch-agent-execution-context.ts 2 src/renderer/src/lib/launch-agent-in-new-tab.ts 1 src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -333,7 +311,6 @@ 2 src/renderer/src/lib/launch-work-item-direct.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts 2 src/renderer/src/lib/local-preflight-context.ts -1 src/renderer/src/lib/new-workspace.ts 1 src/renderer/src/lib/open-agent-message-sender.ts 2 src/renderer/src/lib/provider-runtime-context.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts @@ -380,9 +357,6 @@ 5 src/renderer/src/runtime/runtime-linear-issue-mutations.ts 12 src/renderer/src/runtime/runtime-linear-project-client.ts 3 src/renderer/src/runtime/runtime-repo-client.ts -3 src/renderer/src/runtime/runtime-terminal-inspection.ts -1 src/renderer/src/runtime/runtime-terminal-stream.ts -1 src/renderer/src/runtime/runtime-terminal-verified-input.ts 5 src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts 2 src/renderer/src/runtime/web-runtime-browser-creation.ts 1 src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap-dispatch.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 9cb19d92b224..b6a007bd1767 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -40,9 +40,6 @@ 1 src/renderer/src/runtime/runtime-jira-target.ts 1 src/renderer/src/runtime/runtime-linear-client.ts 3 src/renderer/src/runtime/runtime-repo-client.ts -3 src/renderer/src/runtime/runtime-terminal-inspection.ts -1 src/renderer/src/runtime/runtime-terminal-stream.ts -1 src/renderer/src/runtime/runtime-terminal-verified-input.ts 1 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts 2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts 3 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx index 327397db8b0b..6357b6049166 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -54,7 +54,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) -vi.mock('@/lib/agent-paste-draft', () => ({ getSettingsForAgentTabRuntimeOwner: () => ({}) })) vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn(), emitNativeChatPickerItemAccepted: vi.fn(), diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 85bfeff55736..cfcb0ea406c5 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -72,9 +72,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) -vi.mock('@/lib/agent-paste-draft', () => ({ - getSettingsForAgentTabRuntimeOwner: () => ({}) -})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatTypedCommand: (...args: unknown[]) => mocks.sendNativeChatTypedCommand(...args), @@ -450,7 +447,6 @@ describe('NativeChatComposer', () => { expect(mocks.sendNativeChatMessageWithImageAttachments).toHaveBeenCalledWith( 'codex', - {}, 'pty-1', 'hello', ['/tmp/pasted.png'], @@ -471,7 +467,7 @@ describe('NativeChatComposer', () => { act(() => mocks.fieldProps?.onSend?.()) - expect(mocks.sendNativeChatTypedCommand).toHaveBeenCalledWith({}, 'pty-1', '/status') + expect(mocks.sendNativeChatTypedCommand).toHaveBeenCalledWith('pty-1', '/status') expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() }) @@ -488,7 +484,7 @@ describe('NativeChatComposer', () => { act(() => mocks.fieldProps?.onSend?.()) - expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '$ref-oss', undefined) + expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith('pty-1', '$ref-oss', undefined) expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() }) @@ -505,7 +501,7 @@ describe('NativeChatComposer', () => { act(() => mocks.fieldProps?.onSend?.()) - expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/clear', undefined) + expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith('pty-1', '/clear', undefined) expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() }) @@ -793,7 +789,6 @@ describe('NativeChatComposer', () => { }) expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( - {}, 'pty-1', '/model opus', expect.any(AbortSignal) @@ -802,7 +797,6 @@ describe('NativeChatComposer', () => { expect(onOptimisticSend).not.toHaveBeenCalled() expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Opus' }) expect(onSwitchToTerminal).not.toHaveBeenCalled() @@ -826,14 +820,12 @@ describe('NativeChatComposer', () => { }) expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( - {}, 'pty-1', '/model fable', expect.any(AbortSignal) ) expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Fable' }) expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce() @@ -866,7 +858,6 @@ describe('NativeChatComposer', () => { }) expect(mocks.typeNativeChatCommand).toHaveBeenCalledWith( - {}, 'pty-1', '/model', expect.any(AbortSignal) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 62315c377ece..7bc1dba7d97f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -3,7 +3,6 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' import { forwardRef, useCallback, useState } from 'react' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' -import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { useNativeChatMentionFiles } from './use-native-chat-mention-files' import { useNativeChatDraft } from './use-native-chat-draft' import { useNativeChatComposerRecall } from './use-native-chat-composer-recall' @@ -143,14 +142,13 @@ const NativeChatComposerPane = forwardRef { if (!targetPtyId) { return null } - return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) } - }, [targetPtyId, terminalTabId]) + return { ptyId: targetPtyId } + }, [targetPtyId]) // Why inputOwnedByCard: the hidden field can still hold keyboard focus for a frame. const [hasPty, disabled] = structuredTransport diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx index edca7b507ede..6de3a6d4e28a 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx @@ -19,8 +19,8 @@ const MESSAGE = 'please also run lint' const retained = vi.hoisted((): { session: NativeChatLiveSession | null } => ({ session: null })) const composer = vi.hoisted(() => ({ mounts: 0, typed: vi.fn<(text: string) => boolean>() })) const pty = vi.hoisted(() => ({ - verified: vi.fn<(settings: unknown, id: string, data: string) => Promise>(), - raw: vi.fn<(settings: unknown, id: string, data: string) => boolean>() + verified: vi.fn<(id: string, data: string) => Promise>(), + raw: vi.fn<(id: string, data: string) => boolean>() })) vi.mock('./use-native-chat-retained-session', () => ({ useNativeChatRetainedSession: () => retained.session @@ -68,7 +68,7 @@ vi.mock('./NativeChatComposer', async () => { ) const send = (): void => { let pendingId: string | undefined - const handle = sendNativeChatMessage(null, props.targetPtyId ?? '', MESSAGE, { + const handle = sendNativeChatMessage(props.targetPtyId ?? '', MESSAGE, { onWriteRejected: () => { if (pendingId) { props.optimisticSendOutcome?.reject(pendingId) @@ -185,7 +185,7 @@ describe('a prompt card hides the composer without unmounting it', () => { await act(async () => { fireEvent.click(screen.getByTestId('composer-send')) }) - expect(pty.verified.mock.calls.map((call) => call[2]).join('')).toContain(MESSAGE) + expect(pty.verified.mock.calls.map((call) => call[1]).join('')).toContain(MESSAGE) // The approval arrives inside the body-to-Enter gap. await act(async () => { @@ -197,9 +197,9 @@ describe('a prompt card hides the composer without unmounting it', () => { }) expect(document.querySelector('[data-native-chat-approval-card="true"]')).not.toBeNull() - expect(pty.verified.mock.calls.map((call) => call[2])).not.toContain('\r') + expect(pty.verified.mock.calls.map((call) => call[1])).not.toContain('\r') // Only the pre-body clear: retiring the send types nothing under the card. - expect(pty.raw.mock.calls.map((call) => call[2])).toEqual(['\x15']) + expect(pty.raw.mock.calls.map((call) => call[1])).toEqual(['\x15']) expect(screen.getByTestId('composer-send').closest('[hidden]')).not.toBeNull() expect(screen.getAllByText(MESSAGE).length).toBeGreaterThan(0) expect(screen.getByText('Message not sent')).toBeInTheDocument() diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index e5a2f181ac2a..cbdf7751baf4 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -122,7 +122,7 @@ export function NativeChatResolvedView({ const canSend = useNativeChatCanSend(targetPtyId) // Reuse the verified composer send path for interactive cards and composer // stop (Stop sends ESC, the agent-TUI interrupt key). - const send = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent) + const send = useNativeChatInteractiveSend(paneKey, targetPtyId, agent) // Every send this pane makes brings the latest into view, wherever the reader had scrolled. const { messageListRef, revealLatest } = useNativeChatRevealLatest() const interactiveSend = useNativeChatInteractiveSendReveal(send, targetPtyId, revealLatest) diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts index 72e6c94762de..cfa90f03d20a 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts @@ -21,7 +21,6 @@ describe('Claude model switch confirmation detection', () => { const unsubscribe = vi.fn(() => {}) const observer = createClaudeModelSwitchConfirmationObserver({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Fable 5', subscribeToData: (watcher) => { dataObserver.current = watcher @@ -47,7 +46,6 @@ describe('Claude model switch confirmation detection', () => { const dataObserver = { current: (_data: string): void => {} } const observer = createClaudeModelSwitchConfirmationObserver({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Opus (1M context)', subscribeToData: (watcher) => { dataObserver.current = watcher @@ -69,7 +67,6 @@ describe('Claude model switch confirmation detection', () => { const dataObserver = { current: (_data: string): void => {} } const observer = createClaudeModelSwitchConfirmationObserver({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Opus (1M context)', subscribeToData: (watcher) => { dataObserver.current = watcher @@ -95,7 +92,6 @@ describe('Claude model switch confirmation detection', () => { const submitConfirmation = vi.fn() const observer = createClaudeModelSwitchConfirmationObserver({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Fable 5', subscribeToData: (watcher) => { dataObserver.current = watcher @@ -121,7 +117,6 @@ describe('Claude model switch confirmation detection', () => { const dataObserver = { current: (_data: string): void => {} } const observer = createClaudeModelSwitchConfirmationObserver({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Haiku', subscribeToData: (watcher) => { dataObserver.current = watcher @@ -141,7 +136,6 @@ describe('Claude model switch confirmation detection', () => { it('reports unknown when the PTY observer cannot be established', async () => { const observer = createClaudeModelSwitchConfirmationObserver({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Fable 5', subscribeToData: () => Promise.reject(new Error('unavailable')), timeoutMs: 100 @@ -158,7 +152,6 @@ describe('Claude model switch confirmation detection', () => { let settled = false const observer = createClaudeModelSwitchConfirmationObserver({ ptyId: 'pty-1', - settings: {}, expectedModelLabel: 'Fable 5', subscribeToData: () => vi.fn(() => {}), timeoutMs: 100 diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index 334e4639add8..4784348dc7d5 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -1,4 +1,3 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions' import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' @@ -64,7 +63,6 @@ function hasClaudeModelSwitchRejection(buffer: string): boolean { function subscribeToClaudeModelSwitchData(args: { ptyId: string - settings: Pick | null | undefined subscribeToData?: SubscribeToData watcher: (data: string) => void }): Promise<() => void> | (() => void) { @@ -73,7 +71,6 @@ function subscribeToClaudeModelSwitchData(args: { } if (isRemoteRuntimePtyId(args.ptyId)) { return subscribeToRuntimeTerminalData( - args.settings, args.ptyId, `desktop:native-chat-model-switch:${args.ptyId}`, args.watcher, @@ -85,7 +82,6 @@ function subscribeToClaudeModelSwitchData(args: { export function createClaudeModelSwitchConfirmationObserver(args: { ptyId: string - settings: Pick | null | undefined expectedModelLabel: string | null subscribeToData?: SubscribeToData submitConfirmation?: () => boolean | void @@ -147,7 +143,7 @@ export function createClaudeModelSwitchConfirmationObserver(args: { // exact Claude warning defaults to “Yes” and needs only one Enter. const accepted = args.submitConfirmation ? args.submitConfirmation() !== false - : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT, 'driving') + : sendRuntimePtyInput(args.ptyId, NATIVE_CHAT_SUBMIT, 'driving') if (!accepted) { finish('unknown') return @@ -162,7 +158,6 @@ export function createClaudeModelSwitchConfirmationObserver(args: { try { const subscription = subscribeToClaudeModelSwitchData({ ptyId: args.ptyId, - settings: args.settings, subscribeToData: args.subscribeToData, watcher: observeData }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx index 33a6d6a60950..8e20ac067a95 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx @@ -33,9 +33,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) -vi.mock('@/lib/agent-paste-draft', () => ({ - getSettingsForAgentTabRuntimeOwner: () => ({}) -})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: vi.fn(), sendNativeChatTypedCommand: vi.fn(), diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index efa532c88a14..548fed952a62 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -1,11 +1,9 @@ import { translate } from '@/i18n/i18n' import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' -import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' export type NativeChatResolvedTarget = { ptyId: string - settings: ReturnType } /** Upper bound for clipboard text pulled into the composer via Cmd/Ctrl+V, so a diff --git a/src/renderer/src/components/native-chat/native-chat-input-clear.ts b/src/renderer/src/components/native-chat/native-chat-input-clear.ts index 03b6660dd365..3d8cbabe6e13 100644 --- a/src/renderer/src/components/native-chat/native-chat-input-clear.ts +++ b/src/renderer/src/components/native-chat/native-chat-input-clear.ts @@ -1,6 +1,5 @@ // Clearing the agent's unsubmitted input line before a chat send writes its body. import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' -import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear' // Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends @@ -36,22 +35,11 @@ export type NativeChatSendOptions = { confirmCleared?: () => boolean } -type RuntimeSettings = ReturnType - -export function clearUnsubmittedAgentInput( - settings: RuntimeSettings, - ptyId: string, - options?: NativeChatSendOptions -): void { +export function clearUnsubmittedAgentInput(ptyId: string, options?: NativeChatSendOptions): void { if (options?.clearInput === '') { return } - sendRuntimePtyInput( - settings, - ptyId, - options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - 'driving' - ) + sendRuntimePtyInput(ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, 'driving') } /** @@ -63,13 +51,12 @@ export function clearUnsubmittedAgentInput( * the buffer holds, since the user can type into the TUI directly. */ export function clearThenWrite( - settings: RuntimeSettings, ptyId: string, options: NativeChatSendOptions | undefined, delay: (ms: number, fn: () => void) => void, writeBody: () => void ): void { - clearUnsubmittedAgentInput(settings, ptyId, options) + clearUnsubmittedAgentInput(ptyId, options) const confirmCleared = options?.confirmCleared if (!confirmCleared) { writeBody() @@ -83,7 +70,7 @@ export function clearThenWrite( // An unreadable terminal is unconfirmed; the maximal clear remains safe. } if (!cleared) { - sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') + sendRuntimePtyInput(ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') } writeBody() }) diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts index dd2836825f4a..765639184968 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts @@ -22,15 +22,15 @@ afterEach(() => { it('observes a refused write, skips Enter, and releases the queue for the next user action', async () => { const rejected = vi.fn() io.verified.mockResolvedValueOnce(false) - sendNativeChatMessage(null, 'pane', 'refused', { onWriteRejected: rejected }) + sendNativeChatMessage('pane', 'refused', { onWriteRejected: rejected }) await vi.advanceTimersByTimeAsync(1000) expect(rejected).toHaveBeenCalledOnce() - expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + expect(io.verified.mock.calls.map((call) => call[1])).toEqual([ buildNativeChatPasteBytes('refused') ]) - sendNativeChatMessage(null, 'pane', 'next', { onWriteRejected: rejected }) + sendNativeChatMessage('pane', 'next', { onWriteRejected: rejected }) await vi.advanceTimersByTimeAsync(1000) - expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + expect(io.verified.mock.calls.map((call) => call[1])).toEqual([ buildNativeChatPasteBytes('refused'), buildNativeChatPasteBytes('next'), NATIVE_CHAT_SUBMIT @@ -41,26 +41,26 @@ it('reports a lost acknowledgment once as unconfirmed, never as rejection, and s const unconfirmed = vi.fn() io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) - sendNativeChatMessage(null, 'pane', 'uncertain', { + sendNativeChatMessage('pane', 'uncertain', { onWriteRejected: rejected, onWriteUnconfirmed: unconfirmed }) await vi.advanceTimersByTimeAsync(120000) expect(rejected).not.toHaveBeenCalled() expect(unconfirmed).toHaveBeenCalledOnce() - expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + expect(io.verified.mock.calls.map((call) => call[1])).toEqual([ buildNativeChatPasteBytes('uncertain'), NATIVE_CHAT_SUBMIT ]) }) it('serializes rapid sends through their acknowledged Enter and preserves the paste delay', async () => { const rejected = vi.fn() - sendNativeChatMessage(null, 'pane', 'one', { onWriteRejected: rejected }) - sendNativeChatMessage(null, 'pane', 'two', { onWriteRejected: rejected }) + sendNativeChatMessage('pane', 'one', { onWriteRejected: rejected }) + sendNativeChatMessage('pane', 'two', { onWriteRejected: rejected }) await vi.advanceTimersByTimeAsync(499) expect(io.verified).toHaveBeenCalledOnce() await vi.advanceTimersByTimeAsync(501) - expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + expect(io.verified.mock.calls.map((call) => call[1])).toEqual([ buildNativeChatPasteBytes('one'), NATIVE_CHAT_SUBMIT, buildNativeChatPasteBytes('two'), @@ -77,11 +77,11 @@ it.each(['accepted', 'refused', 'unknown'] as const)( } else if (outcome === 'unknown') { io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) } - sendNativeChatMessage(null, 'pane', 'answer', { onDeliverySettled: settled }) + sendNativeChatMessage('pane', 'answer', { onDeliverySettled: settled }) expect(settled).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1000) expect(settled).toHaveBeenCalledExactlyOnceWith(outcome === 'accepted') - expect(io.verified.mock.calls.map((call) => call[2])).toEqual( + expect(io.verified.mock.calls.map((call) => call[1])).toEqual( outcome === 'refused' ? [buildNativeChatPasteBytes('answer')] : [buildNativeChatPasteBytes('answer'), NATIVE_CHAT_SUBMIT] @@ -97,7 +97,7 @@ it('ignores a pasted answer acknowledgment after cancellation', async () => { }) ) const settled = vi.fn() - const handle = sendNativeChatMessage(null, 'pane', 'answer', { onDeliverySettled: settled }) + const handle = sendNativeChatMessage('pane', 'answer', { onDeliverySettled: settled }) handle.cancel() acknowledge(true) await vi.advanceTimersByTimeAsync(1000) diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.ts index fe9c2b6244fc..04692fdb682f 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -1,5 +1,4 @@ import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' -import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' import { clearConfirmDurationMs, @@ -10,7 +9,6 @@ import { /** Observe write refusals without mistaking transport success for provider acceptance. */ export function sendNativeChatObservedWrites( - settings: ReturnType, ptyId: string, writes: readonly { data: string; delayBeforeMs: number }[], options: NativeChatSendOptions & { stopOnUnconfirmed?: boolean; settleDelayMs?: number } @@ -48,7 +46,7 @@ export function sendNativeChatObservedWrites( if (isCancelled()) { return } - void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving', writeOptions) + void sendRuntimePtyInputVerified(ptyId, write.data, 'driving', writeOptions) .then((accepted) => { if (isCancelled()) { return @@ -86,8 +84,8 @@ export function sendNativeChatObservedWrites( send() } } - clearThenWrite(settings, ptyId, options, delay, () => writeAt(0)) + clearThenWrite(ptyId, options, delay, () => writeAt(0)) }, - { onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } + { onCancelUnsubmitted: () => clearUnsubmittedAgentInput(ptyId, options) } ) } diff --git a/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx index fa1d52d4e01f..010906cd11fb 100644 --- a/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx @@ -46,9 +46,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) -vi.mock('@/lib/agent-paste-draft', () => ({ - getSettingsForAgentTabRuntimeOwner: () => ({}) -})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: vi.fn(), sendNativeChatTypedCommand: vi.fn(), diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index a4e793233173..d18ef1499ee0 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -2,7 +2,6 @@ import { sendNativeChatObservedWrites } from './native-chat-observed-send' import { agentImagePasteWrites, formatAgentImagePath } from '../../../../shared/agent-image-paste' import type { AgentType } from '../../../../shared/agent-status-types' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' -import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { NATIVE_CHAT_SUBMIT_DELAY_MS } from '../../../../shared/native-chat-answer-stepping' import { buildNativeChatImagePasteBytes, @@ -22,8 +21,6 @@ import { sendNativeChatMessage, type NativeChatSendHandle } from './native-chat- export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 -type RuntimeSettings = ReturnType - /** The path text an agent detects as an image. One with spaces or shell characters is escaped as a * terminal image drop is, which agent TUIs undo before checking the file exists. */ export function agentImagePastePath(agent: AgentType, path: string): string { @@ -37,14 +34,13 @@ export function agentImagePastePath(agent: AgentType, path: string): string { export function sendNativeChatMessageWithImageAttachments( agent: AgentType, - settings: RuntimeSettings, ptyId: string, text: string, imagePaths: readonly string[], options?: NativeChatSendOptions ): NativeChatSendHandle { if (imagePaths.length === 0) { - return sendNativeChatMessage(settings, ptyId, text, options) + return sendNativeChatMessage(ptyId, text, options) } const trimmedText = text.trim() if (options?.onWriteRejected) { @@ -60,7 +56,7 @@ export function sendNativeChatMessageWithImageAttachments( }) } writes.push({ data: NATIVE_CHAT_SUBMIT, delayBeforeMs: NATIVE_CHAT_SUBMIT_DELAY_MS }) - return sendNativeChatObservedWrites(settings, ptyId, writes, options) + return sendNativeChatObservedWrites(ptyId, writes, options) } const durationMs = (trimmedText.length > 0 @@ -73,7 +69,7 @@ export function sendNativeChatMessageWithImageAttachments( if (isCancelled()) { return } - clearThenWrite(settings, ptyId, options, delay, () => { + clearThenWrite(ptyId, options, delay, () => { if (isCancelled()) { return } @@ -84,26 +80,26 @@ export function sendNativeChatMessageWithImageAttachments( ), trimmedText.length > 0 )) { - sendRuntimePtyInput(settings, ptyId, payload, 'driving') + sendRuntimePtyInput(ptyId, payload, 'driving') } if (trimmedText.length > 0) { delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') + sendRuntimePtyInput(ptyId, buildNativeChatPasteBytes(text), 'driving') delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') + sendRuntimePtyInput(ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) return } delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') + sendRuntimePtyInput(ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) }, { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(ptyId, options) } ) } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index a10bb87cd3b6..5b3029847f7e 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -29,11 +29,10 @@ import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear' -const SETTINGS = {} as Parameters[0] const PTY = 'pty-launch-draft' const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' -const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => call[2] as string) +const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => String(call[1])) beforeEach(() => { vi.useFakeTimers() @@ -49,19 +48,19 @@ afterEach(() => { describe('sendNativeChatMessage with a parked multi-line draft', () => { it('leads with a clear sized to every line of the draft, not one Ctrl+U', () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) - sendNativeChatMessage(SETTINGS, PTY, 'edited text', { clearInput }) + sendNativeChatMessage(PTY, 'edited text', { clearInput }) expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited text')]) expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) }) it('still defaults to a single Ctrl+U when no draft is parked', () => { - sendNativeChatMessage(SETTINGS, PTY, 'plain') + sendNativeChatMessage(PTY, 'plain') expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) }) it('holds the body until the clear is confirmed, then submits after the gap', () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) - sendNativeChatMessage(SETTINGS, PTY, 'edited', { + sendNativeChatMessage(PTY, 'edited', { clearInput, confirmCleared: () => true }) @@ -76,15 +75,15 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => { vi.useRealTimers() const writeTimes = new Map() - sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => { + sendRuntimePtyInput.mockImplementation((_pty, bytes: string) => { writeTimes.set(bytes, performance.now()) return true }) - sendNativeChatMessage(SETTINGS, PTY, 'edited', { + sendNativeChatMessage(PTY, 'edited', { clearInput: buildAgentTuiClearInputForText(DRAFT), confirmCleared: () => true }) - sendNativeChatMessage(SETTINGS, PTY, 'queued') + sendNativeChatMessage(PTY, 'queued') const blockedUntil = performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50 @@ -106,7 +105,7 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { it('widens to a maximal burst when the draft is still observed on the line', () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) - sendNativeChatMessage(SETTINGS, PTY, 'edited', { + sendNativeChatMessage(PTY, 'edited', { clearInput, confirmCleared: () => false }) @@ -119,7 +118,7 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { }) it('re-clears before the body, never after it', () => { - sendNativeChatMessage(SETTINGS, PTY, 'edited', { + sendNativeChatMessage(PTY, 'edited', { clearInput: buildAgentTuiClearInputForText(DRAFT), confirmCleared: () => false }) @@ -131,7 +130,7 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { }) it('charges the confirm gap to the handle so the send card outlives the Enter', () => { - const withConfirm = sendNativeChatMessage(SETTINGS, PTY, 'a', { + const withConfirm = sendNativeChatMessage(PTY, 'a', { clearInput: '\x15', confirmCleared: () => true }) @@ -142,11 +141,11 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { it('submits before a queued send starts after clear confirmation', async () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) - sendNativeChatMessage(SETTINGS, PTY, 'first', { + sendNativeChatMessage(PTY, 'first', { clearInput, confirmCleared: () => true }) - sendNativeChatMessage(SETTINGS, PTY, 'second') + sendNativeChatMessage(PTY, 'second') await vi.advanceTimersByTimeAsync(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS) @@ -163,7 +162,7 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { describe('image sends with a parked multi-line draft', () => { it('clears every draft line before pasting, so no line rides along with the image', () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) - sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + sendNativeChatMessageWithImageAttachments('claude', PTY, 'caption', ['/tmp/a.png'], { clearInput }) expect(writes()[0]).toBe(clearInput) @@ -171,7 +170,7 @@ describe('image sends with a parked multi-line draft', () => { it('clears exactly once — a second Ctrl+U would wipe the just-pasted image', () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) - sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + sendNativeChatMessageWithImageAttachments('claude', PTY, 'caption', ['/tmp/a.png'], { clearInput }) vi.advanceTimersByTime(10_000) @@ -180,11 +179,11 @@ describe('image sends with a parked multi-line draft', () => { it('submits the image send before a queued message starts', async () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) - sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + sendNativeChatMessageWithImageAttachments('claude', PTY, 'caption', ['/tmp/a.png'], { clearInput, confirmCleared: () => true }) - sendNativeChatMessage(SETTINGS, PTY, 'second') + sendNativeChatMessage(PTY, 'second') await vi.advanceTimersByTimeAsync( NATIVE_CHAT_CLEAR_CONFIRM_MS + diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index dd55ba3be2bd..34f6872936ab 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -28,11 +28,10 @@ import { } from './native-chat-runtime-image-send' import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' -const SETTINGS = {} as Parameters[0] const PTY = 'pty-1' function expectWriteOrder(calls: unknown[][], expected: string[]): void { - expect(calls.map((call) => call[2])).toEqual(expected) + expect(calls.map((call) => call[1])).toEqual(expected) } describe('sendNativeChatMessage', () => { @@ -48,7 +47,7 @@ describe('sendNativeChatMessage', () => { }) it('clears the TUI line, then writes the framed body, before the Enter', () => { - const handle = sendNativeChatMessage(SETTINGS, PTY, 'hello world') + const handle = sendNativeChatMessage(PTY, 'hello world') expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, buildNativeChatPasteBytes('hello world') @@ -57,7 +56,7 @@ describe('sendNativeChatMessage', () => { }) it('does not fire Enter before the proven 500ms gap (busy-agent safety)', () => { - sendNativeChatMessage(SETTINGS, PTY, 'hi') + sendNativeChatMessage(PTY, 'hi') // A short gap would fire Enter while a busy Codex has not yet landed the // paste, submitting an empty box — so nothing must happen before 500ms. vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1) @@ -65,7 +64,7 @@ describe('sendNativeChatMessage', () => { }) it('writes the bare carriage-return Enter as a separate delayed write', () => { - sendNativeChatMessage(SETTINGS, PTY, 'hi') + sendNativeChatMessage(PTY, 'hi') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, @@ -75,7 +74,7 @@ describe('sendNativeChatMessage', () => { }) it('cancels the delayed Enter and re-clears an unsubmitted body', () => { - const handle = sendNativeChatMessage(SETTINGS, PTY, 'hi') + const handle = sendNativeChatMessage(PTY, 'hi') handle.cancel() vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) @@ -88,15 +87,15 @@ describe('sendNativeChatMessage', () => { }) it('clears leftover unsubmitted body on cancel so the next send cannot glue', async () => { - const handle = sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke') + const handle = sendNativeChatMessage(PTY, 'tell me a joke') handle.cancel() - sendNativeChatMessage(SETTINGS, PTY, 'continue') + sendNativeChatMessage(PTY, 'continue') // Queue release after cancel is promise-chained; flush so the next body runs. await Promise.resolve() await Promise.resolve() - expect(sendRuntimePtyInput.mock.calls.map((call) => call[2])).toEqual([ + expect(sendRuntimePtyInput.mock.calls.map((call) => call[1])).toEqual([ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, buildNativeChatPasteBytes('tell me a joke'), NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, // cancel cleanup @@ -106,7 +105,7 @@ describe('sendNativeChatMessage', () => { }) it('does not clear the TUI input when cancel runs after Enter already fired', () => { - const handle = sendNativeChatMessage(SETTINGS, PTY, 'already submitted') + const handle = sendNativeChatMessage(PTY, 'already submitted') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) sendRuntimePtyInput.mockClear() handle.cancel() @@ -115,8 +114,8 @@ describe('sendNativeChatMessage', () => { }) it('serializes rapid sends on the same PTY so bodies cannot glue before Enter', async () => { - sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke') - sendNativeChatMessage(SETTINGS, PTY, 'continue') + sendNativeChatMessage(PTY, 'tell me a joke') + sendNativeChatMessage(PTY, 'continue') // First clear+body are immediate; second sequence waits for the first Enter. expectWriteOrder(sendRuntimePtyInput.mock.calls, [ @@ -134,19 +133,14 @@ describe('sendNativeChatMessage', () => { ]) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( - SETTINGS, - PTY, - NATIVE_CHAT_SUBMIT, - 'driving' - ) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving') expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) }) it('does not let a canceled queued send stall the sends behind it', async () => { - sendNativeChatMessage(SETTINGS, PTY, 'first') - const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled') - sendNativeChatMessage(SETTINGS, PTY, 'third') + sendNativeChatMessage(PTY, 'first') + const canceled = sendNativeChatMessage(PTY, 'canceled') + sendNativeChatMessage(PTY, 'third') canceled.cancel() await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) @@ -161,16 +155,16 @@ describe('sendNativeChatMessage', () => { }) it('does not serialize sends across different PTYs', () => { - sendNativeChatMessage(SETTINGS, 'pty-a', 'one') - sendNativeChatMessage(SETTINGS, 'pty-b', 'two') + sendNativeChatMessage('pty-a', 'one') + sendNativeChatMessage('pty-b', 'two') expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, buildNativeChatPasteBytes('one'), NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, buildNativeChatPasteBytes('two') ]) - expect(sendRuntimePtyInput.mock.calls[1]?.[1]).toBe('pty-a') - expect(sendRuntimePtyInput.mock.calls[3]?.[1]).toBe('pty-b') + expect(sendRuntimePtyInput.mock.calls[1]?.[0]).toBe('pty-a') + expect(sendRuntimePtyInput.mock.calls[3]?.[0]).toBe('pty-b') }) }) @@ -188,12 +182,11 @@ describe('sendNativeChatMessageVerified', () => { it('awaits body acceptance before the delayed Enter write (no pre-clear)', async () => { // Why: model-switch confirmation watches the PTY while this send runs; // verified option commands must not inject Ctrl+U noise. - const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet') + const result = sendNativeChatMessageVerified(PTY, '/model sonnet') await vi.waitFor(() => { expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) }) expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( - SETTINGS, PTY, buildNativeChatPasteBytes('/model sonnet'), 'driving' @@ -202,15 +195,10 @@ describe('sendNativeChatMessageVerified', () => { await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) expect(await result).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( - SETTINGS, - PTY, - NATIVE_CHAT_SUBMIT, - 'driving' - ) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving') expect( sendRuntimePtyInputVerified.mock.calls.some( - (call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT + (call) => call[1] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT ) ).toBe(false) }) @@ -218,40 +206,35 @@ describe('sendNativeChatMessageVerified', () => { it('does not send Enter when the body is rejected', async () => { sendRuntimePtyInputVerified.mockResolvedValueOnce(false) - await expect(sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')).resolves.toBe(false) + await expect(sendNativeChatMessageVerified(PTY, '/model sonnet')).resolves.toBe(false) await vi.runAllTimersAsync() expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) expect( - sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT) + sendRuntimePtyInputVerified.mock.calls.some((call) => call[1] === NATIVE_CHAT_SUBMIT) ).toBe(false) }) it('cancels an in-flight chat Enter before delivering a verified option command', async () => { - sendNativeChatMessage(SETTINGS, PTY, 'hello') + sendNativeChatMessage(PTY, 'hello') expect(sendRuntimePtyInput).toHaveBeenCalled() - const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model haiku') + const result = sendNativeChatMessageVerified(PTY, '/model haiku') // Chat cancel may Ctrl+U the unsubmitted body; Enter from chat must not fire. await Promise.resolve() await Promise.resolve() await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) expect(await result).toBe(true) - const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT) + const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[1] === NATIVE_CHAT_SUBMIT) // Only the verified path's Enter — chat's delayed Enter was cancelled. expect(submits).toHaveLength(0) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( - SETTINGS, - PTY, - NATIVE_CHAT_SUBMIT, - 'driving' - ) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving') }) it('returns false when the delayed Enter wait is aborted', async () => { const controller = new AbortController() - const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal) + const result = sendNativeChatMessageVerified(PTY, '/model sonnet', controller.signal) await vi.waitFor(() => { expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) }) @@ -259,7 +242,7 @@ describe('sendNativeChatMessageVerified', () => { expect(await result).toBe(false) expect( - sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT) + sendRuntimePtyInputVerified.mock.calls.some((call) => call[1] === NATIVE_CHAT_SUBMIT) ).toBe(false) }) }) @@ -277,7 +260,7 @@ describe('typeNativeChatCommand', () => { }) it('writes the Codex picker command as keys instead of one pasted text write', async () => { - const result = typeNativeChatCommand(SETTINGS, PTY, '/model') + const result = typeNativeChatCommand(PTY, '/model') await vi.runAllTimersAsync() await expect(result).resolves.toBe(true) @@ -294,7 +277,7 @@ describe('typeNativeChatCommand', () => { }) it('queues composer commands as the same paced key sequence', async () => { - const handle = sendNativeChatTypedCommand(SETTINGS, PTY, '/status') + const handle = sendNativeChatTypedCommand(PTY, '/status') await vi.runAllTimersAsync() await handle.settled @@ -312,9 +295,9 @@ describe('typeNativeChatCommand', () => { }) it('does not clear into the next queued send after cancellation', async () => { - const command = sendNativeChatTypedCommand(SETTINGS, PTY, '/status') + const command = sendNativeChatTypedCommand(PTY, '/status') command.cancel() - const next = sendNativeChatMessage(SETTINGS, PTY, 'next') + const next = sendNativeChatMessage(PTY, 'next') await vi.runAllTimersAsync() await Promise.all([command.settled, next.settled]) @@ -339,7 +322,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it('escapes a pasted image path with spaces, so an attachment agent sees one path', () => { - sendNativeChatMessageWithImageAttachments('codex', SETTINGS, PTY, '', [ + sendNativeChatMessageWithImageAttachments('codex', PTY, '', [ '/Users/me/Library/Application Support/orca/native-chat-pastes/orca-paste-1-ab.png' ]) expectWriteOrder(sendRuntimePtyInput.mock.calls, [ @@ -349,10 +332,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it.each(['', 'describe'])('separates every OMP image reference before %j', (text) => { - sendNativeChatMessageWithImageAttachments('omp', SETTINGS, PTY, text, [ - '/tmp/a.png', - '/tmp/b.png' - ]) + sendNativeChatMessageWithImageAttachments('omp', PTY, text, ['/tmp/a.png', '/tmp/b.png']) expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, '\x1b[200~@/tmp/a.png\x1b[201~ ', @@ -361,7 +341,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it('sends OMP image paths as framed references, preserving spaces and delayed submit', () => { - sendNativeChatMessageWithImageAttachments('omp', SETTINGS, PTY, 'describe', [ + sendNativeChatMessageWithImageAttachments('omp', PTY, 'describe', [ 'C:\\Images\\screen shot.png' ]) expectWriteOrder(sendRuntimePtyInput.mock.calls, [ @@ -369,24 +349,15 @@ describe('sendNativeChatMessageWithImageAttachments', () => { '\x1b[200~@"C:\\Images\\screen shot.png"\x1b[201~ ' ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe', 'driving') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, 'describe', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( - SETTINGS, - PTY, - NATIVE_CHAT_SUBMIT, - 'driving' - ) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving') }) it('clears the line, then bracket-pastes image paths before prompt text', () => { - const handle = sendNativeChatMessageWithImageAttachments( - 'claude', - SETTINGS, - PTY, - 'what do you see?', - ['/tmp/orca-paste-image.png'] - ) + const handle = sendNativeChatMessageWithImageAttachments('claude', PTY, 'what do you see?', [ + '/tmp/orca-paste-image.png' + ]) expect(handle.settleAfterMs).toBe( NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS @@ -399,25 +370,15 @@ describe('sendNativeChatMessageWithImageAttachments', () => { ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( - SETTINGS, - PTY, - 'what do you see?', - 'driving' - ) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, 'what do you see?', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( - SETTINGS, - PTY, - NATIVE_CHAT_SUBMIT, - 'driving' - ) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving') expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4) }) it('does not append a trailing separator on an attachment-only send', () => { - const handle = sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, '', [ + const handle = sendNativeChatMessageWithImageAttachments('claude', PTY, '', [ '/tmp/orca-paste-image.png' ]) @@ -433,18 +394,11 @@ describe('sendNativeChatMessageWithImageAttachments', () => { vi.advanceTimersByTime(1) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( - SETTINGS, - PTY, - NATIVE_CHAT_SUBMIT, - 'driving' - ) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving') }) it('treats whitespace-only prompt input as attachment-only', () => { - sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, ' ', [ - '/tmp/orca-paste-image.png' - ]) + sendNativeChatMessageWithImageAttachments('claude', PTY, ' ', ['/tmp/orca-paste-image.png']) expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, @@ -453,10 +407,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it('keeps multiple image frames bare and separates only the final frame from prompt text', () => { - sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'hello', [ - '/tmp/a.png', - '/tmp/b.png' - ]) + sendNativeChatMessageWithImageAttachments('claude', PTY, 'hello', ['/tmp/a.png', '/tmp/b.png']) expectWriteOrder(sendRuntimePtyInput.mock.calls, [ NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, @@ -466,7 +417,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it('cancels deferred prompt and Enter writes after the attachment path', () => { - const handle = sendNativeChatMessageWithImageAttachments('claude', SETTINGS, PTY, 'describe', [ + const handle = sendNativeChatMessageWithImageAttachments('claude', PTY, 'describe', [ '/tmp/orca-paste-image.png' ]) handle.cancel() @@ -478,7 +429,7 @@ describe('sendNativeChatMessageWithImageAttachments', () => { '\x1b[200~/tmp/orca-paste-image.png\x1b[201~ ', NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT ]) - expect(sendRuntimePtyInput.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)).toBe( + expect(sendRuntimePtyInput.mock.calls.some((call) => call[1] === NATIVE_CHAT_SUBMIT)).toBe( false ) }) @@ -490,9 +441,9 @@ describe('empty prompt submit', () => { }) it('submits an empty prompt with a bare Enter', () => { - submitNativeChatPrompt(SETTINGS, PTY) + submitNativeChatPrompt(PTY) expect(sendRuntimePtyInput).toHaveBeenCalledOnce() - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT, 'driving') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(PTY, NATIVE_CHAT_SUBMIT, 'driving') }) }) @@ -510,14 +461,14 @@ describe('sendNativeChatAskAnswer', () => { }) it('returns a no-op handle for an empty key group list', () => { - const handle = sendNativeChatAskAnswer(SETTINGS, PTY, []) + const handle = sendNativeChatAskAnswer(PTY, []) expect(handle.settleAfterMs).toBe(0) handle.cancel() expect(sendRuntimePtyInput).not.toHaveBeenCalled() }) it('paces key groups so selector steps render before the next write', async () => { - const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [ + const handle = sendNativeChatAskAnswer(PTY, [ { raw: '1' }, { raw: '2' }, { text: 'custom answer' } @@ -527,26 +478,13 @@ describe('sendNativeChatAskAnswer', () => { ) await vi.advanceTimersByTimeAsync(0) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( - SETTINGS, - PTY, - '1', - 'driving', - undefined - ) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(PTY, '1', 'driving', undefined) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_QUESTION_STEP_MS) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( - SETTINGS, - PTY, - '2', - 'driving', - undefined - ) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(PTY, '2', 'driving', undefined) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_QUESTION_STEP_MS) expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( - SETTINGS, PTY, buildNativeChatPasteBytes('custom answer'), 'driving', @@ -555,7 +493,7 @@ describe('sendNativeChatAskAnswer', () => { }) it('cancels remaining key group timers', async () => { - const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '2' }]) + const handle = sendNativeChatAskAnswer(PTY, [{ raw: '1' }, { raw: '2' }]) await vi.advanceTimersByTimeAsync(0) expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) handle.cancel() @@ -566,14 +504,14 @@ describe('sendNativeChatAskAnswer', () => { it('reports verified delivery only after settling and suppresses it after cancellation', async () => { const onSettled = vi.fn() sendRuntimePtyInputVerified.mockResolvedValueOnce(true).mockResolvedValueOnce(false) - const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '\r' }], onSettled) + const handle = sendNativeChatAskAnswer(PTY, [{ raw: '1' }, { raw: '\r' }], onSettled) await vi.advanceTimersByTimeAsync(handle.settleAfterMs) expect(onSettled).toHaveBeenCalledExactlyOnceWith(false) expect(sendRuntimePtyInput).not.toHaveBeenCalled() const canceledSettled = vi.fn() - const canceled = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }], canceledSettled) + const canceled = sendNativeChatAskAnswer(PTY, [{ raw: '1' }], canceledSettled) canceled.cancel() await vi.runAllTimersAsync() expect(canceledSettled).not.toHaveBeenCalled() @@ -588,10 +526,10 @@ describe('sendNativeChatAskAnswer', () => { }) ) - const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled) + const handle = sendNativeChatAskAnswer(PTY, [{ raw: '2' }], onSettled) await vi.advanceTimersByTimeAsync(handle.settleAfterMs) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving', { + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(PTY, '2', 'driving', { requireWriteSettlement: true }) expect(onSettled).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 948485bc8027..f210c30b35d5 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -7,7 +7,6 @@ import { sendRuntimePtyInput, sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' -import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' import { clearConfirmDurationMs, @@ -45,8 +44,6 @@ export type NativeChatSendHandle = { settled?: Promise } -type RuntimeSettings = ReturnType - /** * Chat message path: * 1. clear any unsubmitted TUI line @@ -56,14 +53,12 @@ type RuntimeSettings = ReturnType * Serialized per PTY so rapid sends cannot glue before Enter. */ export function sendNativeChatMessage( - settings: RuntimeSettings, ptyId: string, text: string, options?: NativeChatSendOptions ): NativeChatSendHandle { if (options?.onWriteRejected || options?.onDeliverySettled) { return sendNativeChatObservedWrites( - settings, ptyId, [ { data: buildNativeChatPasteBytes(text), delayBeforeMs: 0 }, @@ -79,21 +74,21 @@ export function sendNativeChatMessage( if (isCancelled()) { return } - clearThenWrite(settings, ptyId, options, delay, () => { + clearThenWrite(ptyId, options, delay, () => { if (isCancelled()) { return } - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') + sendRuntimePtyInput(ptyId, buildNativeChatPasteBytes(text), 'driving') // Schedule from the actual body write: an overdue clear-confirm callback // must not collapse the required body-to-Enter gap after a renderer stall. delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') + sendRuntimePtyInput(ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) }, { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(ptyId, options) } ) } @@ -127,7 +122,6 @@ function waitForNativeChatSubmit(signal?: AbortSignal): Promise { * chat Enter cannot dismiss Claude's "Switch model?" dialog. */ export async function sendNativeChatMessageVerified( - settings: RuntimeSettings, ptyId: string, text: string, signal?: AbortSignal @@ -144,7 +138,6 @@ export async function sendNativeChatMessageVerified( // Why: option commands await remote/SSH acceptance so the Enter cannot race // ahead of the body while a model-change observer is already armed. const bodyAccepted = await sendRuntimePtyInputVerified( - settings, ptyId, buildNativeChatPasteBytes(text), 'driving' @@ -152,12 +145,11 @@ export async function sendNativeChatMessageVerified( if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { return false } - return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') + return sendRuntimePtyInputVerified(ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** Types a slash command as individual keys so Codex opens its command palette. */ export async function typeNativeChatCommand( - settings: RuntimeSettings, ptyId: string, command: string, signal?: AbortSignal @@ -168,17 +160,13 @@ export async function typeNativeChatCommand( command, signal, write: async (key) => - (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) ? 'accepted' : 'rejected' + (await sendRuntimePtyInputVerified(ptyId, key, 'driving')) ? 'accepted' : 'rejected' }) return outcome === 'accepted' } /** Queues a typed slash command with composer sends on the same PTY. */ -export function sendNativeChatTypedCommand( - settings: RuntimeSettings, - ptyId: string, - command: string -): NativeChatSendHandle { +export function sendNativeChatTypedCommand(ptyId: string, command: string): NativeChatSendHandle { const controller = new AbortController() return enqueueNativeChatPtySend( ptyId, @@ -186,7 +174,7 @@ export function sendNativeChatTypedCommand( ({ isCancelled, markSubmitted }) => { const finish = (outcome: 'accepted' | 'rejected' | 'unknown'): void => { if (!isCancelled() && outcome !== 'accepted') { - clearUnsubmittedAgentInput(settings, ptyId) + clearUnsubmittedAgentInput(ptyId) } markSubmitted() } @@ -197,7 +185,7 @@ export function sendNativeChatTypedCommand( if (isCancelled()) { return 'rejected' } - return (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) + return (await sendRuntimePtyInputVerified(ptyId, key, 'driving')) ? 'accepted' : 'rejected' } @@ -206,7 +194,7 @@ export function sendNativeChatTypedCommand( { onCancelUnsubmitted: () => { controller.abort() - clearUnsubmittedAgentInput(settings, ptyId) + clearUnsubmittedAgentInput(ptyId) } } ) @@ -214,8 +202,8 @@ export function sendNativeChatTypedCommand( /** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the * composer is empty. */ -export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') +export function submitNativeChatPrompt(ptyId: string): void { + sendRuntimePtyInput(ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** @@ -224,7 +212,6 @@ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string) * step so the arrow-navigate selector applies each before the next. */ export function sendNativeChatAskAnswer( - settings: RuntimeSettings, ptyId: string, groups: AskAnswerKeyGroup[], onSettled?: (delivered: boolean) => void @@ -233,7 +220,6 @@ export function sendNativeChatAskAnswer( return { cancel: () => {}, settleAfterMs: 0 } } return sendNativeChatObservedWrites( - settings, ptyId, groups.map((group, index) => ({ data: 'raw' in group ? group.raw : buildNativeChatPasteBytes(group.text), diff --git a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx index 78d62e8f2e42..88369c5157e5 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx @@ -38,9 +38,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) -vi.mock('@/lib/agent-paste-draft', () => ({ - getSettingsForAgentTabRuntimeOwner: () => ({}) -})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: vi.fn(), sendNativeChatTypedCommand: vi.fn(), diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index ddf559077896..2eb6c182a10e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -26,8 +26,7 @@ type AttachmentApi = ReturnType type ProbeApi = AttachmentApi & { adoptDraft: (draft: string) => void } const target: NativeChatResolvedTarget = { - ptyId: 'pty-1', - settings: { activeRuntimeEnvironmentId: null } + ptyId: 'pty-1' } function Probe({ diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts index fbba5699834d..3aa0f77f6b89 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts @@ -30,7 +30,7 @@ export function useNativeChatComposerInterrupt(args: { } const target = resolveTarget() if (target) { - sendRuntimePtyInput(target.settings, target.ptyId, ESC, 'driving') + sendRuntimePtyInput(target.ptyId, ESC, 'driving') } }, [inert, cancelPendingSends, isWorking, onStop, resolveTarget]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index bd09f0cf7476..498f6ff0392b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -40,10 +40,6 @@ vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: (...args: unknown[]) => mocks.sendRuntimePtyInputVerified(...args) })) -vi.mock('@/lib/agent-paste-draft', () => ({ - getSettingsForAgentTabRuntimeOwner: (terminalTabId: string) => ({ terminalTabId }) -})) - vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatAskAnswer: (...args: unknown[]) => mocks.sendNativeChatAskAnswer(...args), sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args) @@ -70,33 +66,25 @@ describe('useNativeChatInteractiveSend', () => { }) it('routes a non-selector answer through the pasted-text send path', () => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'grok')) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) // Grok commits a pasted answer: label text 'B', not option-number keystrokes. - expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith( - { terminalTabId: 'tab-1' }, - 'pty-1', - 'B', - { onDeliverySettled: expect.any(Function) } - ) + expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith('pty-1', 'B', { + onDeliverySettled: expect.any(Function) + }) expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled() }) it('routes a Codex answer through the option-number keystroke path', () => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'codex')) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) // Codex's request_user_input card ignores typed labels (STA-1860 shape): // the 2nd option is delivered as its digit '2', which selects AND commits. expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith( - { terminalTabId: 'tab-1' }, 'pty-1', [{ raw: '2' }], expect.any(Function) @@ -105,15 +93,12 @@ describe('useNativeChatInteractiveSend', () => { }) it('routes a Claude answer through the option-number keystroke path', () => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude')) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) // The 2nd option is delivered as its number '2', not the label 'B' (STA-1860). expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith( - { terminalTabId: 'tab-1' }, 'pty-1', [{ raw: '2' }], expect.any(Function) @@ -123,21 +108,19 @@ describe('useNativeChatInteractiveSend', () => { it('infers OpenClaude answers through its Claude-compatible selector path', () => { const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'openclaude') + useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'openclaude') ) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) - const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3] + const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2] expect(onSettled).toBeTypeOf('function') onSettled?.(true) expect(mocks.inferQuestionAnswered).toHaveBeenCalledOnce() }) it('does nothing when no option is answered', () => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude')) let resultValue: ReturnType | undefined act(() => { @@ -151,7 +134,7 @@ describe('useNativeChatInteractiveSend', () => { it('cancels delayed answer writes when the PTY target changes', () => { const { result, rerender } = renderHook( - ({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', PANE_KEY, targetPtyId, 'codex'), + ({ targetPtyId }) => useNativeChatInteractiveSend(PANE_KEY, targetPtyId, 'codex'), { initialProps: { targetPtyId: 'pty-1' as string | null } } ) @@ -163,7 +146,7 @@ describe('useNativeChatInteractiveSend', () => { it('cancels delayed answer writes when the pane identity changes', () => { const { result, rerender } = renderHook( - ({ paneKey }) => useNativeChatInteractiveSend('tab-1', paneKey, 'pty-1', 'claude'), + ({ paneKey }) => useNativeChatInteractiveSend(paneKey, 'pty-1', 'claude'), { initialProps: { paneKey: PANE_KEY } } ) @@ -174,35 +157,27 @@ describe('useNativeChatInteractiveSend', () => { }) it('cancels delayed answer writes before interrupting the active PTY', () => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude')) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) act(() => result.current.cancel()) expect(mocks.cancel).toHaveBeenCalledOnce() - expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith( - { terminalTabId: 'tab-1' }, - 'pty-1', - '\x1b', - 'driving' - ) + expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith('pty-1', '\x1b', 'driving') }) it.each(['opencode', 'opencode2'] as const)( 'paces two Escape writes for %s Stop and cancels them on rebind', (agent) => { const { result, rerender } = renderHook( - ({ ptyId }) => useNativeChatInteractiveSend('tab-1', PANE_KEY, ptyId, agent), + ({ ptyId }) => useNativeChatInteractiveSend(PANE_KEY, ptyId, agent), { initialProps: { ptyId: 'pty-1' } } ) act(() => result.current.cancel()) - expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith( - { terminalTabId: 'tab-1' }, - 'pty-1', - [{ raw: '\x1b' }, { raw: '\x1b' }] - ) + expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith('pty-1', [ + { raw: '\x1b' }, + { raw: '\x1b' } + ]) rerender({ ptyId: 'pty-2' }) expect(mocks.cancel).toHaveBeenCalledOnce() } @@ -211,12 +186,9 @@ describe('useNativeChatInteractiveSend', () => { it.each(['opencode', 'opencode2'] as const)( 'rejects a %s question with one acknowledged Escape and no delayed Stop', async (agent) => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent) - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', agent)) await expect(result.current.cancelAsk()).resolves.toBe(true) expect(mocks.sendRuntimePtyInputVerified).toHaveBeenCalledExactlyOnceWith( - { terminalTabId: 'tab-1' }, 'pty-1', '\x1b', 'driving', @@ -229,21 +201,16 @@ describe('useNativeChatInteractiveSend', () => { it('reports an Escape whose delivery is unknown as not delivered', async () => { mocks.sendRuntimePtyInputVerified.mockRejectedValueOnce(new Error('timeout')) - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude')) await expect(result.current.cancelAsk()).resolves.toBe(false) }) it.each(['opencode', 'opencode2'] as const)( 'delivers a non-default %s answer through selector keys', (agent) => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent) - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', agent)) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith( - { terminalTabId: 'tab-1' }, 'pty-1', [{ raw: '2' }], expect.any(Function) @@ -253,9 +220,7 @@ describe('useNativeChatInteractiveSend', () => { ) it('can cancel delayed writes without interrupting the replacement prompt', () => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude')) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) act(() => result.current.cancelPending()) @@ -265,14 +230,12 @@ describe('useNativeChatInteractiveSend', () => { }) it('infers a Claude question answer only after every runtime write was delivered', () => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude')) act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled() - const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3] + const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2] expect(onSettled).toBeTypeOf('function') onSettled?.(false) expect(mocks.inferQuestionAnswered).not.toHaveBeenCalled() @@ -288,9 +251,7 @@ describe('useNativeChatInteractiveSend', () => { }) it('infers the answered question baseline, not a replacement that became current mid-send', () => { - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude')) // Answer question A while it is the current waiting question. act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) @@ -307,7 +268,7 @@ describe('useNativeChatInteractiveSend', () => { } } - const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3] + const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2] onSettled?.(true) // The baseline is question A's (captured before delivery), so the server can @@ -323,9 +284,7 @@ describe('useNativeChatInteractiveSend', () => { it('reports verified delivery settlement to the question card', () => { const onDeliverySettled = vi.fn() - const { result } = renderHook(() => - useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') - ) + const { result } = renderHook(() => useNativeChatInteractiveSend(PANE_KEY, 'pty-1', 'claude')) let sendResult: ReturnType | undefined act(() => { @@ -333,7 +292,7 @@ describe('useNativeChatInteractiveSend', () => { }) expect(sendResult).toEqual({ settleAfterMs: 500 }) - const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[3] + const onSettled = mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2] onSettled?.(false) expect(onDeliverySettled).toHaveBeenCalledExactlyOnceWith(false) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 2c7a130bfbff..9ad2d4f53bb6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -2,7 +2,6 @@ import { useCallback, useLayoutEffect, useRef } from 'react' import { useAppStore } from '../../store' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' -import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AgentType } from '../../../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent, @@ -57,7 +56,6 @@ export type NativeChatInteractiveSend = { * `sendNativeChatMessage`. Control strings (option digits, ESC) are written raw. */ export function useNativeChatInteractiveSend( - terminalTabId: string, paneKey: string, targetPtyId: string | null, agent: AgentType @@ -72,38 +70,26 @@ export function useNativeChatInteractiveSend( }, []) // Why: a split can be rebound without unmounting this view. Cancel during // commit so no delayed answer write can race the replacement PTY. - useLayoutEffect( - () => cancelInFlight, - [agent, cancelInFlight, paneKey, targetPtyId, terminalTabId] - ) + useLayoutEffect(() => cancelInFlight, [agent, cancelInFlight, paneKey, targetPtyId]) const sendRaw = useCallback( (raw: string) => { if (!targetPtyId) { return } - sendRuntimePtyInput( - getSettingsForAgentTabRuntimeOwner(terminalTabId), - targetPtyId, - raw, - 'driving' - ) + sendRuntimePtyInput(targetPtyId, raw, 'driving') }, - [terminalTabId, targetPtyId] + [targetPtyId] ) const sendRawVerified = useCallback( (raw: string): Promise => targetPtyId - ? sendRuntimePtyInputVerified( - getSettingsForAgentTabRuntimeOwner(terminalTabId), - targetPtyId, - raw, - 'driving', - { requireWriteSettlement: true } - ).catch(() => false) + ? sendRuntimePtyInputVerified(targetPtyId, raw, 'driving', { + requireWriteSettlement: true + }).catch(() => false) : Promise.resolve(false), - [terminalTabId, targetPtyId] + [targetPtyId] ) const sendAnswer = useCallback( @@ -117,7 +103,6 @@ export function useNativeChatInteractiveSend( } // Cancel any prior in-flight answer before starting a new one. cancelInFlight() - const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId) // Selector TUIs ignore pasted labels; Codex uses a different key sequence. const stepsAnswer = shouldStepNativeChatAskAnswer(agent) const buildsCodexAnswer = resolveNativeChatTranscriptAgent(agent) === 'codex' @@ -152,14 +137,13 @@ export function useNativeChatInteractiveSend( } const handle: NativeChatSendHandle = stepsAnswer ? sendNativeChatAskAnswer( - settings, targetPtyId, buildsCodexAnswer ? buildCodexAskAnswerKeys(prompt, selections) : buildAskAnswerKeys(prompt, selections), onSettled ) - : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections), { + : sendNativeChatMessage(targetPtyId, formatAskAnswer(prompt, selections), { onDeliverySettled: onSettled }) // Why: native-chat answer writes bypass xterm.onData. Infer only after @@ -171,7 +155,7 @@ export function useNativeChatInteractiveSend( settleAfterMs: handle.settleAfterMs } }, - [terminalTabId, paneKey, targetPtyId, agent, cancelInFlight] + [paneKey, targetPtyId, agent, cancelInFlight] ) const cancelAsk = useCallback(() => { @@ -183,15 +167,11 @@ export function useNativeChatInteractiveSend( cancelInFlight() if (resolveNativeChatTranscriptAgent(agent) === 'opencode' && targetPtyId) { // OpenCode confirms interruption with a second Escape; pace writes like mobile Stop. - inFlightRef.current = sendNativeChatAskAnswer( - getSettingsForAgentTabRuntimeOwner(terminalTabId), - targetPtyId, - [{ raw: ESC }, { raw: ESC }] - ) + inFlightRef.current = sendNativeChatAskAnswer(targetPtyId, [{ raw: ESC }, { raw: ESC }]) return } sendRaw(ESC) - }, [agent, cancelInFlight, sendRaw, targetPtyId, terminalTabId]) + }, [agent, cancelInFlight, sendRaw, targetPtyId]) return { sendAnswer, sendRaw, sendRawVerified, cancelPending: cancelInFlight, cancelAsk, cancel } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx index 53ef9eafd77f..0fef6005cf61 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx @@ -60,7 +60,7 @@ describe('useNativeChatPickerCommandDispatch', () => { const hook = renderDispatch('codex', onSubmitted) act(() => hook.result.current(COMMAND)) - expect(sendNativeChatTypedCommand).toHaveBeenCalledWith({}, 'pty-1', '/status') + expect(sendNativeChatTypedCommand).toHaveBeenCalledWith('pty-1', '/status') expect(sendNativeChatMessage).not.toHaveBeenCalled() expect(onSubmitted).toHaveBeenCalledOnce() }) @@ -69,7 +69,7 @@ describe('useNativeChatPickerCommandDispatch', () => { const hook = renderDispatch(agent) act(() => hook.result.current(COMMAND)) - expect(sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/status') + expect(sendNativeChatMessage).toHaveBeenCalledWith('pty-1', '/status') expect(sendNativeChatTypedCommand).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts index ec99002783bf..81260bc2824f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts @@ -79,8 +79,8 @@ export function useNativeChatPickerCommandDispatch(args: { } trackPendingSend( agent === 'codex' - ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) - : sendNativeChatMessage(target.settings, target.ptyId, text) + ? sendNativeChatTypedCommand(target.ptyId, text) + : sendNativeChatMessage(target.ptyId, text) ) emitNativeChatPickerItemAccepted({ agent, itemKind: 'command' }) // Why: picker dispatch is a catalog-verified command send; it must leave diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx index 68565ace5d6c..2a00a37f7d45 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx @@ -62,7 +62,7 @@ beforeEach(() => { it('routes a Claude chat send outcome to its own pending echo', () => { const callbacks = send('claude', 'chat', 'hello') - const options = vi.mocked(sendNativeChatMessage).mock.calls[0]?.[3] + const options = vi.mocked(sendNativeChatMessage).mock.calls[0]?.[2] options?.onWriteRejected?.() options?.onWriteUnconfirmed?.() expect(callbacks.rejected).toHaveBeenCalledWith('pending-1') @@ -71,7 +71,7 @@ it('routes a Claude chat send outcome to its own pending echo', () => { it('routes a Claude image send outcome to its own pending echo', () => { const callbacks = send('claude', 'chat', 'look', ['/tmp/shot.png']) - vi.mocked(sendNativeChatMessageWithImageAttachments).mock.calls[0]?.[5]?.onWriteRejected?.() + vi.mocked(sendNativeChatMessageWithImageAttachments).mock.calls[0]?.[4]?.onWriteRejected?.() expect(callbacks.rejected).toHaveBeenCalledWith('pending-1') }) @@ -80,7 +80,7 @@ it.each([ ['claude', 'command', '/compact'] ] as const)('leaves a %s %s send on the unobserved write path', (agent, classification, draft) => { send(agent, classification, draft) - expect(vi.mocked(sendNativeChatMessage).mock.calls[0]?.[3]?.onWriteRejected).toBeUndefined() + expect(vi.mocked(sendNativeChatMessage).mock.calls[0]?.[2]?.onWriteRejected).toBeUndefined() }) // The pane brings the latest into view on this, whatever the send was classified as. diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts index c1f979d4e840..d70d1d558fb3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts @@ -94,21 +94,20 @@ export function useNativeChatPtyComposerSend(args: { if (classification !== 'chat' && imagePaths.length === 0) { pendingHandle = args.agent === 'codex' && isSlashCommandDraft(text) - ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) - : sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) + ? sendNativeChatTypedCommand(target.ptyId, text) + : sendNativeChatMessage(target.ptyId, text, sendOptions) } else if (imagePaths.length > 0) { pendingHandle = sendNativeChatMessageWithImageAttachments( args.agent, - target.settings, target.ptyId, text, imagePaths, sendOptions ) } else if (text.trim().length > 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) + pendingHandle = sendNativeChatMessage(target.ptyId, text, sendOptions) } else { - submitNativeChatPrompt(target.settings, target.ptyId) + submitNativeChatPrompt(target.ptyId) } if (classification !== 'chat') { if (pendingHandle) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx index f76a78dd7323..0496c8dbaaa0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx @@ -42,12 +42,7 @@ describe('useNativeChatSessionOptionCommand', () => { await act(() => hook.result.current.dispatch('/model')) expect(onSubmitted).toHaveBeenCalledOnce() - expect(typeNativeChatCommand).toHaveBeenCalledWith( - {}, - 'pty-1', - '/model', - expect.any(AbortSignal) - ) + expect(typeNativeChatCommand).toHaveBeenCalledWith('pty-1', '/model', expect.any(AbortSignal)) expect(sendNativeChatMessageVerified).not.toHaveBeenCalled() }) @@ -56,7 +51,6 @@ describe('useNativeChatSessionOptionCommand', () => { await act(() => hook.result.current.dispatch('/model sonnet', { delivery: 'type' })) expect(sendNativeChatMessageVerified).toHaveBeenCalledWith( - {}, 'pty-1', '/model sonnet', expect.any(AbortSignal) diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index c1b034eaf683..71beae47c83c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -74,7 +74,6 @@ export function useNativeChatSessionOptionCommand(args: { if (detectClaudeConfirmation) { observer = createClaudeModelSwitchConfirmationObserver({ ptyId: target.ptyId, - settings: target.settings, expectedModelLabel: options?.expectedChoiceLabel ?? null }) activeObserversRef.current.add(observer) @@ -88,18 +87,8 @@ export function useNativeChatSessionOptionCommand(args: { } const accepted = agent === 'codex' - ? await typeNativeChatCommand( - target.settings, - target.ptyId, - command, - sendController.signal - ) - : await sendNativeChatMessageVerified( - target.settings, - target.ptyId, - command, - sendController.signal - ) + ? await typeNativeChatCommand(target.ptyId, command, sendController.signal) + : await sendNativeChatMessageVerified(target.ptyId, command, sendController.signal) if (!accepted) { throw new Error('The terminal did not accept the command.') } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts index 3ae6afa823fd..9a3c283b0f4b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts @@ -16,7 +16,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, dispatchAttention, @@ -71,7 +70,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, dispatchAttention, @@ -113,7 +111,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, dispatchAttention, @@ -160,7 +157,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, dispatchAttention, @@ -201,7 +197,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion: vi.fn(), dispatchAttention, @@ -243,7 +238,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, dispatchAttention, @@ -272,7 +266,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foreground)), dispatchCompletion, dispatchAttention, @@ -321,7 +314,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion: vi.fn(), dispatchAttention, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts index 75613bd4eebc..61bfc6e706bc 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts @@ -13,7 +13,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -49,7 +48,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -73,7 +71,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -116,7 +113,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -163,7 +159,6 @@ describe('agent completion coordinator', () => { const firstCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -177,7 +172,6 @@ describe('agent completion coordinator', () => { const remountedCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -196,7 +190,6 @@ describe('agent completion coordinator', () => { const firstCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -221,7 +214,6 @@ describe('agent completion coordinator', () => { const remountedCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts index 5004c6d05d98..55b7282ac2ce 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts @@ -28,7 +28,6 @@ function makeOptions(paneKey: string, live: { value: boolean }): AgentCompletion return { paneKey, getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion: vi.fn(), isLive: () => live.value diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts index 036ce1935fd8..f0b1252158ef 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts @@ -15,7 +15,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -71,7 +70,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -103,7 +101,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -134,7 +131,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -165,7 +161,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -190,7 +185,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foregroundProcess)), dispatchCompletion, isLive: () => true @@ -227,7 +221,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts index 918bdf5a5628..dbb5df266b16 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts @@ -17,7 +17,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -55,7 +54,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -78,7 +76,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult('codex')), dispatchCompletion, isLive: () => true @@ -104,7 +101,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foregroundProcess)), dispatchCompletion, isLive: () => true @@ -132,7 +128,6 @@ describe('agent completion coordinator', () => { const hookCoordinator = createAgentCompletionCoordinator({ paneKey, getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(null)), dispatchCompletion, isLive: () => true @@ -157,7 +152,6 @@ describe('agent completion coordinator', () => { const processCoordinator = createAgentCompletionCoordinator({ paneKey, getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => result), dispatchCompletion, isLive: () => true @@ -189,7 +183,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(() => inspection.promise), dispatchCompletion, isLive: () => true @@ -219,7 +212,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -260,7 +252,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(() => inspection.promise), dispatchCompletion, isLive: () => true @@ -288,7 +279,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion, isLive: () => true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts index 64162c8520d7..70da53cf3173 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts @@ -40,7 +40,6 @@ function createCoordinator() { const coordinator = createAgentCompletionCoordinator({ paneKey: PANE, getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, dispatchHookLifecycle, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts index 3489bc0d701c..9339f5f7570b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts @@ -27,7 +27,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult('zsh')), dispatchCompletion, isLive: () => true @@ -51,7 +50,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion, isLive: () => true @@ -86,7 +84,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion, isLive: () => true @@ -121,7 +118,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion, isLive: () => true @@ -151,7 +147,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(() => inspection.promise), dispatchCompletion, isLive: () => true @@ -171,7 +166,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(() => inspection.promise), dispatchCompletion, isLive: () => true @@ -192,7 +186,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult('codex')), dispatchCompletion, isLive: () => true @@ -211,7 +204,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'remote:terminal-1', - getSettings: () => ({ activeRuntimeEnvironmentId: 'env-1' }), inspectProcess: vi.fn(() => inspection.promise), dispatchCompletion, isLive: () => true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts index eb5083ca2cb5..852a2b326b1b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts @@ -16,7 +16,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true, @@ -41,7 +40,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true, @@ -63,7 +61,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true, @@ -84,7 +81,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true, @@ -117,7 +113,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foregroundProcess)), dispatchCompletion, isLive: () => true, @@ -150,7 +145,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foregroundProcess)), dispatchCompletion, isLive: () => true @@ -183,7 +177,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => result), dispatchCompletion, isLive: () => true @@ -226,7 +219,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false as const, @@ -252,7 +244,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false as const, @@ -295,7 +286,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => result), dispatchCompletion, isLive: () => true @@ -324,7 +314,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foregroundProcess)), dispatchCompletion, isLive: () => true @@ -349,7 +338,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foregroundProcess)), dispatchCompletion, shouldSuppressProcessReplacementCompletion: () => true, @@ -376,7 +364,6 @@ describe('agent completion coordinator', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foregroundProcess)), dispatchCompletion, shouldSuppressConfirmedProcessExitCompletion, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts index aeedd4f133bf..f4881ef510e9 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts @@ -23,7 +23,6 @@ describe('agent completion coordinator process-exit turn boundary', () => { paneKey, statusLane: 'pty', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => result), dispatchCompletion: ptyDispatch, isLive: () => true @@ -48,7 +47,6 @@ describe('agent completion coordinator process-exit turn boundary', () => { paneKey, statusLane: 'hook', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(null)), dispatchCompletion, isLive: () => true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts index 9d83b1d6a932..2e9a2afe42ee 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts @@ -20,7 +20,6 @@ describe('agent completion coordinator queued inspections', () => { createAgentCompletionCoordinator({ paneKey: `tab-1:blocked-${index}`, getPtyId: () => `pty-blocked-${index}`, - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true @@ -40,7 +39,6 @@ describe('agent completion coordinator queued inspections', () => { createAgentCompletionCoordinator({ paneKey: `tab-1:stale-${index}`, getPtyId: () => `pty-stale-${index}`, - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true @@ -50,7 +48,6 @@ describe('agent completion coordinator queued inspections', () => { const liveCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:live', getPtyId: () => 'pty-live', - getSettings: () => null, inspectProcess: liveInspectProcess, dispatchCompletion: vi.fn(), isLive: () => true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts index 11d3cd0aac75..2d97070b46cd 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts @@ -13,7 +13,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -41,7 +40,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -63,7 +61,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const firstCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -81,7 +78,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const remounted = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -104,7 +100,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, dispatchHookLifecycle, @@ -167,7 +162,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const localCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -175,7 +169,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const hostCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -230,7 +223,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const localCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -238,7 +230,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const hostCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -287,7 +278,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const localCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -295,7 +285,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const hostCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -349,7 +338,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const localCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -357,7 +345,6 @@ describe('agent completion coordinator stamped turn boundary', () => { const hostCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts index 8e4fe3b45df4..0d1586b15be6 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts @@ -16,7 +16,6 @@ describe('agent completion coordinator stamped turn replay', () => { paneKey: 'tab-1:leaf-1', statusLane: 'pty', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -25,7 +24,6 @@ describe('agent completion coordinator stamped turn replay', () => { paneKey: 'tab-1:leaf-1', statusLane: 'hook', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -81,7 +79,6 @@ describe('agent completion coordinator stamped turn replay', () => { paneKey: 'tab-1:leaf-1', statusLane: 'pty', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -91,7 +88,6 @@ describe('agent completion coordinator stamped turn replay', () => { paneKey: 'tab-1:leaf-1', statusLane: 'hook', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -142,7 +138,6 @@ describe('agent completion coordinator stamped turn replay', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, dispatchHookLifecycle, @@ -196,7 +191,6 @@ describe('agent completion coordinator stamped turn replay', () => { const firstCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -221,7 +215,6 @@ describe('agent completion coordinator stamped turn replay', () => { const remounted = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -243,7 +236,6 @@ describe('agent completion coordinator stamped turn replay', () => { const firstCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -271,7 +263,6 @@ describe('agent completion coordinator stamped turn replay', () => { const remounted = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -293,7 +284,6 @@ describe('agent completion coordinator stamped turn replay', () => { const hookCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -301,7 +291,6 @@ describe('agent completion coordinator stamped turn replay', () => { const paneCoordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -350,7 +339,6 @@ describe('agent completion coordinator stamped turn replay', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index c56e77b5bc7b..8dafd227610f 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -1,5 +1,4 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' @@ -28,9 +27,7 @@ export type AgentCompletionCoordinatorOptions = { /** Remote authorities are event-triggered only; no periodic process polls. */ isRemotePtyId?: (ptyId: string) => boolean getExpectedIncarnationId?: () => string | null - getSettings: () => Pick | null | undefined inspectProcess: ( - settings: Pick | null | undefined, ptyId: string, options?: { expectedIncarnationId?: string; steadyState?: boolean } ) => Promise diff --git a/src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts index 4ed75cfd1616..6b41c458c946 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts @@ -37,7 +37,6 @@ function createCoordinator( const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion, isLive: () => true, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts index fd4d1f448d21..2f8829bbea47 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts @@ -118,8 +118,8 @@ export function createAgentCompletionProcessMonitor({ : {}) } const result = await (Object.keys(inspectOptions).length > 0 - ? options.inspectProcess(options.getSettings(), ptyId, inspectOptions) - : options.inspectProcess(options.getSettings(), ptyId)) + ? options.inspectProcess(ptyId, inspectOptions) + : options.inspectProcess(ptyId)) if ( !state.disposed && generationAtRequest === state.inspectionGeneration && diff --git a/src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts index 37ab0ab4b8a5..af3e426a3cb5 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts @@ -91,7 +91,6 @@ function inspect(result: RuntimeTerminalProcessInspection, roundTripMs = 20): Pr options: { paneKey: 'tab-1:leaf-1', getPtyId: () => SSH_PTY_ID, - getSettings: () => null, isRemotePtyId: () => true, getExpectedIncarnationId: () => INCARNATION } as unknown as AgentCompletionCoordinatorOptions, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts index 4f2286c7eb76..bc0fee2a3d1d 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts @@ -12,14 +12,13 @@ import { describe('agent completion steadyState opt-in', () => { useAgentCompletionCoordinatorLifecycle() - const optionsOf = (call: unknown[]): unknown => call[2] + const optionsOf = (call: unknown[]): unknown => call[1] it('marks cadence polls on a local pane as steadyState', async () => { const inspectProcess = vi.fn(async () => processResult('codex')) const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true, @@ -41,7 +40,6 @@ describe('agent completion steadyState opt-in', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true, @@ -64,7 +62,6 @@ describe('agent completion steadyState opt-in', () => { getPtyId: () => 'remote:pty-1', isRemotePtyId: () => true, getExpectedIncarnationId: () => 'inc-1', - getSettings: () => null, inspectProcess, dispatchCompletion: vi.fn(), isLive: () => true, diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts index 5eafa29e07e9..fe2a33b3cf26 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts @@ -36,7 +36,6 @@ function createUnmarkedPane(args: { visible: boolean }) { paneKey: 'tab-1:leaf-1', statusLane: 'pty', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foreground, hasChildren)), dispatchCompletion: vi.fn(), isLive: () => true, diff --git a/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts b/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts index 3fd342378bd8..55784a507e1c 100644 --- a/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts +++ b/src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts @@ -44,7 +44,6 @@ it.each([false, true])('requires confirmation for pending live work, paired=%s', const p = await prepare(remote) p.actions.handleRequestClosePane(1) expect(p.probePtyRunningWork).toHaveBeenCalledWith( - expect.any(Object), [remote ? 'remote:env-1@@term_original' : 'pty-restored'], expect.any(Object) ) @@ -103,7 +102,7 @@ it('uses the direct SSH identity in the running-work probe', async () => { p.actions.handleRequestClosePane(1) p.verdict('live') await flushPtySideEffects() - expect(p.probePtyRunningWork).toHaveBeenCalledWith(expect.any(Object), [id], expect.any(Object)) + expect(p.probePtyRunningWork).toHaveBeenCalledWith([id], expect.any(Object)) expect(p.closed()).toBe(false) p.actions.handleConfirmClose(false) await p.settle() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 33b7998ef399..e6ececf03af0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -182,11 +182,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { isTrackablePtyId: session.isForegroundTrackingAllowed, readForegroundProcess: (id, options) => isRemotePtyId(id) - ? inspectRuntimeTerminalProcess(useAppStore.getState().settings, id, options) + ? inspectRuntimeTerminalProcess(id, options) : window.api.pty.getForegroundProcess(id), confirmForegroundProcess: (id, options) => isRemotePtyId(id) - ? inspectRuntimeTerminalProcess(useAppStore.getState().settings, id, options) + ? inspectRuntimeTerminalProcess(id, options) : window.api.pty.confirmForegroundProcess(id), isRemotePtyId, getExpectedIncarnationId: () => session.remotePtyIncarnationId ?? null, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index a9eec4111a3a..0d838771c979 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -1,10 +1,8 @@ import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' -import { useAppStore } from '@/store' import type { SessionRestoredBannerReason } from '../session-restored-banner-pane-state' import { hasPtySerializer } from '../pty-buffer-serializer' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { isExpectedAgentProcess } from '../../../../../shared/agent-process-recognition' import { resolveDraftPasteReadyTimeoutMs } from '../../../../../shared/draft-paste-ready-timeout' import { createDraftPasteReadyScanner } from '../../../../../shared/draft-paste-ready-scanner' @@ -117,28 +115,18 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { startupDraftPasteSettled = true session.startupDraftPasteAttempted = true session.cleanupStartupDraftPasteTimers() - const settings = getSettingsForWorktreeRuntimeOwner( - useAppStore.getState(), - session.deps.worktreeId - ) // Why: xterm focus reports share this transport queue. Bypassing it can // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. - void sendAgentDraftPasteContent( - settings, - ptyId, - session.startupDraftPrompt, - 'launch', - async (data) => { - const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch') - if (accepted && !startupDraftInputRecorded) { - // Why: this transport write bypasses xterm's user-input signal; keep - // the composed draft from being discarded by later hibernation. - startupDraftInputRecorded = true - session.recordTerminalInputForHibernation() - } - return accepted + void sendAgentDraftPasteContent(ptyId, session.startupDraftPrompt, 'launch', async (data) => { + const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch') + if (accepted && !startupDraftInputRecorded) { + // Why: this transport write bypasses xterm's user-input signal; keep + // the composed draft from being discarded by later hibernation. + startupDraftInputRecorded = true + session.recordTerminalInputForHibernation() } - ) + return accepted + }) .catch(() => false) .finally(() => { startupDraftPasteInFlight = false @@ -152,12 +140,8 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { if (!ptyId) { return } - const settings = getSettingsForWorktreeRuntimeOwner( - useAppStore.getState(), - session.deps.worktreeId - ) try { - const process = await inspectRuntimeTerminalProcess(settings, ptyId) + const process = await inspectRuntimeTerminalProcess(ptyId) const foreground = process.foregroundProcess?.toLowerCase() ?? '' if ( getStartupDraftPtyId() === ptyId && diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts index 99accd330a81..2ba251c95eac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -184,7 +184,6 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void isRemotePtyId: (ptyId) => Boolean(isRemoteExecutionHostPtyId(ptyId) || isRemoteRuntimePtyId(ptyId)), getExpectedIncarnationId: () => session.remotePtyIncarnationId ?? null, - getSettings: () => useAppStore.getState().settings, inspectProcess: inspectRuntimeTerminalProcess, dispatchHookLifecycle: (payload) => dispatchAgentHookTerminalLifecycle(session.cacheKey, payload), diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts index f08926b2c81d..21cf0e902d83 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts @@ -55,7 +55,6 @@ describe('terminal paste operation ordering', () => { writePty, yieldToEventLoop: async () => { startupDraft ??= sendAgentDraftPasteContent( - null, 'pty-1', 'GENERATED_CONTEXT', 'launch', @@ -83,7 +82,6 @@ describe('terminal paste operation ordering', () => { startupOpened = resolve }) const startup = sendAgentDraftPasteContent( - null, 'pty-1', 'G'.repeat(64 * 1024 + 1), 'launch', diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index efad698fca4f..3cd0c696ab5f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -147,7 +147,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr ) // Why the shared probe rather than a direct inspect: this is the same question the tab-close // guard asks, and the two must not drift on what an unanswered host means. - void probePtyRunningWork(settings, [ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS }) + void probePtyRunningWork([ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS }) .then((probes) => { clearTimeout(probeTimeout) decide(() => { diff --git a/src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts b/src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts index 4d002ee0191d..f673b4fe71b6 100644 --- a/src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts +++ b/src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts @@ -14,8 +14,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ import { probePtyRunningWork } from './pty-running-work-probe' -const SETTINGS = { activeRuntimeEnvironmentId: null } - describe('probePtyRunningWork child-process evidence', () => { beforeEach(() => { vi.clearAllMocks() @@ -28,9 +26,9 @@ describe('probePtyRunningWork child-process evidence', () => { childProcessEvidence: 'no-children' }) - await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 }) - expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(SETTINGS, 'pty-a', { + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith('pty-a', { scanChildProcesses: true }) }) @@ -42,7 +40,7 @@ describe('probePtyRunningWork child-process evidence', () => { childProcessEvidence: 'unverifiable' }) - const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + const [probe] = await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 }) expect(probe).toMatchObject({ verdict: 'unverifiable', @@ -58,7 +56,7 @@ describe('probePtyRunningWork child-process evidence', () => { childProcessEvidence: 'no-children' }) - const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + const [probe] = await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 }) expect(probe?.verdict).toBe('exited') }) @@ -70,7 +68,7 @@ describe('probePtyRunningWork child-process evidence', () => { childProcessEvidence: 'children' }) - const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) + const [probe] = await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 }) expect(probe?.verdict).toBe('live') }) @@ -85,8 +83,8 @@ describe('probePtyRunningWork child-process evidence', () => { hasChildProcesses: false }) - const [live] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 }) - const [idle] = await probePtyRunningWork(SETTINGS, ['pty-b'], { timeoutMs: 1000 }) + const [live] = await probePtyRunningWork(['pty-a'], { timeoutMs: 1000 }) + const [idle] = await probePtyRunningWork(['pty-b'], { timeoutMs: 1000 }) expect(live?.verdict).toBe('live') expect(idle?.verdict).toBe('exited') diff --git a/src/renderer/src/components/terminal/pty-running-work-probe.ts b/src/renderer/src/components/terminal/pty-running-work-probe.ts index 9fe63c380f1f..e97fda749396 100644 --- a/src/renderer/src/components/terminal/pty-running-work-probe.ts +++ b/src/renderer/src/components/terminal/pty-running-work-probe.ts @@ -1,4 +1,3 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { probePtyRunningWorkWithInspection, @@ -10,15 +9,12 @@ export type { PtyRunningWorkVerdict } from '../../../../shared/pty-running-work-probe' -type ProbeSettings = Pick | null | undefined - export function probePtyRunningWork( - settings: ProbeSettings, ptyIds: readonly string[], options: { timeoutMs: number } ): Promise { return probePtyRunningWorkWithInspection(ptyIds, options, (ptyId) => // Close guards require host child-process evidence before acting. - inspectRuntimeTerminalProcess(settings, ptyId, { scanChildProcesses: true }) + inspectRuntimeTerminalProcess(ptyId, { scanChildProcesses: true }) ) } diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts index 6b5b8343086e..21d91ef21dc1 100644 --- a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts @@ -111,7 +111,7 @@ describe('guardRunningTerminalClose', () => { guard(onClose) await settleProbe() - expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a', { + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith('pty-a', { scanChildProcesses: true }) expect(onClose).not.toHaveBeenCalled() @@ -220,7 +220,7 @@ describe('guardRunningTerminalClose', () => { }, agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } }) - inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({ + inspectRuntimeTerminalProcessMock.mockImplementation(async (ptyId: string) => ({ foregroundProcess: ptyId === 'pty-b' ? 'claude' : 'zsh', hasChildProcesses: ptyId === 'pty-b' })) @@ -242,7 +242,7 @@ describe('guardRunningTerminalClose', () => { }, agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } }) - inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({ + inspectRuntimeTerminalProcessMock.mockImplementation(async (ptyId: string) => ({ foregroundProcess: ptyId === 'pty-a' ? 'npm' : 'zsh', hasChildProcesses: ptyId === 'pty-a' })) diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.ts index 6bb9ff5a582b..60082e0c6678 100644 --- a/src/renderer/src/components/terminal/running-terminal-close-guard.ts +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.ts @@ -111,7 +111,7 @@ export function guardRunningTerminalClose(params: { decided = true } - void probePtyRunningWork(settings, ptyIds, { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS }) + void probePtyRunningWork(ptyIds, { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS }) .then((probes) => { if (decided) { return diff --git a/src/renderer/src/components/terminal/window-close-running-work.test.ts b/src/renderer/src/components/terminal/window-close-running-work.test.ts index 77dc4ad745b0..ba8f71ec0cda 100644 --- a/src/renderer/src/components/terminal/window-close-running-work.test.ts +++ b/src/renderer/src/components/terminal/window-close-running-work.test.ts @@ -49,7 +49,7 @@ function setState(ptyIds: string[]): void { /** Answers each pty id from `byPtyId`; anything unlisted never settles. */ function answerWith(byPtyId: Record): void { - inspectRuntimeTerminalProcessMock.mockImplementation((_settings: unknown, ptyId: string) => + inspectRuntimeTerminalProcessMock.mockImplementation((ptyId: string) => ptyId in byPtyId ? Promise.resolve(byPtyId[ptyId]) : new Promise(() => {}) ) } diff --git a/src/renderer/src/components/terminal/window-close-running-work.ts b/src/renderer/src/components/terminal/window-close-running-work.ts index 427ee72dae62..ccb9a34c5935 100644 --- a/src/renderer/src/components/terminal/window-close-running-work.ts +++ b/src/renderer/src/components/terminal/window-close-running-work.ts @@ -57,7 +57,7 @@ export async function assessWindowCloseRunningWork(params: { return { kind: 'none' } } - const probes = await probePtyRunningWork(state.settings, candidatePtyIds, { + const probes = await probePtyRunningWork(candidatePtyIds, { timeoutMs: WINDOW_CLOSE_PROBE_TIMEOUT_MS }) if (probes.some((probe) => probe.verdict === 'live')) { diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 8b43478818c8..e90d22553830 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -236,7 +236,6 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion paneKey, statusLane: 'hook', getPtyId: () => getPtyIdForPaneKey(paneKey), - getSettings: () => useAppStore.getState().settings, inspectProcess: async (): Promise => ({ foregroundProcess: null, hasChildProcesses: false diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index 19d5a30d275c..7fa19bc1762d 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -1,5 +1,4 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' -import type { GlobalSettings } from '../../../shared/global-settings-types' import { getUtf8ByteLengthForCodePoint, readUtf8CodePointAt @@ -27,21 +26,19 @@ const AGENT_DRAFT_PASTE_INERT_ESCAPE = '\u241b' export type AgentDraftPtyInputWriter = (data: string) => boolean | Promise export async function sendAgentDraftPasteContent( - settings: Pick | null | undefined, ptyId: string, content: string, inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { return await runTerminalPtyInputTransaction(ptyId, () => - sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind, writePty) + sendAgentDraftPasteContentNow(ptyId, content, inputKind, writePty) ) } // Why: callers that must keep extra PTY writes (e.g. submit Enter) inside the same // transaction take the lock themselves; taking it again here would deadlock. export async function sendAgentDraftPasteContentNow( - settings: Pick | null | undefined, ptyId: string, content: string, inputKind: TerminalInputKind, @@ -57,7 +54,6 @@ export async function sendAgentDraftPasteContentNow( }) if (!directMeasurement.exceededLimit) { return await writeAgentDraftPtyInput( - settings, ptyId, wrapTerminalBracketedPasteText(terminalContent), inputKind, @@ -75,16 +71,16 @@ export async function sendAgentDraftPasteContentNow( for (const chunk of iterateAgentDraftPasteContentChunks(terminalContent)) { let accepted = false try { - accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, inputKind, writePty) + accepted = await writeAgentDraftPtyInput(ptyId, chunk, inputKind, writePty) } catch { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty) + await closeAgentDraftBracketedPaste(ptyId, inputKind, writePty) } return false } if (!accepted) { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty) + await closeAgentDraftBracketedPaste(ptyId, inputKind, writePty) } return false } @@ -198,19 +194,15 @@ function getSanitizedUtf8ByteLengthForCodePoint(codePoint: number): number { } async function writeAgentDraftPtyInput( - settings: Pick | null | undefined, ptyId: string, data: string, inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { - return writePty - ? await writePty(data) - : await sendRuntimePtyInputVerified(settings, ptyId, data, inputKind) + return writePty ? await writePty(data) : await sendRuntimePtyInputVerified(ptyId, data, inputKind) } async function closeAgentDraftBracketedPaste( - settings: Pick | null | undefined, ptyId: string, inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter @@ -218,7 +210,7 @@ async function closeAgentDraftBracketedPaste( try { // Why: once the opener reached the PTY, a failed content chunk should not // leave the target TUI in bracketed-paste mode. - await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, inputKind, writePty) + await writeAgentDraftPtyInput(ptyId, BRACKETED_PASTE_END, inputKind, writePty) } catch { // The original write already failed; callers only need the paste to fail closed. } diff --git a/src/renderer/src/lib/agent-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index bfc11476ca60..336f4a41ad9f 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -45,7 +45,7 @@ describe('waitForAgentDraftInputReady', () => { const primary = vi.fn() await expect( - waitForAgentDraftInputReady(PTY_ID, 20_000, 'codex-composer-prompt', {}) + waitForAgentDraftInputReady(PTY_ID, 20_000, 'codex-composer-prompt') ).resolves.toBe(true) drainPreHandlerPtyData(PTY_ID, primary) @@ -61,7 +61,7 @@ describe('waitForAgentDraftInputReady', () => { vi.useFakeTimers() vi.stubGlobal('window', globalThis) let settled: boolean | null = null - void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row').then( (ready) => (settled = ready) ) testState.observer!(OPENCODE_BOX) @@ -78,7 +78,7 @@ describe('waitForAgentDraftInputReady', () => { vi.useFakeTimers() vi.stubGlobal('window', globalThis) let settled: boolean | null = null - void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row').then( (ready) => (settled = ready) ) await vi.advanceTimersByTimeAsync(19_000) @@ -94,7 +94,7 @@ describe('waitForAgentDraftInputReady', () => { vi.useFakeTimers() vi.stubGlobal('window', globalThis) let settled: boolean | null = null - void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row').then( (ready) => (settled = ready) ) testState.observer!(OPENCODE_BOX) diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index 22e9c2e85356..d96b01a06b67 100644 --- a/src/renderer/src/lib/agent-draft-readiness.ts +++ b/src/renderer/src/lib/agent-draft-readiness.ts @@ -1,5 +1,4 @@ import type { DraftPasteReadySignal } from '../../../shared/tui-agent-config' -import type { GlobalSettings } from '../../../shared/global-settings-types' import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { replayPreHandlerPtyData } from '@/components/terminal-pane/pty-pre-handler-buffer' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' @@ -21,8 +20,7 @@ const BRACKETED_PASTE_QUIET_MS = 1500 export function waitForAgentDraftInputReady( ptyId: string, timeoutMs: number, - readySignal: DraftPasteReadySignal, - settings: Pick | null | undefined + readySignal: DraftPasteReadySignal ): Promise { return new Promise((resolve) => { let settled = false @@ -75,12 +73,7 @@ export function waitForAgentDraftInputReady( } if (isRemoteRuntimePtyId(ptyId)) { - void subscribeToRuntimeTerminalData( - settings, - ptyId, - `desktop:paste-ready:${ptyId}`, - observeData - ) + void subscribeToRuntimeTerminalData(ptyId, `desktop:paste-ready:${ptyId}`, observeData) .then((remoteUnsubscribe) => { if (settled) { remoteUnsubscribe() diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts index 06260adfb64b..a24486379222 100644 --- a/src/renderer/src/lib/agent-followup-delivery.test.ts +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -44,15 +44,14 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = const delivery = sendFollowupPromptWhenAgentReady({ ptyId: 'pty-1', expectedProcess, - prompt: 'ship it', - settings: null + prompt: 'ship it' }) await vi.advanceTimersByTimeAsync(4 * 150) const delivered = await delivery expect(delivered).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', 'ship it\r', 'launch') }) it(`still refuses to type into a bare ${agent} shell foreground`, async () => { @@ -66,8 +65,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = const delivery = sendFollowupPromptWhenAgentReady({ ptyId: 'pty-1', expectedProcess, - prompt: 'ship it', - settings: null + prompt: 'ship it' }) await vi.advanceTimersByTimeAsync(29 * 150) @@ -86,8 +84,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = const delivery = sendFollowupPromptWhenAgentReady({ ptyId: 'pty-1', expectedProcess, - prompt: 'ship it', - settings: null + prompt: 'ship it' }) await vi.advanceTimersByTimeAsync(29 * 150) @@ -109,11 +106,10 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = const delivered = await sendFollowupPromptWhenAgentReady({ ptyId: 'pty-1', expectedProcess: 'aider', - prompt: 'ship it', - settings: null + prompt: 'ship it' }) expect(delivered).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', 'ship it\r', 'launch') }) }) diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index e520cb6df1f0..efa2287913ab 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -7,22 +7,18 @@ import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { isShellProcess } from '../../../shared/shell-process-detection' -import type { GlobalSettings } from '../../../shared/global-settings-types' - -type RuntimeOwnerSettings = Pick | null | undefined export async function sendFollowupPromptWhenAgentReady(args: { ptyId: string expectedProcess: string prompt: string - settings: RuntimeOwnerSettings }): Promise { - const { ptyId, expectedProcess, prompt, settings } = args - if (!(await waitForAgentForeground(ptyId, expectedProcess, settings))) { + const { ptyId, expectedProcess, prompt } = args + if (!(await waitForAgentForeground(ptyId, expectedProcess))) { return false } try { - return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`, 'launch') + return await sendRuntimePtyInputVerified(ptyId, `${prompt}\r`, 'launch') } catch { return false } @@ -30,17 +26,13 @@ export async function sendFollowupPromptWhenAgentReady(args: { // Why: delayed follow-ups must not type into an arbitrary shell. Require a // positive readiness signal before writing user/task text to the PTY. -async function waitForAgentForeground( - ptyId: string, - expectedProcess: string, - settings: RuntimeOwnerSettings -): Promise { +async function waitForAgentForeground(ptyId: string, expectedProcess: string): Promise { for (let attempt = 0; attempt < 30; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => globalThis.setTimeout(resolve, 150)) } try { - const process = await inspectRuntimeTerminalProcess(settings, ptyId) + const process = await inspectRuntimeTerminalProcess(ptyId) const foreground = process.foregroundProcess?.toLowerCase() ?? '' if (isExpectedAgentProcess(foreground, expectedProcess)) { return true diff --git a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts index 32b0b88c7530..9c4309b433d9 100644 --- a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts @@ -53,7 +53,6 @@ describe('pty-bound agent draft readiness budget', () => { it('delivers when a cold Codex composer becomes ready after 8s', async () => { const promise = pasteDraftToAgentPtyWhenReady({ - tabId: 'tab-1', ptyId: 'pty-1', content: 'draft', agent: 'codex', @@ -63,19 +62,13 @@ describe('pty-bound agent draft readiness budget', () => { await vi.advanceTimersByTimeAsync(10_000) await expect(promise).resolves.toBe(true) - expect(testState.waitForReady).toHaveBeenCalledWith( - 'pty-1', - 20_000, - 'codex-composer-prompt', - {} - ) + expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'codex-composer-prompt') expect(testState.sendInput).toHaveBeenCalledTimes(1) }) it('gives a cold opencode composer the same headroom as Codex', async () => { const onUnconfirmedDelivery = vi.fn() const promise = pasteDraftToAgentPtyWhenReady({ - tabId: 'tab-1', ptyId: 'pty-1', content: 'draft', agent: 'opencode', @@ -89,8 +82,7 @@ describe('pty-bound agent draft readiness budget', () => { expect(testState.waitForReady).toHaveBeenCalledWith( 'pty-1', 20_000, - 'render-cursor-after-bracketed-paste', - {} + 'render-cursor-after-bracketed-paste' ) expect(testState.sendInput).toHaveBeenCalledTimes(1) expect(onUnconfirmedDelivery).not.toHaveBeenCalled() @@ -100,7 +92,6 @@ describe('pty-bound agent draft readiness budget', () => { 'waits for the %s agent row only when Enter follows the paste', async (agent) => { const promise = pasteDraftToAgentPtyWhenReady({ - tabId: 'tab-1', ptyId: 'pty-1', content: 'task', agent, @@ -111,7 +102,7 @@ describe('pty-bound agent draft readiness budget', () => { await vi.advanceTimersByTimeAsync(12_000) await expect(promise).resolves.toBe(true) - expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row', {}) + expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row') } ) @@ -132,7 +123,7 @@ describe('pty-bound agent draft readiness budget', () => { await vi.advanceTimersByTimeAsync(12_000) await expect(promise).resolves.toBe(true) - expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, signal, {}) + expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, signal) }) it('flags a blind paste when only the opencode process, not its composer, was seen', async () => { @@ -145,7 +136,6 @@ describe('pty-bound agent draft readiness budget', () => { }) const onUnconfirmedDelivery = vi.fn() const promise = pasteDraftToAgentPtyWhenReady({ - tabId: 'tab-1', ptyId: 'pty-1', content: 'draft', agent: 'opencode', @@ -163,7 +153,6 @@ describe('pty-bound agent draft readiness budget', () => { it('keeps the 8s readiness deadline for agents without an agent-specific budget', async () => { const onTimeout = vi.fn() const promise = pasteDraftToAgentPtyWhenReady({ - tabId: 'tab-1', ptyId: 'pty-1', content: 'draft', agent: 'gemini', @@ -177,8 +166,7 @@ describe('pty-bound agent draft readiness budget', () => { expect(testState.waitForReady).toHaveBeenCalledWith( 'pty-1', 8000, - 'render-quiet-after-bracketed-paste', - {} + 'render-quiet-after-bracketed-paste' ) expect(onTimeout).toHaveBeenCalledTimes(1) expect(testState.sendInput).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts index 84ad14770d1d..a8dee509d05e 100644 --- a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -105,12 +105,7 @@ describe('post-paste submit retry Enter', () => { await expect(promise).resolves.toBe(true) expect(enterWrites()).toHaveLength(2) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( - {}, - 'pty-1', - '\r', - 'launch' - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith('pty-1', '\r', 'launch') expect(vi.getTimerCount()).toBe(0) }) @@ -135,7 +130,7 @@ describe('post-paste submit retry Enter', () => { it('holds the PTY input transaction across the retry Enter', async () => { const writes: string[] = [] testState.sendRuntimePtyInputVerified.mockImplementation( - async (_settings: unknown, _ptyId: string, data: string) => { + async (_ptyId: string, data: string) => { writes.push(data) return true } @@ -146,7 +141,6 @@ describe('post-paste submit retry Enter', () => { await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) // Competing paste on the same PTY: it must not open a frame the retry can land in. const competing = sendAgentDraftPasteContent( - {}, 'pty-1', 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1), 'driving' @@ -178,7 +172,7 @@ describe('post-paste submit retry Enter', () => { }) function enterWrites(): unknown[][] { - return testState.sendRuntimePtyInputVerified.mock.calls.filter((call) => call[2] === '\r') + return testState.sendRuntimePtyInputVerified.mock.calls.filter((call) => call[1] === '\r') } function startCodexSubmit(): Promise { diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 2427c7a1e3bf..3a6be024c3ea 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -4,7 +4,6 @@ import { AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, AGENT_DRAFT_PASTE_MAX_BYTES, chunkAgentDraftPasteContent, - getSettingsForAgentTabRuntimeOwner, iterateAgentDraftPasteContentChunks, pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady, @@ -58,8 +57,8 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, // Why drop the kind: this suite pins write shapes; startup-draft-input-kind.test.ts pins kinds. - sendRuntimePtyInputVerified: (settings: unknown, ptyId: string, data: string) => - testState.sendRuntimePtyInputVerified(settings, ptyId, data), + sendRuntimePtyInputVerified: (ptyId: string, data: string) => + testState.sendRuntimePtyInputVerified(ptyId, data), inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess })) @@ -135,11 +134,7 @@ describe('pasteDraftWhenAgentReady', () => { testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) expect(vi.getTimerCount()).toBe(0) }) @@ -168,14 +163,10 @@ describe('pasteDraftWhenAgentReady', () => { expect(testState.replayPreHandlerPtyData).toHaveBeenCalledWith('pty-1', testState.ptyObserver) await flushMicrotasks() - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, 'pty-1', '\r') expect(testState.unsubscribe).toHaveBeenCalledTimes(1) expect(vi.getTimerCount()).toBe(0) }) @@ -198,11 +189,7 @@ describe('pasteDraftWhenAgentReady', () => { await vi.advanceTimersByTimeAsync(1) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) }) it('pastes into opencode as soon as show-cursor renders after bracketed paste is enabled', async () => { @@ -220,11 +207,7 @@ describe('pasteDraftWhenAgentReady', () => { testState.ptyObserver?.(SHOW_CURSOR) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) expect(vi.getTimerCount()).toBe(0) }) @@ -248,11 +231,7 @@ describe('pasteDraftWhenAgentReady', () => { testState.ptyObserver?.(SHOW_CURSOR) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) expect(vi.getTimerCount()).toBe(0) }) @@ -308,11 +287,7 @@ describe('pasteDraftWhenAgentReady', () => { await vi.advanceTimersByTimeAsync(20_000) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) // The composer was never observed; the caller must be able to hedge its success notice. expect(onUnconfirmedDelivery).toHaveBeenCalledTimes(1) }) @@ -336,11 +311,7 @@ describe('pasteDraftWhenAgentReady', () => { await flushMicrotasks(5) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) }) it('does not paste for agents that already use native draft prefill', async () => { @@ -371,16 +342,12 @@ describe('pasteDraftWhenAgentReady', () => { await flushMicrotasks() expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) await vi.advanceTimersByTimeAsync(49) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, 'pty-1', '\r') }) it('does not submit when the verified paste write fails', async () => { @@ -446,11 +413,7 @@ describe('pasteDraftWhenAgentReady', () => { await vi.advanceTimersByTimeAsync(8000) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) }) it('waits past the default budget for a cold-boot Codex composer glyph (STA-3367)', async () => { @@ -474,11 +437,7 @@ describe('pasteDraftWhenAgentReady', () => { testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) expect(vi.getTimerCount()).toBe(0) }) @@ -532,11 +491,7 @@ describe('pasteDraftWhenAgentReady', () => { testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) }) it.each(['tab', 'pty'] as const)( @@ -568,7 +523,6 @@ describe('pasteDraftWhenAgentReady', () => { testState.inspectRuntimeTerminalProcess.mockReturnValue(new Promise(() => {})) const promise = pasteDraftToAgentPtyWhenReady({ - tabId: 'tab-1', ptyId: 'pty-1', content: ISSUE_URL, agent: 'codex', @@ -587,7 +541,7 @@ describe('pasteDraftWhenAgentReady', () => { expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() }) - it('routes tab-owned paste writes through the worktree runtime owner', async () => { + it('pastes into a tab whose workspace a server owns', async () => { testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } testState.appState.repos = [ @@ -605,14 +559,10 @@ describe('pasteDraftWhenAgentReady', () => { testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'owner-runtime' }, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) }) - it('routes legacy remote PTY readiness subscription through the tab owner', async () => { + it('subscribes to a remote PTY by its id, never by the focused server', async () => { testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } testState.appState.ptyIdsByTabId = { 'tab-1': ['remote:terminal-handle'] } testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } @@ -622,12 +572,7 @@ describe('pasteDraftWhenAgentReady', () => { testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } testState.isRemoteRuntimePtyId.mockReturnValue(true) testState.subscribeToRuntimeTerminalData.mockImplementation( - async ( - _settings: unknown, - _ptyId: string, - _clientId: string, - observer: (data: string) => void - ) => { + async (_ptyId: string, _clientId: string, observer: (data: string) => void) => { testState.ptyObserver = observer return testState.unsubscribe } @@ -644,7 +589,6 @@ describe('pasteDraftWhenAgentReady', () => { await expect(promise).resolves.toBe(true) expect(testState.subscribeToRuntimeTerminalData).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'owner-runtime' }, 'remote:terminal-handle', 'desktop:paste-ready:remote:terminal-handle', expect.any(Function) @@ -660,11 +604,7 @@ describe('pasteDraftWhenAgentReady', () => { expect(testState.subscribeToPtyData).not.toHaveBeenCalled() expect(testState.subscribeToRuntimeTerminalData).not.toHaveBeenCalled() expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', PASTED_ISSUE_URL) await flushMicrotasks() await vi.advanceTimersByTimeAsync(49) @@ -672,13 +612,13 @@ describe('pasteDraftWhenAgentReady', () => { await vi.advanceTimersByTimeAsync(1) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, 'pty-1', '\r') }) it('holds the PTY transaction across the paste and its submit Enter', async () => { const writes: string[] = [] testState.sendRuntimePtyInputVerified.mockImplementation( - async (_settings: unknown, _ptyId: string, data: string) => { + async (_ptyId: string, data: string) => { writes.push(data) return true } @@ -688,7 +628,6 @@ describe('pasteDraftWhenAgentReady', () => { await flushMicrotasks() // Competing chunked paste on the same PTY: it must not open a frame the Enter can land in. const competing = sendAgentDraftPasteContent( - {}, 'pty-1', 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1), 'driving' @@ -728,16 +667,10 @@ describe('pasteDraftWhenAgentReady', () => { await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith( 1, - { activeRuntimeEnvironmentId: 'owner-runtime' }, 'pty-right', PASTED_ISSUE_URL ) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith( - 2, - { activeRuntimeEnvironmentId: 'owner-runtime' }, - 'pty-right', - '\r' - ) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, 'pty-right', '\r') }) it('streams large running-agent drafts as bounded bracketed chunks before submit', async () => { @@ -752,22 +685,22 @@ describe('pasteDraftWhenAgentReady', () => { await flushMicrotasks(20) const calls = testState.sendRuntimePtyInputVerified.mock.calls - expect(calls.at(0)).toEqual([{}, 'pty-1', '\x1b[200~']) - expect(calls.at(-1)?.[2]).toBe('\x1b[201~') + expect(calls.at(0)).toEqual(['pty-1', '\x1b[200~']) + expect(calls.at(-1)?.[1]).toBe('\x1b[201~') expect( calls .slice(1, -1) - .map((call) => call[2]) + .map((call) => call[1]) .join('') ).toBe(content) for (const call of calls.slice(1, -1)) { - expect((call[2] as string).length).toBeLessThanOrEqual(AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES) + expect(String(call[1]).length).toBeLessThanOrEqual(AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES) } await vi.advanceTimersByTimeAsync(50) await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith('pty-1', '\r') }) it('normalizes multiline running-agent drafts like terminal paste', async () => { @@ -777,7 +710,6 @@ describe('pasteDraftWhenAgentReady', () => { }) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, 'pty-1', '\x1b[200~line one\rline two\rline three\x1b[201~' ) @@ -802,9 +734,9 @@ describe('pasteDraftWhenAgentReady', () => { ).resolves.toBe(false) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(3) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(1, {}, 'pty-1', '[200~') - expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(3, {}, 'pty-1', '[201~') - expect(testState.sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === '\r')).toBe( + expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(1, 'pty-1', '[200~') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(3, 'pty-1', '[201~') + expect(testState.sendRuntimePtyInputVerified.mock.calls.some((call) => call[1] === '\r')).toBe( false ) }) @@ -859,7 +791,7 @@ describe('pasteDraftWhenAgentReady', () => { it('yields during large accepted-size preflight before writing agent draft chunks', async () => { const content = 'x'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 300 * 1024) - const promise = sendAgentDraftPasteContent({}, 'pty-1', content, 'driving') + const promise = sendAgentDraftPasteContent('pty-1', content, 'driving') await flushMicrotasks(5) expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() @@ -867,46 +799,19 @@ describe('pasteDraftWhenAgentReady', () => { await vi.runOnlyPendingTimersAsync() await flushMicrotasks(10) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith({}, 'pty-1', '\x1b[200~') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith('pty-1', '\x1b[200~') await expect(promise).resolves.toBe(true) }) it('rejects oversized agent drafts before any PTY write', async () => { const oversized = 'x'.repeat(AGENT_DRAFT_PASTE_MAX_BYTES + 1) - const result = sendAgentDraftPasteContent({}, 'pty-1', oversized, 'driving') + const result = sendAgentDraftPasteContent('pty-1', oversized, 'driving') await expect(result).resolves.toBe(false) expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() }) }) -describe('getSettingsForAgentTabRuntimeOwner', () => { - beforeEach(() => { - testState.appState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - testState.appState.tabsByWorktree = {} - testState.appState.repos = [] - testState.appState.worktreesByRepo = {} - }) - - it('falls back to focused settings when the tab is not mapped to a worktree', () => { - expect(getSettingsForAgentTabRuntimeOwner('missing-tab')).toEqual({ - activeRuntimeEnvironmentId: 'focused-runtime' - }) - }) - - it('uses the tab worktree owner when mapped', () => { - testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } - testState.appState.repos = [ - { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } - ] - testState.appState.worktreesByRepo = { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } - - expect(getSettingsForAgentTabRuntimeOwner('tab-1')).toEqual({ - activeRuntimeEnvironmentId: 'owner-runtime' - }) - }) -}) - async function flushMicrotasks(iterations = 2): Promise { for (let index = 0; index < iterations; index += 1) { await Promise.resolve() diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 8a6fd9629254..a5c829e8acae 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,4 +1,3 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' @@ -16,7 +15,6 @@ import { } from '@/components/terminal-pane/terminal-bracketed-paste' import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' import { waitForAgentReady } from './agent-ready-wait' -import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' import { sendAgentDraftPasteContentNow } from './agent-draft-paste-content' import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' @@ -45,20 +43,6 @@ export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_M // also stops one slow step from spending the other's budget (STA-3367). const PTY_SPAWN_TIMEOUT_MS = 8000 -export function getSettingsForAgentTabRuntimeOwner( - tabId: string -): Pick | null | undefined { - const store = useAppStore.getState() - for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree ?? {})) { - if (tabs?.some((tab) => tab.id === tabId)) { - // Why: legacy remote PTY ids may not embed their runtime owner. The tab's - // worktree still identifies which host should receive readiness/send RPCs. - return getSettingsForWorktreeRuntimeOwner(store, worktreeId) - } - } - return store.settings -} - /** * Wait until the agent on `tabId` has rendered its input-accepting TUI, * then bracketed-paste `content` into its input buffer. By default the @@ -102,14 +86,12 @@ export async function pasteDraftWhenAgentReady(args: { } const readySignal = resolvePasteReadySignal(agentConfig, submit === true) - const settings = getSettingsForAgentTabRuntimeOwner(tabId) const readinessTimeoutMs = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) const readinessResult = waitForAgentDraftInputReadyOnTab({ tabId, spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, readinessTimeoutMs, - readySignal, - settings + readySignal }) // Why: a closed gate is the caller's own outcome to report; the bounded wait above just lapses. if (args.sendGate && !(await args.sendGate)) { @@ -143,7 +125,6 @@ export async function pasteDraftWhenAgentReady(args: { } return await sendBracketedPasteToAgent({ - settings, ptyId, content, submit: submit === true, @@ -154,7 +135,6 @@ export async function pasteDraftWhenAgentReady(args: { } export async function pasteDraftToAgentPtyWhenReady(args: { - tabId: string ptyId: string content: string agent?: TuiAgent @@ -164,31 +144,21 @@ export async function pasteDraftToAgentPtyWhenReady(args: { onTimeout?: () => void onUnconfirmedDelivery?: () => void }): Promise { - const { - tabId, - ptyId, - content, - agent, - submit, - forcePaste, - timeoutMs, - onTimeout, - onUnconfirmedDelivery - } = args + const { ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = + args const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) { return false } - const settings = getSettingsForAgentTabRuntimeOwner(tabId) const readySignal = resolvePasteReadySignal(agentConfig, submit === true) const budget = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) - const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings) + const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal) if (!ready) { const fallbackReady = agentConfig && agent !== 'codex' - ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) + ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000) : false if (!fallbackReady) { onTimeout?.() @@ -198,7 +168,6 @@ export async function pasteDraftToAgentPtyWhenReady(args: { } return await sendBracketedPasteToAgent({ - settings, ptyId, content, submit: submit === true, @@ -214,7 +183,6 @@ export async function submitPromptToAgentPty(args: { content: string }): Promise { return await sendBracketedPasteToAgent({ - settings: getSettingsForAgentTabRuntimeOwner(args.tabId), ptyId: args.ptyId, content: args.content, submit: true, @@ -230,21 +198,20 @@ export async function sendBracketedPasteToRunningAgent(args: { } async function sendBracketedPasteToAgent(args: { - settings?: Pick | null ptyId: string content: string submit: boolean agent?: TuiAgent inputKind: TerminalInputKind }): Promise { - const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args + const { ptyId, content, submit, agent } = args const { inputKind } = args const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined try { // Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent // paste on this PTY can slip between them and submit a half-written prompt. return await runTerminalPtyInputTransaction(ptyId, async () => { - const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind) + const pasted = await sendAgentDraftPasteContentNow(ptyId, content, inputKind) if (!pasted || !submit) { return pasted } @@ -253,14 +220,14 @@ async function sendBracketedPasteToAgent(args: { // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) + const submitted = await sendRuntimePtyInputVerified(ptyId, '\r', inputKind) if (submitRetryDelayMs !== undefined) { // Why: agents that render their composer before Enter is live silently eat // the first Enter; the retry is best-effort and never downgrades `submitted`. await new Promise((resolve) => window.setTimeout(resolve, submitRetryDelayMs)) try { - await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) + await sendRuntimePtyInputVerified(ptyId, '\r', inputKind) } catch { // Why: a rejected retry leaves the first Enter's verdict untouched. } @@ -278,7 +245,6 @@ function waitForAgentDraftInputReadyOnTab(args: { spawnTimeoutMs: number readinessTimeoutMs: number readySignal: Parameters[2] - settings: Pick | null | undefined }): Promise<{ ptyId: string; ready: boolean } | null> { return new Promise((resolve) => { let selectedPtyId: string | null = null @@ -308,12 +274,9 @@ function waitForAgentDraftInputReadyOnTab(args: { unsubscribeStore?.() // Why: Zustand subscribers run inside updateTabPtyId. Registering the // sidecar here precedes the transport's immediate pre-handler drain. - void waitForAgentDraftInputReady( - ptyId, - args.readinessTimeoutMs, - args.readySignal, - args.settings - ).then((ready) => finish({ ptyId, ready })) + void waitForAgentDraftInputReady(ptyId, args.readinessTimeoutMs, args.readySignal).then( + (ready) => finish({ ptyId, ready }) + ) } const bindFromState = (state: ReturnType): void => { const ptyId = state.ptyIdsByTabId[args.tabId]?.[0] @@ -331,14 +294,13 @@ function waitForAgentDraftInputReadyOnTab(args: { async function waitForExpectedAgentOnPty( ptyId: string, expectedProcess: string, - timeoutMs: number, - settings: Pick | null | undefined + timeoutMs: number ): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { const process = await withDeadline( - inspectRuntimeTerminalProcess(settings, ptyId), + inspectRuntimeTerminalProcess(ptyId), Math.max(0, deadline - Date.now()) ) if (!process) { diff --git a/src/renderer/src/lib/agent-ready-wait.ts b/src/renderer/src/lib/agent-ready-wait.ts index 8bd7cb8677be..627e3520ab62 100644 --- a/src/renderer/src/lib/agent-ready-wait.ts +++ b/src/renderer/src/lib/agent-ready-wait.ts @@ -91,7 +91,7 @@ export async function waitForAgentReady( } try { - const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId) + const process = await inspectRuntimeTerminalProcess(ptyId) const foreground = process.foregroundProcess?.toLowerCase() ?? '' if (isExpectedAgentProcess(foreground, expectedProcess)) { return { ready: true, reason: 'foreground-match' } diff --git a/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts b/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts index 1fd132600283..031747c3ea47 100644 --- a/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts +++ b/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts @@ -183,7 +183,7 @@ describe('spurious shell readings on Codex-launched panes', () => { }) it('never routes a remote runtime pane through the local confirm bridge', async () => { - seedPane({ ptyId: 'remote:term-1', launchAgent: 'codex' }) + seedPane({ ptyId: 'remote:env-1@@term-1', launchAgent: 'codex' }) useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index 67a418acffea..d30fade64507 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -388,14 +388,14 @@ describe('markLiveCodexSessionsForRestart', () => { }) }) - it('inspects remote runtime PTYs through the active runtime environment', async () => { + it('inspects remote runtime PTYs on the server their id names', async () => { useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, tabsByWorktree: { wt1: [ { id: 'tab-1', - ptyId: 'remote:term-1', + ptyId: 'remote:env-1@@term-1', worktreeId: 'wt1', title: 'orca-1', customTitle: null, @@ -406,7 +406,7 @@ describe('markLiveCodexSessionsForRestart', () => { ] }, ptyIdsByTabId: { - 'tab-1': ['remote:term-1'] + 'tab-1': ['remote:env-1@@term-1'] } }) runtimeEnvironmentCall.mockResolvedValue({ @@ -434,7 +434,7 @@ describe('markLiveCodexSessionsForRestart', () => { params: { terminal: 'term-1' }, timeoutMs: 15_000 }) - expect(useAppStore.getState().codexRestartNoticeByPtyId['remote:term-1']).toEqual({ + expect(useAppStore.getState().codexRestartNoticeByPtyId['remote:env-1@@term-1']).toEqual({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index ab6a83ce0cf0..e5d42d51adea 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -92,7 +92,6 @@ async function readRecordedCodexPaneLanes( * null (scan failed or unsupported) both keep today's ineligible outcome. */ async function isConfirmedCodexForegroundDespiteShellReading( - state: AppState, ptyId: string, launchAgent: TuiAgent | undefined, inspection: RuntimeTerminalProcessInspection @@ -105,7 +104,7 @@ async function isConfirmedCodexForegroundDespiteShellReading( ) { return false } - const confirmed = await confirmRuntimeTerminalForegroundProcess(state.settings, ptyId) + const confirmed = await confirmRuntimeTerminalForegroundProcess(ptyId) return isCodexForegroundProcess(confirmed) } @@ -159,7 +158,7 @@ async function scanCodexPanes( laneSource: lane.source } } - const inspection = await inspectRuntimeTerminalProcess(state.settings, ptyId).then( + const inspection = await inspectRuntimeTerminalProcess(ptyId).then( (result) => result, // Why: one stale remote pane must not hide restart notices for other confirmed Codex panes. () => null @@ -167,12 +166,7 @@ async function scanCodexPanes( const eligible = inspection !== null && (isCodexRestartEligiblePane({ inspection, launchAgent: tab.launchAgent }) || - (await isConfirmedCodexForegroundDespiteShellReading( - state, - ptyId, - tab.launchAgent, - inspection - ))) + (await isConfirmedCodexForegroundDespiteShellReading(ptyId, tab.launchAgent, inspection))) return { ptyId, eligible, diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 3bca34e60f27..74c572da522c 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -244,7 +244,6 @@ export async function launchAgentBackgroundSession( throw new Error('Runtime terminal id is invalid.') } unsubscribeData = await subscribeToRuntimeTerminalData( - store.settings, ptyId, `desktop:background:${tab.id}`, handleData diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts index 23206c874359..d2ab492be0c8 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts @@ -48,7 +48,7 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, inspectRuntimeTerminalProcess: vi.fn(async () => null), - sendRuntimePtyInputVerified: async (_settings: unknown, _ptyId: string, data: string) => { + sendRuntimePtyInputVerified: async (_ptyId: string, data: string) => { testState.writes.push({ at: Date.now(), data }) return true } diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index d3ca4fa01e63..4baee9bf9d4f 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -78,7 +78,6 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ })) vi.mock('@/lib/agent-paste-draft', () => ({ - getSettingsForAgentTabRuntimeOwner: () => store.settings, pasteDraftToAgentPtyWhenReady: mockPasteDraftToAgentPtyWhenReady })) @@ -294,7 +293,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { }) expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, 'pty-1', 'fix the spinner\r', 'launch' @@ -411,7 +409,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { }) expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ - tabId: 'tab-1', ptyId: 'pty-1', content: 'review this before sending', agent: 'claude', @@ -455,7 +452,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { }) expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ - tabId: 'agent-tab', ptyId: 'agent-pty', content: 'Linear context draft', agent: 'codex', @@ -512,7 +508,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ - tabId: 'tab-1', ptyId: 'pty-delayed', content: 'https://github.com/stablyai/orca/pull/2051', agent: 'codex', @@ -594,7 +589,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ - tabId: 'tab-1', ptyId: 'startup-pty', content: 'linked draft', agent: 'codex', diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 6d5cf5ed39e9..927e23b2ae1e 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,8 +1,5 @@ import { useAppStore } from '@/store' -import { - getSettingsForAgentTabRuntimeOwner, - pasteDraftToAgentPtyWhenReady -} from '@/lib/agent-paste-draft' +import { pasteDraftToAgentPtyWhenReady } from '@/lib/agent-paste-draft' import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' @@ -245,12 +242,11 @@ export async function ensureAgentStartupInTerminal(args: { } async function deliverAgentStartupToTerminal( - tabId: string, + _tabId: string, ptyId: string, startup: AgentStartupPlan ): Promise { const draftPrompt = startup.draftPrompt ?? null - const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId) // Why: followupPrompt is the legacy path for stdin-after-start agents // (aider, goose, etc.) that need their initial prompt typed into the live // session and submitted. Wait until the agent owns the PTY before writing. @@ -258,8 +254,7 @@ async function deliverAgentStartupToTerminal( const delivered = await sendFollowupPromptWhenAgentReady({ ptyId, expectedProcess: startup.expectedProcess, - prompt: startup.followupPrompt, - settings: runtimeSettings + prompt: startup.followupPrompt }) // Why: a dropped follow-up is otherwise silent — surface the same toast the // draft path uses so the user knows to open the workspace and paste it. @@ -273,7 +268,6 @@ async function deliverAgentStartupToTerminal( // launch-work-item-direct flow so both behave identically. if (draftPrompt) { await pasteDraftToAgentPtyWhenReady({ - tabId, ptyId, content: draftPrompt, agent: startup.agent, diff --git a/src/renderer/src/lib/startup-draft-input-kind.test.ts b/src/renderer/src/lib/startup-draft-input-kind.test.ts index a69ea68a103e..935a92909b78 100644 --- a/src/renderer/src/lib/startup-draft-input-kind.test.ts +++ b/src/renderer/src/lib/startup-draft-input-kind.test.ts @@ -86,7 +86,6 @@ async function paneRouteKinds(): Promise { async function helperRouteKinds(): Promise { const { kinds } = stubPtyApi() const pasted = pasteDraftToAgentPtyWhenReady({ - tabId: 'tab-1', ptyId: 'pty-1', content: DRAFT, agent: 'aider' diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index d1d4dcc9fed8..c32f42317319 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -109,14 +109,7 @@ describe('runtime terminal owner routing', () => { }) it('sends input through the PTY owning environment instead of the active one', async () => { - expect( - sendRuntimePtyInput( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - 'x', - 'driving' - ) - ).toBe(true) + expect(sendRuntimePtyInput('remote:env-1@@terminal-1', 'x', 'driving')).toBe(true) await vi.waitFor(() => { expect(runtimeCall).toHaveBeenCalledWith({ @@ -134,12 +127,10 @@ describe('runtime terminal owner routing', () => { }) it('inspects the PTY owning environment instead of the active one', async () => { - await expect( - inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1' - ) - ).resolves.toMatchObject({ foregroundProcess: 'bash', hasChildProcesses: true }) + await expect(inspectRuntimeTerminalProcess('remote:env-1@@terminal-1')).resolves.toMatchObject({ + foregroundProcess: 'bash', + hasChildProcesses: true + }) expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -156,11 +147,7 @@ describe('runtime terminal owner routing', () => { // scan and answered `unverifiable`, which the guard reads as running work -- a confirmation // dialog on every idle close of a remote Windows pane. Found by review on #18591. it('forwards scanChildProcesses to the PTY owning environment', async () => { - await inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - { scanChildProcesses: true } - ) + await inspectRuntimeTerminalProcess('remote:env-1@@terminal-1', { scanChildProcesses: true }) expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -171,11 +158,10 @@ describe('runtime terminal owner routing', () => { }) it('forwards scanChildProcesses alongside the incarnation fence', async () => { - await inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - { expectedIncarnationId: 'incarnation-1', scanChildProcesses: true } - ) + await inspectRuntimeTerminalProcess('remote:env-1@@terminal-1', { + expectedIncarnationId: 'incarnation-1', + scanChildProcesses: true + }) expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -190,10 +176,7 @@ describe('runtime terminal owner routing', () => { }) it('omits scanChildProcesses when the caller is only polling', async () => { - await inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1' - ) + await inspectRuntimeTerminalProcess('remote:env-1@@terminal-1') expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -212,12 +195,7 @@ describe('runtime terminal owner routing', () => { _meta: { runtimeId: 'runtime-1' } }) - await expect( - inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1' - ) - ).resolves.toEqual({ + await expect(inspectRuntimeTerminalProcess('remote:env-1@@terminal-1')).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false, verdict: 'unverifiable', @@ -232,7 +210,7 @@ describe('runtime terminal owner routing', () => { foregroundProcessEvidence: liveEvidence('pty-1', 'codex') }) - await expect(inspectRuntimeTerminalProcess(null, 'ssh:host@@pty-1')).resolves.toMatchObject({ + await expect(inspectRuntimeTerminalProcess('ssh:host@@pty-1')).resolves.toMatchObject({ foregroundProcess: 'codex', hasChildProcesses: true }) @@ -247,7 +225,7 @@ describe('runtime terminal owner routing', () => { hasChildProcesses: true }) - await expect(inspectRuntimeTerminalProcess(null, 'ssh:host@@pty-1')).resolves.toEqual({ + await expect(inspectRuntimeTerminalProcess('ssh:host@@pty-1')).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false, verdict: 'unverifiable', @@ -263,12 +241,7 @@ describe('runtime terminal owner routing', () => { error: { code, message: code } }) - await expect( - inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-stale' - ) - ).resolves.toEqual({ + await expect(inspectRuntimeTerminalProcess('remote:env-1@@terminal-stale')).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false, verdict: 'unverifiable', @@ -280,10 +253,7 @@ describe('runtime terminal owner routing', () => { it('maps lost contact and timeout to client-only unverifiable results', async () => { runtimeCall.mockRejectedValueOnce(new Error('SSH connection lost, reconnecting...')) await expect( - inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-transport' - ) + inspectRuntimeTerminalProcess('remote:env-1@@terminal-transport') ).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false, @@ -293,10 +263,7 @@ describe('runtime terminal owner routing', () => { runtimeCall.mockRejectedValueOnce(new Error('Request timed out before completion')) await expect( - inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-timeout' - ) + inspectRuntimeTerminalProcess('remote:env-1@@terminal-timeout') ).resolves.toMatchObject({ verdict: 'unverifiable', reason: 'timeout' }) }) @@ -319,12 +286,9 @@ describe('runtime terminal owner routing', () => { it('still throws an unclassified programming error', async () => { runtimeCall.mockRejectedValueOnce(new Error('inspection invariant violated')) - await expect( - inspectRuntimeTerminalProcess( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-bug' - ) - ).rejects.toThrow('inspection invariant violated') + await expect(inspectRuntimeTerminalProcess('remote:env-1@@terminal-bug')).rejects.toThrow( + 'inspection invariant violated' + ) }) it('records accepted fire-and-forget runtime input against the owning pane key', async () => { @@ -345,14 +309,7 @@ describe('runtime terminal owner routing', () => { } }) - expect( - sendRuntimePtyInput( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - 'x', - 'driving' - ) - ).toBe(true) + expect(sendRuntimePtyInput('remote:env-1@@terminal-1', 'x', 'driving')).toBe(true) await vi.waitFor(() => { expect(useAppStore.getState().lastTerminalInputAtByPaneKey[PANE_KEY]).toEqual( @@ -381,14 +338,7 @@ describe('runtime terminal owner routing', () => { recordRuntimeTerminalInputForPtyId('remote:env-1@@terminal-1', 123) useAppStore.setState({ lastTerminalInputAtByPaneKey: {} }) - expect( - sendRuntimePtyInput( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - 'x', - 'driving' - ) - ).toBe(true) + expect(sendRuntimePtyInput('remote:env-1@@terminal-1', 'x', 'driving')).toBe(true) const nextLeafId = '22222222-2222-4222-8222-222222222222' useAppStore.setState({ terminalLayoutsByTabId: { @@ -431,14 +381,7 @@ describe('runtime terminal owner routing', () => { } }) - expect( - sendRuntimePtyInput( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - 'x', - 'driving' - ) - ).toBe(true) + expect(sendRuntimePtyInput('remote:env-1@@terminal-1', 'x', 'driving')).toBe(true) await vi.waitFor(() => { expect(runtimeCall).toHaveBeenCalled() @@ -453,12 +396,7 @@ describe('runtime terminal owner routing', () => { }) await expect( - sendRuntimePtyInputVerified( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-stale', - 'x', - 'driving' - ) + sendRuntimePtyInputVerified('remote:env-1@@terminal-stale', 'x', 'driving') ).resolves.toBe(false) }) @@ -470,12 +408,7 @@ describe('runtime terminal owner routing', () => { }) await expect( - sendRuntimePtyInputVerified( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - 'x', - 'driving' - ) + sendRuntimePtyInputVerified('remote:env-1@@terminal-1', 'x', 'driving') ).resolves.toBe(false) expect(runtimeCall).toHaveBeenCalledWith({ @@ -493,9 +426,7 @@ describe('runtime terminal owner routing', () => { it('uses accepted local writes for verified input', async () => { localWriteAccepted.mockResolvedValue(true) - await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving') - ).resolves.toBe(true) + await expect(sendRuntimePtyInputVerified('local-pty', 'x', 'driving')).resolves.toBe(true) expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving') expect(localWrite).not.toHaveBeenCalled() @@ -504,9 +435,7 @@ describe('runtime terminal owner routing', () => { it('rejects oversized fire-and-forget local input before IPC writes', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) - expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') - ).toBe(false) + expect(sendRuntimePtyInput('local-pty', text, 'driving')).toBe(false) expect(localWrite).not.toHaveBeenCalled() expect(localWriteAccepted).not.toHaveBeenCalled() @@ -515,14 +444,7 @@ describe('runtime terminal owner routing', () => { it('rejects oversized fire-and-forget remote input before runtime RPC', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) - expect( - sendRuntimePtyInput( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - text, - 'driving' - ) - ).toBe(false) + expect(sendRuntimePtyInput('remote:env-1@@terminal-1', text, 'driving')).toBe(false) expect(runtimeTransportCall).not.toHaveBeenCalled() expect(localWrite).not.toHaveBeenCalled() @@ -533,9 +455,7 @@ describe('runtime terminal owner routing', () => { try { const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') - ).toBe(true) + expect(sendRuntimePtyInput('local-pty', text, 'driving')).toBe(true) expect(localWrite).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(0) @@ -552,9 +472,7 @@ describe('runtime terminal owner routing', () => { try { const text = makeByteOversizedTerminalInput() - expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') - ).toBe(true) + expect(sendRuntimePtyInput('local-pty', text, 'driving')).toBe(true) await vi.runAllTimersAsync() @@ -568,14 +486,7 @@ describe('runtime terminal owner routing', () => { it('rejects oversized verified input before fallback fire-and-forget writes', async () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) - await expect( - sendRuntimePtyInputVerified( - { activeRuntimeEnvironmentId: null }, - 'local-pty', - text, - 'driving' - ) - ).resolves.toBe(false) + await expect(sendRuntimePtyInputVerified('local-pty', text, 'driving')).resolves.toBe(false) expect(localWriteAccepted).not.toHaveBeenCalled() expect(localWrite).not.toHaveBeenCalled() @@ -586,12 +497,7 @@ describe('runtime terminal owner routing', () => { localWriteAccepted.mockResolvedValue(true) try { const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - const accepted = sendRuntimePtyInputVerified( - { activeRuntimeEnvironmentId: null }, - 'local-pty', - text, - 'driving' - ) + const accepted = sendRuntimePtyInputVerified('local-pty', text, 'driving') expect(localWriteAccepted).not.toHaveBeenCalled() @@ -609,12 +515,7 @@ describe('runtime terminal owner routing', () => { vi.useFakeTimers() try { const text = makeByteOversizedTerminalInput() - const accepted = sendRuntimePtyInputVerified( - { activeRuntimeEnvironmentId: null }, - 'local-pty', - text, - 'driving' - ) + const accepted = sendRuntimePtyInputVerified('local-pty', text, 'driving') await vi.runAllTimersAsync() @@ -645,12 +546,7 @@ describe('runtime terminal owner routing', () => { }) await expect( - sendRuntimePtyInputVerified( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - 'x', - 'driving' - ) + sendRuntimePtyInputVerified('remote:env-1@@terminal-1', 'x', 'driving') ).resolves.toBe(true) expect(useAppStore.getState().lastTerminalInputAtByPaneKey[PANE_KEY]).toEqual( @@ -677,12 +573,7 @@ describe('runtime terminal owner routing', () => { }) await expect( - sendRuntimePtyInputVerified( - { activeRuntimeEnvironmentId: 'env-2' }, - 'remote:env-1@@terminal-1', - 'x', - 'driving' - ) + sendRuntimePtyInputVerified('remote:env-1@@terminal-1', 'x', 'driving') ).resolves.toBe(false) expect(useAppStore.getState().lastTerminalInputAtByPaneKey[PANE_KEY]).toBeUndefined() @@ -848,9 +739,7 @@ describe('runtime terminal owner routing', () => { it('reports success after fallback fire-and-forget writes when local acceptance cannot be verified', async () => { localWriteAccepted.mockResolvedValue(false) - await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving') - ).resolves.toBe(true) + await expect(sendRuntimePtyInputVerified('local-pty', 'x', 'driving')).resolves.toBe(true) expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving') expect(localWrite).toHaveBeenCalledWith('local-pty', 'x', 'driving') diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index 9592988aee15..c6435187fc25 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -1,14 +1,14 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { RuntimeTerminalSend } from '../../../shared/runtime-types' import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input' import { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording' export { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import { getRemoteRuntimePtyEnvironmentId, - getRemoteRuntimeTerminalHandle + getRemoteRuntimePtyOwner } from './runtime-terminal-stream' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { classifyTerminalProcessInspectionFailure, clientOnlyUnverifiableInspection, @@ -67,17 +67,12 @@ function normalizeInspectionResult( } export async function inspectRuntimeTerminalProcess( - settings: Pick | null | undefined, ptyId: string, options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean; steadyState?: boolean } ): Promise { - const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) - const target = ownerEnvironmentId - ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) - : getActiveRuntimeTarget(settings) - const terminal = getRemoteRuntimeTerminalHandle(ptyId) + const owner = getRemoteRuntimePtyOwner(ptyId) const remote = isRemoteInspectionPtyId(ptyId) - if (target.kind !== 'environment' || !terminal) { + if (!owner) { try { const result = await (options ? window.api.pty.inspectProcess(ptyId, options) @@ -94,10 +89,10 @@ export async function inspectRuntimeTerminalProcess( try { const result = await callRuntimeRpc<{ process: RuntimeTerminalProcessInspection }>( - target, + owner.target, 'terminal.inspectProcess', { - terminal, + terminal: owner.terminal, ...(options?.expectedIncarnationId ? { expectedIncarnationId: options.expectedIncarnationId } : {}), @@ -126,14 +121,10 @@ export async function inspectRuntimeTerminalProcess( * must read as "no new evidence", never as a shell confirmation. */ export async function confirmRuntimeTerminalForegroundProcess( - settings: Pick | null | undefined, ptyId: string ): Promise { - const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) - const target = ownerEnvironmentId - ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) - : getActiveRuntimeTarget(settings) - if (target.kind === 'environment' && getRemoteRuntimeTerminalHandle(ptyId)) { + // Why any remote id: an id without an owner segment still runs off this host, never locally. + if (isRemoteRuntimePtyId(ptyId)) { return null } const confirmForegroundProcess = window.api.pty.confirmForegroundProcess @@ -145,7 +136,6 @@ export async function confirmRuntimeTerminalForegroundProcess( } export function sendRuntimePtyInput( - settings: Pick | null | undefined, ptyId: string, data: string, inputKind: TerminalInputKind @@ -160,38 +150,33 @@ export function sendRuntimePtyInput( void tooLarge .then((resolvedTooLarge) => { if (!resolvedTooLarge) { - sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind) + sendRuntimePtyInputWithinLimit(ptyId, data, inputKind) } }) .catch(() => {}) return true } - return sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind) + return sendRuntimePtyInputWithinLimit(ptyId, data, inputKind) } // Why the kind reaches only the local write: terminal.send has no launch kind, and its query-reply // kind is for mobile clients, so the host classifies a desktop's environment write by its bytes. function sendRuntimePtyInputWithinLimit( - settings: Pick | null | undefined, ptyId: string, data: string, inputKind: TerminalInputKind ): boolean { - const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) - const target = ownerEnvironmentId - ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) - : getActiveRuntimeTarget(settings) - const terminal = getRemoteRuntimeTerminalHandle(ptyId) - if (target.kind !== 'environment' || !terminal) { + const owner = getRemoteRuntimePtyOwner(ptyId) + if (!owner) { window.api.pty.write(ptyId, data, inputKind) recordRuntimeTerminalInputForPtyId(ptyId) return true } void callRuntimeRpc<{ send: RuntimeTerminalSend }>( - target, + owner.target, 'terminal.send', - { terminal, text: data, client: DESKTOP_RUNTIME_CLIENT }, + { terminal: owner.terminal, text: data, client: DESKTOP_RUNTIME_CLIENT }, { timeoutMs: 15_000 } ) .then((result) => { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 0224964456d7..ed3c21d96e7e 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -82,7 +82,6 @@ describe('remote runtime terminal data subscriptions', () => { const watcher = vi.fn() const dispose = await subscribeToRuntimeTerminalData( - { activeRuntimeEnvironmentId: 'env-fallback' }, 'remote:env-1@@terminal-1', 'watcher-1', watcher @@ -151,7 +150,6 @@ describe('remote runtime terminal data subscriptions', () => { it('echoes the opaque stream generation in cumulative source-range ACKs', async () => { const dispose = await subscribeToRuntimeTerminalData( - { activeRuntimeEnvironmentId: 'env-fallback' }, 'remote:env-1@@terminal-1', 'watcher-1', vi.fn() @@ -195,7 +193,6 @@ describe('remote runtime terminal data subscriptions', () => { it('can start at the live tail without replaying the initial snapshot', async () => { const watcher = vi.fn() const subscription = subscribeToRuntimeTerminalData( - { activeRuntimeEnvironmentId: 'env-fallback' }, 'remote:env-1@@terminal-1', 'watcher-1', watcher, @@ -248,13 +245,11 @@ describe('remote runtime terminal data subscriptions', () => { it('keeps the shared terminal multiplexer until the last watcher closes', async () => { const firstDispose = await subscribeToRuntimeTerminalData( - { activeRuntimeEnvironmentId: 'env-fallback' }, 'remote:env-1@@terminal-1', 'watcher-1', vi.fn() ) const secondDispose = await subscribeToRuntimeTerminalData( - { activeRuntimeEnvironmentId: 'env-fallback' }, 'remote:env-1@@terminal-2', 'watcher-2', vi.fn() @@ -276,12 +271,7 @@ describe('remote runtime terminal data subscriptions', () => { runtimeSubscribe.mockRejectedValueOnce(new Error('offline')) await expect( - subscribeToRuntimeTerminalData( - { activeRuntimeEnvironmentId: 'env-fallback' }, - 'remote:env-1@@terminal-1', - 'watcher-1', - vi.fn() - ) + subscribeToRuntimeTerminalData('remote:env-1@@terminal-1', 'watcher-1', vi.fn()) ).rejects.toThrow('offline') expect(sendBinary).not.toHaveBeenCalled() @@ -302,7 +292,6 @@ describe('remote runtime terminal data subscriptions', () => { }) const subscriptionPromise = subscribeToRuntimeTerminalData( - { activeRuntimeEnvironmentId: 'env-fallback' }, 'remote:env-1@@terminal-1', 'watcher-1', vi.fn() diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts index 0c9604b71d1c..844cc160b5fe 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -1,5 +1,4 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' -import { RuntimeRpcCallError, getActiveRuntimeTarget } from './runtime-rpc-client' +import { RuntimeRpcCallError } from './runtime-rpc-client' import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer' import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' @@ -27,6 +26,22 @@ export function getRemoteRuntimePtyEnvironmentId(ptyId: string): string | null { return parseRemoteRuntimePtyId(ptyId)?.environmentId ?? null } +/** + * The server a remote PTY belongs to, named by the id itself. An id without an owner segment names + * no server, so it is never dialed on whichever server happens to be focused. + */ +export function getRemoteRuntimePtyOwner( + ptyId: string +): { target: { kind: 'environment'; environmentId: string }; terminal: string } | null { + const parsed = parseRemoteRuntimePtyId(ptyId) + return parsed?.environmentId && parsed.handle + ? { + target: { kind: 'environment', environmentId: parsed.environmentId }, + terminal: parsed.handle + } + : null +} + export function runtimeTerminalErrorMessage(error: unknown): string { if (error instanceof RuntimeRpcCallError) { return error.message @@ -35,20 +50,16 @@ export function runtimeTerminalErrorMessage(error: unknown): string { } export async function subscribeToRuntimeTerminalData( - settings: Pick | null | undefined, ptyId: string, clientId: string, watcher: (data: string) => void, options?: RuntimeTerminalDataSubscriptionOptions ): Promise<() => void> { - const terminal = getRemoteRuntimeTerminalHandle(ptyId) - const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) - const target = ownerEnvironmentId - ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) - : getActiveRuntimeTarget(settings) - if (target.kind !== 'environment' || !terminal) { + const owner = getRemoteRuntimePtyOwner(ptyId) + if (!owner) { return () => {} } + const { target, terminal } = owner let resolveLiveTail: (() => void) | null = null let rejectLiveTail: ((error: Error) => void) | null = null diff --git a/src/renderer/src/runtime/runtime-terminal-verified-input.test.ts b/src/renderer/src/runtime/runtime-terminal-verified-input.test.ts index b5f4232bf0f5..43cb5bca5a3d 100644 --- a/src/renderer/src/runtime/runtime-terminal-verified-input.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-verified-input.test.ts @@ -45,7 +45,7 @@ describe('verified input that requires provider settlement', () => { ] as const)('reads the %s remote verdict', async (_label, send, accepted) => { hostReply(send) await expect( - sendRuntimePtyInputVerified(null, REMOTE_PTY, 'x', 'driving', { + sendRuntimePtyInputVerified(REMOTE_PTY, 'x', 'driving', { requireWriteSettlement: true }) ).resolves.toBe(accepted) @@ -67,7 +67,7 @@ describe('verified input that requires provider settlement', () => { } }) await expect( - sendRuntimePtyInputVerified(null, REMOTE_PTY, 'x', 'driving', { + sendRuntimePtyInputVerified(REMOTE_PTY, 'x', 'driving', { requireWriteSettlement: true }) ).rejects.toThrow('acknowledgment unavailable') @@ -79,9 +79,9 @@ describe('verified input that requires provider settlement', () => { describe('local verified input', () => { it('keeps the fire-and-forget fallback for ordinary input the provider cannot accept', async () => { localWriteAccepted.mockResolvedValue(false) - await expect( - sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', 'x', 'driving') - ).resolves.toBe(true) + await expect(sendRuntimePtyInputVerified('ssh:conn-1@@pty-1', 'x', 'driving')).resolves.toBe( + true + ) expect(localWriteAccepted).toHaveBeenCalledWith('ssh:conn-1@@pty-1', 'x', 'driving') expect(localWrite).toHaveBeenCalledWith('ssh:conn-1@@pty-1', 'x', 'driving') }) @@ -90,17 +90,36 @@ describe('local verified input', () => { const settled = { requireWriteSettlement: true } as const localWriteAccepted.mockResolvedValueOnce(true) await expect( - sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', 'answer', 'driving', settled) + sendRuntimePtyInputVerified('ssh:conn-1@@pty-1', 'answer', 'driving', settled) ).resolves.toBe(true) localWriteAccepted.mockResolvedValueOnce(false) await expect( - sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', '1', 'driving', settled) + sendRuntimePtyInputVerified('ssh:conn-1@@pty-1', '1', 'driving', settled) ).resolves.toBe(false) localWriteAccepted.mockRejectedValueOnce(new Error('acknowledgment unavailable')) await expect( - sendRuntimePtyInputVerified(null, 'ssh:conn-1@@pty-1', '\r', 'driving', settled) + sendRuntimePtyInputVerified('ssh:conn-1@@pty-1', '\r', 'driving', settled) ).rejects.toThrow('acknowledgment unavailable') expect(localWriteAccepted).toHaveBeenCalledWith('ssh:conn-1@@pty-1', '\r', 'driving', settled) expect(localWrite).not.toHaveBeenCalled() }) }) + +describe('owner of the PTY', () => { + it('never dials a server for a remote id that names no owner', async () => { + localWriteAccepted.mockResolvedValue(false) + // Why: this id used to reach whichever server was focused, even when another one owned it. + await sendRuntimePtyInputVerified('remote:terminal-1', 'x', 'driving', { + requireWriteSettlement: true + }) + expect(runtimeSend).not.toHaveBeenCalled() + }) + + it('sends to the server the id names', async () => { + hostReply({ accepted: true }) + await sendRuntimePtyInputVerified(REMOTE_PTY, 'x', 'driving') + expect(runtimeSend).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'terminal.send' }) + ) + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-verified-input.ts b/src/renderer/src/runtime/runtime-terminal-verified-input.ts index 62b72b137c8d..df433c261556 100644 --- a/src/renderer/src/runtime/runtime-terminal-verified-input.ts +++ b/src/renderer/src/runtime/runtime-terminal-verified-input.ts @@ -1,14 +1,10 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { RuntimeTerminalSend } from '../../../shared/runtime-types' import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input' import { readTerminalSendAcknowledgment } from '../../../shared/terminal-send-acknowledgment' import { classifyTerminalProcessInspectionFailure } from '../../../shared/terminal-process-inspection' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' -import { - getRemoteRuntimePtyEnvironmentId, - getRemoteRuntimeTerminalHandle -} from './runtime-terminal-stream' +import { callRuntimeRpc } from './runtime-rpc-client' +import { getRemoteRuntimePtyOwner } from './runtime-terminal-stream' import { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording' const DESKTOP_RUNTIME_CLIENT = { id: 'orca-desktop', type: 'desktop' } as const @@ -18,7 +14,6 @@ const DESKTOP_RUNTIME_CLIENT = { id: 'orca-desktop', type: 'desktop' } as const * current host for its acknowledgment; a lost one rejects. */ export async function sendRuntimePtyInputVerified( - settings: Pick | null | undefined, ptyId: string, data: string, inputKind: TerminalInputKind, @@ -28,12 +23,8 @@ export async function sendRuntimePtyInputVerified( if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) { return false } - const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) - const target = ownerEnvironmentId - ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) - : getActiveRuntimeTarget(settings) - const terminal = getRemoteRuntimeTerminalHandle(ptyId) - if (target.kind !== 'environment' || !terminal) { + const owner = getRemoteRuntimePtyOwner(ptyId) + if (!owner) { if (options?.requireWriteSettlement) { // Why: an answer that dismisses its card must not fall back to an unacknowledged write. const accepted = await window.api.pty.writeAccepted(ptyId, data, inputKind, options) @@ -54,6 +45,7 @@ export async function sendRuntimePtyInputVerified( return accepted } + const { target, terminal } = owner try { const result = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( target, diff --git a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts index 72b28dd9130d..fedd7255b6da 100644 --- a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts @@ -131,7 +131,6 @@ describe('paired session-tab agent completion notifications', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -169,7 +168,6 @@ describe('paired session-tab agent completion notifications', () => { const coordinator = createAgentCompletionCoordinator({ paneKey, getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -263,7 +261,6 @@ describe('paired session-tab agent completion notifications', () => { const coordinator = createAgentCompletionCoordinator({ paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true @@ -285,7 +282,6 @@ describe('paired session-tab agent completion notifications', () => { paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), statusLane: 'hook', getPtyId: () => 'pty-1', - getSettings: () => null, inspectProcess: vi.fn(), dispatchCompletion, isLive: () => true diff --git a/tests/e2e/__mocks__/native-chat-ssh-delivery.ts b/tests/e2e/__mocks__/native-chat-ssh-delivery.ts index d0ad5595954a..e4765d319376 100644 --- a/tests/e2e/__mocks__/native-chat-ssh-delivery.ts +++ b/tests/e2e/__mocks__/native-chat-ssh-delivery.ts @@ -36,12 +36,15 @@ vi.mock('../../../src/main/ipc/pty/provider/registry', async (importOriginal) => }) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) } })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: (...args: unknown[]) => io.rpc(...args) })) vi.mock('@/runtime/runtime-terminal-stream', () => ({ getRemoteRuntimePtyEnvironmentId: (id: string) => (id.startsWith('remote:') ? 'owner' : null), - getRemoteRuntimeTerminalHandle: (id: string) => (id.startsWith('remote:') ? 'terminal' : null) + getRemoteRuntimeTerminalHandle: (id: string) => (id.startsWith('remote:') ? 'terminal' : null), + getRemoteRuntimePtyOwner: (id: string) => + id.startsWith('remote:') + ? { target: { kind: 'environment', environmentId: 'owner' }, terminal: 'terminal' } + : null })) export function createSshDelivery(mode: 'accepted' | 'lost' | 'slow' = 'accepted') { diff --git a/tests/e2e/native-chat-ssh-delivery.unit.test.ts b/tests/e2e/native-chat-ssh-delivery.unit.test.ts index 76566838e829..806007eaf671 100644 --- a/tests/e2e/native-chat-ssh-delivery.unit.test.ts +++ b/tests/e2e/native-chat-ssh-delivery.unit.test.ts @@ -40,7 +40,7 @@ describe('prompt delivery through production IPC, provider and paired host', () [{ indices: [], other: text }] ) const onSettled = vi.fn() - sendNativeChatAskAnswer(null, ssh.id, groups, onSettled) + sendNativeChatAskAnswer(ssh.id, groups, onSettled) await vi.advanceTimersByTimeAsync(2000) expect(ssh.bytes).not.toContain('\r') expect(onSettled).not.toHaveBeenCalled() @@ -60,7 +60,6 @@ describe('prompt delivery through production IPC, provider and paired host', () close = ssh.close const onSettled = vi.fn() const handle = sendNativeChatAskAnswer( - null, ssh.id, [{ text: `${'X'.repeat(32768)}TAIL` }, { raw: '\r' }], onSettled @@ -78,7 +77,7 @@ describe('prompt delivery through production IPC, provider and paired host', () const ssh = createSshDelivery() close = ssh.close const onDeliverySettled = vi.fn() - sendNativeChatMessage(null, ssh.id, 'body', { onDeliverySettled }) + sendNativeChatMessage(ssh.id, 'body', { onDeliverySettled }) await vi.advanceTimersByTimeAsync(600) expect(ssh.bytes.join('')).toBe('\x15body\r') expect(onDeliverySettled).toHaveBeenCalledExactlyOnceWith(true) @@ -103,15 +102,9 @@ describe('prompt delivery through production IPC, provider and paired host', () } return reply.result }) - const pending = sendRuntimePtyInputVerified( - null, - 'remote:owner@@terminal', - '\x1b', - 'driving', - { - requireWriteSettlement: true - } - ) + const pending = sendRuntimePtyInputVerified('remote:owner@@terminal', '\x1b', 'driving', { + requireWriteSettlement: true + }) await (mode === 'accepted' ? expect(pending).resolves.toBe(true) : expect(pending).rejects.toThrow('acknowledgment unavailable')) @@ -145,15 +138,15 @@ describe('prompt delivery through production IPC, provider and paired host', () return reply.result }) await expect( - sendRuntimePtyInputVerified(null, 'remote:owner@@terminal', 'answer', 'driving', { + sendRuntimePtyInputVerified('remote:owner@@terminal', 'answer', 'driving', { requireWriteSettlement: true }) ).resolves.toBe(true) + await expect(sendNativeChatMessageVerified('remote:owner@@terminal', 'body')).resolves.toBe( + true + ) await expect( - sendNativeChatMessageVerified(null, 'remote:owner@@terminal', 'body') - ).resolves.toBe(true) - await expect( - sendAgentDraftPasteContentNow(null, 'remote:owner@@terminal', 'launch', 'launch') + sendAgentDraftPasteContentNow('remote:owner@@terminal', 'launch', 'launch') ).resolves.toBe(true) expect(ssh.bytes).toEqual(['answer', 'body', '\r', '\x1b[200~launch\x1b[201~']) expect(ssh.settlement).not.toHaveBeenCalled() @@ -164,12 +157,12 @@ describe('prompt delivery through production IPC, provider and paired host', () io.rpc.mockResolvedValue({ send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }) const onWriteUnconfirmed = vi.fn() const onWriteRejected = vi.fn() - sendNativeChatMessage(null, 'remote:owner@@terminal', 'hello', { + sendNativeChatMessage('remote:owner@@terminal', 'hello', { onWriteRejected, onWriteUnconfirmed }) const onDeliverySettled = vi.fn() - sendNativeChatMessage(null, 'remote:owner@@terminal', 'answer', { onDeliverySettled }) + sendNativeChatMessage('remote:owner@@terminal', 'answer', { onDeliverySettled }) await vi.advanceTimersByTimeAsync(2000) expect(onWriteUnconfirmed).not.toHaveBeenCalled() expect(onWriteRejected).not.toHaveBeenCalled() @@ -179,7 +172,7 @@ describe('prompt delivery through production IPC, provider and paired host', () it('sends ordinary paired input without asking the host for provider settlement', async () => { io.rpc.mockResolvedValue({ send: { handle: 'terminal', accepted: true, bytesWritten: 4 } }) await expect( - sendRuntimePtyInputVerified(null, 'remote:owner@@terminal', 'body', 'driving') + sendRuntimePtyInputVerified('remote:owner@@terminal', 'body', 'driving') ).resolves.toBe(true) expect(io.rpc).toHaveBeenCalledWith( expect.anything(), @@ -253,7 +246,7 @@ describe('prompt delivery through production IPC, provider and paired host', () [{ indices: [], other: 'custom answer' }] ) const onSettled = vi.fn() - sendNativeChatAskAnswer(null, 'remote:owner@@terminal', groups, onSettled) + sendNativeChatAskAnswer('remote:owner@@terminal', groups, onSettled) await vi.advanceTimersByTimeAsync(4000) expect(ssh.bytes).toEqual(groups.map((group) => ('raw' in group ? group.raw : group.text))) expect(ssh.settlement).not.toHaveBeenCalled() diff --git a/tests/e2e/remote-agent-completion-authority.unit.test.ts b/tests/e2e/remote-agent-completion-authority.unit.test.ts index 3b4fa8c436b8..8949886ebd59 100644 --- a/tests/e2e/remote-agent-completion-authority.unit.test.ts +++ b/tests/e2e/remote-agent-completion-authority.unit.test.ts @@ -50,7 +50,6 @@ describe('remote agent completion authority', () => { getPtyId: () => REMOTE_PTY_ID, isRemotePtyId: () => true, getExpectedIncarnationId: () => 'inc-remote', - getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), inspectProcess: inspectRuntimeTerminalProcess, dispatchCompletion, isLive: () => true @@ -71,7 +70,6 @@ describe('remote agent completion authority', () => { getPtyId: () => `${REMOTE_PTY_ID}-${index}`, isRemotePtyId: () => true, getExpectedIncarnationId: () => 'inc-remote', - getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), inspectProcess: inspectRuntimeTerminalProcess, dispatchCompletion: vi.fn(), isLive: () => true @@ -92,7 +90,6 @@ describe('remote agent completion authority', () => { getPtyId: () => REMOTE_PTY_ID, isRemotePtyId: () => true, getExpectedIncarnationId: () => 'inc-remote', - getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), inspectProcess: inspectRuntimeTerminalProcess, dispatchCompletion, isLive: () => true @@ -116,7 +113,6 @@ describe('remote agent completion authority', () => { getPtyId: () => REMOTE_PTY_ID, isRemotePtyId: () => true, getExpectedIncarnationId: () => 'inc-remote', - getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), inspectProcess: inspectRuntimeTerminalProcess, dispatchCompletion, isLive: () => true @@ -143,7 +139,6 @@ describe('remote agent completion authority', () => { getPtyId: () => REMOTE_PTY_ID, isRemotePtyId: () => true, getExpectedIncarnationId: () => 'inc-remote', - getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), inspectProcess: inspectRuntimeTerminalProcess, dispatchCompletion, isLive: () => true @@ -169,7 +164,6 @@ describe('remote agent completion authority', () => { getPtyId: () => REMOTE_PTY_ID, isRemotePtyId: () => true, getExpectedIncarnationId: () => 'inc-remote', - getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), inspectProcess: inspectRuntimeTerminalProcess, dispatchCompletion, isLive: () => true @@ -211,7 +205,6 @@ describe('remote agent completion authority', () => { getPtyId: () => REMOTE_PTY_ID, isRemotePtyId: () => true, getExpectedIncarnationId: () => 'inc-remote', - getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }), inspectProcess: inspectRuntimeTerminalProcess, dispatchCompletion: (_title: string, meta?: AgentCompletionDispatchMeta) => { outcomes.push({ From 572f178080c042781f8aafb9ae62508f04ca0ce6 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 12:02:01 -0700 Subject: [PATCH 005/110] refactor(agents): agent, session and port calls route by their workspace owner (#27283) Agent note sends, background agent and terminal launches, structured chat reveal and activation, and the tab strip's Windows shell probe take their transport from the workspace owner's environment instead of building owner-shaped settings for getActiveRuntimeTarget. The automation observer and the Codex selection lane read the owner from the PTY id alone, so an ownerless remote id is never assigned the focused server. Local advertised-URL events always rescan this computer, even when a server is the default host. Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 22 ++----- config/owner-routing-baseline.txt | 11 ---- .../ports/WorkspacePortScanner.test.tsx | 66 +++++-------------- .../components/ports/WorkspacePortScanner.tsx | 28 +++----- .../tab-bar/TabBar.os-file-drop.test.tsx | 1 - .../TabBar.worktree-write-gate.test.tsx | 1 - ...abBar.worktree-write-gate.windows.test.tsx | 1 - ...-bar-runtime-model-worktree-write-probe.ts | 2 - .../tab-bar/use-tab-bar-runtime-model.ts | 4 +- ...ai-vault-structured-session-reveal.test.ts | 14 ++-- .../activate-ai-vault-structured-session.ts | 24 +++---- .../src/lib/active-agent-note-send.ts | 14 ++-- .../src/lib/active-agent-note-target.ts | 17 +++-- .../active-agent-terminal-send-readiness.ts | 6 +- .../src/lib/automation-session-observer.ts | 16 ++--- .../src/lib/codex-pane-selection-lane.test.ts | 4 +- .../src/lib/codex-pane-selection-lane.ts | 10 +-- .../lib/launch-agent-background-session.ts | 9 +-- ...unch-worktree-background-terminals.test.ts | 12 ++-- .../launch-worktree-background-terminals.ts | 8 +-- ...tured-agent-session-tab-activation.test.ts | 7 +- ...structured-agent-session-tab-activation.ts | 9 +-- 22 files changed, 94 insertions(+), 192 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index cabbe4f13aac..d605d8379568 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -9,10 +9,9 @@ 1 src/renderer/src/components/Landing.tsx 1 src/renderer/src/components/LinearIssueWorkspace.tsx 1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx -1 src/renderer/src/components/NativeChatResumeOnRestartModal.tsx 1 src/renderer/src/components/activity/ActivityPrototypePage.tsx 1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx -3 src/renderer/src/components/activity/activity-thread-actions.ts +2 src/renderer/src/components/activity/activity-thread-actions.ts 1 src/renderer/src/components/activity/use-agent-pane-threads.ts 4 src/renderer/src/components/agent/AgentSettingsDialog.tsx 1 src/renderer/src/components/automations/automation-host-client.ts @@ -71,15 +70,12 @@ 1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts 2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts -1 src/renderer/src/components/ports/WorkspacePortScanner.tsx 1 src/renderer/src/components/quick-open-file-list-test-harness.ts 3 src/renderer/src/components/repo/CreateFromPicker.tsx 1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx 2 src/renderer/src/components/right-sidebar/FileExplorer.tsx 1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx 1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx -1 src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts -1 src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts 3 src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx @@ -174,7 +170,6 @@ 1 src/renderer/src/components/sidebar/SidebarAgentsList.tsx 1 src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx 1 src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx -1 src/renderer/src/components/sidebar/WorktreeCardAgents.tsx 2 src/renderer/src/components/sidebar/WorktreeList.tsx 1 src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx 1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts @@ -217,12 +212,10 @@ 1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts 1 src/renderer/src/components/tab-bar/TabBar.tsx 3 src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts -1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts -4 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +3 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts 2 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts 1 src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts -1 src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx -3 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts +2 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts 5 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts 1 src/renderer/src/components/task-page/TaskPage.tsx 1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts @@ -249,8 +242,6 @@ 2 src/renderer/src/components/use-worktree-jump-palette-filter.ts 3 src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts 2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts -2 src/renderer/src/hooks/automation-dispatch-handler.ts -1 src/renderer/src/hooks/automation-dispatch-workspace.ts 1 src/renderer/src/hooks/composer-state/attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts @@ -281,16 +272,14 @@ 7 src/renderer/src/hooks/useSettingsNavigationMetadata.ts 1 src/renderer/src/hooks/useUnreadDockBadge.ts 2 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts -10 src/renderer/src/lib/activate-ai-vault-structured-session.ts +3 src/renderer/src/lib/activate-ai-vault-structured-session.ts 1 src/renderer/src/lib/active-agent-note-send.ts 1 src/renderer/src/lib/active-agent-note-target.ts 1 src/renderer/src/lib/agent-background-session-launch-host.ts 1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts 3 src/renderer/src/lib/agent-hibernation-coordinator.ts -1 src/renderer/src/lib/automation-session-observer.ts 1 src/renderer/src/lib/client-creation-action-error.ts 3 src/renderer/src/lib/client-creation-action-policy.ts -3 src/renderer/src/lib/codex-pane-selection-lane.ts 1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host 1 src/renderer/src/lib/doc-preview-grants.ts 3 src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -311,7 +300,6 @@ 2 src/renderer/src/lib/launch-work-item-direct.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts 2 src/renderer/src/lib/local-preflight-context.ts -1 src/renderer/src/lib/open-agent-message-sender.ts 2 src/renderer/src/lib/provider-runtime-context.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts 4 src/renderer/src/lib/repo-runtime-owner.ts @@ -319,7 +307,7 @@ 1 src/renderer/src/lib/runtime-session-mirror-owners.ts 1 src/renderer/src/lib/runtime-session-mirror-targets.ts 1 src/renderer/src/lib/single-runtime-legacy-owner.ts -3 src/renderer/src/lib/structured-agent-session-tab-activation.ts +1 src/renderer/src/lib/structured-agent-session-tab-activation.ts 1 src/renderer/src/lib/tab-number-shortcuts.ts 1 src/renderer/src/lib/terminal-tab-owner-activation.ts 4 src/renderer/src/lib/terminal-worktree-route.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index b6a007bd1767..2df2bea27553 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -5,7 +5,6 @@ 1 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts 1 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts 1 src/renderer/src/components/linear-api-key-dialog.tsx -1 src/renderer/src/components/ports/WorkspacePortScanner.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -13,22 +12,12 @@ 1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts 1 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx 1 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts -1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts -1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts 1 src/renderer/src/hooks/useIssueMetadata.ts 1 src/renderer/src/hooks/useSettingsNavigationMetadata.ts -3 src/renderer/src/lib/activate-ai-vault-structured-session.ts -1 src/renderer/src/lib/active-agent-note-send.ts -1 src/renderer/src/lib/active-agent-note-target.ts -1 src/renderer/src/lib/automation-session-observer.ts -1 src/renderer/src/lib/codex-pane-selection-lane.ts 1 src/renderer/src/lib/github-pr-start-point.ts -1 src/renderer/src/lib/launch-agent-background-session.ts -1 src/renderer/src/lib/launch-worktree-background-terminals.ts -1 src/renderer/src/lib/structured-agent-session-tab-activation.ts 1 src/renderer/src/lib/worktree-creation-flow-startup.ts 1 src/renderer/src/runtime/runtime-git-client-context.ts 5 src/renderer/src/runtime/runtime-git-diff-client.ts diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx index 7741368d025b..92a7aa061695 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx @@ -416,55 +416,6 @@ describe('WorkspacePortScanner', () => { } }) - it('clears the refreshing state when a retained-host focus change supersedes a poll', async () => { - let env1Calls = 0 - let env2Calls = 0 - let resolveSecondPoll!: (result: { ok: true; result: WorkspacePortScanResult }) => void - const secondPoll = new Promise<{ ok: true; result: WorkspacePortScanResult }>((resolve) => { - resolveSecondPoll = resolve - }) - runtimeEnvironmentCall.mockImplementation(({ selector, method }) => { - if (method !== 'workspacePorts.scan') { - return Promise.resolve({ ok: false, error: { code: 'method_not_found', message: method } }) - } - if (selector === 'env-1') { - env1Calls += 1 - return env1Calls === 1 ? Promise.resolve({ ok: true, result: liveScan }) : secondPoll - } - env2Calls += 1 - return env2Calls === 1 ? Promise.resolve({ ok: true, result: emptyScan }) : secondPoll - }) - markRuntimeEnvironmentCompatible('env-2') - addRemoteWorkspace('env-2') - - await act(async () => { - root?.render() - await flushPromises() - }) - - await act(async () => { - vi.advanceTimersByTime(30_000) - await flushPromises() - }) - expect(useAppStore.getState().workspacePortScanRefreshing).toBe(true) - - await act(async () => { - useAppStore.setState({ - settings: { - ...getDefaultSettings('/tmp/orca-workspaces'), - activeRuntimeEnvironmentId: 'env-2' - } - }) - await flushPromises() - }) - expect(useAppStore.getState().workspacePortScanRefreshing).toBe(false) - - await act(async () => { - resolveSecondPoll({ ok: true, result: emptyScan }) - await flushPromises() - }) - }) - it('removes multiple stale host scans in one map publication', async () => { markRuntimeEnvironmentCompatible('env-2') markRuntimeEnvironmentCompatible('env-3') @@ -648,6 +599,23 @@ describe('advertised URL refresh bursts', () => { expect(localScan).toHaveBeenCalledTimes(1) }) + it('rescans this computer on a local URL change while a server is the default host', async () => { + // Why: the event comes from this app's processes; the default host setting used to mute it. + seedRemoteWorkspace('env-1') + await act(async () => { + root?.render() + await flushPromises() + }) + localScan.mockClear() + const changed = vi.mocked(window.api.workspacePorts.onAdvertisedUrlChanged).mock + .calls[0][0] as () => void + await act(async () => { + changed() + await flushPromises() + }) + expect(localScan).toHaveBeenCalledTimes(1) + }) + it('skips the settled scan while hidden and accepts the next visible URL change', async () => { let visibility: DocumentVisibilityState = 'visible' const restore = overrideDocumentVisibilityState(() => visibility) diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.tsx index f8f2d11ee40a..197a60249c69 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { useAppStore } from '@/store' import { getHasAnyWorktreesFromState } from '@/store/selectors' -import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { mergeWorkspacePortScans, WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY, @@ -25,6 +25,8 @@ type WorkspacePortScannerRefreshOptions = { } // Why: keep live ports through one dropped SSH/IPC scan while reachable empty scans clear now. const WORKSPACE_PORT_SCAN_FAILURE_THRESHOLD = 2 +// Why: advertised-URL events come from this app's own processes, whatever server is the default. +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } function makeUnavailableScan(reason: string): WorkspacePortScanResult { return { @@ -50,8 +52,6 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): const scanTargetsRef = useRef([]) const portScanDebounceRef = useRef(new Map()) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) - const scanKey = workspacePortScanKeyForTarget(runtimeTarget) const scanTargets = useMemo( () => buildExecutionHostRegistry({ repos, settings }) @@ -144,14 +144,11 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): sourceChanged ||= scansByKey[key] !== result scansByKey[key] = result } - const activeScan = scansByKey[scanKey] const merged = mergeWorkspacePortScans(scansByKey) const projectionKey = allTargets.length > 1 ? WORKSPACE_PORT_ALL_HOSTS_SCAN_KEY - : activeScan - ? scanKey - : workspacePortScanKeyForTarget(allTargets[0]) + : workspacePortScanKeyForTarget(allTargets[0]) if (sourceChanged || useAppStore.getState().workspacePortScan?.key !== projectionKey) { // Why: one store update for the whole poll — a large host set must not // fan out a notification to every subscriber per host. @@ -173,13 +170,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): inFlightRef.current = promise return promise }, - [ - hasWorktrees, - scanKey, - setWorkspacePortScan, - replaceWorkspacePortScans, - setWorkspacePortScanRefreshing - ] + [hasWorktrees, setWorkspacePortScan, replaceWorkspacePortScans, setWorkspacePortScanRefreshing] ) useEffect(() => { @@ -276,9 +267,6 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): if (!enabled) { return } - if (runtimeTarget.kind !== 'local') { - return - } let burstRefresh: Promise | null = null let eventSequence = 0 @@ -301,7 +289,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): return } // Keep the leading scan through the quiet window so sequential events share it too. - burstRefresh ??= refresh({ force: true, targets: [runtimeTarget] }) + burstRefresh ??= refresh({ force: true, targets: [LOCAL_TARGET] }) void burstRefresh.finally(() => { if (disposed || sequence !== eventSequence) { return @@ -318,7 +306,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): if (disposed || sequence !== eventSequence || !isWindowVisible()) { return } - void refresh({ force: true, targets: [runtimeTarget] }) + void refresh({ force: true, targets: [LOCAL_TARGET] }) }, WORKSPACE_PORT_ADVERTISED_URL_SETTLE_MS) }) }) @@ -328,7 +316,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): clearRetryTimer() unsubscribe() } - }, [enabled, refresh, runtimeTarget]) + }, [enabled, refresh]) return null } diff --git a/src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx b/src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx index 3aaaedcc2ee3..80edf6466259 100644 --- a/src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx @@ -18,7 +18,6 @@ vi.mock('@/hooks/useDetectedAgents', () => tabBarRuntimeModelStubs().detectedAge vi.mock('@/hooks/useAgentDetectionTarget', () => tabBarRuntimeModelStubs().detectionTarget()) vi.mock('@/lib/connection-context', () => tabBarRuntimeModelStubs().connectionContext()) vi.mock('@/lib/worktree-runtime-owner', () => tabBarRuntimeModelStubs().runtimeOwner()) -vi.mock('@/runtime/runtime-rpc-client', () => tabBarRuntimeModelStubs().runtimeRpcClient()) vi.mock('@/lib/native-chat-transcript-readability', () => tabBarRuntimeModelStubs().nativeChatReadability() ) diff --git a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx index 66e283cf754e..a33caa8aba15 100644 --- a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx @@ -32,7 +32,6 @@ vi.mock('@/hooks/useDetectedAgents', () => tabBarRuntimeModelStubs().detectedAge vi.mock('@/hooks/useAgentDetectionTarget', () => tabBarRuntimeModelStubs().detectionTarget()) vi.mock('@/lib/connection-context', () => tabBarRuntimeModelStubs().connectionContext()) vi.mock('@/lib/worktree-runtime-owner', () => tabBarRuntimeModelStubs().runtimeOwner()) -vi.mock('@/runtime/runtime-rpc-client', () => tabBarRuntimeModelStubs().runtimeRpcClient()) vi.mock('@/lib/native-chat-transcript-readability', () => tabBarRuntimeModelStubs().nativeChatReadability() ) diff --git a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx index 75a132e47c0a..ae51456cdfd1 100644 --- a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx @@ -26,7 +26,6 @@ vi.mock('@/hooks/useDetectedAgents', () => tabBarRuntimeModelStubs().detectedAge vi.mock('@/hooks/useAgentDetectionTarget', () => tabBarRuntimeModelStubs().detectionTarget()) vi.mock('@/lib/connection-context', () => tabBarRuntimeModelStubs().connectionContext()) vi.mock('@/lib/worktree-runtime-owner', () => tabBarRuntimeModelStubs().runtimeOwner()) -vi.mock('@/runtime/runtime-rpc-client', () => tabBarRuntimeModelStubs().runtimeRpcClient()) vi.mock('@/lib/native-chat-transcript-readability', () => tabBarRuntimeModelStubs().nativeChatReadability() ) diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts index 028316b9b843..157420713c7b 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts @@ -81,7 +81,6 @@ const CREATION_POLICY = Object.freeze({ 'mobile-emulator': { state: 'enabled' } }) const DETECTED_AGENTS = Object.freeze({ detectedIds: Object.freeze([]) }) -const RUNTIME_TARGET = Object.freeze({ kind: 'local' }) const CREATE_MENU = Object.freeze({}) const ITEM_PROJECTION = Object.freeze({ orderedItems: Object.freeze([]), @@ -109,7 +108,6 @@ export function tabBarRuntimeModelStubs(): Record Record ({ useAgentDetectionTargetForWorktree: () => null }), connectionContext: () => ({ getConnectionIdFromState: () => null }), runtimeOwner: () => ({ getRuntimeEnvironmentIdForWorktree: () => null }), - runtimeRpcClient: () => ({ getActiveRuntimeTarget: () => RUNTIME_TARGET }), nativeChatReadability: () => ({ isNativeChatTranscriptLocalReadable: () => false }), creationPolicy: () => ({ getClientCreationActionPolicy: () => CREATION_POLICY }), agentProjections: () => ({ selectTabBarAgentProjections: () => AGENT_PROJECTIONS }), diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts index 792093937a69..a39e40b576ae 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -13,7 +13,7 @@ import { getConnectionIdFromState } from '@/lib/connection-context' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { getWindowsTerminalCapabilityOwnerKey, @@ -164,7 +164,7 @@ export function useTabBarRuntimeModel({ worktreeConnectionId ) const runtimeTarget = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), + () => runtimeTargetForOwnerEnvironment(activeRuntimeEnvironmentId), [activeRuntimeEnvironmentId] ) const shouldProbeWindowsShellCapabilities = diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts b/src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts index 32025b8dad03..21e0700e636c 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts @@ -14,15 +14,11 @@ const mocks = vi.hoisted(() => ({ environmentIdFor: vi.fn() })) -vi.mock('@/runtime/runtime-rpc-client', async () => { - // The real target resolution is the thing under test; only the transport is stubbed. - const { getActiveRuntimeTarget } = await import('@/runtime/runtime-client-target') - return { - getActiveRuntimeTarget, - callRuntimeRpc: mocks.call, - runtimeEnvironmentSupportsCapability: mocks.supports - } -}) +// The real target resolution is the thing under test; only the transport is stubbed. +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.call, + runtimeEnvironmentSupportsCapability: mocks.supports +})) vi.mock('./worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: mocks.environmentIdFor diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts index 7b8fa10c664b..cd1ba1cbf145 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -7,10 +7,10 @@ import { useAppStore } from '@/store' import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' import { callRuntimeRpc, - getActiveRuntimeTarget, runtimeEnvironmentSupportsCapability, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { @@ -112,12 +112,9 @@ export async function activateAiVaultStructuredSession( // second click runs the whole sequence again and lands its own toast. const hostKey = executionHostIdForStructuredTarget( target ?? - getActiveRuntimeTarget({ - activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree( - useAppStore.getState(), - structured.workspaceId - ) - }) + runtimeTargetForOwnerEnvironment( + getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), structured.workspaceId) + ) ) const key = `${hostKey}\0${structured.workspaceId}\0${structured.sessionId}` const inFlight = activationsInFlight.get(key) @@ -206,12 +203,9 @@ export async function revealStructuredSession(target: { }): Promise { const host = target.target ?? - getActiveRuntimeTarget({ - activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree( - useAppStore.getState(), - target.worktreeId - ) - }) + runtimeTargetForOwnerEnvironment( + getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), target.worktreeId) + ) // Negotiated against the host that will answer this call, not the local one: a paired host runs // its own build, and its method-not-found is indistinguishable from a refusal we should surface. // A local host is this build, so it always has the method and needs no round trip to prove it. @@ -270,9 +264,7 @@ async function refreshStructuredSessionTabs( const state = useAppStore.getState() const host = target ?? - getActiveRuntimeTarget({ - activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId) - }) + runtimeTargetForOwnerEnvironment(getRuntimeEnvironmentIdForWorktree(state, worktreeId)) // Every other caller that applies an inventory fences it on the sync generation. Structured chat // can be switched off while this call is in flight, which wipes the mirror; without this the // answer would land afterwards and re-seed a chat row into a renderer that just discarded them. diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index 48cced68feb8..95e803ddcf53 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -1,7 +1,11 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { findActiveRuntimeTerminal, getActiveTerminalNoteTarget } from './active-agent-note-target' import type { ActiveTerminalNoteTarget } from './active-agent-note-target' import type { ActiveAgentNotesSendResult } from './active-agent-note-send-result' @@ -71,8 +75,8 @@ async function sendNotesToActiveAgentSessionInternal({ if (!noteTarget) { return reportNoteSendFailure({ status: 'no-active-terminal', code: 'no-note-target' }, null) } - const runtimeTarget = getActiveRuntimeTarget( - getSettingsForWorktreeRuntimeOwner(state, worktreeId) + const runtimeTarget = runtimeTargetForOwnerEnvironment( + getRuntimeEnvironmentIdForWorktree(state, worktreeId) ) const terminal = await findActiveRuntimeTerminal( runtimeTarget, @@ -174,7 +178,7 @@ async function sendNotesToActiveAgentSessionInternal({ } async function sendPromptToExplicitAgentTarget( - runtimeTarget: ReturnType, + runtimeTarget: RuntimeClientTarget, terminalHandle: string, prompt: string ): Promise { diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index 2b114166d1ff..414da293ff86 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -1,13 +1,17 @@ import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { toHostSessionTabId } from '../../../shared/terminal-surface-id' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../shared/agent-status-types' import type { AppState } from '@/store/types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { - getSettingsForWorktreeRuntimeOwner, + getRuntimeEnvironmentIdForWorktree, type WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' @@ -49,13 +53,12 @@ export type ActiveTerminalNoteTargetState = { > runtimePaneTitlesByTabId?: Record | undefined> agentStatusByPaneKey?: Record - settings: Parameters[0] } & Pick type ActiveAgentRuntimeProbeDescriptor = { key: string worktreeId: string - runtimeTarget: ReturnType + runtimeTarget: RuntimeClientTarget noteTarget: ActiveTerminalNoteTarget } @@ -122,8 +125,8 @@ export function getActiveAgentRuntimeProbeDescriptor( } // Route by the worktree's owner host so the probe targets the host that runs // this worktree's agent terminal, not the focused runtime. - const runtimeTarget = getActiveRuntimeTarget( - getSettingsForWorktreeRuntimeOwner(state, worktreeId) + const runtimeTarget = runtimeTargetForOwnerEnvironment( + getRuntimeEnvironmentIdForWorktree(state, worktreeId) ) const runtimeKey = runtimeTarget.kind === 'environment' ? `env:${runtimeTarget.environmentId}` : 'local' @@ -159,7 +162,7 @@ export async function probeActiveAgentNoteTarget({ } export async function findActiveRuntimeTerminal( - runtimeTarget: ReturnType, + runtimeTarget: RuntimeClientTarget, worktreeId: string, noteTarget: ActiveTerminalNoteTarget, timeoutMs: number diff --git a/src/renderer/src/lib/active-agent-terminal-send-readiness.ts b/src/renderer/src/lib/active-agent-terminal-send-readiness.ts index b432366527f7..eab15eb5c7a0 100644 --- a/src/renderer/src/lib/active-agent-terminal-send-readiness.ts +++ b/src/renderer/src/lib/active-agent-terminal-send-readiness.ts @@ -2,7 +2,7 @@ import type { RuntimeTerminalAgentStatus } from '../../../shared/runtime-types' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' import type { ActiveAgentNotesSendFailureCode } from './active-agent-note-send-result' import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' -import type { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { runtimeFailureCode, TERMINAL_RUNTIME_FAILURE_CODES @@ -24,7 +24,7 @@ export type TerminalAgentSendReadinessResult = { } export async function getTerminalAgentSendReadiness( - runtimeTarget: ReturnType, + runtimeTarget: RuntimeClientTarget, terminalHandle: string, options: { allowLegacyFallback: boolean } ): Promise { @@ -65,7 +65,7 @@ export async function getTerminalAgentSendReadiness( } async function getLegacyTerminalAgentSendStatus( - runtimeTarget: ReturnType, + runtimeTarget: RuntimeClientTarget, terminalHandle: string ): Promise { try { diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index d96cb49b14e3..995cb9758964 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -1,12 +1,9 @@ import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getRemoteRuntimeTerminalMultiplexer } from '@/runtime/remote-runtime-terminal-multiplexer' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' -import { - getRemoteRuntimePtyEnvironmentId, - getRemoteRuntimeTerminalHandle -} from '@/runtime/runtime-terminal-stream' +import { getRemoteRuntimePtyOwner } from '@/runtime/runtime-terminal-stream' import { useAppStore } from '@/store' import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import { runtimeWaitExitCode } from '@/lib/agent-background-session-exit' @@ -68,14 +65,11 @@ export async function observeExistingAutomationSession(args: { if (isRemoteRuntimePtyId(ptyId)) { let disposed = false - const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) - const runtimeTarget = ownerEnvironmentId - ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) - : getActiveRuntimeTarget(useAppStore.getState().settings) - const terminal = getRemoteRuntimeTerminalHandle(ptyId) - if (runtimeTarget.kind !== 'environment' || !terminal) { + const owner = getRemoteRuntimePtyOwner(ptyId) + if (!owner) { return () => {} } + const { target: runtimeTarget, terminal } = owner const stream = await getRemoteRuntimeTerminalMultiplexer( runtimeTarget.environmentId ).subscribeTerminal({ diff --git a/src/renderer/src/lib/codex-pane-selection-lane.test.ts b/src/renderer/src/lib/codex-pane-selection-lane.test.ts index 8ffae69187b7..bf54ad75ab4b 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.test.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts @@ -231,14 +231,14 @@ describe('resolveCodexPaneSelectionLaneKey', () => { ).toBe('env:env-owner') }) - it('routes an owner-less remote pane to the active environment, as inspection does', () => { + it('never gives an owner-less remote pane the focused server, as inspection does not', () => { expect( resolveCodexPaneSelectionLaneKey({ state: laneState({ activeRuntimeEnvironmentId: 'env-1' }), tab: HOST_TAB, ptyId: 'remote:term-1' }) - ).toBe('env:env-1') + ).toBe('remote-runtime') }) it('keeps an owner-less remote pane off the host lane when no environment is active', () => { diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts index 27bee15e5392..0bd228319248 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -1,5 +1,5 @@ import type { AppState } from '@/store' -import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { getCodexSelectionLaneKey, @@ -174,12 +174,8 @@ export function resolveCodexPaneSelectionLaneKey(args: { }): string { const remoteParts = parseRemoteRuntimePtyId(args.ptyId) if (remoteParts !== null) { - const runtimeTarget = getActiveRuntimeTarget(args.state.settings) - // Why: mirror inspectRuntimeTerminalProcess — an owner-less remote id is - // routed to whichever environment is active, so that is its lane too. - const environmentId = - remoteParts.environmentId?.trim() || - (runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null) + // Why: mirror inspectRuntimeTerminalProcess — an owner-less remote id reaches no server. + const environmentId = remoteParts.environmentId?.trim() return environmentId ? `${RUNTIME_ENVIRONMENT_LANE_PREFIX}${environmentId}` : UNATTRIBUTED_REMOTE_LANE_KEY diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 74c572da522c..fb2739f3caa3 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchAgentBackgroundSessionArgs, @@ -17,8 +18,8 @@ import { type EagerPtyHandle } from '@/components/terminal-pane/pty-dispatcher' import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { retireProvider } from '@/lib/retire-unowned-background-terminal' import { createRuntimeAgentBackgroundTerminal } from '@/lib/runtime-agent-background-create' import { @@ -57,8 +58,8 @@ export async function launchAgentBackgroundSession( }) const { platform: launchPlatform, isRemote, isLocalHost } = launchHost // Route by the worktree's owner host, not the focused runtime. - const runtimeTarget = getActiveRuntimeTarget( - getSettingsForWorktreeRuntimeOwner(store, worktreeId) + const runtimeTarget = runtimeTargetForOwnerEnvironment( + getRuntimeEnvironmentIdForWorktree(store, worktreeId) ) if (hasExtraAgentArgs(args.extraAgentArgs) && runtimeTarget.kind === 'environment') { // Why: that server rebuilds the command from its own settings and would drop the extras. diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts index 88ab463e60d7..af7d330fe4e8 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts @@ -10,7 +10,7 @@ const mockUpdateTabPtyId = vi.fn() const mockClearTabPtyId = vi.fn() const mockCloseTab = vi.fn() const mockRegisterEagerPtyBuffer = vi.fn() -const mockGetActiveRuntimeTarget = vi.fn() +const mockOwnerEnvironmentId = vi.fn() let uuidIndex = 0 @@ -58,11 +58,7 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ })) vi.mock('@/lib/worktree-runtime-owner', () => ({ - getSettingsForWorktreeRuntimeOwner: () => state.settings -})) - -vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: mockGetActiveRuntimeTarget + getRuntimeEnvironmentIdForWorktree: () => mockOwnerEnvironmentId() })) describe('launchWorktreeBackgroundTerminals', () => { @@ -102,7 +98,7 @@ describe('launchWorktreeBackgroundTerminals', () => { }) let ptyIndex = 0 mockSpawn.mockImplementation(async () => ({ id: `pty-${++ptyIndex}` })) - mockGetActiveRuntimeTarget.mockReturnValue({ kind: 'local' }) + mockOwnerEnvironmentId.mockReturnValue(null) vi.stubGlobal('window', { api: { pty: { @@ -347,7 +343,7 @@ describe('launchWorktreeBackgroundTerminals', () => { }) it('does not duplicate runtime-owned setup/defaultTabs handled by runtime create', async () => { - mockGetActiveRuntimeTarget.mockReturnValue({ kind: 'environment', environmentId: 'env-1' }) + mockOwnerEnvironmentId.mockReturnValue('env-1') const { launchWorktreeBackgroundTerminals } = await import('./launch-worktree-background-terminals') diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index b5b31b5f97c8..d96e1e47412e 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -1,6 +1,6 @@ import { createBrowserUuid } from '@/lib/browser-uuid' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' import { registerBackgroundPaneBuffer } from '@/lib/background-pane-exit-output' @@ -252,8 +252,8 @@ export async function launchWorktreeBackgroundTerminals( return } const store = useAppStore.getState() - const runtimeTarget = getActiveRuntimeTarget( - getSettingsForWorktreeRuntimeOwner(store, args.worktreeId) + const runtimeTarget = runtimeTargetForOwnerEnvironment( + getRuntimeEnvironmentIdForWorktree(store, args.worktreeId) ) if (runtimeTarget.kind === 'environment') { // Runtime-owned worktrees materialize setup/defaultTabs inside createManagedWorktree. diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts index 04b9ab5f2ca7..2c9b4804a346 100644 --- a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts @@ -18,12 +18,7 @@ vi.mock('./worktree-runtime-owner', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: mocks.callRuntimeRpc, - getActiveRuntimeTarget: ({ - activeRuntimeEnvironmentId - }: { - activeRuntimeEnvironmentId: string - }) => ({ kind: 'environment', environmentId: activeRuntimeEnvironmentId }) + callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('@/runtime/runtime-worktree-selector', () => ({ diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.ts index 29dbbec132f8..3613525017de 100644 --- a/src/renderer/src/lib/structured-agent-session-tab-activation.ts +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.ts @@ -1,10 +1,7 @@ import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' import { useAppStore } from '@/store' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - type RuntimeClientTarget -} from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { executionHostIdForStructuredTarget, structuredAgentSessionOwnerForTab @@ -51,7 +48,7 @@ export function activateStructuredAgentSessionTab(args: { state.setActiveTabType('agent-session', args.worktreeId) const environmentId = getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) void callRuntimeRpc( - args.target ?? getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + args.target ?? runtimeTargetForOwnerEnvironment(environmentId), 'session.tabs.activate', { worktree: toRuntimeWorktreeSelector(args.worktreeId), From ca1aac6af4d83e219f2fa2bd44123e1c3b6eafeb Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 12:14:05 -0700 Subject: [PATCH 006/110] refactor(github): PR and work-item calls carry the repo owner's host (#27289) getRuntimeRepoTarget takes the transport its caller already routed by instead of reading the Active Server setting (with a raw focus default). Work-item, comment, check and review-thread requests get it from getGitHubSourceTarget (task source host, else the repo's owner); PR lookups from the repo row; PR refreshes from the candidate's owner. Hosted-review fetches route by hostedReviewRepoOwnerTarget. An unknown repo stays on this computer instead of following the focused server. Cache keys are unchanged. Deletes settingsForHostedReviewRepoOwner, settingsForGitHubFocusedRepoOwner and getGitHubWorkItemSourceSettings. Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 15 +++---- config/owner-routing-baseline.txt | 2 - .../src/store/github/check-actions.ts | 11 +++-- .../src/store/github/comment-fetch-actions.ts | 8 +++- .../store/github/comment-mutation-actions.ts | 10 +++-- .../github/conversation-comment-actions.ts | 8 +++- .../src/store/github/issue-actions.ts | 8 +++- .../src/store/github/pull-request-actions.ts | 3 +- .../store/github/pull-request-execution.ts | 5 ++- .../src/store/github/repository-routing.ts | 13 +++--- .../src/store/github/review-thread-actions.ts | 8 +++- .../github/work-item-aggregate-actions.ts | 21 +++------ .../store/github/work-item-fetch-actions.ts | 11 ++--- .../store/github/work-item-routing.test.ts | 26 ++++++++++- .../src/store/github/work-item-routing.ts | 44 +++++++++++-------- .../github-work-items-error-envelope.test.ts | 12 ++--- .../github-work-items-pagination.test.ts | 27 +++--------- .../github-work-items-runtime-routing.test.ts | 39 +++++++--------- .../store/slices/hosted-review-cache-state.ts | 23 +--------- .../src/store/slices/hosted-review.test.ts | 32 ++++---------- .../src/store/slices/hosted-review.ts | 11 +++-- .../src/store/slices/store-test-helpers.ts | 22 ++++++++++ 22 files changed, 176 insertions(+), 183 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index d605d8379568..796e601cdc0e 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -5,7 +5,6 @@ # Prune after removing reads: pnpm check:owner-routing-ratchet --prune 2 src/renderer/src/App.tsx 3 src/renderer/src/app-shell/use-app-shell-services.ts -1 src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts 1 src/renderer/src/components/Landing.tsx 1 src/renderer/src/components/LinearIssueWorkspace.tsx 1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -362,14 +361,12 @@ 2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts 5 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts 1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts -1 src/renderer/src/store/github/pull-request-execution.ts -2 src/renderer/src/store/github/refresh-routing-actions.ts -3 src/renderer/src/store/github/repository-routing.ts -2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts +1 src/renderer/src/store/github/repository-routing.ts +1 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts 2 src/renderer/src/store/github/work-item-aggregate-actions.ts 3 src/renderer/src/store/github/work-item-fetch-actions.ts -3 src/renderer/src/store/github/work-item-routing.ts -8 src/renderer/src/store/index.ts +2 src/renderer/src/store/github/work-item-routing.ts +7 src/renderer/src/store/index.ts 6 src/renderer/src/store/project-groups/nested-repository-operations.ts 2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts 7 src/renderer/src/store/project-groups/project-group-mutations.ts @@ -404,9 +401,7 @@ 1 src/renderer/src/store/slices/editor/actions/open-file-apply.ts 1 src/renderer/src/store/slices/editor/create-editor-slice.ts 1 src/renderer/src/store/slices/github-cache-key.ts -1 src/renderer/src/store/slices/github-slice-test-harness.ts 1 src/renderer/src/store/slices/hosted-review-cache-identity.ts -1 src/renderer/src/store/slices/hosted-review.ts 1 src/renderer/src/store/slices/jira-collection-read-actions.ts 13 src/renderer/src/store/slices/jira-connection-actions.ts 4 src/renderer/src/store/slices/jira-issue-read-actions.ts @@ -432,7 +427,7 @@ 1 src/renderer/src/store/slices/runtime-status.ts 1 src/renderer/src/store/slices/settings-owner-hydration-publication.ts 5 src/renderer/src/store/slices/settings.ts -7 src/renderer/src/store/slices/store-test-helpers.ts +6 src/renderer/src/store/slices/store-test-helpers.ts 2 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts 2 src/renderer/src/store/slices/terminal-tab-retirement.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 2df2bea27553..7928dee88233 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -33,7 +33,6 @@ 2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts 3 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts 1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts -1 src/renderer/src/store/github/repository-routing.ts 6 src/renderer/src/store/project-groups/nested-repository-operations.ts 2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts 4 src/renderer/src/store/project-groups/project-group-mutations.ts @@ -45,7 +44,6 @@ 1 src/renderer/src/store/repos/repo-update.ts 1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 1 src/renderer/src/store/slices/diff-comment-persistence.ts -1 src/renderer/src/store/slices/hosted-review.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts diff --git a/src/renderer/src/store/github/check-actions.ts b/src/renderer/src/store/github/check-actions.ts index a942757ba487..5cb51c500569 100644 --- a/src/renderer/src/store/github/check-actions.ts +++ b/src/renderer/src/store/github/check-actions.ts @@ -17,7 +17,11 @@ import { nextProviderRequestId, ownsInflightRequest } from './request-coordination' -import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' +import { + getGitHubRepoSourceSettings, + getGitHubSourceTarget, + getGitHubWorkItemRequestContext +} from './work-item-routing' export const createCheckActions = ( set: Parameters>[0], @@ -121,7 +125,7 @@ export const createCheckActions = ( const request = (async () => { const requestContext = getGitHubWorkItemRequestContext( get(), - requestSettings, + getGitHubSourceTarget(get(), repo, options?.sourceContext), repoId ?? repoPath, repoPath, options?.sourceContext @@ -205,10 +209,9 @@ export const createCheckActions = ( options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const requestContext = getGitHubWorkItemRequestContext( get(), - requestSettings, + getGitHubSourceTarget(get(), repo, options?.sourceContext), repoId ?? repoPath, repoPath, options?.sourceContext diff --git a/src/renderer/src/store/github/comment-fetch-actions.ts b/src/renderer/src/store/github/comment-fetch-actions.ts index 1971dc4f7181..983f1860e9c3 100644 --- a/src/renderer/src/store/github/comment-fetch-actions.ts +++ b/src/renderer/src/store/github/comment-fetch-actions.ts @@ -6,7 +6,11 @@ import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' import { isFresh, withBoundedCacheEntry } from './cache-policy' import { inflightCommentsRequests } from './request-coordination' -import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' +import { + getGitHubRepoSourceSettings, + getGitHubSourceTarget, + getGitHubWorkItemRequestContext +} from './work-item-routing' export const createCommentFetchActions = ( set: Parameters>[0], @@ -42,7 +46,7 @@ export const createCommentFetchActions = ( try { const requestContext = getGitHubWorkItemRequestContext( get(), - requestSettings, + getGitHubSourceTarget(get(), repo, options?.sourceContext), repoId ?? repoPath, repoPath, options?.sourceContext diff --git a/src/renderer/src/store/github/comment-mutation-actions.ts b/src/renderer/src/store/github/comment-mutation-actions.ts index 2762a95333fd..2277446b7291 100644 --- a/src/renderer/src/store/github/comment-mutation-actions.ts +++ b/src/renderer/src/store/github/comment-mutation-actions.ts @@ -8,7 +8,11 @@ import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' import { withBoundedCacheEntry } from './cache-policy' import { hasUsableCommentPayload, mergePRCommentIntoList } from './pr-comment-cache' -import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' +import { + getGitHubRepoSourceSettings, + getGitHubSourceTarget, + getGitHubWorkItemRequestContext +} from './work-item-routing' export const createCommentMutationActions = ( set: Parameters>[0], @@ -32,7 +36,7 @@ export const createCommentMutationActions = ( ) const requestContext = getGitHubWorkItemRequestContext( get(), - requestSettings, + getGitHubSourceTarget(get(), repo, options?.sourceContext), repoId ?? repoPath, repoPath, options?.sourceContext @@ -148,7 +152,7 @@ export const createCommentMutationActions = ( const requestContext = getGitHubWorkItemRequestContext( get(), - requestSettings, + getGitHubSourceTarget(get(), repo, options?.sourceContext), repoId ?? repoPath, repoPath, options?.sourceContext diff --git a/src/renderer/src/store/github/conversation-comment-actions.ts b/src/renderer/src/store/github/conversation-comment-actions.ts index 383c27eaa7a6..bf54367a7603 100644 --- a/src/renderer/src/store/github/conversation-comment-actions.ts +++ b/src/renderer/src/store/github/conversation-comment-actions.ts @@ -7,7 +7,11 @@ import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' import { withBoundedCacheEntry } from './cache-policy' import { hasUsableCommentPayload, mergePRCommentIntoList } from './pr-comment-cache' -import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' +import { + getGitHubRepoSourceSettings, + getGitHubSourceTarget, + getGitHubWorkItemRequestContext +} from './work-item-routing' export const createConversationCommentActions = ( set: Parameters>[0], @@ -31,7 +35,7 @@ export const createConversationCommentActions = ( ) const requestContext = getGitHubWorkItemRequestContext( get(), - requestSettings, + getGitHubSourceTarget(get(), repo, options?.sourceContext), repoId ?? repoPath, repoPath, options?.sourceContext diff --git a/src/renderer/src/store/github/issue-actions.ts b/src/renderer/src/store/github/issue-actions.ts index 57638369c93e..c881994a0f63 100644 --- a/src/renderer/src/store/github/issue-actions.ts +++ b/src/renderer/src/store/github/issue-actions.ts @@ -8,7 +8,11 @@ import { isFresh, withBoundedCacheEntry } from './cache-policy' import { debouncedSaveCache } from './cache-persistence' import { inflightIssueRequests } from './request-coordination' import { findRepoForGitHubOwner } from './repository-routing' -import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' +import { + getGitHubRepoSourceSettings, + getGitHubSourceTarget, + getGitHubWorkItemRequestContext +} from './work-item-routing' export const createIssueActions = ( set: Parameters>[0], @@ -42,7 +46,7 @@ export const createIssueActions = ( try { const requestContext = getGitHubWorkItemRequestContext( get(), - requestSettings, + getGitHubSourceTarget(get(), repo, options?.sourceContext), repoId ?? repoPath, repoPath, options?.sourceContext diff --git a/src/renderer/src/store/github/pull-request-actions.ts b/src/renderer/src/store/github/pull-request-actions.ts index 17852c1d57f9..72152e006fcf 100644 --- a/src/renderer/src/store/github/pull-request-actions.ts +++ b/src/renderer/src/store/github/pull-request-actions.ts @@ -11,7 +11,7 @@ import { buildGitHubPRRefreshStateClearToken } from './pr-refresh-state' import { githubHostedReviewFallbackPRNumber, prLookupHintKey } from './pr-result-routing' import { nextLookupGeneration } from '../lookup-generation-sequence' import { inflightPRRequests, prRequestGenerations } from './request-coordination' -import { settingsForGitHubRepoOwner } from './work-item-routing' +import { getGitHubSourceTarget, settingsForGitHubRepoOwner } from './work-item-routing' import { findWorktreeById, shouldApplyDivergedLinkedPRClear, @@ -134,6 +134,7 @@ export const createPullRequestActions = ( repo, repoId, requestSettings, + requestTarget: getGitHubSourceTarget(get(), repo), cacheKey, cached, linkedPRNumber, diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index a1e954081a41..21f48d530d83 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -1,4 +1,5 @@ import type { StateCreator } from 'zustand' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' import type { Repo } from '../../../../shared/repo-types' @@ -35,6 +36,7 @@ export function startPullRequestLookup(args: { repo: Repo | undefined repoId: string | undefined requestSettings: AppState['settings'] + requestTarget: RuntimeClientTarget cacheKey: string cached: CacheEntry | undefined linkedPRNumber: number | null @@ -54,6 +56,7 @@ export function startPullRequestLookup(args: { repo, repoId, requestSettings, + requestTarget, cacheKey, cached, linkedPRNumber, @@ -66,7 +69,7 @@ export function startPullRequestLookup(args: { } = args const request = (async () => { try { - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestTarget) const candidateWorktree = options?.worktreeId ? findWorktreeById(get(), options.worktreeId) : null diff --git a/src/renderer/src/store/github/repository-routing.ts b/src/renderer/src/store/github/repository-routing.ts index 95967b079795..cc711d399027 100644 --- a/src/renderer/src/store/github/repository-routing.ts +++ b/src/renderer/src/store/github/repository-routing.ts @@ -5,7 +5,10 @@ import type { GitHubPRRefreshReason } from '../../../../shared/github/pull-request-refresh-types' import type { Repo } from '../../../../shared/repo-types' -import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '../../runtime/runtime-client-target' import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' import { LOCAL_EXECUTION_HOST_ID, @@ -15,12 +18,12 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' +/** The server-side repo for a request already routed to `target`; `null` when it runs here. */ export function getRuntimeRepoTarget( state: AppState, repoPath: string, - settings: AppState['settings'] = state.settings + target: RuntimeClientTarget ): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { - const target = getActiveRuntimeTarget(settings) if (target.kind !== 'environment') { return null } @@ -52,9 +55,7 @@ export function getPRRefreshRuntimeRepoTarget( return getRuntimeRepoTarget( state, candidate.repoPath, - state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings']) + runtimeTargetForOwnerEnvironment(ownerRuntimeEnvironmentId) ) } diff --git a/src/renderer/src/store/github/review-thread-actions.ts b/src/renderer/src/store/github/review-thread-actions.ts index 299e61d0ba4f..ae49ce3a842d 100644 --- a/src/renderer/src/store/github/review-thread-actions.ts +++ b/src/renderer/src/store/github/review-thread-actions.ts @@ -3,7 +3,11 @@ import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' -import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' +import { + getGitHubRepoSourceSettings, + getGitHubSourceTarget, + getGitHubWorkItemRequestContext +} from './work-item-routing' export const createReviewThreadActions = ( set: Parameters>[0], @@ -42,7 +46,7 @@ export const createReviewThreadActions = ( const requestContext = getGitHubWorkItemRequestContext( get(), - requestSettings, + getGitHubSourceTarget(get(), repo, options?.sourceContext), repoId ?? repoPath, repoPath, options?.sourceContext diff --git a/src/renderer/src/store/github/work-item-aggregate-actions.ts b/src/renderer/src/store/github/work-item-aggregate-actions.ts index 3fb7738650f2..3e71cf72683d 100644 --- a/src/renderer/src/store/github/work-item-aggregate-actions.ts +++ b/src/renderer/src/store/github/work-item-aggregate-actions.ts @@ -24,10 +24,10 @@ import { findRepoForGitHubOwner } from './repository-routing' import { countGitHubWorkItemsForRepo, getGitHubWorkItemRequestContext, - getGitHubWorkItemSourceSettings, getWorkItemsCacheKeyForOwner, isGitHubUnavailableWorkItemsError, - listGitHubWorkItemsForRepo + listGitHubWorkItemsForRepo, + getGitHubSourceTarget } from './work-item-routing' export const createWorkItemAggregateActions = ( @@ -114,10 +114,9 @@ export const createWorkItemAggregateActions = ( repos.map(async (r) => { const requestState = get() const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = getGitHubWorkItemSourceSettings(requestState, repo, r.sourceContext) const requestContext = getGitHubWorkItemRequestContext( requestState, - requestSettings, + getGitHubSourceTarget(requestState, repo, r.sourceContext), r.repoId, r.path, r.sourceContext @@ -194,14 +193,9 @@ export const createWorkItemAggregateActions = ( try { const requestState = get() const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState, - repo, - r.sourceContext - ) const requestContext = getGitHubWorkItemRequestContext( requestState, - requestSettings, + getGitHubSourceTarget(requestState, repo, r.sourceContext), r.repoId, r.path, r.sourceContext @@ -236,14 +230,9 @@ export const createWorkItemAggregateActions = ( ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(options.sourceContext)) : getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath) const cached = get().workItemsCache[key] - const requestSettings = getGitHubWorkItemSourceSettings( - requestState, - repo, - options?.sourceContext - ) const requestContext = getGitHubWorkItemRequestContext( requestState, - requestSettings, + getGitHubSourceTarget(requestState, repo, options?.sourceContext), repoId, repoPath, options?.sourceContext diff --git a/src/renderer/src/store/github/work-item-fetch-actions.ts b/src/renderer/src/store/github/work-item-fetch-actions.ts index 305529d51603..26904d761ceb 100644 --- a/src/renderer/src/store/github/work-item-fetch-actions.ts +++ b/src/renderer/src/store/github/work-item-fetch-actions.ts @@ -22,9 +22,9 @@ import { getGitHubWorkItemRequestContext, getGitHubWorkItemSourceCacheScope, getGitHubWorkItemSourceHostId, - getGitHubWorkItemSourceSettings, getWorkItemsCacheKeyForOwner, - listGitHubWorkItemsForRepo + listGitHubWorkItemsForRepo, + getGitHubSourceTarget } from './work-item-routing' export const createWorkItemFetchActions = ( @@ -89,11 +89,6 @@ export const createWorkItemFetchActions = ( } const requestState = get() const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState, - repo, - options?.sourceContext - ) const ownerHostId = getGitHubWorkItemSourceHostId(requestState, repo, options?.sourceContext) const cacheScope = getGitHubWorkItemSourceCacheScope(requestState, repo, options?.sourceContext) const key = workItemsCacheKey(repoId, limit, query, cacheScope) @@ -105,7 +100,7 @@ export const createWorkItemFetchActions = ( const requestInvalidationNonce = requestState.workItemsInvalidationNonce const requestContext = getGitHubWorkItemRequestContext( requestState, - requestSettings, + getGitHubSourceTarget(requestState, repo, options?.sourceContext), repoId, repoPath, options?.sourceContext diff --git a/src/renderer/src/store/github/work-item-routing.test.ts b/src/renderer/src/store/github/work-item-routing.test.ts index af2b98b989cf..326f058b8fbf 100644 --- a/src/renderer/src/store/github/work-item-routing.test.ts +++ b/src/renderer/src/store/github/work-item-routing.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { TaskSourceContext } from '../../../../shared/task-source-context' import { getDefaultSettings } from '../../../../shared/constants' import type { Repo } from '../../../../shared/repo-types' -import { getGitHubRepoSourceSettings, getGitHubWorkItemSourceSettings } from './work-item-routing' +import { getGitHubRepoSourceSettings, getGitHubSourceTarget } from './work-item-routing' const settings = { ...getDefaultSettings('/home/user'), @@ -33,9 +33,16 @@ function stateWith(repos: Repo[]): Parameters): { + activeRuntimeEnvironmentId: string | null +} { + const target = getGitHubSourceTarget(...args) + return { activeRuntimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null } +} + describe.each([ ['getGitHubRepoSourceSettings', getGitHubRepoSourceSettings], - ['getGitHubWorkItemSourceSettings', getGitHubWorkItemSourceSettings] + ['getGitHubSourceTarget', targetEnvironmentId] ])('%s with a GitHub task source', (_name, resolve) => { it('keeps a runtime-owned repo on its owner when the source has no runtime (#7623)', () => { expect( @@ -65,3 +72,18 @@ describe.each([ expect(resolve(state, localRepo, localSource)?.activeRuntimeEnvironmentId).toBeNull() }) }) + +describe('getGitHubSourceTarget without a task source', () => { + it("sends a repo's request to its owner, not the focused server", () => { + expect(getGitHubSourceTarget(stateWith([runtimeRepo]), runtimeRepo)).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + expect(getGitHubSourceTarget(stateWith([localRepo]), localRepo)).toEqual({ kind: 'local' }) + }) + + it('keeps an unknown repo on this computer while a server is focused', () => { + // Before: the focused server answered for a repo no row owns. + expect(getGitHubSourceTarget(stateWith([]), undefined)).toEqual({ kind: 'local' }) + }) +}) diff --git a/src/renderer/src/store/github/work-item-routing.ts b/src/renderer/src/store/github/work-item-routing.ts index 10d5479efd0b..6b3f40107ffe 100644 --- a/src/renderer/src/store/github/work-item-routing.ts +++ b/src/renderer/src/store/github/work-item-routing.ts @@ -14,6 +14,11 @@ import { import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' import { callRuntimeRpc, RuntimeRpcCallError } from '../../runtime/runtime-rpc-client' import { getHostlessSourceRepoOwnerEnvironmentId } from '@/lib/repo-runtime-owner' +import { + runtimeTargetForOwnerEnvironment, + runtimeTargetForOwnerHostId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { workItemsCacheKey } from './cache-identity' import { findRepoForGitHubOwner, @@ -79,15 +84,6 @@ function settingsForGitHubTaskSource( ) } -export function settingsForGitHubFocusedRepoOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo?.executionHostId && !repo?.connectionId) { - return settings - } - return settingsForGitHubRepoOwner(settings, repo) -} export function getWorkItemsCacheKeyForOwner( state: Partial>, repoId: string, @@ -128,7 +124,7 @@ export function getGitHubWorkItemSourceCacheScope( return getGitHubWorkItemSourceHostId(state, repo, sourceContext) } -export function getGitHubWorkItemSourceSettings( +export function getGitHubRepoSourceSettings( state: GitHubSourceRoutingState, repo: Pick | undefined, sourceContext?: TaskSourceContext | null @@ -136,23 +132,35 @@ export function getGitHubWorkItemSourceSettings( if (sourceContext?.provider === 'github') { return settingsForGitHubTaskSource(state, repo, sourceContext) } - return settingsForGitHubFocusedRepoOwner(state.settings, repo) + return settingsForGitHubRepoOwner(state.settings, repo) } -export function getGitHubRepoSourceSettings( - state: GitHubSourceRoutingState, +/** + * Transport for a repo's GitHub request: a row-less task source's host, else the repo's owner. + * Local and SSH repos use this app's credentials; an unknown repo stays here too (main refuses + * repos it does not own) instead of following the focused server. + */ +export function getGitHubSourceTarget( + state: Pick, repo: Pick | undefined, sourceContext?: TaskSourceContext | null -): AppState['settings'] { +): RuntimeClientTarget { if (sourceContext?.provider === 'github') { - return settingsForGitHubTaskSource(state, repo, sourceContext) + const sourceHost = parseExecutionHostId(sourceContext.hostId) + return runtimeTargetForOwnerEnvironment( + sourceHost?.kind === 'runtime' + ? sourceHost.environmentId + : getHostlessSourceRepoOwnerEnvironmentId(state.repos, repo?.id) + ) } - return settingsForGitHubRepoOwner(state.settings, repo) + return ( + (repo ? runtimeTargetForOwnerHostId(getRepoExecutionHostId(repo)) : null) ?? { kind: 'local' } + ) } export function getGitHubWorkItemRequestContext( state: AppState, - settings: AppState['settings'], + requestTarget: RuntimeClientTarget, repoId: string, repoPath: string, sourceContext?: TaskSourceContext | null @@ -171,7 +179,7 @@ export function getGitHubWorkItemRequestContext( } } } - const runtimeRepo = getRuntimeRepoTarget(state, repoPath, settings) + const runtimeRepo = getRuntimeRepoTarget(state, repoPath, requestTarget) return { repoId, repoPath, diff --git a/src/renderer/src/store/slices/github-work-items-error-envelope.test.ts b/src/renderer/src/store/slices/github-work-items-error-envelope.test.ts index da8f8217e632..5b09c579a120 100644 --- a/src/renderer/src/store/slices/github-work-items-error-envelope.test.ts +++ b/src/renderer/src/store/slices/github-work-items-error-envelope.test.ts @@ -5,7 +5,7 @@ import { resetRemoteRuntimeMocks, runtimeEnvironmentCall } from './github-slice-test-harness' -import type { AppState } from '../types' +import { runtimeOwnedRepoState } from './store-test-helpers' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' @@ -304,10 +304,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { error: { code: 'runtime_error', message: 'HTTP 503: Service Unavailable' }, _meta: { runtimeId: 'remote-runtime' } }) - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [{ id: 'runtime-repo-id', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-1', 'runtime-repo-id', '/server/repo')) try { const result = await store @@ -338,10 +335,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }, _meta: { runtimeId: null } }) - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [{ id: 'runtime-repo-id', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-1', 'runtime-repo-id', '/server/repo')) try { const result = await store diff --git a/src/renderer/src/store/slices/github-work-items-pagination.test.ts b/src/renderer/src/store/slices/github-work-items-pagination.test.ts index d04b478fe657..800a1e1131c2 100644 --- a/src/renderer/src/store/slices/github-work-items-pagination.test.ts +++ b/src/renderer/src/store/slices/github-work-items-pagination.test.ts @@ -5,7 +5,7 @@ import { resetRemoteRuntimeMocks, runtimeEnvironmentCall } from './github-slice-test-harness' -import type { AppState } from '../types' +import { runtimeOwnedRepoState } from './store-test-helpers' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from './github-work-items-query-bounds' @@ -158,10 +158,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [{ id: 'runtime-repo-id', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-1', 'runtime-repo-id', '/server/repo')) const result = await store .getState() @@ -213,10 +210,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [{ id: 'runtime-repo-id', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-1', 'runtime-repo-id', '/server/repo')) const result = await store .getState() @@ -246,10 +240,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [{ id: 'runtime-repo-id', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-1', 'runtime-repo-id', '/server/repo')) const result = await store .getState() @@ -277,10 +268,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [{ id: 'runtime-repo-id', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-1', 'runtime-repo-id', '/server/repo')) const result = await store .getState() @@ -299,10 +287,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [{ id: 'runtime-repo-id', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-1', 'runtime-repo-id', '/server/repo')) const result = await store .getState() diff --git a/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts b/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts index 5053883c11b8..d892815b4bdd 100644 --- a/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts +++ b/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts @@ -7,6 +7,7 @@ import { runtimeEnvironmentCall } from './github-slice-test-harness' import type { AppState } from '../types' +import { runtimeOwnedRepoState } from './store-test-helpers' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' @@ -47,7 +48,8 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { path: '/server/repo', displayName: 'repo', badgeColor: 'blue', - addedAt: 1 + addedAt: 1, + executionHostId: 'runtime:env-1' } ] store.setState({ @@ -308,10 +310,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const store = createTestStore() const error = new Error('Access denied: unknown repository path') const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [{ id: 'runtime-repo-id', path: '/server/known-repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-1', 'runtime-repo-id', '/server/known-repo')) mockApi.gh.listWorkItems.mockRejectedValueOnce(error) try { @@ -371,19 +370,16 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-start' }, - repos: [{ id: 'repo-start', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-start', 'repo-start', '/server/repo')) const queued = store .getState() .fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open', { force: true }) - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-switched' }, - repos: [{ id: 'repo-switched', path: '/server/repo', name: 'repo', kind: 'git' }], - workItemsCache: {} - } as unknown as Partial) + store.setState( + runtimeOwnedRepoState('env-switched', 'repo-switched', '/server/repo', { + workItemsCache: {} + }) + ) for (const resolve of blockingResolvers) { resolve({ items: [], @@ -438,20 +434,17 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) ) - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-old' }, - repos: [{ id: 'repo-old-runtime', path: '/server/repo', name: 'repo', kind: 'git' }] - } as unknown as Partial) + store.setState(runtimeOwnedRepoState('env-old', 'repo-old-runtime', '/server/repo')) const oldFetch = store .getState() .fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open') await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-new' }, - repos: [{ id: 'repo-new-runtime', path: '/server/repo', name: 'repo', kind: 'git' }], - workItemsCache: {} - } as unknown as Partial) + store.setState( + runtimeOwnedRepoState('env-new', 'repo-new-runtime', '/server/repo', { + workItemsCache: {} + }) + ) const newFetch = store .getState() .fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open') diff --git a/src/renderer/src/store/slices/hosted-review-cache-state.ts b/src/renderer/src/store/slices/hosted-review-cache-state.ts index 36d392cf7c51..9d43cf4b6c35 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-state.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-state.ts @@ -6,8 +6,7 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import type { AppState } from '../types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { runtimeTargetForOwnerHostId, type RuntimeClientTarget @@ -213,26 +212,6 @@ export function withHostedReviewCacheEntry( return pruned } -export function settingsForHostedReviewRepoOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo) { - return settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return settings - ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - // Why: local and SSH-owned reviews are served by the desktop client's local - // IPC path, even when the sidebar is focused on a runtime host. - return settings - ? { ...settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) -} - /** * Transport for a review on a known repo: a server's repo goes to that server; local and SSH repos * run on this app's IPC. An unknown repo also stays on this app; focus never names an owner. diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index e1f3d07db2fe..737c7af62a0f 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -235,34 +235,20 @@ describe('hosted review slice', () => { ).toBeUndefined() }) - it('routes active runtime review lookups through runtime RPC', async () => { - runtimeRpc.callRuntimeRpc.mockResolvedValueOnce(review) + it('keeps a review lookup for an unknown repo on this computer while a server is focused', async () => { + // Before: the focused server answered for a repo no row owns. + mockApi.hostedReview.forBranch.mockResolvedValueOnce(review) const store = makeStore({ activeRuntimeEnvironmentId: 'env-win' } as AppState['settings']) - await expect( - store.getState().fetchHostedReviewForBranch('C:\\repo', 'feature/windows', { - linkedGitHubPR: 12 - }) - ).resolves.toEqual(review) + await store.getState().fetchHostedReviewForBranch('C:\\repo', 'feature/windows', { + linkedGitHubPR: 12 + }) - expect(mockApi.hostedReview.forBranch).not.toHaveBeenCalled() - expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'env-win' }, - 'hostedReview.forBranch', - { - repo: 'C:\\repo', - repoPath: 'C:\\repo', - branch: 'feature/windows', - currentHeadOid: null, - linkedGitHubPR: 12, - linkedGitLabMR: null, - linkedBitbucketPR: null, - linkedAzureDevOpsPR: null, - linkedGiteaPR: null - }, - { timeoutMs: 30_000 } + expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled() + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: 'C:\\repo', branch: 'feature/windows' }) ) }) diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index fa8d50a6eef2..f04e82c5f191 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -6,7 +6,7 @@ import type { HostedReviewCreationEligibilityArgs, HostedReviewInfo } from '../../../../shared/hosted-review' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { AppState } from '../types' import { nextLookupGeneration } from '../lookup-generation-sequence' import { @@ -23,7 +23,6 @@ import { isFreshHostedReview, isStaleMergedGitHubReviewForHead, hostedReviewRepoOwnerTarget, - settingsForHostedReviewRepoOwner, shouldRefetchForLinkedHint, shouldRefetchGitHubScopedResultForNoHint, withCreationEligibilityTimeout, @@ -153,14 +152,14 @@ export const createHostedReviewSlice: StateCreator = {} +): Partial { + return { + repos: [ + { + id, + path, + displayName: 'repo', + badgeColor: '', + addedAt: 0, + executionHostId: `runtime:${environmentId}` + } + ], + ...extra + } +} From 81867c8ea044e220f78316e41b0a3fa11e5d4680 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 12:32:46 -0700 Subject: [PATCH 007/110] refactor(sources): row-less sources and host-less surfaces name their host (#27290) * refactor(sources): row-less sources and host-less surfaces name their host Linear and Jira clients take a RowLessSource: a task-source context, an explicit host, or defaultScopeSource(settings) when no Tasks source is chosen, so raw settings no longer compile. defaultScopeHost joins defaultCreationHost as the second sanctioned reader of the default-host setting (both behind one private read, both ratchet seeds), and the status-bar account switchers, settings navigation and the settings host picker's initial default use it. Skill discovery routes by the owner id its install terminal resolves, and the workspace-board Linear sync by the workspace owner. * fix(lint): count the default scope source and assign-then-return readers defaultScopeSource is a sanctioned read of the default host, so its call sites count. A function that assigns a reader's result to a local and returns that local is a reader too, which keeps the skill-discovery hook counted. --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 316 +++++++++++++----- config/owner-routing-baseline.txt | 7 - .../scripts/check-owner-routing-ratchet.mjs | 29 +- .../check-owner-routing-ratchet.test.mjs | 33 ++ .../src/components/JiraIssueWorkspace.tsx | 3 +- .../src/components/LinearIssueTextEditor.tsx | 3 +- .../src/components/LinearIssueWorkspace.tsx | 3 +- .../src/components/LinearItemDrawer.tsx | 3 +- .../src/components/jira-user-picker.tsx | 6 +- .../linear-issue-project-selector.tsx | 3 +- .../components/linear-issue-sub-issues.tsx | 3 +- .../linear-item-drawer-comment-footer.tsx | 3 +- .../linear-item-drawer-edit-controller.tsx | 3 +- .../settings/use-settings-store-model.ts | 5 +- .../use-workspace-board-task-status-sync.ts | 7 +- .../workspace-board-task-status-sync.test.ts | 53 +-- .../workspace-board-task-status-sync.ts | 5 +- .../status-bar/ClaudeSwitcherMenu.tsx | 5 +- .../use-codex-switcher-controller.ts | 5 +- .../task-page-jira-grouping.test.ts | 4 +- .../task-page-linear-issue-model.tsx | 3 +- .../jira/IssueAssigneeField.test.tsx | 7 +- .../task-page/jira/IssueAssigneeField.tsx | 3 +- .../components/task-page/linear/Filters.tsx | 3 +- .../use-new-linear-issue-projects.ts | 55 +++ .../use-task-page-jira-creation-metadata.ts | 5 +- .../use-task-page-jira-issue-creation.ts | 5 +- .../use-task-page-jira-list-effects.ts | 3 +- .../use-task-page-jira-list-state.ts | 3 +- .../components/use-task-page-linear-board.ts | 5 +- .../use-task-page-linear-creation-state.ts | 61 +--- .../use-task-page-linear-issue-creation.ts | 28 +- .../use-task-page-linear-project-creation.ts | 30 +- .../use-task-page-provider-metadata.ts | 3 +- ...e-active-skill-discovery-runtime-target.ts | 9 +- .../src/hooks/useIssueMetadata.test.tsx | 10 +- src/renderer/src/hooks/useIssueMetadata.ts | 21 +- .../hooks/useSettingsNavigationMetadata.ts | 5 +- .../src/lib/default-creation-host.test.ts | 27 +- src/renderer/src/lib/default-creation-host.ts | 42 ++- .../src/lib/linear-issue-url-lookup.ts | 3 +- .../src/runtime/runtime-jira-client.test.ts | 34 +- .../src/runtime/runtime-jira-target.ts | 26 +- .../src/runtime/runtime-linear-client.test.ts | 145 ++++---- .../src/runtime/runtime-linear-client.ts | 35 +- .../store/slices/jira-connection-actions.ts | 15 +- .../store/slices/jira-read-coordination.ts | 19 +- .../store/slices/linear-invalidation.test.ts | 6 +- .../linear-scoped-collection-cache.test.ts | 36 +- .../linear/linear-slice-connection-actions.ts | 19 +- .../store/slices/linear/linear-slice-scope.ts | 7 +- .../linear/linear-slice-status-actions.ts | 3 +- 52 files changed, 736 insertions(+), 439 deletions(-) create mode 100644 src/renderer/src/components/use-new-linear-issue-projects.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 796e601cdc0e..e45e3582fd74 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -4,9 +4,12 @@ # the default host, through defaultCreationHost. Everything else routes by the owner. # Prune after removing reads: pnpm check:owner-routing-ratchet --prune 2 src/renderer/src/App.tsx -3 src/renderer/src/app-shell/use-app-shell-services.ts +5 src/renderer/src/app-shell/use-app-shell-services.ts +9 src/renderer/src/components/JiraIssueWorkspace.tsx 1 src/renderer/src/components/Landing.tsx -1 src/renderer/src/components/LinearIssueWorkspace.tsx +2 src/renderer/src/components/LinearIssueTextEditor.tsx +3 src/renderer/src/components/LinearIssueWorkspace.tsx +3 src/renderer/src/components/LinearItemDrawer.tsx 1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx 1 src/renderer/src/components/activity/ActivityPrototypePage.tsx 1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx @@ -22,15 +25,27 @@ 1 src/renderer/src/components/automations/use-automations-page-destination-form.ts 2 src/renderer/src/components/automations/use-automations-page-setup-state.ts 1 src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.tsx +1 src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.tsx +1 src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx 1 src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx +1 src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.tsx 1 src/renderer/src/components/browser-pane/browser-reopen-on-server.ts 1 src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts +2 src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts +1 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx 1 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts 1 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx 2 src/renderer/src/components/cmd-j/quick-action-context.ts +2 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts +1 src/renderer/src/components/dashboard/launch-dashboard-agent.ts +1 src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +2 src/renderer/src/components/editor/EditorContent.tsx +1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx +1 src/renderer/src/components/editor/EditorEditFileSurface.tsx +1 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx 1 src/renderer/src/components/editor/MonacoGutterContextMenu.tsx 1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx 4 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts @@ -41,10 +56,11 @@ 1 src/renderer/src/components/editor/markdown-preview-link-actions.ts 1 src/renderer/src/components/editor/markdown-preview-system-link-action.ts 1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts +2 src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts 4 src/renderer/src/components/editor/useEditorPanelContentState.ts 5 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts 1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts -1 src/renderer/src/components/editor/useMarkdownDocuments.ts +2 src/renderer/src/components/editor/useMarkdownDocuments.ts 2 src/renderer/src/components/feature-wall/ConnectIntegrationsList.tsx 1 src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx 1 src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx @@ -54,27 +70,49 @@ 1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts 1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts 1 src/renderer/src/components/jira-connect-dialog.tsx +1 src/renderer/src/components/jira-user-picker.tsx 1 src/renderer/src/components/landing-preflight-runtime.ts 1 src/renderer/src/components/linear-api-key-dialog.tsx +3 src/renderer/src/components/linear-issue-project-selector.tsx +2 src/renderer/src/components/linear-issue-sub-issues.tsx +2 src/renderer/src/components/linear-issue-workspace-detail-state.ts +2 src/renderer/src/components/linear-item-drawer-comment-footer.tsx +6 src/renderer/src/components/linear-item-drawer-edit-controller.tsx +1 src/renderer/src/components/linear-item-drawer-edit-section.tsx 2 src/renderer/src/components/native-chat-resume-modal.test-support.tsx 1 src/renderer/src/components/native-chat-resume-tree.test-support.ts +1 src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx +1 src/renderer/src/components/native-chat/NativeChatComposer.tsx 1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx 1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx +1 src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx +1 src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +1 src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +5 src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts +2 src/renderer/src/components/native-chat/native-chat-attachment-upload.ts 1 src/renderer/src/components/native-chat/native-chat-file-link.ts -1 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts +3 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts 2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts 2 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +1 src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +1 src/renderer/src/components/native-chat/use-native-chat-file-drops.ts +1 src/renderer/src/components/native-chat/use-native-chat-link-actions.ts +1 src/renderer/src/components/native-chat/use-native-chat-session-options.ts +2 src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts 1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts -2 src/renderer/src/components/new-workspace/use-linear-source-connection.ts -1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +4 src/renderer/src/components/new-workspace/use-linear-source-connection.ts +1 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts +2 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts 2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts 1 src/renderer/src/components/quick-open-file-list-test-harness.ts 3 src/renderer/src/components/repo/CreateFromPicker.tsx -1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx +4 src/renderer/src/components/right-sidebar/ChecksPanel.tsx 2 src/renderer/src/components/right-sidebar/FileExplorer.tsx 1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx 1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx +1 src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts +1 src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts 3 src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx @@ -90,21 +128,24 @@ 2 src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts 5 src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts 1 src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts +2 src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts 2 src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts 1 src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts 7 src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts 1 src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx -2 src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts +3 src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts 2 src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts 1 src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts 1 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts -2 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts +3 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts 2 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts 1 src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-run-snapshot.ts 1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts +1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts 1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts 1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts 2 src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts +1 src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts 1 src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx 1 src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts 1 src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts @@ -119,10 +160,11 @@ 1 src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts 2 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts 3 src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts +1 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts 4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts -1 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts -1 src/renderer/src/components/right-sidebar/useFileSearchScope.ts -2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +2 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +2 src/renderer/src/components/right-sidebar/useFileSearchScope.ts +3 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts 3 src/renderer/src/components/settings/BaseRefPicker.tsx 2 src/renderer/src/components/settings/BrowserPane.tsx 1 src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -135,8 +177,9 @@ 1 src/renderer/src/components/settings/RepositoryHooksSection.tsx 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx 4 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx -1 src/renderer/src/components/settings/Settings.tsx +2 src/renderer/src/components/settings/Settings.tsx 1 src/renderer/src/components/settings/SettingsSetupGuidePane.tsx +1 src/renderer/src/components/settings/SettingsSidebar.tsx 1 src/renderer/src/components/settings/ShortcutsPane.tsx 2 src/renderer/src/components/settings/TasksPane.tsx 1 src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -166,27 +209,37 @@ 3 src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx 1 src/renderer/src/components/sidebar/SavedHostScopeWidenGate.tsx 1 src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx +1 src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx 1 src/renderer/src/components/sidebar/SidebarAgentsList.tsx 1 src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx 1 src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx +1 src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx +1 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +1 src/renderer/src/components/sidebar/WorktreeCardPorts.tsx 2 src/renderer/src/components/sidebar/WorktreeList.tsx 1 src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx +1 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx 1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts +2 src/renderer/src/components/sidebar/delete-worktree-flow.ts 1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts 2 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +1 src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts 2 src/renderer/src/components/sidebar/sidebar-host-options.ts 1 src/renderer/src/components/sidebar/sidebar-project-drop.ts 2 src/renderer/src/components/sidebar/use-add-repo-host-selection.ts -1 src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts +2 src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts 1 src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts 1 src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts 1 src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts 1 src/renderer/src/components/sidebar/use-workspace-reference-details.ts +1 src/renderer/src/components/sidebar/use-worktree-card-controller.ts +1 src/renderer/src/components/sidebar/use-worktree-card-review-details.ts 1 src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts 1 src/renderer/src/components/sidebar/useCreateRepo.ts 2 src/renderer/src/components/sidebar/useSidebarProjectDrop.ts 1 src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts 2 src/renderer/src/components/sidebar/visible-worktrees.ts +3 src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts 1 src/renderer/src/components/sidebar/workspace-options-menu-items.tsx 2 src/renderer/src/components/sidebar/workspace-reference-details.ts 1 src/renderer/src/components/sidebar/workspace-reference-refresh-targets.ts @@ -195,52 +248,94 @@ 2 src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts 2 src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts 1 src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +1 src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx 1 src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +1 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts 1 src/renderer/src/components/skills/SkillsPage.tsx 7 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx 1 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx +1 src/renderer/src/components/status-bar/PortsStatusSegment.tsx 1 src/renderer/src/components/status-bar/SshStatusSegment.tsx 1 src/renderer/src/components/status-bar/StatusBarSurface.tsx +1 src/renderer/src/components/status-bar/ports-status-popover-rows.tsx 2 src/renderer/src/components/status-bar/provider-account-sync-key.ts 1 src/renderer/src/components/status-bar/status-bar-claude-accounts.ts 1 src/renderer/src/components/status-bar/status-bar-codex-accounts.ts 1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts 7 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts 1 src/renderer/src/components/status-bar/use-status-bar-controller.ts -1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts +2 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts 1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts +1 src/renderer/src/components/status-bar/workspace-space-decision-details.ts +1 src/renderer/src/components/tab-bar/QuickLaunchButton.tsx 1 src/renderer/src/components/tab-bar/TabBar.tsx +1 src/renderer/src/components/tab-bar/TabBarItemRow.tsx 3 src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts -3 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +4 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts 2 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts 1 src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts 2 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts 5 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts -1 src/renderer/src/components/task-page/TaskPage.tsx +1 src/renderer/src/components/tab-group/workspace-tab-close-commands.ts +1 src/renderer/src/components/task-page-jira-status-order.ts +2 src/renderer/src/components/task-page-linear-issue-model.tsx +11 src/renderer/src/components/task-page/TaskPage.tsx +2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx +1 src/renderer/src/components/task-page/linear/Filters.tsx +1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +1 src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts +1 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +1 src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +1 src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts +1 src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts +1 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts 1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts +1 src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts 2 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts 1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts -1 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts +2 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts +1 src/renderer/src/components/terminal-pane/terminal-keyboard-hook.ts +1 src/renderer/src/components/terminal-pane/terminal-keyboard-runtime.ts 1 src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts 2 src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts -1 src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts -1 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +1 src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts +2 src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts +3 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts -1 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts +2 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +2 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts +1 src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-paste-listeners.ts 1 src/renderer/src/components/terminal-workspace-model.ts -1 src/renderer/src/components/terminal/terminal-tab-create.ts +1 src/renderer/src/components/terminal/terminal-tab-actions.ts +4 src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts +2 src/renderer/src/components/terminal/terminal-tab-create.ts +2 src/renderer/src/components/use-new-linear-issue-projects.ts +4 src/renderer/src/components/use-task-page-jira-creation-metadata.ts +4 src/renderer/src/components/use-task-page-jira-issue-creation.ts +2 src/renderer/src/components/use-task-page-jira-list-effects.ts +2 src/renderer/src/components/use-task-page-jira-list-state.ts +4 src/renderer/src/components/use-task-page-linear-board.ts +6 src/renderer/src/components/use-task-page-linear-creation-state.ts +4 src/renderer/src/components/use-task-page-linear-issue-creation.ts +2 src/renderer/src/components/use-task-page-linear-project-creation.ts +2 src/renderer/src/components/use-task-page-provider-metadata.ts 2 src/renderer/src/components/use-task-page-source-availability.ts 2 src/renderer/src/components/use-task-page-store-bindings.ts 2 src/renderer/src/components/use-terminal-activation-actions.ts 2 src/renderer/src/components/use-terminal-bulk-close-actions.ts +1 src/renderer/src/components/use-terminal-controller.ts 8 src/renderer/src/components/use-terminal-create-actions.ts +1 src/renderer/src/components/use-terminal-watcher-effects.ts 2 src/renderer/src/components/use-worktree-jump-palette-controller.ts +1 src/renderer/src/components/use-worktree-jump-palette-create-action.ts 2 src/renderer/src/components/use-worktree-jump-palette-filter.ts 3 src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts 2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts +1 src/renderer/src/components/use-worktree-jump-palette-task-url.ts +1 src/renderer/src/components/worktree-jump-palette-create-worktree.ts 1 src/renderer/src/hooks/composer-state/attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts @@ -252,7 +347,8 @@ 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts -2 src/renderer/src/hooks/editor-external-watch-targets.ts +1 src/renderer/src/hooks/editor-external-watch-target-rows.ts +3 src/renderer/src/hooks/editor-external-watch-targets.ts 1 src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts 1 src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts 6 src/renderer/src/hooks/ipc-events-test-harness.ts @@ -261,29 +357,41 @@ 1 src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts 1 src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts 2 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts -3 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts +1 src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts +2 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts +1 src/renderer/src/hooks/use-terminal-quick-command-hosts.ts 1 src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +2 src/renderer/src/hooks/useAgentDetectionTarget.ts 1 src/renderer/src/hooks/useEditorExternalWatch.ts 1 src/renderer/src/hooks/useInstalledAgentSkills.ts 1 src/renderer/src/hooks/useIpcEvents.ts -1 src/renderer/src/hooks/useIssueMetadata.ts +4 src/renderer/src/hooks/useIssueMetadata.ts 1 src/renderer/src/hooks/useLinearProviderConnected.ts 7 src/renderer/src/hooks/useSettingsNavigationMetadata.ts 1 src/renderer/src/hooks/useUnreadDockBadge.ts 2 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts -3 src/renderer/src/lib/activate-ai-vault-structured-session.ts +4 src/renderer/src/lib/activate-ai-vault-structured-session.ts 1 src/renderer/src/lib/active-agent-note-send.ts 1 src/renderer/src/lib/active-agent-note-target.ts 1 src/renderer/src/lib/agent-background-session-launch-host.ts +1 src/renderer/src/lib/agent-detection-target-inventory.ts 1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts 3 src/renderer/src/lib/agent-hibernation-coordinator.ts +2 src/renderer/src/lib/agent-launch-route-input.ts +1 src/renderer/src/lib/agent-message-send.ts +1 src/renderer/src/lib/ai-vault-resume-platform.ts +1 src/renderer/src/lib/ai-vault-tab-title-requests.ts +2 src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts 1 src/renderer/src/lib/client-creation-action-error.ts 3 src/renderer/src/lib/client-creation-action-policy.ts +1 src/renderer/src/lib/codex-account-restart-startup.ts 1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host 1 src/renderer/src/lib/doc-preview-grants.ts +1 src/renderer/src/lib/empty-workspace-default-agent-chat.ts 3 src/renderer/src/lib/ensure-hooks-confirmed.ts -1 src/renderer/src/lib/execute-open-editor-path-move.ts -1 src/renderer/src/lib/execution-host-facts.ts +3 src/renderer/src/lib/execute-open-editor-path-move.ts +1 src/renderer/src/lib/execution-host-display-label.ts +2 src/renderer/src/lib/execution-host-facts.ts 6 src/renderer/src/lib/file-preview.ts 2 src/renderer/src/lib/folder-workspace-runtime-owner.ts 1 src/renderer/src/lib/github-pr-start-point.ts @@ -293,29 +401,42 @@ 1 src/renderer/src/lib/launch-agent-background-session.ts 1 src/renderer/src/lib/launch-agent-execution-context.ts 2 src/renderer/src/lib/launch-agent-in-new-tab.ts -1 src/renderer/src/lib/launch-agent-web-host-tab.ts +2 src/renderer/src/lib/launch-agent-web-host-tab.ts 2 src/renderer/src/lib/launch-ai-vault-session.ts +1 src/renderer/src/lib/launch-structured-agent-session.ts 1 src/renderer/src/lib/launch-work-item-direct-preflight.ts 2 src/renderer/src/lib/launch-work-item-direct.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts +1 src/renderer/src/lib/linear-issue-url-lookup.ts 2 src/renderer/src/lib/local-preflight-context.ts +3 src/renderer/src/lib/open-agent-message-sender.ts 2 src/renderer/src/lib/provider-runtime-context.ts +2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts 4 src/renderer/src/lib/repo-runtime-owner.ts 2 src/renderer/src/lib/resolve-owner.ts +2 src/renderer/src/lib/resume-sleeping-agent-session.ts 1 src/renderer/src/lib/runtime-session-mirror-owners.ts 1 src/renderer/src/lib/runtime-session-mirror-targets.ts +1 src/renderer/src/lib/runtime-workspace-file-route.ts 1 src/renderer/src/lib/single-runtime-legacy-owner.ts -1 src/renderer/src/lib/structured-agent-session-tab-activation.ts +1 src/renderer/src/lib/sleeping-agent-session-launch.ts +1 src/renderer/src/lib/sleeping-record-execution-host-scope.ts +1 src/renderer/src/lib/structured-agent-session-launch.ts +1 src/renderer/src/lib/structured-agent-session-provisional-tab.ts +4 src/renderer/src/lib/structured-agent-session-tab-activation.ts 1 src/renderer/src/lib/tab-number-shortcuts.ts +1 src/renderer/src/lib/terminal-close-route.ts 1 src/renderer/src/lib/terminal-tab-owner-activation.ts -4 src/renderer/src/lib/terminal-worktree-route.ts +5 src/renderer/src/lib/terminal-worktree-route.ts 3 src/renderer/src/lib/unread-badge-count-selector.ts 2 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts 1 src/renderer/src/lib/workspace-browser-tab-close.ts -4 src/renderer/src/lib/workspace-browser-tab-open.ts +5 src/renderer/src/lib/workspace-browser-tab-open.ts +1 src/renderer/src/lib/workspace-file-drag-source.ts +1 src/renderer/src/lib/workspace-tab-commands.ts 1 src/renderer/src/lib/workspace-tab-palette-activation.ts -1 src/renderer/src/lib/workspace-terminal-host-authority.ts +3 src/renderer/src/lib/workspace-terminal-host-authority.ts 1 src/renderer/src/lib/worktree-activation-pty-inventory.ts 3 src/renderer/src/lib/worktree-activation.ts 1 src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -323,11 +444,14 @@ 1 src/renderer/src/lib/worktree-creation-flow-startup.ts 1 src/renderer/src/lib/worktree-creation-flow.ts 1 src/renderer/src/lib/worktree-creation-hook-preparation.ts -1 src/renderer/src/lib/worktree-operation-route.ts +1 src/renderer/src/lib/worktree-initial-terminal-seeding.ts +1 src/renderer/src/lib/worktree-jump-navigation.ts +2 src/renderer/src/lib/worktree-operation-route.ts 1 src/renderer/src/lib/worktree-row-open-in-owner.ts -3 src/renderer/src/lib/worktree-runtime-owner.ts +6 src/renderer/src/lib/worktree-runtime-owner.ts 1 src/renderer/src/lib/worktree-sort-order-host-split.ts 1 src/renderer/src/runtime/close-mirrored-editor-tab.ts +1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts 1 src/renderer/src/runtime/runtime-client-target.ts 2 src/renderer/src/runtime/runtime-git-client-context.ts 5 src/renderer/src/runtime/runtime-git-diff-client.ts @@ -339,12 +463,15 @@ 17 src/renderer/src/runtime/runtime-jira-client.ts 2 src/renderer/src/runtime/runtime-jira-summary-client.ts 1 src/renderer/src/runtime/runtime-jira-target.ts -4 src/renderer/src/runtime/runtime-jira-user-fields-client.ts +5 src/renderer/src/runtime/runtime-jira-user-fields-client.ts 8 src/renderer/src/runtime/runtime-linear-client.ts -5 src/renderer/src/runtime/runtime-linear-issue-mutations.ts +6 src/renderer/src/runtime/runtime-linear-issue-mutations.ts 12 src/renderer/src/runtime/runtime-linear-project-client.ts 3 src/renderer/src/runtime/runtime-repo-client.ts +3 src/renderer/src/runtime/structured-agent-session-owner.ts +1 src/renderer/src/runtime/structured-conversation-name.ts 5 src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts +1 src/renderer/src/runtime/use-worktree-runtime-target.ts 2 src/renderer/src/runtime/web-runtime-browser-creation.ts 1 src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap-dispatch.ts 1 src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts @@ -356,41 +483,45 @@ 1 src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts 2 src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts 1 src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts -2 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts +3 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts 2 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts 2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts -5 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +6 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts 1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts 1 src/renderer/src/store/github/repository-routing.ts -1 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts +2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts 2 src/renderer/src/store/github/work-item-aggregate-actions.ts 3 src/renderer/src/store/github/work-item-fetch-actions.ts 2 src/renderer/src/store/github/work-item-routing.ts -7 src/renderer/src/store/index.ts +9 src/renderer/src/store/index.ts 6 src/renderer/src/store/project-groups/nested-repository-operations.ts 2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts -7 src/renderer/src/store/project-groups/project-group-mutations.ts +10 src/renderer/src/store/project-groups/project-group-mutations.ts 1 src/renderer/src/store/projects/project-host-routing.ts 1 src/renderer/src/store/projects/project-update.ts 2 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts -1 src/renderer/src/store/repos/owner-routing.ts +1 src/renderer/src/store/repos/converted-ssh-browser-pages.ts +2 src/renderer/src/store/repos/owner-routing.ts 2 src/renderer/src/store/repos/repo-add-actions.ts 2 src/renderer/src/store/repos/repo-catalog-actions.ts 2 src/renderer/src/store/repos/repo-removal.ts -2 src/renderer/src/store/repos/repo-update.ts +3 src/renderer/src/store/repos/repo-update.ts 2 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 2 src/renderer/src/store/repos/safe-auto-fork-sync.ts 1 src/renderer/src/store/slices/browser-slice-test-harness.ts -14 src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts +1 src/renderer/src/store/slices/browser/browser-close-actions.ts +16 src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts 1 src/renderer/src/store/slices/browser/browser-host-actions.ts -7 src/renderer/src/store/slices/browser/browser-host-state.ts -1 src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts -12 src/renderer/src/store/slices/browser/browser-profile-import-actions.ts -11 src/renderer/src/store/slices/browser/browser-profile-list-actions.ts -6 src/renderer/src/store/slices/browser/browser-tab-actions.ts +10 src/renderer/src/store/slices/browser/browser-host-state.ts +2 src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts +1 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts +14 src/renderer/src/store/slices/browser/browser-profile-import-actions.ts +14 src/renderer/src/store/slices/browser/browser-profile-list-actions.ts +7 src/renderer/src/store/slices/browser/browser-tab-actions.ts +1 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts 1 src/renderer/src/store/slices/browser/create-browser-slice.ts 1 src/renderer/src/store/slices/browser/paired-browser-tab-creator.ts -3 src/renderer/src/store/slices/diff-comment-persistence.ts +5 src/renderer/src/store/slices/diff-comment-persistence.ts 2 src/renderer/src/store/slices/editor-slice-test-harness.ts 4 src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts 1 src/renderer/src/store/slices/editor/actions/git-remote-status.ts @@ -402,63 +533,80 @@ 1 src/renderer/src/store/slices/editor/create-editor-slice.ts 1 src/renderer/src/store/slices/github-cache-key.ts 1 src/renderer/src/store/slices/hosted-review-cache-identity.ts -1 src/renderer/src/store/slices/jira-collection-read-actions.ts -13 src/renderer/src/store/slices/jira-connection-actions.ts -4 src/renderer/src/store/slices/jira-issue-read-actions.ts -5 src/renderer/src/store/slices/jira-read-coordination.ts -3 src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts -3 src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts -3 src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts -3 src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts -3 src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts -6 src/renderer/src/store/slices/linear/linear-issue-list-actions.ts -3 src/renderer/src/store/slices/linear/linear-project-detail-actions.ts -3 src/renderer/src/store/slices/linear/linear-project-issue-actions.ts -3 src/renderer/src/store/slices/linear/linear-project-list-actions.ts -13 src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts -4 src/renderer/src/store/slices/linear/linear-slice-scope.ts -3 src/renderer/src/store/slices/linear/linear-slice-status-actions.ts -3 src/renderer/src/store/slices/linear/linear-team-actions.ts +5 src/renderer/src/store/slices/jira-collection-read-actions.ts +31 src/renderer/src/store/slices/jira-connection-actions.ts +8 src/renderer/src/store/slices/jira-issue-read-actions.ts +6 src/renderer/src/store/slices/jira-read-coordination.ts +3 src/renderer/src/store/slices/jira.ts +1 src/renderer/src/store/slices/linear-slice-test-harness.ts +11 src/renderer/src/store/slices/linear.ts +5 src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts +5 src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts +6 src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts +5 src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts +1 src/renderer/src/store/slices/linear/linear-invalidation-actions.ts +2 src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts +6 src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts +9 src/renderer/src/store/slices/linear/linear-issue-list-actions.ts +5 src/renderer/src/store/slices/linear/linear-project-detail-actions.ts +5 src/renderer/src/store/slices/linear/linear-project-issue-actions.ts +6 src/renderer/src/store/slices/linear/linear-project-list-actions.ts +33 src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts +5 src/renderer/src/store/slices/linear/linear-slice-scope.ts +9 src/renderer/src/store/slices/linear/linear-slice-status-actions.ts +6 src/renderer/src/store/slices/linear/linear-team-actions.ts 2 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/project-group-owner-routing.ts 1 src/renderer/src/store/slices/repo-host-identity.ts 1 src/renderer/src/store/slices/repo-reorder-host-split.ts 10 src/renderer/src/store/slices/repos.ts 1 src/renderer/src/store/slices/runtime-status.ts -1 src/renderer/src/store/slices/settings-owner-hydration-publication.ts +2 src/renderer/src/store/slices/settings-owner-hydration-publication.ts 5 src/renderer/src/store/slices/settings.ts -6 src/renderer/src/store/slices/store-test-helpers.ts +8 src/renderer/src/store/slices/store-test-helpers.ts +1 src/renderer/src/store/slices/tabs/tabs-close-actions.ts 2 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts 2 src/renderer/src/store/slices/terminal-tab-retirement.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts 3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts -12 src/renderer/src/store/slices/worktrees.ts -2 src/renderer/src/store/slices/worktrees/create/create-worktree.ts +15 src/renderer/src/store/slices/worktrees.ts +3 src/renderer/src/store/slices/worktrees/create/create-worktree.ts +1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts 2 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts 2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts -6 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts +7 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts 4 src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts -2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts -5 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts -1 src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts -1 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts -2 src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts +4 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts +7 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +2 src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts +2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +3 src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts 2 src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts 4 src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts -4 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +5 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts 3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts -7 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts +3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts +8 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts 1 src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts -1 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts +1 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts 1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts 1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts +1 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts 2 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +1 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts 2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts 1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts 1 src/renderer/src/store/terminals/terminal-shutdown.ts +1 src/renderer/src/store/terminals/terminal-tab-attention.ts +1 src/renderer/src/store/terminals/terminal-tab-close-providers.ts 1 src/renderer/src/store/terminals/terminal-workspace-routing.ts +1 src/renderer/src/web/preload-api/web-app-api.ts 3 src/renderer/src/web/preload-api/web-preference-normalization.ts -16 src/renderer/src/web/preload-api/web-preferences-store.ts -6 src/renderer/src/web/preload-api/web-settings-api.ts -1 src/renderer/src/web/web-preload-api.ts +22 src/renderer/src/web/preload-api/web-preferences-store.ts +13 src/renderer/src/web/preload-api/web-settings-api.ts +9 src/renderer/src/web/preload-api/web-ui-api.ts +3 src/renderer/src/web/preload-api/web-workspace-session-api.ts +3 src/renderer/src/web/web-preload-api.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 7928dee88233..05f10d68f0d7 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -10,13 +10,8 @@ 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx 1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts 1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts -1 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx -1 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts -1 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts -1 src/renderer/src/hooks/useIssueMetadata.ts -1 src/renderer/src/hooks/useSettingsNavigationMetadata.ts 1 src/renderer/src/lib/github-pr-start-point.ts 1 src/renderer/src/lib/worktree-creation-flow-startup.ts 1 src/renderer/src/runtime/runtime-git-client-context.ts @@ -26,8 +21,6 @@ 10 src/renderer/src/runtime/runtime-git-sync-client.ts 9 src/renderer/src/runtime/runtime-git-working-tree-client.ts 3 src/renderer/src/runtime/runtime-hooks-client.ts -1 src/renderer/src/runtime/runtime-jira-target.ts -1 src/renderer/src/runtime/runtime-linear-client.ts 3 src/renderer/src/runtime/runtime-repo-client.ts 1 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts 2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts diff --git a/config/scripts/check-owner-routing-ratchet.mjs b/config/scripts/check-owner-routing-ratchet.mjs index 0c78a91b6c5a..b557cbdd5187 100644 --- a/config/scripts/check-owner-routing-ratchet.mjs +++ b/config/scripts/check-owner-routing-ratchet.mjs @@ -18,7 +18,14 @@ const IMPORT_EXPORT_LIST = /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\}/g // Calls and value uses (`.map(helper)`); definitions and type queries are not routing. const FOCUS_ROUTING_USE = new RegExp(`(?\s*(?!\(?\s*\{)/g -function valueStatements(body) { +function statementsFrom(body, start) { const statements = [] - for (const match of body.matchAll(VALUE_STATEMENT_START)) { + for (const match of body.matchAll(start)) { let depth = 0 let end = match.index + match[0].length for (; end < body.length; end += 1) { @@ -95,9 +102,21 @@ function valueStatements(body) { break } } - statements.push(body.slice(match.index, end)) + statements.push({ name: match[1], text: body.slice(match.index, end) }) } - return statements.join('\n') + return statements +} + +// `const id = reader(…)` counts only when `id` is then returned (assign-then-return). +const LOCAL_ASSIGNMENT_START = /\b(?:const|let)\s+([\w$]+)\s*(?::[^=\n]*)?=(?![=>])/g + +function valueStatements(body) { + const returned = statementsFrom(body, VALUE_STATEMENT_START).map(({ text }) => text) + const returnedIds = new Set(returned.join('\n').match(/[\w$]+/g)) + const assigned = statementsFrom(body, LOCAL_ASSIGNMENT_START) + .filter(({ name }) => returnedIds.has(name)) + .map(({ text }) => text) + return [...returned, ...assigned].join('\n') } const IMPORT_STATEMENT = diff --git a/config/scripts/check-owner-routing-ratchet.test.mjs b/config/scripts/check-owner-routing-ratchet.test.mjs index 89dc6e2b5c24..8ffb9bd5da29 100644 --- a/config/scripts/check-owner-routing-ratchet.test.mjs +++ b/config/scripts/check-owner-routing-ratchet.test.mjs @@ -183,6 +183,39 @@ describe('countFocusSettingReads', () => { } }) + it('discovers a reader assigned to a local that is then returned', () => { + const readers = discoverFocusReaders( + new Map([ + [ + 'src/renderer/src/focus.ts', + 'export function focusedId(s) {\n return s.activeRuntimeEnvironmentId\n}\n' + ], + [ + 'src/renderer/src/hook.ts', + [ + "import { focusedId } from './focus'", + 'export function useTarget(s) {', + ' const id = focusedId(s)', + ' const unrelated = focusedId(s)', + ' return id ? { id } : null', + '}', + 'export function sideEffectOnly(s) {', + ' const id = focusedId(s)', + ' log(id)', + ' return null', + '}' + ].join('\n') + ] + ]) + ) + expect(readers.namesFor('src/renderer/src/hook.ts').has('useTarget')).toBe(true) + expect(readers.namesFor('src/renderer/src/hook.ts').has('sideEffectOnly')).toBe(false) + }) + + it('counts the default scope source as a read', () => { + expect(countFocusSettingReads('linearConnect(defaultScopeSource(get().settings), key)')).toBe(1) + }) + it('counts destructuring reads', () => { expect(countFocusSettingReads('const { activeRuntimeEnvironmentId } = s')).toBe(1) expect( diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index e8676b14b698..36960d519cbd 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -31,6 +31,7 @@ import { translate } from '@/i18n/i18n' import { JiraIssueMetadataBar, JiraIssueWorkspaceHeader } from './jira-issue-workspace-chrome' import { JiraIssueCommentComposer, JiraIssueWorkspaceContent } from './jira-issue-workspace-content' import { getJiraIssueWorkspaceActions } from './jira-issue-workspace-actions' +import { defaultScopeSource } from '@/lib/default-creation-host' type JiraIssueWorkspaceProps = { issue: JiraIssue | null @@ -46,7 +47,7 @@ export default function JiraIssueWorkspace({ sourceContext }: JiraIssueWorkspaceProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const patchJiraIssue = useAppStore((s) => s.patchJiraIssue) const [fullIssue, setFullIssue] = useState(null) const [issueLoading, setIssueLoading] = useState(false) diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index 3bc96a93cd0d..2a15d3e2f522 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -20,6 +20,7 @@ import { resolveLinearIssueTextDraftState } from './linear-issue-text-draft-state' import { translate } from '@/i18n/i18n' +import { defaultScopeSource } from '@/lib/default-creation-host' type LinearIssueTextEditorProps = { issue: LinearIssue @@ -37,7 +38,7 @@ export function LinearIssueTextEditor({ sourceContext }: LinearIssueTextEditorProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const [draftState, setDraftState] = useState(() => createLinearIssueTextDraftState(issue)) const [savingField, setSavingField] = useState(null) diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index cafe1be15060..dc874807c124 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -17,6 +17,7 @@ import { LinearIssueSubIssues } from './linear-issue-sub-issues' import { useLinearIssueWorkspaceDetail } from './linear-issue-workspace-detail-state' import { LinearIssueWorkspaceHeader } from './linear-issue-workspace-header' import { LinearIssueWorkspaceSidebar } from './linear-issue-workspace-sidebar' +import { defaultScopeSource } from '@/lib/default-creation-host' type LinearIssueWorkspaceProps = { issue: LinearIssue | null @@ -49,7 +50,7 @@ function LinearIssueWorkspaceIssue({ requestKey }: LinearIssueWorkspaceIssueProps): React.JSX.Element { const settings = useAppStore((state) => state.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const allWorktrees = useAllWorktrees() const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) const attachmentWorkspaces = useMemo( diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 6cb74ef71b1d..f1051444a7b3 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -16,6 +16,7 @@ import { type LinearLocalComment } from '@/components/linear-item-drawer-types' import { renderLinearItemDrawerSheet } from '@/components/linear-item-drawer-sheet' +import { defaultScopeSource } from '@/lib/default-creation-host' export { LinearIssueEditSection } from '@/components/linear-item-drawer-edit-section' export { LinearIssueCommentFooter } from '@/components/linear-item-drawer-comment-footer' @@ -37,7 +38,7 @@ export default function LinearItemDrawer({ const hasEditedRef = useRef(false) const optimisticCommentsRef = useRef([]) const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const allWorktrees = useAllWorktrees() const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const attachmentWorkspaces = useMemo( diff --git a/src/renderer/src/components/jira-user-picker.tsx b/src/renderer/src/components/jira-user-picker.tsx index a9770dffec6d..1f8be2c78e92 100644 --- a/src/renderer/src/components/jira-user-picker.tsx +++ b/src/renderer/src/components/jira-user-picker.tsx @@ -3,11 +3,9 @@ import { LoaderCircle } from 'lucide-react' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { jiraSearchUsers } from '@/runtime/runtime-jira-client' +import { jiraSearchUsers, type RuntimeJiraSettings } from '@/runtime/runtime-jira-client' import { translate } from '@/i18n/i18n' -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { JiraUser } from '../../../shared/jira-types' -import type { TaskSourceContext } from '../../../shared/task-source-context' const USER_SEARCH_DEBOUNCE_MS = 250 @@ -60,7 +58,7 @@ export function JiraUserPicker({ fixedOptions, searchUsers }: { - providerSettings: TaskSourceContext | GlobalSettings | null + providerSettings: RuntimeJiraSettings siteId?: string | null value: string selectedUser: JiraUser | null diff --git a/src/renderer/src/components/linear-issue-project-selector.tsx b/src/renderer/src/components/linear-issue-project-selector.tsx index 4bdf2de99271..e9af05084644 100644 --- a/src/renderer/src/components/linear-issue-project-selector.tsx +++ b/src/renderer/src/components/linear-issue-project-selector.tsx @@ -12,6 +12,7 @@ import { useAppStore } from '@/store' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearProjectSummary } from '../../../shared/linear/project-types' import type { TaskSourceContext } from '../../../shared/task-source-context' +import { defaultScopeSource } from '@/lib/default-creation-host' export function LinearIssueProjectSelector({ issue, @@ -23,7 +24,7 @@ export function LinearIssueProjectSelector({ sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((state) => state.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const patchLinearIssue = useAppStore((state) => state.patchLinearIssue) const [open, setOpen] = useState(false) const [query, setQuery] = useState('') diff --git a/src/renderer/src/components/linear-issue-sub-issues.tsx b/src/renderer/src/components/linear-issue-sub-issues.tsx index 8d08f338f430..63c0dca78566 100644 --- a/src/renderer/src/components/linear-issue-sub-issues.tsx +++ b/src/renderer/src/components/linear-issue-sub-issues.tsx @@ -11,6 +11,7 @@ import { linearCreateSubIssue } from '@/runtime/runtime-linear-issue-mutations' import { useAppStore } from '@/store' import type { LinearIssue, LinearIssueChildSummary } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' +import { defaultScopeSource } from '@/lib/default-creation-host' function mergeLinearSubIssues( serverSubIssues: LinearIssueChildSummary[] | undefined, @@ -34,7 +35,7 @@ export function LinearIssueSubIssues({ sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((state) => state.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const fetchLinearIssue = useAppStore((state) => state.fetchLinearIssue) const [open, setOpen] = useState(false) const [title, setTitle] = useState('') diff --git a/src/renderer/src/components/linear-item-drawer-comment-footer.tsx b/src/renderer/src/components/linear-item-drawer-comment-footer.tsx index 6f6a12fe5738..41e349a812de 100644 --- a/src/renderer/src/components/linear-item-drawer-comment-footer.tsx +++ b/src/renderer/src/components/linear-item-drawer-comment-footer.tsx @@ -15,6 +15,7 @@ import { linearAddIssueComment } from '@/runtime/runtime-linear-issue-mutations' import { translate } from '@/i18n/i18n' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { LinearLocalComment } from '@/components/linear-item-drawer-types' +import { defaultScopeSource } from '@/lib/default-creation-host' export function LinearIssueCommentFooter({ issueId, @@ -30,7 +31,7 @@ export function LinearIssueCommentFooter({ sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const submitShortcutLabel = getScreenSubmitShortcutLabel() const [body, setBody] = useState('') const [submitting, setSubmitting] = useState(false) diff --git a/src/renderer/src/components/linear-item-drawer-edit-controller.tsx b/src/renderer/src/components/linear-item-drawer-edit-controller.tsx index f36a3893cfd3..773ce5f9fff0 100644 --- a/src/renderer/src/components/linear-item-drawer-edit-controller.tsx +++ b/src/renderer/src/components/linear-item-drawer-edit-controller.tsx @@ -12,6 +12,7 @@ import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' import { translate } from '@/i18n/i18n' import { formatLinearEstimateInput } from '@/components/linear-item-drawer-edit-controls' import type { LinearIssueEditSectionProps } from '@/components/linear-item-drawer-types' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useLinearIssueEditController({ issue, @@ -24,7 +25,7 @@ export function useLinearIssueEditController({ const [estimatePopoverOpen, setEstimatePopoverOpen] = useState(false) const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const { isPending, run } = useImmediateMutation() const { diff --git a/src/renderer/src/components/settings/use-settings-store-model.ts b/src/renderer/src/components/settings/use-settings-store-model.ts index f56e5cb8c281..71312bec1720 100644 --- a/src/renderer/src/components/settings/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/use-settings-store-model.ts @@ -32,6 +32,7 @@ import { removeSettingsProjectFromAllHosts } from './settings-project-list' import { resolveSettingsHostScope, type SettingsHostChoice } from './settings-host-scope' +import { defaultScopeHost } from '@/lib/default-creation-host' export function useSettingsStoreModel() { const settings = useAppStore((s) => s.settings) @@ -148,7 +149,9 @@ export function useSettingsStoreModel() { // Why: session-only (deliberately not persisted) unlock — Option-click the Experimental page title reveals the hidden group. const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false) const [settingsHostChoice, setSettingsHostChoice] = useState(null) - const defaultRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId + const defaultScope = defaultScopeHost(settings) + const defaultRuntimeEnvironmentId = + defaultScope.kind === 'environment' ? defaultScope.environmentId : null // Why: keyed on ids so status-only catalog updates keep the scope (and its loads) stable. const savedEnvironmentIdsKey = runtimeEnvironments.map((environment) => environment.id).join('\n') // Why: the web client's only host is its server, so it never offers a choice. diff --git a/src/renderer/src/components/sidebar/use-workspace-board-task-status-sync.ts b/src/renderer/src/components/sidebar/use-workspace-board-task-status-sync.ts index aad80bce3a29..ad2f391a727e 100644 --- a/src/renderer/src/components/sidebar/use-workspace-board-task-status-sync.ts +++ b/src/renderer/src/components/sidebar/use-workspace-board-task-status-sync.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' import { translate } from '@/i18n/i18n' import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' import { @@ -131,7 +131,10 @@ export function useWorkspaceBoardTaskStatusSync(args: { targetStatus: request.targetStatus, worktreesById: args.worktreesById, getSettingsForWorktree: (worktreeId) => - getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId), + // Why: a workspace no row places on a server is this computer's. + runtimeTargetForWorkspaceOwner(useAppStore.getState(), { workspaceId: worktreeId }) ?? { + kind: 'local' + }, getLatestWorkspaceStatus: (worktreeId) => useAppStore.getState().getKnownWorktreeById(worktreeId)?.workspaceStatus }) diff --git a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts index 880b03289d9f..6ad46c25672c 100644 --- a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts +++ b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts @@ -85,7 +85,7 @@ function setup(overrides: Partial = {}) { worktreeIds: [item.id], targetStatus: target, worktreesById: new Map([[item.id, item]]), - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + settings: { kind: 'environment', environmentId: 'runtime-1' }, getLatestWorkspaceStatus: () => target.id, deps: { getIssue, teamStates, updateIssue } }) @@ -99,17 +99,17 @@ describe('syncWorkspaceBoardTaskStatuses', () => { await expect(run()).resolves.toEqual({ updated: 1, skipped: 0, failed: 0, messages: [] }) expect(getIssue).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'runtime-1' }, + { kind: 'environment', environmentId: 'runtime-1' }, 'ORC-1', 'workspace-1' ) expect(teamStates).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'runtime-1' }, + { kind: 'environment', environmentId: 'runtime-1' }, 'team-1', 'workspace-1' ) expect(updateIssue).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'runtime-1' }, + { kind: 'environment', environmentId: 'runtime-1' }, 'issue-1', { stateId: 'state-review' }, 'workspace-1' @@ -126,15 +126,15 @@ describe('syncWorkspaceBoardTaskStatuses', () => { worktreeIds: [item.id], targetStatus: target, worktreesById: new Map([[item.id, item]]), - settings: null, + settings: { kind: 'local' }, getLatestWorkspaceStatus: () => target.id, deps: { getIssue, teamStates, updateIssue } }) - expect(getIssue).toHaveBeenCalledWith(null, 'ORC-1', undefined) - expect(teamStates).toHaveBeenCalledWith(null, 'team-1', 'issue-workspace') + expect(getIssue).toHaveBeenCalledWith({ kind: 'local' }, 'ORC-1', undefined) + expect(teamStates).toHaveBeenCalledWith({ kind: 'local' }, 'team-1', 'issue-workspace') expect(updateIssue).toHaveBeenCalledWith( - null, + { kind: 'local' }, 'issue-1', { stateId: 'state-review' }, 'issue-workspace' @@ -152,7 +152,8 @@ describe('syncWorkspaceBoardTaskStatuses', () => { const teamStates = vi.fn().mockResolvedValue([state()]) const updateIssue = vi.fn<() => Promise>().mockResolvedValue({ ok: true }) const getSettingsForWorktree = vi.fn((worktreeId: string) => ({ - activeRuntimeEnvironmentId: worktreeId.startsWith('repo-a') ? 'runtime-a' : 'runtime-b' + kind: 'environment' as const, + environmentId: worktreeId.startsWith('repo-a') ? 'runtime-a' : 'runtime-b' })) await syncWorkspaceBoardTaskStatuses({ @@ -162,7 +163,7 @@ describe('syncWorkspaceBoardTaskStatuses', () => { [first.id, first], [second.id, second] ]), - settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + settings: { kind: 'environment', environmentId: 'focused-runtime' }, getSettingsForWorktree, getLatestWorkspaceStatus: () => target.id, deps: { getIssue, teamStates, updateIssue } @@ -172,51 +173,51 @@ describe('syncWorkspaceBoardTaskStatuses', () => { expect(getSettingsForWorktree).toHaveBeenCalledWith(second.id) expect(getIssue).toHaveBeenNthCalledWith( 1, - { activeRuntimeEnvironmentId: 'runtime-a' }, + { kind: 'environment', environmentId: 'runtime-a' }, 'ORC-1', 'workspace-1' ) expect(getIssue).toHaveBeenNthCalledWith( 2, - { activeRuntimeEnvironmentId: 'runtime-b' }, + { kind: 'environment', environmentId: 'runtime-b' }, 'ORC-2', 'workspace-1' ) expect(updateIssue).toHaveBeenNthCalledWith( 1, - { activeRuntimeEnvironmentId: 'runtime-a' }, + { kind: 'environment', environmentId: 'runtime-a' }, 'issue-1', { stateId: 'state-review' }, 'workspace-1' ) expect(updateIssue).toHaveBeenNthCalledWith( 2, - { activeRuntimeEnvironmentId: 'runtime-b' }, + { kind: 'environment', environmentId: 'runtime-b' }, 'issue-2', { stateId: 'state-review' }, 'workspace-1' ) }) - it('preserves null settings from the moved worktree resolver', async () => { + it('uses this computer when the moved worktree resolver names it', async () => { const { item, target, getIssue, teamStates, updateIssue } = setup() - const getSettingsForWorktree = vi.fn(() => null) + const getSettingsForWorktree = vi.fn(() => ({ kind: 'local' as const })) await syncWorkspaceBoardTaskStatuses({ worktreeIds: [item.id], targetStatus: target, worktreesById: new Map([[item.id, item]]), - settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + settings: { kind: 'environment', environmentId: 'focused-runtime' }, getSettingsForWorktree, getLatestWorkspaceStatus: () => target.id, deps: { getIssue, teamStates, updateIssue } }) expect(getSettingsForWorktree).toHaveBeenCalledWith(item.id) - expect(getIssue).toHaveBeenCalledWith(null, 'ORC-1', 'workspace-1') - expect(teamStates).toHaveBeenCalledWith(null, 'team-1', 'workspace-1') + expect(getIssue).toHaveBeenCalledWith({ kind: 'local' }, 'ORC-1', 'workspace-1') + expect(teamStates).toHaveBeenCalledWith({ kind: 'local' }, 'team-1', 'workspace-1') expect(updateIssue).toHaveBeenCalledWith( - null, + { kind: 'local' }, 'issue-1', { stateId: 'state-review' }, 'workspace-1' @@ -230,7 +231,7 @@ describe('syncWorkspaceBoardTaskStatuses', () => { worktreeIds: [item.id], targetStatus: target, worktreesById: new Map([[item.id, item]]), - settings: null, + settings: { kind: 'local' }, getLatestWorkspaceStatus: () => target.id, deps: { getIssue, teamStates, updateIssue } }) @@ -286,7 +287,7 @@ describe('syncWorkspaceBoardTaskStatuses', () => { worktreeIds: [item.id], targetStatus: target, worktreesById: new Map([[item.id, item]]), - settings: null, + settings: { kind: 'local' }, getLatestWorkspaceStatus: () => 'done', deps: { getIssue, teamStates, updateIssue } }) @@ -312,7 +313,7 @@ describe('syncWorkspaceBoardTaskStatuses', () => { worktreeIds: [item.id], targetStatus: targetStatus(), worktreesById: new Map([[item.id, item]]), - settings: null, + settings: { kind: 'local' }, getLatestWorkspaceStatus: () => 'in-review', deps: { getIssue, teamStates, updateIssue } }) @@ -323,7 +324,7 @@ describe('syncWorkspaceBoardTaskStatuses', () => { worktreeIds: [item.id], targetStatus: targetStatus({ id: 'done', label: 'Done' }), worktreesById: new Map([[item.id, item]]), - settings: null, + settings: { kind: 'local' }, getLatestWorkspaceStatus: () => 'done', deps: { getIssue, teamStates, updateIssue } }) @@ -336,14 +337,14 @@ describe('syncWorkspaceBoardTaskStatuses', () => { expect(updateIssue).toHaveBeenNthCalledWith( 1, - null, + { kind: 'local' }, 'issue-1', { stateId: 'state-review' }, 'workspace-1' ) expect(updateIssue).toHaveBeenNthCalledWith( 2, - null, + { kind: 'local' }, 'issue-1', { stateId: 'state-done' }, 'workspace-1' diff --git a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts index b4e76ca0a173..399c8a5e6cd1 100644 --- a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts +++ b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts @@ -177,9 +177,10 @@ async function syncLinearWorktreeStatus( return skipped(result) } - const settings = args.getSettingsForWorktree + // Why: no source has always meant this computer. + const settings = (args.getSettingsForWorktree ? args.getSettingsForWorktree(worktreeId) - : args.settings + : args.settings) ?? { kind: 'local' } const linkedWorkspaceId = worktree.linkedLinearIssueWorkspaceId ?? undefined try { diff --git a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx index af3603359a8e..0f6b2a83329f 100644 --- a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx +++ b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx @@ -8,7 +8,7 @@ import { import { useAppStore } from '../../store' import type { ClaudeRateLimitAccountsState } from '../../../../shared/managed-account-types' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { defaultScopeHost } from '@/lib/default-creation-host' import { fetchProviderAccountsSnapshot, selectClaudeProviderAccount @@ -81,7 +81,8 @@ export function ClaudeSwitcherMenu({ const settings = useAppStore((s) => s.settings) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + // Why: the status bar has no host picker; it shows the default scope host's accounts. + const runtimeTarget = useMemo(() => defaultScopeHost(settings), [settings]) const providerAccountHostLabel = hasActiveRuntimeEnvironment ? (runtimeEnvironments.find( (environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim() diff --git a/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts b/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts index 60f4addb66ba..61743ad5095b 100644 --- a/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts +++ b/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CodexRateLimitAccountsState } from '../../../../shared/managed-account-types' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' import { useAppStore } from '../../store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { defaultScopeHost } from '@/lib/default-creation-host' import { fetchProviderAccountsSnapshot, selectCodexProviderAccount @@ -68,7 +68,8 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) { const settings = useAppStore((s) => s.settings) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + // Why: the status bar has no host picker; it shows the default scope host's accounts. + const runtimeTarget = useMemo(() => defaultScopeHost(settings), [settings]) const providerAccountHostLabel = hasActiveRuntimeEnvironment ? (runtimeEnvironments.find( (environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim() diff --git a/src/renderer/src/components/task-page-jira-grouping.test.ts b/src/renderer/src/components/task-page-jira-grouping.test.ts index fbfa5e5b0693..8d9386c6050a 100644 --- a/src/renderer/src/components/task-page-jira-grouping.test.ts +++ b/src/renderer/src/components/task-page-jira-grouping.test.ts @@ -159,7 +159,9 @@ describe('Jira issue status grouping', () => { throw new Error('Expected one Jira project scope') } - await expect(loadTaskPageJiraProjectStatusOrder(null, 'runtime:old', scope)).resolves.toEqual({ + await expect( + loadTaskPageJiraProjectStatusOrder({ kind: 'local' }, 'runtime:old', scope) + ).resolves.toEqual({ statusIdsByColumn: [] }) expect(warn).toHaveBeenCalledWith('[jira] Failed to load project status order:', error) diff --git a/src/renderer/src/components/task-page-linear-issue-model.tsx b/src/renderer/src/components/task-page-linear-issue-model.tsx index 96c4024b3c84..dfe30b6be58b 100644 --- a/src/renderer/src/components/task-page-linear-issue-model.tsx +++ b/src/renderer/src/components/task-page-linear-issue-model.tsx @@ -18,6 +18,7 @@ import { getLinearStateMarkerStyle } from '@/components/linear-state-pill-style' import { LoaderCircle, ChevronDown } from 'lucide-react' +import { defaultScopeSource } from '@/lib/default-creation-host' export type LinearProjectTab = 'overview' | 'issues' export type LinearGroupSection = { key: string @@ -84,7 +85,7 @@ export function LinearStateCell({ sourceContext?: TaskSourceContext | null }): React.JSX.Element { const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings + const providerSettings = sourceContext ?? defaultScopeSource(settings) const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) const states = useTeamStates(issue.team.id, providerSettings, issue.workspaceId) const [open, setOpen] = useState(false) diff --git a/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx b/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx index 3bff2e62614e..1b297185db1e 100644 --- a/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx +++ b/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx @@ -31,7 +31,12 @@ it('drops old provider candidates before another host with identical project IDs fireEvent.click(screen.getByRole('button', { name: 'Assignee' })) await screen.findByRole('button', { name: 'Local user' }) expect(screen.getByRole('button', { name: 'Assign to me (Local me)' })).toBeTruthy() - expect(search).toHaveBeenCalledWith(null, 'PRJ', '', 'same-site') + expect(search).toHaveBeenCalledWith( + { kind: 'default-scope', settings: null }, + 'PRJ', + '', + 'same-site' + ) search.mockReturnValueOnce(new Promise(() => {})) rerender( diff --git a/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx b/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx index f5091cfe1423..1a6e68229361 100644 --- a/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx +++ b/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx @@ -6,6 +6,7 @@ import { hasJiraAssigneeCreateField } from '@/components/task-page-jira-create-f import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection' import { jiraListAssignableUsersForProject } from '@/runtime/runtime-jira-client' import { translate } from '@/i18n/i18n' +import { defaultScopeSource } from '@/lib/default-creation-host' export function TaskPageJiraIssueAssigneeField({ model @@ -34,7 +35,7 @@ export function TaskPageJiraIssueAssigneeField({ setNewJiraIssueAssignee, newJiraIssueSubmitting } = model - const providerSettings = jiraTaskSourceContext ?? settings + const providerSettings = jiraTaskSourceContext ?? defaultScopeSource(settings) const projectKey = newJiraIssueTargetProject?.key const projectSiteId = newJiraIssueTargetProject?.siteId const searchAssignableUsers = useCallback( diff --git a/src/renderer/src/components/task-page/linear/Filters.tsx b/src/renderer/src/components/task-page/linear/Filters.tsx index bc2ebfc159bb..5f63ad1ba5da 100644 --- a/src/renderer/src/components/task-page/linear/Filters.tsx +++ b/src/renderer/src/components/task-page/linear/Filters.tsx @@ -8,6 +8,7 @@ import { Plus, LoaderCircle, RefreshCw, Search, X } from 'lucide-react' import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' import { Input } from '@/components/ui/input' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' +import { defaultScopeSource } from '@/lib/default-creation-host' export function TaskPageLinearFilters({ model }: { @@ -215,7 +216,7 @@ export function TaskPageLinearFilters({ selectedTeamIds={[...linearTeamSelection]} availableTeams={linearTeamOptions} teamsSettled={availableTeams.length > 0} - settings={linearTaskSourceContext ?? settings} + settings={linearTaskSourceContext ?? defaultScopeSource(settings)} /> ) : null}
diff --git a/src/renderer/src/components/use-new-linear-issue-projects.ts b/src/renderer/src/components/use-new-linear-issue-projects.ts new file mode 100644 index 000000000000..3ae64a3473c7 --- /dev/null +++ b/src/renderer/src/components/use-new-linear-issue-projects.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { LinearProjectSummary } from '../../../shared/linear/project-types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { defaultScopeSource } from '@/lib/default-creation-host' +import { linearListProjects } from '@/runtime/runtime-linear-project-client' + +/** Projects offered by the new Linear issue dialog for its target team. */ +export function useNewLinearIssueProjects(args: { + open: boolean + targetTeam: { workspaceId?: string | null } | null + selectedLinearWorkspaceId: string + linearTaskSourceContext: TaskSourceContext | null + settings: Pick | null +}) { + const { open, targetTeam, selectedLinearWorkspaceId, linearTaskSourceContext, settings } = args + const [newLinearIssueProjects, setNewLinearIssueProjects] = useState([]) + const [newLinearIssueProjectsLoading, setNewLinearIssueProjectsLoading] = useState(false) + useEffect(() => { + let cancelled = false + if (!open || !targetTeam) { + setNewLinearIssueProjects([]) + setNewLinearIssueProjectsLoading(false) + return + } + setNewLinearIssueProjectsLoading(true) + const targetWorkspaceId = + targetTeam.workspaceId || + (selectedLinearWorkspaceId !== 'all' ? selectedLinearWorkspaceId : null) + // Why: with no Tasks source chosen, the list comes from the default scope host. + const source = linearTaskSourceContext ?? defaultScopeSource(settings) + linearListProjects(source, undefined, 100, targetWorkspaceId) + .then((p) => { + if (!cancelled) { + setNewLinearIssueProjects(p.items) + } + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) { + setNewLinearIssueProjectsLoading(false) + } + }) + return () => { + // Why: project lists are workspace-scoped; stale responses must not populate the composer after a team/workspace switch. + cancelled = true + } + }, [open, targetTeam, linearTaskSourceContext, settings, selectedLinearWorkspaceId]) + return { + newLinearIssueProjects, + setNewLinearIssueProjects, + newLinearIssueProjectsLoading, + setNewLinearIssueProjectsLoading + } +} diff --git a/src/renderer/src/components/use-task-page-jira-creation-metadata.ts b/src/renderer/src/components/use-task-page-jira-creation-metadata.ts index dac221ed0e11..00157cfeb329 100644 --- a/src/renderer/src/components/use-task-page-jira-creation-metadata.ts +++ b/src/renderer/src/components/use-task-page-jira-creation-metadata.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { jiraListIssueTypes, jiraListCreateFields } from '@/runtime/runtime-jira-client' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageJiraCreationMetadata(model: TaskPageJiraCreationStateModel) { const { settings, @@ -29,7 +30,7 @@ export function useTaskPageJiraCreationMetadata(model: TaskPageJiraCreationState setAvailableJiraIssueTypes([]) setJiraIssueTypesLoading(true) void jiraListIssueTypes( - jiraTaskSourceContext ?? settings, + jiraTaskSourceContext ?? defaultScopeSource(settings), newJiraIssueTargetProject.id, newJiraIssueTargetProject.siteId ) @@ -84,7 +85,7 @@ export function useTaskPageJiraCreationMetadata(model: TaskPageJiraCreationState setJiraCreateFieldsError(null) setNewJiraIssueCustomFieldValues({}) void jiraListCreateFields( - jiraTaskSourceContext ?? settings, + jiraTaskSourceContext ?? defaultScopeSource(settings), newJiraIssueTargetProject.id, newJiraIssueTargetType.id, newJiraIssueTargetProject.siteId diff --git a/src/renderer/src/components/use-task-page-jira-issue-creation.ts b/src/renderer/src/components/use-task-page-jira-issue-creation.ts index af8ce0f71563..e51687793d34 100644 --- a/src/renderer/src/components/use-task-page-jira-issue-creation.ts +++ b/src/renderer/src/components/use-task-page-jira-issue-creation.ts @@ -7,6 +7,7 @@ import { import { jiraCreateIssue, jiraGetIssue } from '@/runtime/runtime-jira-client' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationModel) { const { settings, @@ -53,7 +54,7 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM setNewJiraIssueSubmitting(true) const submitProviderRuntimeContextKey = providerRuntimeContextKey try { - const result = await jiraCreateIssue(jiraTaskSourceContext ?? settings, { + const result = await jiraCreateIssue(jiraTaskSourceContext ?? defaultScopeSource(settings), { siteId: newJiraIssueTargetProject.siteId, projectId: newJiraIssueTargetProject.id, issueTypeId: newJiraIssueTargetType.id, @@ -93,7 +94,7 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM setNewJiraIssueAssignee(null) setJiraRefreshNonce((n) => n + 1) void jiraGetIssue( - jiraTaskSourceContext ?? settings, + jiraTaskSourceContext ?? defaultScopeSource(settings), result.key, newJiraIssueTargetProject.siteId ) diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.ts b/src/renderer/src/components/use-task-page-jira-list-effects.ts index 6cdd282b0516..160578a2c0a4 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.ts @@ -8,6 +8,7 @@ import { import { createTaskPageJiraLoadFailureState } from '@/components/task-page-jira-load-state' import { searchTaskPageJiraIssues } from '@/components/task-page-jira-search' import { JIRA_ITEM_LIMIT, TASK_SEARCH_DEBOUNCE_MS } from './task-page-source-context' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffectsModel) { const { settings, @@ -107,7 +108,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect projectScope ) void loadTaskPageJiraProjectStatusOrder( - jiraTaskSourceContext ?? settings, + jiraTaskSourceContext ?? defaultScopeSource(settings), jiraTaskSourceScopeKey, projectScope ).then((order) => { diff --git a/src/renderer/src/components/use-task-page-jira-list-state.ts b/src/renderer/src/components/use-task-page-jira-list-state.ts index 7c471ffcfb41..9d7168c12c46 100644 --- a/src/renderer/src/components/use-task-page-jira-list-state.ts +++ b/src/renderer/src/components/use-task-page-jira-list-state.ts @@ -10,6 +10,7 @@ import type { JiraPrioritiesBySite } from './jira-issue-sorter' import { jiraListPriorities } from '@/runtime/runtime-jira-client' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { const { settings, jiraConnected, selectedJiraSiteId, taskSource, jiraTaskSourceContext } = model // Jira tab state @@ -51,7 +52,7 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { try { return [ siteId, - await jiraListPriorities(jiraTaskSourceContext ?? settings, siteId) + await jiraListPriorities(jiraTaskSourceContext ?? defaultScopeSource(settings), siteId) ] as const } catch { return [siteId, [] as JiraPriority[]] as const diff --git a/src/renderer/src/components/use-task-page-linear-board.ts b/src/renderer/src/components/use-task-page-linear-board.ts index cd4a149cd6cc..af1a42c9a13c 100644 --- a/src/renderer/src/components/use-task-page-linear-board.ts +++ b/src/renderer/src/components/use-task-page-linear-board.ts @@ -17,6 +17,7 @@ import { findLinearWorkflowStateForStatus, getLinearStatusSectionState } from './task-page-linear-issue-model' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageLinearBoard(model: TaskPageLinearListProjectionModel) { const { settings, @@ -114,7 +115,7 @@ export function useTaskPageLinearBoard(model: TaskPageLinearListProjectionModel) } try { const states = await linearTeamStates( - linearTaskSourceContext ?? settings, + linearTaskSourceContext ?? defaultScopeSource(settings), issue.team.id, issue.workspaceId ) @@ -151,7 +152,7 @@ export function useTaskPageLinearBoard(model: TaskPageLinearListProjectionModel) }) applyFallbackState(nextState) const result = await linearUpdateIssue( - linearTaskSourceContext ?? settings, + linearTaskSourceContext ?? defaultScopeSource(settings), issue.id, { stateId: workflowState.id diff --git a/src/renderer/src/components/use-task-page-linear-creation-state.ts b/src/renderer/src/components/use-task-page-linear-creation-state.ts index f7aef435c0e4..674478b75a37 100644 --- a/src/renderer/src/components/use-task-page-linear-creation-state.ts +++ b/src/renderer/src/components/use-task-page-linear-creation-state.ts @@ -2,10 +2,10 @@ import type { TaskPageJiraListProjectionModel } from './use-task-page-jira-list- import { useState, useMemo, useEffect, type SetStateAction } from 'react' import { useTeamMembers, useTeamLabels, useTeamStates } from '@/hooks/useIssueMetadata' import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention' -import type { LinearProjectSummary } from '../../../shared/linear/project-types' -import { linearListProjects } from '@/runtime/runtime-linear-project-client' +import { useNewLinearIssueProjects } from './use-new-linear-issue-projects' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { writeNewLinearIssueDraft, writeNewLinearProjectDraft } from './task-page-draft-storage' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageLinearCreationState(model: TaskPageJiraListProjectionModel) { const { settings, @@ -39,12 +39,12 @@ export function useTaskPageLinearCreationState(model: TaskPageJiraListProjection ) const newLinearProjectMembers = useTeamMembers( newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null, - settings, + defaultScopeSource(settings), newLinearProjectTargetTeam?.workspaceId ) const newLinearProjectLabels = useTeamLabels( newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null, - settings, + defaultScopeSource(settings), newLinearProjectTargetTeam?.workspaceId ) const setNewLinearProjectTeamId = (id: string | null): void => { @@ -86,43 +86,18 @@ export function useTaskPageLinearCreationState(model: TaskPageJiraListProjection () => availableTeams.find((t) => t.id === newLinearIssueTeamId) ?? availableTeams[0] ?? null, [availableTeams, newLinearIssueTeamId] ) - const [newLinearIssueProjects, setNewLinearIssueProjects] = useState([]) - const [newLinearIssueProjectsLoading, setNewLinearIssueProjectsLoading] = useState(false) - useEffect(() => { - let cancelled = false - if (!newLinearIssueOpen || !linearConnected || !newLinearIssueTargetTeam) { - setNewLinearIssueProjects([]) - setNewLinearIssueProjectsLoading(false) - return - } - setNewLinearIssueProjectsLoading(true) - const targetWorkspaceId = - newLinearIssueTargetTeam.workspaceId || - (selectedLinearWorkspaceId !== 'all' ? selectedLinearWorkspaceId : null) - linearListProjects(linearTaskSourceContext ?? settings, undefined, 100, targetWorkspaceId) - .then((p) => { - if (!cancelled) { - setNewLinearIssueProjects(p.items) - } - }) - .catch(() => {}) - .finally(() => { - if (!cancelled) { - setNewLinearIssueProjectsLoading(false) - } - }) - return () => { - // Why: project lists are workspace-scoped; stale responses must not populate the composer after a team/workspace switch. - cancelled = true - } - }, [ - linearConnected, - newLinearIssueOpen, - newLinearIssueTargetTeam, + const { + newLinearIssueProjects, + setNewLinearIssueProjects, + newLinearIssueProjectsLoading, + setNewLinearIssueProjectsLoading + } = useNewLinearIssueProjects({ + open: newLinearIssueOpen && linearConnected, + targetTeam: newLinearIssueTargetTeam, + selectedLinearWorkspaceId, linearTaskSourceContext, - settings, - selectedLinearWorkspaceId - ]) + settings + }) const setNewLinearIssueTeamId = (value: SetStateAction): void => { const id = typeof value === 'function' ? value(newLinearIssueTeamId) : value setNewLinearIssueTeamIdState(id) @@ -139,17 +114,17 @@ export function useTaskPageLinearCreationState(model: TaskPageJiraListProjection } const newLinearStates = useTeamStates( linearConnected ? newLinearIssueTargetTeam?.id || null : null, - settings, + defaultScopeSource(settings), newLinearIssueTargetTeam?.workspaceId ) const newLinearMembers = useTeamMembers( linearConnected ? newLinearIssueTargetTeam?.id || null : null, - settings, + defaultScopeSource(settings), newLinearIssueTargetTeam?.workspaceId ) const newLinearLabels = useTeamLabels( linearConnected ? newLinearIssueTargetTeam?.id || null : null, - settings, + defaultScopeSource(settings), newLinearIssueTargetTeam?.workspaceId ) useEffect(() => { diff --git a/src/renderer/src/components/use-task-page-linear-issue-creation.ts b/src/renderer/src/components/use-task-page-linear-issue-creation.ts index 2886c50bfe0c..dd42b4bd8c1c 100644 --- a/src/renderer/src/components/use-task-page-linear-issue-creation.ts +++ b/src/renderer/src/components/use-task-page-linear-issue-creation.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { linearCreateIssue, linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' import { useAppStore } from '@/store' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageLinearIssueCreation(model: TaskPageLinearProjectCreationModel) { const { settings, @@ -57,17 +58,20 @@ export function useTaskPageLinearIssueCreation(model: TaskPageLinearProjectCreat setNewLinearIssueSubmitting(true) const submitProviderRuntimeContextKey = providerRuntimeContextKey try { - const result = await linearCreateIssue(linearTaskSourceContext ?? settings, { - teamId: newLinearIssueTargetTeam.id, - title, - description: newLinearIssueBody || undefined, - workspaceId: newLinearIssueTargetTeam.workspaceId, - stateId: newLinearIssueStateId || undefined, - priority: newLinearIssuePriority, - assigneeId: newLinearIssueAssigneeId || undefined, - projectId: newLinearIssueProjectId || null, - labelIds: newLinearIssueLabelIds.length > 0 ? newLinearIssueLabelIds : undefined - }) + const result = await linearCreateIssue( + linearTaskSourceContext ?? defaultScopeSource(settings), + { + teamId: newLinearIssueTargetTeam.id, + title, + description: newLinearIssueBody || undefined, + workspaceId: newLinearIssueTargetTeam.workspaceId, + stateId: newLinearIssueStateId || undefined, + priority: newLinearIssuePriority, + assigneeId: newLinearIssueAssigneeId || undefined, + projectId: newLinearIssueProjectId || null, + labelIds: newLinearIssueLabelIds.length > 0 ? newLinearIssueLabelIds : undefined + } + ) if (submitProviderRuntimeContextKey !== providerRuntimeContextKeyRef.current) { return } @@ -105,7 +109,7 @@ export function useTaskPageLinearIssueCreation(model: TaskPageLinearProjectCreat // Why: auto-select the new issue so the user sees exactly what was filed (mirrors the GitHub create-issue flow). void linearGetIssue( - linearTaskSourceContext ?? settings, + linearTaskSourceContext ?? defaultScopeSource(settings), result.id, newLinearIssueTargetTeam.workspaceId ) diff --git a/src/renderer/src/components/use-task-page-linear-project-creation.ts b/src/renderer/src/components/use-task-page-linear-project-creation.ts index 56c2776fe449..8fdeb9887097 100644 --- a/src/renderer/src/components/use-task-page-linear-project-creation.ts +++ b/src/renderer/src/components/use-task-page-linear-project-creation.ts @@ -3,6 +3,7 @@ import { useCallback } from 'react' import { linearCreateProject } from '@/runtime/runtime-linear-project-client' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageLinearProjectCreation(model: TaskPageGitHubIssueCreationModel) { const { settings, @@ -47,19 +48,22 @@ export function useTaskPageLinearProjectCreation(model: TaskPageGitHubIssueCreat } setNewLinearProjectSubmitting(true) try { - const result = await linearCreateProject(linearTaskSourceContext ?? settings, { - name, - description: newLinearProjectDescription.trim() || undefined, - content: newLinearProjectContent.trim() || undefined, - teamIds: [newLinearProjectTargetTeam.id], - workspaceId: newLinearProjectTargetTeam.workspaceId, - leadId: newLinearProjectLeadId || undefined, - memberIds: newLinearProjectMemberIds.length > 0 ? newLinearProjectMemberIds : undefined, - labelIds: newLinearProjectLabelIds.length > 0 ? newLinearProjectLabelIds : undefined, - priority: newLinearProjectPriority, - startDate: newLinearProjectStartDate || undefined, - targetDate: newLinearProjectTargetDate || undefined - }) + const result = await linearCreateProject( + linearTaskSourceContext ?? defaultScopeSource(settings), + { + name, + description: newLinearProjectDescription.trim() || undefined, + content: newLinearProjectContent.trim() || undefined, + teamIds: [newLinearProjectTargetTeam.id], + workspaceId: newLinearProjectTargetTeam.workspaceId, + leadId: newLinearProjectLeadId || undefined, + memberIds: newLinearProjectMemberIds.length > 0 ? newLinearProjectMemberIds : undefined, + labelIds: newLinearProjectLabelIds.length > 0 ? newLinearProjectLabelIds : undefined, + priority: newLinearProjectPriority, + startDate: newLinearProjectStartDate || undefined, + targetDate: newLinearProjectTargetDate || undefined + } + ) if (!result.ok) { toast.error( result.error || diff --git a/src/renderer/src/components/use-task-page-provider-metadata.ts b/src/renderer/src/components/use-task-page-provider-metadata.ts index 57f01c6126d3..9816f95a6127 100644 --- a/src/renderer/src/components/use-task-page-provider-metadata.ts +++ b/src/renderer/src/components/use-task-page-provider-metadata.ts @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import type { LinearTeam } from '../../../shared/linear/workspace-types' import type { JiraProject } from '../../../shared/jira-types' import { jiraListProjects } from '@/runtime/runtime-jira-client' +import { defaultScopeSource } from '@/lib/default-creation-host' export function useTaskPageProviderMetadata(model: TaskPageResumeRestorationModel) { const { settings, @@ -75,7 +76,7 @@ export function useTaskPageProviderMetadata(model: TaskPageResumeRestorationMode let cancelled = false setAvailableJiraProjects([]) setJiraProjectsLoading(true) - void jiraListProjects(jiraTaskSourceContext ?? settings, selectedJiraSiteId) + void jiraListProjects(jiraTaskSourceContext ?? defaultScopeSource(settings), selectedJiraSiteId) .then((projects) => { if (!cancelled) { setAvailableJiraProjects(projects) diff --git a/src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts b/src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts index 90f1f91b039a..f5edafed2065 100644 --- a/src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts +++ b/src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts @@ -1,6 +1,9 @@ import { useMemo } from 'react' import { getSingleFocusedRuntimeEnvironmentId } from '@/lib/single-runtime-legacy-owner' -import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { useAppStore } from '@/store' /** Distinguishes "not known yet" from "known to be the local host", without @@ -36,9 +39,7 @@ export function useActiveSkillDiscoveryRuntimeTarget(): RuntimeClientTarget | nu }) return useMemo( () => - ownerKey === UNRESOLVED - ? null - : getActiveRuntimeTarget({ activeRuntimeEnvironmentId: parseOwnerKey(ownerKey) }), + ownerKey === UNRESOLVED ? null : runtimeTargetForOwnerEnvironment(parseOwnerKey(ownerKey)), [ownerKey] ) } diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index e040ee78cb5a..5c2952c3d86b 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -200,7 +200,7 @@ describe('useIssueMetadata hooks', () => { function StatesProbe(): null { renders += 1 // Fresh settings object each render — the storm trigger. - const metadata = useTeamStates('team-1', { activeRuntimeEnvironmentId: null }, 'ws-1') + const metadata = useTeamStates('team-1', { kind: 'local' }, 'ws-1') states = metadata.data return null } @@ -220,7 +220,7 @@ describe('useIssueMetadata hooks', () => { function StatesProbe(): null { renders += 1 - const metadata = useTeamStates('team-1', { activeRuntimeEnvironmentId: null }, 'ws-1') + const metadata = useTeamStates('team-1', { kind: 'local' }, 'ws-1') error = metadata.error return null } @@ -254,11 +254,7 @@ describe('useIssueMetadata hooks', () => { }) function MultiProbe(): null { - const metadata = useTeamsStates( - ['team-fe', 'team-be'], - { activeRuntimeEnvironmentId: null }, - 'ws-1' - ) + const metadata = useTeamsStates(['team-fe', 'team-be'], { kind: 'local' }, 'ws-1') states = metadata.data as { id: string; name: string }[] return null } diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index 2ce65c3055f0..1d77f3883293 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -1,17 +1,15 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { linearTeamLabels, linearTeamMembers, linearTeamStates } from '@/runtime/runtime-linear-project-client' -import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' +import { getLinearRuntimeTarget, type RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import type { LinearLabel, LinearMember, LinearWorkflowState } from '../../../shared/linear/workspace-types' -import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' import { unionLinearMetadataById } from '../components/linear-issue-attribute-filter-team-ids' import { clearMetadataRequestStore, @@ -28,14 +26,15 @@ const linearStateStore = createMetadataRequestStore() const linearLabelStore = createMetadataRequestStore() const linearMemberStore = createMetadataRequestStore() +// Why: an omitted source has always meant this computer. +const THIS_COMPUTER: RuntimeLinearSettings = { kind: 'local' } + function linearMetadataCacheKey( teamId: string, - settings: RuntimeLinearSettings, + settings: RuntimeLinearSettings | undefined, workspaceId?: string | null ): string { - const runtimeSettings = - settings && 'kind' in settings ? getTaskSourceRuntimeSettings(settings) : settings - const target = getActiveRuntimeTarget(runtimeSettings) + const target = getLinearRuntimeTarget(settings ?? THIS_COMPUTER) const workspaceKey = workspaceId ?? 'selected' return target.kind === 'environment' ? `runtime:${target.environmentId}:${workspaceKey}:${teamId}` @@ -189,21 +188,19 @@ const loadTeamStates = ( teamId: string, workspaceId: string | null | undefined ): Promise => - linearTeamStates(settings, teamId, workspaceId).then((states) => states as LinearWorkflowState[]) + linearTeamStates(settings ?? THIS_COMPUTER, teamId, workspaceId) const loadTeamLabels = ( settings: RuntimeLinearSettings | undefined, teamId: string, workspaceId: string | null | undefined -): Promise => - linearTeamLabels(settings, teamId, workspaceId).then((labels) => labels as LinearLabel[]) +): Promise => linearTeamLabels(settings ?? THIS_COMPUTER, teamId, workspaceId) const loadTeamMembers = ( settings: RuntimeLinearSettings | undefined, teamId: string, workspaceId: string | null | undefined -): Promise => - linearTeamMembers(settings, teamId, workspaceId).then((members) => members as LinearMember[]) +): Promise => linearTeamMembers(settings ?? THIS_COMPUTER, teamId, workspaceId) export function useTeamsStates( teamIds: readonly string[], diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index bda7a5d407c8..1e03d291dc5b 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -18,7 +18,7 @@ import { isWindowsTerminalCapabilityHost, useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { defaultScopeHost } from '@/lib/default-creation-host' import { useAppStore } from '@/store' import { useProjectHostSetupProjection } from '@/store/selectors' import type { ProjectGroupingModel } from '@/components/sidebar/worktree-list/grouping/project-grouping' @@ -130,7 +130,8 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey( settings?.activeRuntimeEnvironmentId ) - const runtimeTarget = getActiveRuntimeTarget(settings) + // Why: navigation is shared with the palette, which has no host picker. + const runtimeTarget = defaultScopeHost(settings) const capabilityLoadTarget = isWebClient ? { kind: 'local' as const } : runtimeTarget const windowsTerminalCapabilities = useWindowsTerminalCapabilities( isWindows || isWebClient || runtimeTarget.kind === 'environment', diff --git a/src/renderer/src/lib/default-creation-host.test.ts b/src/renderer/src/lib/default-creation-host.test.ts index 05ee35739059..0537648793ca 100644 --- a/src/renderer/src/lib/default-creation-host.test.ts +++ b/src/renderer/src/lib/default-creation-host.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { defaultCreationHost } from './default-creation-host' +import { + defaultCreationHost, + defaultScopeHost, + defaultScopeSource, + rowLessSourceTarget +} from './default-creation-host' describe('defaultCreationHost', () => { it('is this computer unless a server is chosen', () => { @@ -11,3 +16,23 @@ describe('defaultCreationHost', () => { }) }) }) + +describe('row-less sources', () => { + it('starts on the default host until a source or host is named', () => { + const settings = { activeRuntimeEnvironmentId: 'env-a' } + expect(defaultScopeHost(settings)).toEqual({ kind: 'environment', environmentId: 'env-a' }) + expect(rowLessSourceTarget(defaultScopeSource(settings))).toEqual({ + kind: 'environment', + environmentId: 'env-a' + }) + expect(rowLessSourceTarget({ kind: 'local' })).toEqual({ kind: 'local' }) + expect( + rowLessSourceTarget({ + kind: 'task-source', + provider: 'linear', + projectId: 'p', + hostId: 'runtime:env-b' + }) + ).toEqual({ kind: 'environment', environmentId: 'env-b' }) + }) +}) diff --git a/src/renderer/src/lib/default-creation-host.ts b/src/renderer/src/lib/default-creation-host.ts index 9fe9e06cff88..5170686a3d13 100644 --- a/src/renderer/src/lib/default-creation-host.ts +++ b/src/renderer/src/lib/default-creation-host.ts @@ -1,13 +1,45 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { TaskSourceContext } from '../../../shared/task-source-context' import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { taskSourceRuntimeTarget } from './task-source-runtime-target' + +type DefaultHostSettings = Pick | null | undefined + +// The one read of the setting; the exported readers below are the sanctioned ways to reach it. +function readDefaultHost(settings: DefaultHostSettings): RuntimeClientTarget { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } +} /** * The "Default host for new projects" setting. Only a creation flow with no source row reads it; * everything else routes by the resource's owner. */ -export function defaultCreationHost( - settings: Pick | null | undefined -): RuntimeClientTarget { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } +export function defaultCreationHost(settings: DefaultHostSettings): RuntimeClientTarget { + return readDefaultHost(settings) +} + +/** + * Initial host of a row-less source (Linear, Jira, the status-bar account switchers) or a settings + * scope, until the page's own host picker overrides it. + */ +export function defaultScopeHost(settings: DefaultHostSettings): RuntimeClientTarget { + return readDefaultHost(settings) +} + +/** "No source chosen yet": a row-less source that runs on the default scope host. */ +export type DefaultScopeSource = { kind: 'default-scope'; settings: DefaultHostSettings } + +export function defaultScopeSource(settings: DefaultHostSettings): DefaultScopeSource { + return { kind: 'default-scope', settings } +} + +/** A row-less source: its task-source context, an explicit host, or the default scope host. */ +export type RowLessSource = TaskSourceContext | RuntimeClientTarget | DefaultScopeSource + +export function rowLessSourceTarget(source: RowLessSource): RuntimeClientTarget { + if (source.kind === 'task-source') { + return taskSourceRuntimeTarget(source) + } + return source.kind === 'default-scope' ? readDefaultHost(source.settings) : source } diff --git a/src/renderer/src/lib/linear-issue-url-lookup.ts b/src/renderer/src/lib/linear-issue-url-lookup.ts index e2ea3ed9aefd..14f76da309a4 100644 --- a/src/renderer/src/lib/linear-issue-url-lookup.ts +++ b/src/renderer/src/lib/linear-issue-url-lookup.ts @@ -33,7 +33,8 @@ export async function lookupLinearIssueUrl({ knownStatus, sourceContext, fetchLinearIssue, - readLinearStatus = linearStatus + // Why: no source context has always read this computer's Linear status. + readLinearStatus = (context) => linearStatus(context ?? { kind: 'local' }) }: { intent: LinearIssueUrlIntent knownStatus: Pick< diff --git a/src/renderer/src/runtime/runtime-jira-client.test.ts b/src/renderer/src/runtime/runtime-jira-client.test.ts index 5718f8cfff30..82c456d6ba88 100644 --- a/src/renderer/src/runtime/runtime-jira-client.test.ts +++ b/src/renderer/src/runtime/runtime-jira-client.test.ts @@ -177,7 +177,9 @@ describe('runtime Jira client search bounds', () => { }) it('rejects oversized local Jira search before IPC', async () => { - await expect(jiraSearchIssues(null, 'secret-token-value'.repeat(1024), 30)).resolves.toEqual([]) + await expect( + jiraSearchIssues({ kind: 'local' }, 'secret-token-value'.repeat(1024), 30) + ).resolves.toEqual([]) expect(jiraSearchIssuesLocal).not.toHaveBeenCalled() expect(runtimeCall).not.toHaveBeenCalled() @@ -186,7 +188,7 @@ describe('runtime Jira client search bounds', () => { it('rejects oversized runtime Jira assignee search before RPC', async () => { await expect( jiraListAssignableUsers( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, 'ORCA-1', 'x'.repeat(9 * 1024), 'site-1' @@ -200,7 +202,7 @@ describe('runtime Jira client search bounds', () => { it('routes local Jira user search through IPC', async () => { jiraSearchUsersLocal.mockResolvedValue([{ accountId: 'account-1', displayName: 'Ada' }]) - await expect(jiraSearchUsers(null, 'Ada', 'site-1')).resolves.toEqual([ + await expect(jiraSearchUsers({ kind: 'local' }, 'Ada', 'site-1')).resolves.toEqual([ { accountId: 'account-1', displayName: 'Ada' } ]) @@ -222,7 +224,7 @@ describe('runtime Jira client search bounds', () => { }) await expect( - jiraSearchUsers({ activeRuntimeEnvironmentId: 'env-1' }, 'Ada', 'site-1') + jiraSearchUsers({ kind: 'environment', environmentId: 'env-1' }, 'Ada', 'site-1') ).resolves.toEqual([{ accountId: 'account-1', displayName: 'Ada' }]) expect(runtimeCall).toHaveBeenNthCalledWith( 1, @@ -241,9 +243,9 @@ describe('runtime Jira client search bounds', () => { it('degrades remote Jira user search when the host lacks the capability', async () => { runtimeCall.mockResolvedValue(createRuntimeStatusWithoutJiraUserFieldsCapability()) - await expect(jiraSearchUsers({ activeRuntimeEnvironmentId: 'env-1' }, 'Ada')).resolves.toEqual( - [] - ) + await expect( + jiraSearchUsers({ kind: 'environment', environmentId: 'env-1' }, 'Ada') + ).resolves.toEqual([]) expect(runtimeCall).toHaveBeenCalledTimes(1) expect(runtimeCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'jira.searchUsers' }) @@ -255,7 +257,7 @@ describe('runtime Jira client search bounds', () => { await expect( jiraCreateIssue( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, { projectId: 'project-1', issueTypeId: 'type-1', @@ -285,7 +287,7 @@ describe('runtime Jira client search bounds', () => { await expect( jiraCreateIssue( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, { projectId: 'project-1', issueTypeId: 'type-1', title: 'Issue' } ) ).resolves.toMatchObject({ ok: true, key: 'ORCA-1' }) @@ -309,7 +311,7 @@ describe('runtime Jira client search bounds', () => { await expect( jiraCreateIssue( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, { projectId: 'project-1', issueTypeId: 'type-1', @@ -338,13 +340,13 @@ describe('runtime Jira client search bounds', () => { it('uses project-scoped search locally and rejects oversized queries before IPC', async () => { jiraProjectUsersLocal.mockResolvedValue([]) - await jiraListAssignableUsersForProject(null, '100', 'Ada', 'site-1') + await jiraListAssignableUsersForProject({ kind: 'local' }, '100', 'Ada', 'site-1') expect(jiraProjectUsersLocal).toHaveBeenCalledWith({ projectIdOrKey: '100', query: 'Ada', siteId: 'site-1' }) - await jiraListAssignableUsersForProject(null, '100', 'x'.repeat(20_000), 'site-1') + await jiraListAssignableUsersForProject({ kind: 'local' }, '100', 'x'.repeat(20_000), 'site-1') expect(jiraProjectUsersLocal).toHaveBeenCalledTimes(1) }) @@ -355,7 +357,7 @@ describe('runtime Jira client search bounds', () => { : { id: 'rpc-1', ok: true, result: [], _meta: { runtimeId: 'remote-runtime' } } ) await jiraListAssignableUsersForProject( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, '100', 'Ada', 'site-1' @@ -372,7 +374,7 @@ describe('runtime Jira client search bounds', () => { runtimeCall.mockReset().mockResolvedValue(createRuntimeStatusWithoutJiraUserFieldsCapability()) await expect( jiraListAssignableUsersForProject( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, '100', 'Ada', 'site-1' @@ -408,10 +410,10 @@ describe('runtime Jira client search bounds', () => { ) await expect( - jiraGetIssue({ activeRuntimeEnvironmentId: 'env-1' }, 'ORCA-1', 'site-1') + jiraGetIssue({ kind: 'environment', environmentId: 'env-1' }, 'ORCA-1', 'site-1') ).resolves.toMatchObject({ key: 'ORCA-1' }) await expect( - jiraIssueComments({ activeRuntimeEnvironmentId: 'env-1' }, 'ORCA-1', 'site-1') + jiraIssueComments({ kind: 'environment', environmentId: 'env-1' }, 'ORCA-1', 'site-1') ).resolves.toMatchObject([{ id: 'comment-1' }]) expect(runtimeSubscribe).toHaveBeenNthCalledWith( diff --git a/src/renderer/src/runtime/runtime-jira-target.ts b/src/renderer/src/runtime/runtime-jira-target.ts index 610ac82c0b13..c7874b68a906 100644 --- a/src/renderer/src/runtime/runtime-jira-target.ts +++ b/src/renderer/src/runtime/runtime-jira-target.ts @@ -1,24 +1,8 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' -import { - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../shared/task-source-context' -import { getActiveRuntimeTarget } from './runtime-rpc-client' +import type { RuntimeClientTarget } from './runtime-client-target' +import { rowLessSourceTarget, type RowLessSource } from '@/lib/default-creation-host' -export type RuntimeJiraSettings = - | Pick - | TaskSourceContext - | null - | undefined +export type RuntimeJiraSettings = RowLessSource -function isTaskSourceRuntimeSettings(settings: RuntimeJiraSettings): settings is TaskSourceContext { - return settings !== null && settings !== undefined && 'kind' in settings -} - -export function getJiraRuntimeTarget( - settings: RuntimeJiraSettings -): ReturnType { - return getActiveRuntimeTarget( - isTaskSourceRuntimeSettings(settings) ? getTaskSourceRuntimeSettings(settings) : settings - ) +export function getJiraRuntimeTarget(source: RuntimeJiraSettings): RuntimeClientTarget { + return rowLessSourceTarget(source) } diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts index 77e4bbbf9573..6310d4e1e1e3 100644 --- a/src/renderer/src/runtime/runtime-linear-client.test.ts +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -106,22 +106,23 @@ describe('runtime linear client', () => { }) linearCreateProjectLocal.mockResolvedValue({ ok: true, project: { id: 'project-1' } }) - await expect(linearStatus({ activeRuntimeEnvironmentId: null })).resolves.toEqual({ + await expect(linearStatus({ kind: 'local' })).resolves.toEqual({ connected: false, viewer: null }) - await expect( - linearSearchIssues({ activeRuntimeEnvironmentId: null }, 'bug', 10) - ).resolves.toEqual([{ id: 'issue-1' }]) - await expect( - linearListIssues({ activeRuntimeEnvironmentId: null }, 'all', 72, 'workspace-1') - ).resolves.toEqual({ items: [{ id: 'issue-2' }], hasMore: true }) + await expect(linearSearchIssues({ kind: 'local' }, 'bug', 10)).resolves.toEqual([ + { id: 'issue-1' } + ]) + await expect(linearListIssues({ kind: 'local' }, 'all', 72, 'workspace-1')).resolves.toEqual({ + items: [{ id: 'issue-2' }], + hasMore: true + }) await linearCreateSubIssue( - { activeRuntimeEnvironmentId: null }, + { kind: 'local' }, { parentIssueId: 'issue-1', teamId: 'team-1', title: 'Child task' } ) await linearCreateProject( - { activeRuntimeEnvironmentId: null }, + { kind: 'local' }, { name: 'Roadmap', teamIds: ['team-1'], workspaceId: 'workspace-1' } ) @@ -152,23 +153,19 @@ describe('runtime linear client', () => { it('wraps legacy local Linear issue list arrays as collection results', async () => { linearListIssuesLocal.mockResolvedValue([{ id: 'legacy-issue' }]) - await expect( - linearListIssues({ activeRuntimeEnvironmentId: null }, 'assigned', 20) - ).resolves.toEqual({ items: [{ id: 'legacy-issue' }] }) + await expect(linearListIssues({ kind: 'local' }, 'assigned', 20)).resolves.toEqual({ + items: [{ id: 'legacy-issue' }] + }) }) it('rejects oversized local Linear search queries before IPC', async () => { await expect( - linearSearchIssues( - { activeRuntimeEnvironmentId: null }, - 'secret-token-value'.repeat(1024), - 10 - ) + linearSearchIssues({ kind: 'local' }, 'secret-token-value'.repeat(1024), 10) ).resolves.toEqual([]) - await expect( - linearListProjects({ activeRuntimeEnvironmentId: null }, 'x'.repeat(9 * 1024), 10) - ).resolves.toEqual({ items: [] }) + await expect(linearListProjects({ kind: 'local' }, 'x'.repeat(9 * 1024), 10)).resolves.toEqual({ + items: [] + }) expect(linearSearchIssuesLocal).not.toHaveBeenCalled() expect(linearListProjectsLocal).not.toHaveBeenCalled() @@ -179,7 +176,7 @@ describe('runtime linear client', () => { delete (window.api.linear as { listProjects?: unknown }).listProjects await expect( - linearListProjects({ activeRuntimeEnvironmentId: null }, 'roadmap', 10, 'workspace-1') + linearListProjects({ kind: 'local' }, 'roadmap', 10, 'workspace-1') ).resolves.toEqual({ items: [] }) }) @@ -192,47 +189,25 @@ describe('runtime linear client', () => { linearListCustomViewIssuesLocal.mockResolvedValueOnce({ items: [{ id: 'issue-2' }] }) linearListCustomViewProjectsLocal.mockResolvedValueOnce({ items: [{ id: 'project-2' }] }) - await linearListProjects({ activeRuntimeEnvironmentId: null }, 'roadmap', 10, 'workspace-1', { + await linearListProjects({ kind: 'local' }, 'roadmap', 10, 'workspace-1', { force: true }) - await linearGetProject({ activeRuntimeEnvironmentId: null }, 'project-1', 'workspace-1', { + await linearGetProject({ kind: 'local' }, 'project-1', 'workspace-1', { + force: true + }) + await linearListProjectIssues({ kind: 'local' }, 'project-1', 10, 'workspace-1', { + force: true + }) + await linearListCustomViews({ kind: 'local' }, 'project', 10, 'workspace-1', { force: true }) + await linearGetCustomView({ kind: 'local' }, 'view-1', 'project', 'workspace-1', { + force: true + }) + await linearListCustomViewIssues({ kind: 'local' }, 'view-1', 10, 'workspace-1', { + force: true + }) + await linearListCustomViewProjects({ kind: 'local' }, 'view-2', 10, 'workspace-1', { force: true }) - await linearListProjectIssues( - { activeRuntimeEnvironmentId: null }, - 'project-1', - 10, - 'workspace-1', - { force: true } - ) - await linearListCustomViews( - { activeRuntimeEnvironmentId: null }, - 'project', - 10, - 'workspace-1', - { force: true } - ) - await linearGetCustomView( - { activeRuntimeEnvironmentId: null }, - 'view-1', - 'project', - 'workspace-1', - { force: true } - ) - await linearListCustomViewIssues( - { activeRuntimeEnvironmentId: null }, - 'view-1', - 10, - 'workspace-1', - { force: true } - ) - await linearListCustomViewProjects( - { activeRuntimeEnvironmentId: null }, - 'view-2', - 10, - 'workspace-1', - { force: true } - ) expect(linearListProjectsLocal).toHaveBeenCalledWith({ query: 'roadmap', @@ -299,10 +274,10 @@ describe('runtime linear client', () => { _meta: { runtimeId: 'runtime-1' } }) - await linearStatus({ activeRuntimeEnvironmentId: 'env-1' }) - await linearSearchIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'bug', 10, 'all') + await linearStatus({ kind: 'environment', environmentId: 'env-1' }) + await linearSearchIssues({ kind: 'environment', environmentId: 'env-1' }, 'bug', 10, 'all') await expect( - linearListIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'all', 72, 'workspace-1') + linearListIssues({ kind: 'environment', environmentId: 'env-1' }, 'all', 72, 'workspace-1') ).resolves.toEqual({ items: [{ id: 'issue-2' }], hasMore: true }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { @@ -337,7 +312,7 @@ describe('runtime linear client', () => { }) await expect( - linearListIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'assigned', 20) + linearListIssues({ kind: 'environment', environmentId: 'env-1' }, 'assigned', 20) ).resolves.toEqual({ items: [{ id: 'legacy-issue' }] }) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -366,7 +341,7 @@ describe('runtime linear client', () => { ) await expect( - linearListIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'all', 20, 'workspace-1', { + linearListIssues({ kind: 'environment', environmentId: 'env-1' }, 'all', 20, 'workspace-1', { stateIds: ['state-1'], priorities: [], assignee: null, @@ -390,7 +365,7 @@ describe('runtime linear client', () => { }) await expect( - linearListIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'all', 20, 'workspace-1', { + linearListIssues({ kind: 'environment', environmentId: 'env-1' }, 'all', 20, 'workspace-1', { stateIds: [], priorities: [2], assignee: null, @@ -425,7 +400,7 @@ describe('runtime linear client', () => { }) await expect( - linearListIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'assigned', 20) + linearListIssues({ kind: 'environment', environmentId: 'env-1' }, 'assigned', 20) ).resolves.toEqual({ items: [] }) }) @@ -487,17 +462,17 @@ describe('runtime linear client', () => { }) await linearCreateIssue( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, { teamId: 'team-1', title: 'Fix bug', workspaceId: 'workspace-1' } ) await linearUpdateIssue( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, 'issue-1', { estimate: 5, priority: 2 }, 'workspace-1' ) await linearCreateSubIssue( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, { parentIssueId: 'issue-1', teamId: 'team-1', @@ -506,10 +481,15 @@ describe('runtime linear client', () => { projectId: 'project-1' } ) - await linearListTeams({ activeRuntimeEnvironmentId: 'env-1' }, 'all') - await linearListProjects({ activeRuntimeEnvironmentId: 'env-1' }, 'roadmap', 10, 'workspace-1') + await linearListTeams({ kind: 'environment', environmentId: 'env-1' }, 'all') + await linearListProjects( + { kind: 'environment', environmentId: 'env-1' }, + 'roadmap', + 10, + 'workspace-1' + ) await linearCreateProject( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, { name: 'Roadmap', description: 'Summary', @@ -518,7 +498,7 @@ describe('runtime linear client', () => { priority: 2 } ) - await linearSelectWorkspace({ activeRuntimeEnvironmentId: 'env-1' }, 'workspace-1') + await linearSelectWorkspace({ kind: 'environment', environmentId: 'env-1' }, 'workspace-1') expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { selector: 'env-1', @@ -619,39 +599,44 @@ describe('runtime linear client', () => { _meta: { runtimeId: 'runtime-1' } }) - await linearGetProject({ activeRuntimeEnvironmentId: 'env-1' }, 'project-1', 'workspace-1', { - force: true - }) + await linearGetProject( + { kind: 'environment', environmentId: 'env-1' }, + 'project-1', + 'workspace-1', + { + force: true + } + ) await linearListProjectIssues( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, 'project-1', 10, 'workspace-1', { force: true } ) await linearListCustomViews( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, 'project', 10, 'workspace-1', { force: true } ) await linearGetCustomView( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, 'view-1', 'project', 'workspace-1', { force: true } ) await linearListCustomViewIssues( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, 'view-1', 10, 'workspace-1', { force: true } ) await linearListCustomViewProjects( - { activeRuntimeEnvironmentId: 'env-1' }, + { kind: 'environment', environmentId: 'env-1' }, 'view-2', 10, 'workspace-1', diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index 745a4d35b3ae..e4576172d07e 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -1,4 +1,3 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearProjectDetail } from '../../../shared/linear/project-types' import type { @@ -7,15 +6,9 @@ import type { LinearViewer, LinearWorkspaceSelection } from '../../../shared/linear/workspace-types' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - runtimeEnvironmentSupportsCapability -} from './runtime-rpc-client' -import { - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../shared/task-source-context' +import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' +import type { RuntimeClientTarget } from './runtime-client-target' +import { rowLessSourceTarget, type RowLessSource } from '@/lib/default-creation-host' import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' import type { LinearIssueAttributeFilter } from '../../../shared/linear/issue-attribute-filter' import { @@ -24,11 +17,7 @@ import { } from '../../../shared/linear/issue-attribute-filter' import { LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' -export type RuntimeLinearSettings = - | Pick - | TaskSourceContext - | null - | undefined +export type RuntimeLinearSettings = RowLessSource // Why: mixed-version remotes must not look like an empty filtered result. The // Linear store swallows most read failures; this typed error is rethrown so UI @@ -62,20 +51,8 @@ export function linearReadForce(options?: LinearReadOptions): { force: true } | return options?.force ? { force: true } : {} } -function isTaskSourceRuntimeSettings( - settings: RuntimeLinearSettings -): settings is TaskSourceContext { - return settings !== null && settings !== undefined && 'kind' in settings -} - -export function getLinearRuntimeTarget( - settings: RuntimeLinearSettings -): ReturnType { - // Why: task source context makes provider ownership explicit; legacy callers - // still pass focused runtime settings until Tasks finishes migrating. - return getActiveRuntimeTarget( - isTaskSourceRuntimeSettings(settings) ? getTaskSourceRuntimeSettings(settings) : settings - ) +export function getLinearRuntimeTarget(source: RuntimeLinearSettings): RuntimeClientTarget { + return rowLessSourceTarget(source) } function normalizeLinearIssueCollectionResult( diff --git a/src/renderer/src/store/slices/jira-connection-actions.ts b/src/renderer/src/store/slices/jira-connection-actions.ts index 8eab27c758e4..1d01e79ec77d 100644 --- a/src/renderer/src/store/slices/jira-connection-actions.ts +++ b/src/renderer/src/store/slices/jira-connection-actions.ts @@ -22,6 +22,7 @@ import { jiraStatusUpdate, nextJiraStatusReadGeneration } from './jira-read-coordination' +import { defaultScopeSource } from '@/lib/default-creation-host' type JiraConnectionActions = Pick< JiraSlice, @@ -62,7 +63,7 @@ export function createJiraConnectionActions( set({ jiraStatusChecked: false }) } try { - const status = await jiraStatus(get().settings) + const status = await jiraStatus(defaultScopeSource(get().settings)) if ( mutationGeneration !== currentJiraMutationGeneration() || !isCurrentJiraStatusRead(statusReadGeneration) || @@ -100,7 +101,7 @@ export function createJiraConnectionActions( const requestGeneration = beginJiraMutation() const contextKey = getProviderRuntimeContextKey(get().settings) try { - const result = await jiraConnect(get().settings, args) + const result = await jiraConnect(defaultScopeSource(get().settings), args) if ( result.ok && isCurrentJiraMutation(requestGeneration) && @@ -132,14 +133,14 @@ export function createJiraConnectionActions( const requestGeneration = beginJiraMutation() const contextKey = getProviderRuntimeContextKey(get().settings) try { - const result = await jiraTestConnection(get().settings, siteId) + const result = await jiraTestConnection(defaultScopeSource(get().settings), siteId) if ( !isCurrentJiraMutation(requestGeneration) || !isCurrentJiraRuntimeContext(contextKey, get().settings) ) { return result } - const status = await jiraStatus(get().settings) + const status = await jiraStatus(defaultScopeSource(get().settings)) if ( isCurrentJiraMutation(requestGeneration) && isCurrentJiraRuntimeContext(contextKey, get().settings) @@ -155,7 +156,7 @@ export function createJiraConnectionActions( selectJiraSite: async (siteId) => { const requestGeneration = beginJiraMutation() const contextKey = getProviderRuntimeContextKey(get().settings) - const status = await jiraSelectSite(get().settings, siteId) + const status = await jiraSelectSite(defaultScopeSource(get().settings), siteId) if ( !isCurrentJiraMutation(requestGeneration) || getProviderRuntimeContextKey(get().settings) !== contextKey @@ -169,7 +170,7 @@ export function createJiraConnectionActions( disconnectJira: async (siteId) => { const requestGeneration = beginJiraMutation() const contextKey = getProviderRuntimeContextKey(get().settings) - await jiraDisconnect(get().settings, siteId) + await jiraDisconnect(defaultScopeSource(get().settings), siteId) if ( !isCurrentJiraMutation(requestGeneration) || !isCurrentJiraRuntimeContext(contextKey, get().settings) @@ -177,7 +178,7 @@ export function createJiraConnectionActions( return } clearJiraInflightRequests() - const status = await jiraStatus(get().settings) + const status = await jiraStatus(defaultScopeSource(get().settings)) if ( !isCurrentJiraMutation(requestGeneration) || !isCurrentJiraRuntimeContext(contextKey, get().settings) diff --git a/src/renderer/src/store/slices/jira-read-coordination.ts b/src/renderer/src/store/slices/jira-read-coordination.ts index 6d38ff652eb6..b677552fbd93 100644 --- a/src/renderer/src/store/slices/jira-read-coordination.ts +++ b/src/renderer/src/store/slices/jira-read-coordination.ts @@ -13,6 +13,8 @@ import { } from '../../../../shared/task-source-context' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { parseJiraStatusError } from '../../../../shared/jira-status-error' +import { defaultScopeSource } from '@/lib/default-creation-host' +import type { RowLessSource } from '@/lib/default-creation-host' const CACHE_TTL = 60_000 const MAX_CACHE_ENTRIES = 500 @@ -29,7 +31,7 @@ export type SharedJiraSummaryRequest = InflightJiraReadRequest } export type JiraReadScope = { - settings: AppState['settings'] | TaskSourceContext | null + settings: RowLessSource contextKey: string cachePrefix: string | null explicitSource: boolean @@ -195,7 +197,8 @@ export function getJiraReadScope( ): JiraReadScope { if (!sourceContext) { return { - settings, + // Why: no Tasks source chosen yet; the default scope host is the row-less source's host. + settings: defaultScopeSource(settings), contextKey: getProviderRuntimeContextKey(settings), cachePrefix: null, explicitSource: false @@ -214,11 +217,15 @@ export function scopedJiraCacheKey(scope: JiraReadScope, key: string): string { return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key } -function jiraConnectionRevisionContextKey( - settings: AppState['settings'] | TaskSourceContext | null -): string { +function jiraConnectionRevisionContextKey(source: RowLessSource): string { return getProviderRuntimeContextKey( - settings && 'kind' in settings ? getTaskSourceRuntimeSettings(settings) : settings + source.kind === 'default-scope' + ? source.settings + : source.kind === 'task-source' + ? getTaskSourceRuntimeSettings(source) + : { + activeRuntimeEnvironmentId: source.kind === 'environment' ? source.environmentId : null + } ) } diff --git a/src/renderer/src/store/slices/linear-invalidation.test.ts b/src/renderer/src/store/slices/linear-invalidation.test.ts index 06220136d482..3a39ae92b0c7 100644 --- a/src/renderer/src/store/slices/linear-invalidation.test.ts +++ b/src/renderer/src/store/slices/linear-invalidation.test.ts @@ -150,7 +150,11 @@ describe('createLinearSlice invalidation', () => { expect(store.getState().linearListCache).toEqual({}) expect(store.getState().linearProjectIssueCache).toEqual({}) expect(store.getState().linearCustomViewIssueCache).toEqual({}) - expect(linearGetIssue).toHaveBeenCalledWith(null, 'issue-id', 'workspace-1') + expect(linearGetIssue).toHaveBeenCalledWith( + { kind: 'default-scope', settings: null }, + 'issue-id', + 'workspace-1' + ) }) it('connect invalidates cached Linear rows and waits for refreshed status', async () => { diff --git a/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts b/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts index 46bb849b2de9..6c3bf6ce2a66 100644 --- a/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts +++ b/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts @@ -133,9 +133,15 @@ describe('createLinearSlice caching', () => { hasMore: true }) - expect(linearListProjectIssues).toHaveBeenCalledWith(null, 'project-1', 120, 'workspace-1', { - force: undefined - }) + expect(linearListProjectIssues).toHaveBeenCalledWith( + { kind: 'default-scope', settings: null }, + 'project-1', + 120, + 'workspace-1', + { + force: undefined + } + ) expect( store.getState().linearProjectIssueCache['workspace-1::project-issues::project-1::120']?.data ).toMatchObject({ items: [{ id: 'LIN-120' }] }) @@ -234,9 +240,15 @@ describe('createLinearSlice caching', () => { hasMore: true }) - expect(linearListCustomViewIssues).toHaveBeenCalledWith(null, 'view-1', 120, 'workspace-1', { - force: undefined - }) + expect(linearListCustomViewIssues).toHaveBeenCalledWith( + { kind: 'default-scope', settings: null }, + 'view-1', + 120, + 'workspace-1', + { + force: undefined + } + ) expect( store.getState().linearCustomViewIssueCache['workspace-1::custom-view-issues::view-1::120'] ?.data @@ -315,9 +327,15 @@ describe('createLinearSlice caching', () => { store.getState().fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true }) ).resolves.toMatchObject({ id: 'view-1' }) - expect(linearGetCustomView).toHaveBeenCalledWith(null, 'view-1', 'project', 'workspace-1', { - force: true - }) + expect(linearGetCustomView).toHaveBeenCalledWith( + { kind: 'default-scope', settings: null }, + 'view-1', + 'project', + 'workspace-1', + { + force: true + } + ) await vi.waitFor(() => { expect(store.getState().linearStatus.credentialError).toBeUndefined() }) diff --git a/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts b/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts index c799e190c75c..434bf1bbce70 100644 --- a/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts +++ b/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts @@ -17,6 +17,7 @@ import { } from './linear-slice-request-state' import { isCurrentLinearRuntimeContext } from './linear-slice-scope' import { linearStatusScopeSignature } from './linear-cache' +import { defaultScopeSource } from '@/lib/default-creation-host' export function createLinearConnectionActions( set: LinearSliceSet, @@ -34,16 +35,14 @@ export function createLinearConnectionActions( const requestGeneration = beginLinearMutation() const contextKey = getProviderRuntimeContextKey(get().settings) try { - const result = (await linearTestConnection(get().settings, workspaceId)) as - | { ok: true; viewer: LinearViewer } - | { ok: false; error: string } + const result = await linearTestConnection(defaultScopeSource(get().settings), workspaceId) if ( !isCurrentLinearMutation(requestGeneration) || !isCurrentLinearRuntimeContext(contextKey, get().settings) ) { return result } - const status = await linearStatus(get().settings) + const status = await linearStatus(defaultScopeSource(get().settings)) if ( isCurrentLinearMutation(requestGeneration) && isCurrentLinearRuntimeContext(contextKey, get().settings) @@ -83,7 +82,7 @@ export function createLinearConnectionActions( const requestGeneration = beginLinearMutation() const contextKey = getProviderRuntimeContextKey(get().settings) try { - const result = await linearConnect(get().settings, apiKey) + const result = await linearConnect(defaultScopeSource(get().settings), apiKey) if ( result.ok && isCurrentLinearMutation(requestGeneration) && @@ -103,7 +102,7 @@ export function createLinearConnectionActions( linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {} }) - const status = await linearStatus(get().settings) + const status = await linearStatus(defaultScopeSource(get().settings)) if ( !isCurrentLinearMutation(requestGeneration) || !isCurrentLinearRuntimeContext(contextKey, get().settings) @@ -140,7 +139,7 @@ export function createLinearConnectionActions( selectLinearWorkspace: async (workspaceId) => { const requestGeneration = beginLinearMutation() const contextKey = getProviderRuntimeContextKey(get().settings) - const status = await linearSelectWorkspace(get().settings, workspaceId) + const status = await linearSelectWorkspace(defaultScopeSource(get().settings), workspaceId) if ( !isCurrentLinearMutation(requestGeneration) || !isCurrentLinearRuntimeContext(contextKey, get().settings) @@ -169,7 +168,7 @@ export function createLinearConnectionActions( disconnectLinear: async () => { const requestGeneration = beginLinearMutation() const contextKey = getProviderRuntimeContextKey(get().settings) - await linearDisconnect(get().settings) + await linearDisconnect(defaultScopeSource(get().settings)) if ( !isCurrentLinearMutation(requestGeneration) || !isCurrentLinearRuntimeContext(contextKey, get().settings) @@ -198,14 +197,14 @@ export function createLinearConnectionActions( disconnectLinearWorkspace: async (workspaceId) => { const requestGeneration = beginLinearMutation() const contextKey = getProviderRuntimeContextKey(get().settings) - await linearDisconnectWorkspace(get().settings, workspaceId) + await linearDisconnectWorkspace(defaultScopeSource(get().settings), workspaceId) if ( !isCurrentLinearMutation(requestGeneration) || !isCurrentLinearRuntimeContext(contextKey, get().settings) ) { return } - const status = await linearStatus(get().settings) + const status = await linearStatus(defaultScopeSource(get().settings)) if ( !isCurrentLinearMutation(requestGeneration) || !isCurrentLinearRuntimeContext(contextKey, get().settings) diff --git a/src/renderer/src/store/slices/linear/linear-slice-scope.ts b/src/renderer/src/store/slices/linear/linear-slice-scope.ts index 6a36b740c647..29e6d9cab727 100644 --- a/src/renderer/src/store/slices/linear/linear-slice-scope.ts +++ b/src/renderer/src/store/slices/linear/linear-slice-scope.ts @@ -11,6 +11,8 @@ import { } from '../../../../../shared/linear/issue-attribute-filter' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { getLinearCacheGeneration, getLinearMutationGeneration } from './linear-slice-request-state' +import { defaultScopeSource } from '@/lib/default-creation-host' +import type { RowLessSource } from '@/lib/default-creation-host' export function normalizeListAttributeFilter( attributeFilter?: LinearIssueAttributeFilter | null @@ -22,7 +24,7 @@ export function normalizeListAttributeFilter( } export type LinearReadScope = { - settings: AppState['settings'] | TaskSourceContext | null + settings: RowLessSource contextKey: string cachePrefix: string | null explicitSource: boolean @@ -55,7 +57,8 @@ export function getLinearReadScope( ): LinearReadScope { if (!sourceContext) { return { - settings, + // Why: no Tasks source chosen yet; the default scope host is the row-less source's host. + settings: defaultScopeSource(settings), contextKey: getProviderRuntimeContextKey(settings), cachePrefix: null, explicitSource: false diff --git a/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts b/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts index 5fa5be619455..1c9311de241b 100644 --- a/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts +++ b/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts @@ -12,6 +12,7 @@ import { } from './linear-slice-request-state' import { linearStatusScopeSignature } from './linear-cache' import { isCurrentLinearRuntimeContext } from './linear-slice-scope' +import { defaultScopeSource } from '@/lib/default-creation-host' export function createLinearStatusActions( set: LinearSliceSet, @@ -30,7 +31,7 @@ export function createLinearStatusActions( const mutationGeneration = getLinearMutationGeneration() const statusReadGeneration = nextLinearStatusReadGeneration() - const request = linearStatus(get().settings) + const request = linearStatus(defaultScopeSource(get().settings)) .then((status) => { if ( mutationGeneration !== getLinearMutationGeneration() || From 4f03c23796b49973aada00a04257bb97ecd89ae3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 12:58:09 -0700 Subject: [PATCH 008/110] refactor(projects): repo, project group and folder catalog calls route by their owner (#27288) Catalog fetches, path-status probes, project and group mutations, nested scans and imports now take the owner's host explicitly instead of falling back to the Active Server setting. A new project with no picked host still goes to the default host, through defaultCreationHost. Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 27 +--- config/owner-routing-baseline.txt | 15 --- .../settings-project-section-renderer.tsx | 4 +- .../src/components/sidebar/AddRepoSteps.tsx | 6 +- .../sidebar/ProjectAddedDialog.test.tsx | 14 +- .../components/sidebar/ProjectAddedDialog.tsx | 7 +- .../sidebar/add-repo-runtime-owner.ts | 14 +- ...er-workspace-composer-path-status.test.tsx | 24 +++- .../folder-workspace-composer-path-status.ts | 7 +- .../use-worktree-context-menu-commands.ts | 15 ++- .../components/sidebar/useAddRepoCloneFlow.ts | 6 +- .../useAddRepoNestedImportFlow.test.ts | 4 +- .../sidebar/useAddRepoNestedImportFlow.ts | 6 +- .../useAddRepoNestedReviewController.ts | 4 +- .../sidebar/useAddRepoNestedReviewState.ts | 14 +- .../sidebar/useAddRepoServerPathFlow.ts | 6 +- .../src/components/sidebar/useCreateRepo.ts | 5 +- .../listing/use-folder-path-statuses.ts | 27 ++-- .../rows/use-project-group-dialogs.ts | 14 +- .../composer-state/composer-store-actions.ts | 2 +- .../ipc-events/project-catalog-ipc-bridge.ts | 8 +- .../workspace-activation-path-gate.test.ts | 2 +- .../folder-path-status-actions.ts | 16 +-- .../folder-workspace-catalog-actions.ts | 8 +- .../folder-workspace-mutations.ts | 36 ++---- .../folder-workspace-routing.ts | 37 ++++-- .../github/work-item-mutation-actions.ts | 2 +- .../nested-repository-operations.ts | 29 ++--- .../project-group-catalog-actions.ts | 8 +- .../project-groups/project-group-mutations.ts | 35 +++-- .../store/projects/project-host-routing.ts | 19 --- .../src/store/projects/project-update.ts | 7 +- .../repos/all-host-repo-catalog-actions.ts | 5 +- src/renderer/src/store/repos/owner-routing.ts | 11 -- .../src/store/repos/repo-add-actions.ts | 19 +-- .../src/store/repos/repo-catalog-actions.ts | 8 +- src/renderer/src/store/repos/repo-removal.ts | 13 +- src/renderer/src/store/repos/repo-state.ts | 45 ++++--- src/renderer/src/store/repos/repo-update.ts | 14 +- .../repos/runtime-repo-catalog-actions.ts | 4 +- ...r-workspace-owner-routed-mutations.test.ts | 18 ++- .../slices/project-group-owner-routing.ts | 36 ++---- .../src/store/slices/repos-add-races.test.ts | 11 +- .../slices/repos-all-hosts-generation.test.ts | 9 +- .../src/store/slices/repos-all-hosts.test.ts | 2 +- .../repos-catalog-merge-identity.test.ts | 53 ++++---- .../repos-catalog-notifications.test.ts | 13 +- .../repos-cross-host-refresh-identity.test.ts | 13 +- ...repos-module-lifetime-coordinators.test.ts | 11 +- ...pos-nested-import-refresh-failures.test.ts | 1 + .../repos-nested-ssh-projection.test.ts | 11 +- .../repos-project-group-create-race.test.ts | 38 ++++-- .../repos-project-groups-delete.test.ts | 5 +- ...repos-project-groups-owner-routing.test.ts | 6 +- .../store/slices/repos-project-groups.test.ts | 114 +++++++++-------- .../repos-project-host-capability.test.ts | 9 +- .../repos-project-host-lifecycle.test.ts | 9 +- .../slices/repos-project-runtime.test.ts | 121 +++++++++++++++--- .../slices/repos-refresh-identity.test.ts | 87 +++++++------ .../repos-runtime-project-groups.test.ts | 9 +- .../repos-selected-owner-routing.test.ts | 11 +- .../repos-setup-script-dismissals.test.ts | 9 +- .../store/slices/repos-stale-fetch.test.ts | 23 ++-- src/renderer/src/store/slices/repos.test.ts | 38 +++--- ...me-catalog-merge-removed-env-guard.test.ts | 9 +- src/renderer/src/store/slices/settings.ts | 3 +- 66 files changed, 691 insertions(+), 525 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index e45e3582fd74..81b66780de21 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -219,7 +219,6 @@ 2 src/renderer/src/components/sidebar/WorktreeList.tsx 1 src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx 1 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx -1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts 2 src/renderer/src/components/sidebar/delete-worktree-flow.ts 1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts 2 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -234,8 +233,6 @@ 1 src/renderer/src/components/sidebar/use-workspace-reference-details.ts 1 src/renderer/src/components/sidebar/use-worktree-card-controller.ts 1 src/renderer/src/components/sidebar/use-worktree-card-review-details.ts -1 src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts -1 src/renderer/src/components/sidebar/useCreateRepo.ts 2 src/renderer/src/components/sidebar/useSidebarProjectDrop.ts 1 src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts 2 src/renderer/src/components/sidebar/visible-worktrees.ts @@ -246,10 +243,8 @@ 1 src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx 1 src/renderer/src/components/sidebar/worktree-filter-visibility.ts 2 src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts -2 src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts 1 src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts 1 src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx -1 src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx 1 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts 1 src/renderer/src/components/skills/SkillsPage.tsx 7 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx @@ -484,29 +479,20 @@ 2 src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts 1 src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts 3 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts -2 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts -2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts -6 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts -1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts +1 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts 1 src/renderer/src/store/github/repository-routing.ts 2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts 2 src/renderer/src/store/github/work-item-aggregate-actions.ts 3 src/renderer/src/store/github/work-item-fetch-actions.ts 2 src/renderer/src/store/github/work-item-routing.ts 9 src/renderer/src/store/index.ts -6 src/renderer/src/store/project-groups/nested-repository-operations.ts -2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts -10 src/renderer/src/store/project-groups/project-group-mutations.ts -1 src/renderer/src/store/projects/project-host-routing.ts -1 src/renderer/src/store/projects/project-update.ts -2 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +3 src/renderer/src/store/project-groups/project-group-mutations.ts +1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts 1 src/renderer/src/store/repos/converted-ssh-browser-pages.ts 2 src/renderer/src/store/repos/owner-routing.ts 2 src/renderer/src/store/repos/repo-add-actions.ts -2 src/renderer/src/store/repos/repo-catalog-actions.ts -2 src/renderer/src/store/repos/repo-removal.ts -3 src/renderer/src/store/repos/repo-update.ts -2 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +1 src/renderer/src/store/repos/repo-update.ts +1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 2 src/renderer/src/store/repos/safe-auto-fork-sync.ts 1 src/renderer/src/store/slices/browser-slice-test-harness.ts 1 src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -556,10 +542,9 @@ 9 src/renderer/src/store/slices/linear/linear-slice-status-actions.ts 6 src/renderer/src/store/slices/linear/linear-team-actions.ts 2 src/renderer/src/store/slices/preflight.ts -1 src/renderer/src/store/slices/project-group-owner-routing.ts 1 src/renderer/src/store/slices/repo-host-identity.ts 1 src/renderer/src/store/slices/repo-reorder-host-split.ts -10 src/renderer/src/store/slices/repos.ts +4 src/renderer/src/store/slices/repos.ts 1 src/renderer/src/store/slices/runtime-status.ts 2 src/renderer/src/store/slices/settings-owner-hydration-publication.ts 5 src/renderer/src/store/slices/settings.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 05f10d68f0d7..15568aaff917 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -9,7 +9,6 @@ 1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx 1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts -1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/lib/github-pr-start-point.ts @@ -22,20 +21,6 @@ 9 src/renderer/src/runtime/runtime-git-working-tree-client.ts 3 src/renderer/src/runtime/runtime-hooks-client.ts 3 src/renderer/src/runtime/runtime-repo-client.ts -1 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts -2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts -3 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts -1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts -6 src/renderer/src/store/project-groups/nested-repository-operations.ts -2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts -4 src/renderer/src/store/project-groups/project-group-mutations.ts -1 src/renderer/src/store/projects/project-host-routing.ts -1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts -2 src/renderer/src/store/repos/repo-add-actions.ts -2 src/renderer/src/store/repos/repo-catalog-actions.ts -1 src/renderer/src/store/repos/repo-removal.ts -1 src/renderer/src/store/repos/repo-update.ts -1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 1 src/renderer/src/store/slices/diff-comment-persistence.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts diff --git a/src/renderer/src/components/settings/settings-project-section-renderer.tsx b/src/renderer/src/components/settings/settings-project-section-renderer.tsx index 40aeecf37a77..d6ad190b5e42 100644 --- a/src/renderer/src/components/settings/settings-project-section-renderer.tsx +++ b/src/renderer/src/components/settings/settings-project-section-renderer.tsx @@ -72,7 +72,9 @@ export function renderProjectSettingsSections(context: SettingsRenderContext): R wslAvailable={terminal.windowsTerminalCapabilities.wslAvailable} wslDistros={terminal.windowsTerminalCapabilities.wslDistros} wslCapabilitiesLoading={terminal.windowsTerminalCapabilities.isLoading} - updateProject={model.updateProject} + updateProject={(projectId, updates) => + model.updateProject(projectId, updates, getRepoExecutionHostId(repo)) + } /> ) : null} diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index 248bcd87058c..c17386073de1 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -23,11 +23,11 @@ export function useRemoteRepo( onGitRepoReady?: (repoId: string, executionHostId?: ExecutionHostId) => void | Promise, scanNestedRepos?: ( path: string, - connectionId?: string, - controls?: { + connectionId: string | undefined, + controls: { scanId?: string onProgress?: (scan: NestedRepoScanResult) => void - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null } ) => Promise, showNestedRepoReview?: ( diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx index 8a9651aa6a83..b7df5f79ad8a 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ closeModal: vi.fn(), repos: [] as Repo[], worktreesByRepo: {} as Record, - fetchRepos: vi.fn(), + fetchReposForAllHosts: vi.fn(), fetchWorktrees: vi.fn(), setHideDefaultBranchWorkspace: vi.fn() }, @@ -68,7 +68,7 @@ describe('ProjectAddedDialog', () => { mocks.state.modalData = { repoId: 'repo-1' } mocks.state.repos = [makeRepo()] mocks.state.worktreesByRepo = {} - mocks.state.fetchRepos.mockResolvedValue(undefined) + mocks.state.fetchReposForAllHosts.mockResolvedValue(undefined) mocks.state.fetchWorktrees.mockResolvedValue(true) }) @@ -166,7 +166,7 @@ describe('ProjectAddedDialog', () => { it('refreshes repos before closing a stale repo id', async () => { mocks.state.repos = [] - mocks.state.fetchRepos.mockImplementation(async () => { + mocks.state.fetchReposForAllHosts.mockImplementation(async () => { mocks.state.repos = [] }) const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog') @@ -174,13 +174,13 @@ describe('ProjectAddedDialog', () => { renderToStaticMarkup() await flushPromises() - expect(mocks.state.fetchRepos).toHaveBeenCalledTimes(1) + expect(mocks.state.fetchReposForAllHosts).toHaveBeenCalledTimes(1) expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) }) - it('waits for repo hydration and does not close when fetchRepos supplies the repo', async () => { + it('waits for repo hydration and does not close when fetchReposForAllHosts supplies the repo', async () => { mocks.state.repos = [] - mocks.state.fetchRepos.mockImplementation(async () => { + mocks.state.fetchReposForAllHosts.mockImplementation(async () => { mocks.state.repos = [makeRepo()] }) const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog') @@ -188,7 +188,7 @@ describe('ProjectAddedDialog', () => { renderToStaticMarkup() await flushPromises() - expect(mocks.state.fetchRepos).toHaveBeenCalledTimes(1) + expect(mocks.state.fetchReposForAllHosts).toHaveBeenCalledTimes(1) expect(mocks.state.closeModal).not.toHaveBeenCalled() expect(mocks.finishProjectAddWithDefaultCheckout).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx index 6532a39cae8e..25f10c739255 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx @@ -14,7 +14,7 @@ export default function ProjectAddedDialog(): null { const modalData = useAppStore((s) => s.modalData as ProjectAddedModalData) const closeModal = useAppStore((s) => s.closeModal) const repos = useAppStore((s) => s.repos) - const fetchRepos = useAppStore((s) => s.fetchRepos) + const fetchReposForAllHosts = useAppStore((s) => s.fetchReposForAllHosts) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const handoffRunRef = useRef(0) @@ -47,7 +47,8 @@ export default function ProjectAddedDialog(): null { pendingRepoHydrationRef.current = repoId let cancelled = false void (async () => { - await fetchRepos() + // Why: no row yet names the new project's host, so look on every host. + await fetchReposForAllHosts() if (cancelled) { return } @@ -112,7 +113,7 @@ export default function ProjectAddedDialog(): null { }, [ activeModal, closeModal, - fetchRepos, + fetchReposForAllHosts, fetchWorktrees, repo, repoId, diff --git a/src/renderer/src/components/sidebar/add-repo-runtime-owner.ts b/src/renderer/src/components/sidebar/add-repo-runtime-owner.ts index 9aa461153557..c929eed87aed 100644 --- a/src/renderer/src/components/sidebar/add-repo-runtime-owner.ts +++ b/src/renderer/src/components/sidebar/add-repo-runtime-owner.ts @@ -4,7 +4,10 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' export type CapturedRuntimeOwner = string | null | undefined @@ -35,12 +38,9 @@ export function worktreeRefreshOptions( } } +/** The host the Add Project dialog picked; no picked server means this app. */ export function resolveAddRepoRuntimeTarget( - runtimeEnvironmentId: string | null | undefined, - settings: Parameters[0] + runtimeEnvironmentId: string | null | undefined ): RuntimeClientTarget { - const environmentId = runtimeEnvironmentId?.trim() - return environmentId - ? { kind: 'environment', environmentId } - : getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: null }) + return runtimeTargetForOwnerEnvironment(runtimeEnvironmentId ?? null) } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx index 56665ee9306d..959f447928cc 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx @@ -28,7 +28,7 @@ let root: Root | null = null let container: HTMLDivElement | null = null function HookProbe(): null { - const result = useFolderWorkspaceComposerPathStatus(projectGroup, true) + const result = useFolderWorkspaceComposerPathStatus(projectGroup, true, null) ;( globalThis as { __folderWorkspaceComposerPathStatusResult?: typeof result } ).__folderWorkspaceComposerPathStatusResult = result @@ -53,7 +53,9 @@ describe('useFolderWorkspaceComposerPathStatus', () => { vi.useFakeTimers() vi.setSystemTime(20_000) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = useAppStore + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) const fetchFolderWorkspacePathStatus = vi.fn().mockResolvedValue(null) useAppStore.setState({ projectGroups: [projectGroup], @@ -112,7 +114,10 @@ describe('useFolderWorkspaceComposerPathStatus', () => { } ).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError ).toContain('/workspace/platform') - expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { force: true }) + expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { + force: true, + runtimeEnvironmentId: null + }) }) it('unblocks creation when the first path status check settles without cache', async () => { @@ -151,14 +156,19 @@ describe('useFolderWorkspaceComposerPathStatus', () => { } ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate ).toBe(false) - expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { force: true }) + expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { + force: true, + runtimeEnvironmentId: null + }) }) it('does not block creation for an unavailable path status', () => { vi.useFakeTimers() vi.setSystemTime(20_000) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = useAppStore + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) useAppStore.setState({ projectGroups: [projectGroup], fetchFolderWorkspacePathStatus: vi.fn().mockResolvedValue(null), @@ -195,7 +205,9 @@ describe('useFolderWorkspaceComposerPathStatus', () => { vi.useFakeTimers() vi.setSystemTime(20_000) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = useAppStore + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) useAppStore.setState({ projectGroups: [projectGroup], fetchFolderWorkspacePathStatus: vi.fn(), diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts index f014c915f3e2..2fb9f3dc6f2c 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts @@ -12,7 +12,7 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' export function useFolderWorkspaceComposerPathStatus( projectGroup: ProjectGroup | null, open: boolean, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null ): { pathStatusBlocksCreate: boolean pathStatusProjectError: string | null @@ -48,10 +48,7 @@ export function useFolderWorkspaceComposerPathStatus( const [completedPathStatusRefreshKeys, setCompletedPathStatusRefreshKeys] = useState< ReadonlySet >(() => new Set()) - const pathStatusRouteOptions = useMemo( - () => ({ runtimeEnvironmentId: runtimeEnvironmentId ?? null }), - [runtimeEnvironmentId] - ) + const pathStatusRouteOptions = useMemo(() => ({ runtimeEnvironmentId }), [runtimeEnvironmentId]) const pathStatusCacheKey = pathStatusRequest ? getFolderWorkspacePathStatusCacheKey(pathStatusRequest, pathStatusRouteOptions) : null diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts index 30920343680f..b63725b5e8e8 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import type { useAppStore } from '@/store' import type { Repo } from '../../../../shared/repo-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' import { createWorktreeContextMenuDeleteIntent, @@ -74,9 +75,10 @@ export function useWorktreeContextMenuCommands(args: { if (!args.repo) { return } - const group = await args.createProjectGroup(name) + const hostId = getRepoExecutionHostId(args.repo) + const group = await args.createProjectGroup(name, hostId) if (group) { - await args.moveProjectToGroup(args.repo.id, group.id) + await args.moveProjectToGroup(args.repo.id, group.id, undefined, hostId) } }, [args] @@ -86,13 +88,18 @@ export function useWorktreeContextMenuCommands(args: { if (!args.repo || args.repo.projectGroupId === groupId) { return } - void args.moveProjectToGroup(args.repo.id, groupId) + void args.moveProjectToGroup( + args.repo.id, + groupId, + undefined, + getRepoExecutionHostId(args.repo) + ) }, [args] ) const handleRemoveProjectFromGroup = useCallback(() => { if (args.repo) { - void args.moveProjectToGroup(args.repo.id, null) + void args.moveProjectToGroup(args.repo.id, null, undefined, getRepoExecutionHostId(args.repo)) } }, [args]) const handleAssignWorkspaceStatus = useCallback( diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts index a81a3eb3be67..ef07127bc09f 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { toast } from 'sonner' -import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' import type { Repo } from '../../../../shared/repo-types' @@ -132,10 +131,7 @@ export function useAddRepoCloneFlow({ setCloneError(null) setCloneProgress(null) try { - const target = resolveAddRepoRuntimeTarget( - activeRuntimeEnvironmentId, - useAppStore.getState().settings - ) + const target = resolveAddRepoRuntimeTarget(activeRuntimeEnvironmentId) const repo = sshTargetId?.trim() ? await window.api.repos.cloneRemote({ connectionId: sshTargetId.trim(), diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts index b099e4c6ce8b..c5a03d6ca39a 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts @@ -81,6 +81,7 @@ function useTestAddRepoNestedImportFlow( nestedConnectionId: null, nestedGroupName: 'platform', nestedImportScanId: 'scan-1', + nestedRuntimeEnvironmentId: null, activeRuntimeEnvironmentId: null, closeModal: mocks.state.closeModal, fetchWorktrees: vi.fn(), @@ -195,7 +196,8 @@ describe('useAddRepoNestedImportFlow open folder fallback', () => { expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { folderPath: '/workspace/platform', - connectionId: 'ssh-builder' + connectionId: 'ssh-builder', + runtimeEnvironmentId: null }) }) diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts index 2a347d0abb1c..5b340d949d98 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts @@ -16,7 +16,7 @@ import type { } from '../../../../shared/project-group-types' import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers' import { translate } from '@/i18n/i18n' -import { worktreeRefreshOptions, type CapturedRuntimeOwner } from './add-repo-runtime-owner' +import { worktreeRefreshOptions } from './add-repo-runtime-owner' import { completeNestedFolderOpen } from './complete-nested-folder-open' import { defaultProjectGroupNameForPath } from './add-repo-dialog-types' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -45,7 +45,7 @@ export function useAddRepoNestedImportFlow({ nestedConnectionId: string | null nestedGroupName: string nestedImportScanId: string | null - nestedRuntimeEnvironmentId?: CapturedRuntimeOwner + nestedRuntimeEnvironmentId: string | null activeRuntimeEnvironmentId: string | null | undefined closeModal: () => void fetchWorktrees: (repoId: string, options?: WorktreeFetchOptions) => Promise @@ -55,7 +55,7 @@ export function useAddRepoNestedImportFlow({ projectPaths: string[] connectionId?: string scanId?: string - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null mode: 'group' | 'separate' }) => Promise getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts b/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts index f015883762b0..761062d3b38d 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts @@ -23,7 +23,7 @@ export function useAddRepoNestedReviewController({ reviewRuntimeEnvironmentId: string | null | undefined cancelNestedRepoScan: ( scanId: string, - options?: { runtimeEnvironmentId?: string | null } + options: { runtimeEnvironmentId: string | null } ) => Promise closeModal: () => void fetchWorktrees: (repoId: string, options?: WorktreeFetchOptions) => Promise @@ -33,7 +33,7 @@ export function useAddRepoNestedReviewController({ projectPaths: string[] connectionId?: string scanId?: string - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null mode: 'group' | 'separate' }) => Promise onGitRepoReady: ( diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts b/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts index 8e44b5a40766..a86538eae59f 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts @@ -22,7 +22,7 @@ export function useAddRepoNestedReviewState({ activeRuntimeEnvironmentId: string | null | undefined cancelNestedRepoScan: ( scanId: string, - options?: { runtimeEnvironmentId?: string | null } + options: { runtimeEnvironmentId: string | null } ) => Promise setStep: (step: AddRepoDialogStep) => void }): { @@ -35,7 +35,7 @@ export function useAddRepoNestedReviewState({ nestedScanInProgress: boolean nestedScanId: string | null nestedImportScanId: string | null - nestedRuntimeEnvironmentId: string | null | undefined + nestedRuntimeEnvironmentId: string | null setNestedSelectedPaths: Dispatch>> setNestedGroupName: Dispatch> setNestedScanInProgress: Dispatch> @@ -56,11 +56,9 @@ export function useAddRepoNestedReviewState({ const [nestedScanInProgress, setNestedScanInProgress] = useState(false) const [nestedScanId, setNestedScanId] = useState(null) const [nestedImportScanId, setNestedImportScanId] = useState(null) - const [nestedRuntimeEnvironmentId, setNestedRuntimeEnvironmentId] = useState< - string | null | undefined - >(undefined) + const [nestedRuntimeEnvironmentId, setNestedRuntimeEnvironmentId] = useState(null) const nestedScanIdRef = useRef(null) - const nestedScanRuntimeEnvironmentIdRef = useRef(undefined) + const nestedScanRuntimeEnvironmentIdRef = useRef(null) const getNestedRepoRuntimeKind = useCallback( (connectionId: string | null): NestedRepoTelemetryRuntimeKind => { @@ -91,9 +89,9 @@ export function useAddRepoNestedReviewState({ ) const setActiveNestedScanId = useCallback( - (scanId: string | null, runtimeEnvironmentId?: string | null): void => { + (scanId: string | null, runtimeEnvironmentId: string | null = null): void => { nestedScanIdRef.current = scanId - nestedScanRuntimeEnvironmentIdRef.current = scanId ? runtimeEnvironmentId : undefined + nestedScanRuntimeEnvironmentIdRef.current = scanId ? runtimeEnvironmentId : null setNestedScanId(scanId) }, [] diff --git a/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts b/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts index 9810be00be88..666e975a792e 100644 --- a/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts @@ -50,11 +50,11 @@ export function useAddRepoServerPathFlow({ getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind scanNestedRepos: ( path: string, - connectionId?: string, - controls?: { + connectionId: string | undefined, + controls: { scanId?: string onProgress?: (scan: NestedRepoScanResult) => void - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null } ) => Promise setActiveNestedScanId: (scanId: string | null, runtimeEnvironmentId?: string | null) => void diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index f73b3e2790a1..feef6782c035 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -100,10 +100,7 @@ export function useCreateRepo( setIsCreating(true) setCreateError(null) try { - const target = resolveAddRepoRuntimeTarget( - options.runtimeEnvironmentId, - useAppStore.getState().settings - ) + const target = resolveAddRepoRuntimeTarget(options.runtimeEnvironmentId) // Why: Create Project is intentionally Git-only; non-Git folders use the // existing add-folder flows instead of this path. const createKind = 'git' as const diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts index 825c97268986..0ca94755a0ee 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts @@ -24,15 +24,13 @@ export function useFolderWorkspacePathStatusRows(args: { folderWorkspacePathStatuses, fetchFolderWorkspacePathStatus, getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId + getFreshFolderWorkspacePathStatus } = useAppStore( useShallow((s) => ({ folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null + getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus })) ) const folderPathStatusRepoMembershipKey = useMemo( @@ -78,26 +76,28 @@ export function useFolderWorkspacePathStatusRows(args: { string, { request: FolderPathStatusRequest - options?: { runtimeEnvironmentId: string | null } + options: { runtimeEnvironmentId: string | null } } >() + // Why: a request whose group row has not landed has no owner to ask, so it waits. + const addRequest = (request: FolderPathStatusRequest): void => { + const options = getFolderPathStatusRouteOptions(request) + if (options) { + requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + } + } for (const group of projectGroups) { if (group.parentPath) { - const request = { scope: 'project-group' as const, projectGroupId: group.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + addRequest({ scope: 'project-group' as const, projectGroupId: group.id }) } } for (const workspace of folderWorkspaces) { - const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + addRequest({ scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id }) } for (const { request, options } of requests.values()) { void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) } }, [ - activeRuntimeEnvironmentId, fetchFolderWorkspacePathStatus, folderPathStatusRepoMembershipKey, folderPathStatusSshConnectionKey, @@ -109,6 +109,9 @@ export function useFolderWorkspacePathStatusRows(args: { const getCachedFolderWorkspacePathStatus = useCallback( (request: FolderPathStatusRequest) => { const options = getFolderPathStatusRouteOptions(request) + if (!options) { + return null + } const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. void folderWorkspacePathStatuses[cacheKey] diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts index fb027095064f..c9c3a87b2ac1 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts @@ -5,7 +5,10 @@ import { translate } from '@/i18n/i18n' import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { Repo } from '../../../../../../shared/repo-types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { + getRepoExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' export type ProjectGroupNameDialogState = | { type: 'create-from-repo'; repo: Repo } @@ -86,14 +89,14 @@ export function useProjectGroupDialogs(args: { if (repo.projectGroupId === groupId) { return } - void moveProjectToGroup(repo.id, groupId) + void moveProjectToGroup(repo.id, groupId, undefined, getRepoExecutionHostId(repo)) }, [moveProjectToGroup] ) const handleRemoveProjectFromGroup = useCallback( (repo: Repo) => { - void moveProjectToGroup(repo.id, null) + void moveProjectToGroup(repo.id, null, undefined, getRepoExecutionHostId(repo)) }, [moveProjectToGroup] ) @@ -111,9 +114,10 @@ export function useProjectGroupDialogs(args: { return } if (nameDialog.type === 'create-from-repo') { - const group = await createProjectGroup(name) + const hostId = getRepoExecutionHostId(nameDialog.repo) + const group = await createProjectGroup(name, hostId) if (group) { - await moveProjectToGroup(nameDialog.repo.id, group.id) + await moveProjectToGroup(nameDialog.repo.id, group.id, undefined, hostId) } return } diff --git a/src/renderer/src/hooks/composer-state/composer-store-actions.ts b/src/renderer/src/hooks/composer-state/composer-store-actions.ts index 71edb7e419c8..6ff5bdc86e39 100644 --- a/src/renderer/src/hooks/composer-state/composer-store-actions.ts +++ b/src/renderer/src/hooks/composer-state/composer-store-actions.ts @@ -23,7 +23,7 @@ export type ComposerStoreActions = { createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }, - options?: { runtimeEnvironmentId?: string | null } + options: { runtimeEnvironmentId: string | null } ) => Promise closeModal: () => void openSettingsPage: () => void diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts index 4264c786b866..2194f1099230 100644 --- a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -23,9 +23,11 @@ export function registerProjectCatalogIpcBridge( })() return } - void state.fetchProjectGroups() - void state.fetchFolderWorkspaces() - void state.fetchRepos().then(remountTerminalTabsAwaitingHostHydration) + // Why: this event is this app's own catalog changing, whichever host is the default. + const localOwner = { runtimeEnvironmentId: null } + void state.fetchProjectGroups(localOwner) + void state.fetchFolderWorkspaces(localOwner) + void state.fetchRepos(localOwner).then(remountTerminalTabsAwaitingHostHydration) }) ) diff --git a/src/renderer/src/lib/workspace-activation-path-gate.test.ts b/src/renderer/src/lib/workspace-activation-path-gate.test.ts index 2c8a836e05c7..7ede5eb2a1d5 100644 --- a/src/renderer/src/lib/workspace-activation-path-gate.test.ts +++ b/src/renderer/src/lib/workspace-activation-path-gate.test.ts @@ -70,7 +70,7 @@ async function seedFolderWorkspace( .getState() .fetchFolderWorkspacePathStatus( { scope: 'folder-workspace', folderWorkspaceId: folderWorkspace.id }, - { force: true } + { force: true, runtimeEnvironmentId: null } ) } diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts b/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts index b2f795c36f40..2c8be71fd64a 100644 --- a/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts +++ b/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts @@ -1,7 +1,8 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import type { RepoSlice } from '../repos/repo-state' import { getFolderWorkspacePathStatusRequestSnapshotForRead, @@ -9,9 +10,8 @@ import { getFreshFolderWorkspacePathStatusFromCache } from './folder-path-status' import { - getFolderWorkspacePathStatusRouteSettings, getFolderWorkspacePathStatusScopeKey, - getRuntimeTargetCachePrefix + getPathStatusOwnerCachePrefix } from './folder-workspace-routing' export function createFolderPathStatusActions( @@ -25,9 +25,7 @@ export function createFolderPathStatusActions( > { return { getFolderWorkspacePathStatusCacheKey: (request, options) => - `${getRuntimeTargetCachePrefix( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - )}:${getFolderWorkspacePathStatusScopeKey(request)}`, + `${getPathStatusOwnerCachePrefix(options)}:${getFolderWorkspacePathStatusScopeKey(request)}`, getFreshFolderWorkspacePathStatus: (request, options) => { const state = get() @@ -45,13 +43,11 @@ export function createFolderPathStatusActions( entry: cached, requestSnapshot }) - if (!options?.force && freshCachedStatus) { + if (!options.force && freshCachedStatus) { return freshCachedStatus } try { - const target = getActiveRuntimeTarget( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) const status = target.kind === 'local' ? await window.api.folderWorkspaces.getPathStatus(request) diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts index e5dfff1081c9..dbef2e065926 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import type { FetchedFolderWorkspaceCatalog } from './folder-workspace-catalog' import type { HostCatalogFence } from '../host-catalog-fencing' import type { RepoSlice } from '../repos/repo-state' @@ -24,9 +24,7 @@ export function createFolderWorkspaceCatalogActions( fetchFolderWorkspaces: async (options) => { try { const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) const fence = claimHostCatalogFence(get, 'folder-workspaces', target) const catalog = await fetchFolderWorkspaceCatalogForTarget(target, get().projectGroups) if (!isHostCatalogFenceCurrent(get, fence)) { @@ -58,7 +56,7 @@ export function createFolderWorkspaceCatalogActions( }) } catch (err) { console.error('Failed to fetch folder workspaces:', err) - if (options?.throwOnError) { + if (options.throwOnError) { throw err } } diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts index e80047064cf8..fb55f9b71b39 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts @@ -6,23 +6,22 @@ import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { assertRuntimeEnvironmentCapability, - callRuntimeRpc, - getActiveRuntimeTarget + callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' import { findFolderWorkspaceOwner, - getExecutionHostIdForFolderWorkspace, - getRuntimeEnvironmentIdForFolderWorkspace + getExecutionHostIdForFolderWorkspace } from '@/lib/folder-workspace-runtime-owner' import { FolderWorkspaceUpdateCoordinator } from '../slices/folder-workspace-update-coordinator' import type { FolderWorkspaceUpdates, RepoSlice } from '../repos/repo-state' import { getRuntimeTargetHostId } from '../runtime-target-host' import { adoptFromEndpoint } from '../adopt-from-endpoint' import { + folderWorkspaceOwnerTarget, folderWorkspaceUpdateInvalidatesPathStatus, - getFolderWorkspacePathStatusRouteSettings, mergeFolderWorkspaceUpdateResponse } from './folder-workspace-routing' import { @@ -66,10 +65,8 @@ export function createFolderWorkspaceMutationActions( return { createFolderWorkspace: async (args, options) => { try { - // Why: a new folder has no owner yet, so creation follows the caller-selected path-status host. - const target = getActiveRuntimeTarget( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - ) + // Why: a new folder has no row yet, so creation goes to the host the caller picked. + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) await assertWorkspaceAttachmentWriteCapability(target, args) if ( target.kind === 'environment' && @@ -123,13 +120,11 @@ export function createFolderWorkspaceMutationActions( if (!existingWorkspace) { return false } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( - state, - folderWorkspaceId, - executionHostId - ) // Why: owner-scoped mutations must not follow whichever runtime happens to be focused. - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) + const target = folderWorkspaceOwnerTarget(state, folderWorkspaceId, executionHostId) + if (!target) { + return false + } const ownerHostId = executionHostId ?? getRuntimeTargetHostId(target) const sourceWorkspace = state.folderWorkspaces.find( (workspace) => @@ -259,19 +254,16 @@ export function createFolderWorkspaceMutationActions( folderWorkspaceId, executionHostId ) - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( - state, - folderWorkspaceId, - executionHostId - ) + const target = folderWorkspaceOwnerTarget(state, folderWorkspaceId, executionHostId) + if (!target) { + return false + } const workspaceKey = folderWorkspaceKey(folderWorkspaceId) // Why before the host call: its announcement can start a refresh that drops these tabs. const chatDraftKeys = captureWorkspaceChatDraftKeys(state, [ { workspaceId: workspaceKey, executionHostId: ownerHostId } ]) try { - // Why: deletion targets the folder's owner; focus may be on a different host. - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) const deleted = target.kind === 'local' ? await window.api.folderWorkspaces.delete({ folderWorkspaceId }) diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts index b5151bb65cf8..b2351a7c768d 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts @@ -1,7 +1,13 @@ import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status' -import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '../../runtime/runtime-client-target' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { FolderWorkspacePathStatusRouteOptions } from '../repos/repo-state' import type { FolderWorkspaceUpdateField } from './folder-workspace-mutations' @@ -17,22 +23,13 @@ export function getFolderWorkspacePathStatusScopeKey( return `folder-workspace:${request.folderWorkspaceId}` } -export function getRuntimeTargetCachePrefix( - settings: Pick | null | undefined +export function getPathStatusOwnerCachePrefix( + owner: FolderWorkspacePathStatusRouteOptions ): string { - const target = getActiveRuntimeTarget(settings) + const target = runtimeTargetForOwnerEnvironment(owner.runtimeEnvironmentId) return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } -export function getFolderWorkspacePathStatusRouteSettings( - options: FolderWorkspacePathStatusRouteOptions | undefined, - fallbackSettings: GlobalSettings | null -): Pick | null | undefined { - return options && 'runtimeEnvironmentId' in options - ? { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null } - : fallbackSettings -} - export function folderWorkspaceUpdateInvalidatesPathStatus( fields: readonly FolderWorkspaceUpdateField[] ): boolean { @@ -66,3 +63,15 @@ export function mergeFolderWorkspaceUpdateResponse( next.updatedAt = Math.max(current.updatedAt, updated.updatedAt) return next } + +/** The folder workspace's owner, or `null` when its rows name none or disagree. */ +export function folderWorkspaceOwnerTarget( + state: WorktreeOperationRouteState, + folderWorkspaceId: string, + executionHostId: ExecutionHostId | null | undefined +): RuntimeClientTarget | null { + return runtimeTargetForWorkspaceOwner(state, { + workspaceId: folderWorkspaceKey(folderWorkspaceId), + ...(executionHostId ? { hostId: executionHostId } : {}) + }) +} diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts index a6883927c047..486d5ffba60a 100644 --- a/src/renderer/src/store/github/work-item-mutation-actions.ts +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -95,7 +95,7 @@ export const createWorkItemMutationActions = ( } ) // Why: the optimistic patch may now disagree with disk; resync rather than leave a lie on screen. - void get().fetchRepos() + void get().fetchReposForAllHosts() } // Why: clear inflight dedupe BEFORE bumping the nonce so the re-triggered fetch can't collapse onto a pre-flip in-flight entry. clearInflightWorkItemsForRepo(repoId, repoPath) diff --git a/src/renderer/src/store/project-groups/nested-repository-operations.ts b/src/renderer/src/store/project-groups/nested-repository-operations.ts index 111d359a4bcf..dcb8f4a76241 100644 --- a/src/renderer/src/store/project-groups/nested-repository-operations.ts +++ b/src/renderer/src/store/project-groups/nested-repository-operations.ts @@ -5,11 +5,8 @@ import type { NestedRepoScanResult, ProjectGroupImportResult } from '../../../../shared/project-group-types' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - settingsForRuntimeOwner -} from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import { translate } from '@/i18n/i18n' import type { RepoSlice } from '../repos/repo-state' @@ -30,12 +27,11 @@ export function createNestedRepositoryActions( return { scanNestedRepos: async (path, connectionId, controls) => { try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, controls?.runtimeEnvironmentId) - ) + // Why: Add Project scans on the host the user picked; there is no row yet. + const target = runtimeTargetForOwnerEnvironment(controls.runtimeEnvironmentId) if (target.kind === 'local') { const unsubscribe = - controls?.scanId && controls.onProgress + controls.scanId && controls.onProgress ? window.api.projectGroups.onNestedScanProgress(({ scanId, scan }) => { if (scanId === controls.scanId) { controls.onProgress?.(normalizeNestedRepoScanResult(scan)) @@ -47,7 +43,7 @@ export function createNestedRepositoryActions( await window.api.projectGroups.scanNested({ path, connectionId, - scanId: controls?.scanId + scanId: controls.scanId }) ) } finally { @@ -71,9 +67,7 @@ export function createNestedRepositoryActions( cancelNestedRepoScan: async (scanId, options) => { try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) if (target.kind !== 'local') { return false } @@ -86,9 +80,7 @@ export function createNestedRepositoryActions( importNestedRepos: async (args) => { try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, args.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(args.runtimeEnvironmentId) const result = target.kind === 'local' ? await window.api.projectGroups.importNested(args) @@ -104,10 +96,7 @@ export function createNestedRepositoryActions( }, { timeoutMs: 60_000 } ) - const catalogOptions = - 'runtimeEnvironmentId' in args - ? { runtimeEnvironmentId: args.runtimeEnvironmentId } - : undefined + const catalogOptions = { runtimeEnvironmentId: args.runtimeEnvironmentId } await get().fetchProjectGroups(catalogOptions) await get().fetchFolderWorkspaces(catalogOptions) await (args.runtimeEnvironmentId diff --git a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts index 39de358e99c0..e7a314b6b774 100644 --- a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts +++ b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import type { FetchedProjectGroupCatalog } from './project-group-catalog' import type { HostCatalogFence } from '../host-catalog-fencing' import type { RepoSlice } from '../repos/repo-state' @@ -19,9 +19,7 @@ export function createProjectGroupCatalogActions( return { fetchProjectGroups: async (options) => { try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) const fence = claimHostCatalogFence(get, 'project-groups', target) const catalog = await fetchProjectGroupCatalogForTarget(target) if (!isHostCatalogFenceCurrent(get, fence)) { @@ -42,7 +40,7 @@ export function createProjectGroupCatalogActions( }) } catch (err) { console.error('Failed to fetch project groups:', err) - if (options?.throwOnError) { + if (options.throwOnError) { throw err } } diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index f5fd46d7e5ec..d25a367cd5bb 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -7,15 +7,15 @@ import { getProjectGroupHostId, projectGroupMatchesOwnerHost, resolveProjectGroupOwnerHostId, - settingsForProjectGroupOwner + runtimeTargetForProjectGroupOwner } from '../slices/project-group-owner-routing' import { findRepoForHost, repoMatchesHostIdentity } from '../slices/repo-host-identity' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerHostId } from '../../runtime/runtime-client-target' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { ProjectRemovalFailure, RepoSlice } from '../repos/repo-state' import { mergeProjectCompatibilityForHostRepoChange } from '../repos/repo-catalog-identity' import { applyProjectGroupDeleteCascade } from './project-group-removal-state' -import { settingsForRepoOwner } from '../repos/owner-routing' import { adoptFromEndpoint } from '../adopt-from-endpoint' export function createProjectGroupMutationActions( @@ -30,9 +30,13 @@ export function createProjectGroupMutationActions( | 'moveProjectToGroup' > { return { - createProjectGroup: async (name) => { + createProjectGroup: async (name, hostId) => { try { - const target = getActiveRuntimeTarget(get().settings) + // Why: a new group lands on the host of the project that seeds it, not the focused one. + const target = runtimeTargetForOwnerHostId(hostId) + if (!target) { + return null + } const group = target.kind === 'local' ? await window.api.projectGroups.create({ @@ -75,9 +79,10 @@ export function createProjectGroupMutationActions( try { // Why: the sidebar lists groups from every host, so the mutation follows the group's owner, not the focused host. const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) - const target = getActiveRuntimeTarget( - settingsForProjectGroupOwner(get(), groupId, options?.hostId) - ) + const target = runtimeTargetForProjectGroupOwner(get(), groupId, options?.hostId) + if (!target) { + return false + } const updated = target.kind === 'local' ? await window.api.projectGroups.update({ groupId, updates }) @@ -110,9 +115,10 @@ export function createProjectGroupMutationActions( try { // Why: deletion targets the group's owner host (see updateProjectGroup); focus may be elsewhere. const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) - const target = getActiveRuntimeTarget( - settingsForProjectGroupOwner(get(), groupId, options?.hostId) - ) + const target = runtimeTargetForProjectGroupOwner(get(), groupId, options?.hostId) + if (!target) { + return false + } const deleted = target.kind === 'local' ? await window.api.projectGroups.delete({ groupId }) @@ -228,12 +234,13 @@ export function createProjectGroupMutationActions( } }, - moveProjectToGroup: async (projectId, groupId, order) => { + moveProjectToGroup: async (projectId, groupId, order, hostId) => { try { - if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) { + const repo = findRepoForHost(get().repos, projectId, { settings: get().settings, hostId }) + const target = repo ? runtimeTargetForOwnerHostId(getRepoExecutionHostId(repo)) : null + if (!target) { return false } - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const moved = target.kind === 'local' ? await window.api.projectGroups.moveProject({ diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index 2db4a0bc64c5..cb1626cedc04 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -1,4 +1,3 @@ -import type { AppState } from '../types' import type { Project, ProjectHostSetup, @@ -21,10 +20,8 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { assertRuntimeEnvironmentCapability, callRuntimeRpc, - getActiveRuntimeTarget, type RuntimeClientTarget } from '../../runtime/runtime-rpc-client' -import { getRuntimeTargetHostId } from '../runtime-target-host' import { adoptFromEndpoint } from '../adopt-from-endpoint' export function getProjectSetupRuntimeTarget( @@ -36,22 +33,6 @@ export function getProjectSetupRuntimeTarget( : { kind: 'local' } } -export function getProjectUpdateRuntimeTarget( - state: AppState, - projectId: string -): RuntimeClientTarget { - const target = getActiveRuntimeTarget(state.settings) - if (target.kind !== 'environment') { - return target - } - const runtimeHostId = getRuntimeTargetHostId(target) - return state.projectHostSetups.some( - (setup) => setup.projectId === projectId && setup.hostId === runtimeHostId - ) - ? target - : { kind: 'local' } -} - function normalizeProjectCatalogProjection( projection: ProjectHostSetupProjection ): ProjectHostSetupProjection { diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index 75c65f24ee36..7bdb858efd7e 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -4,7 +4,7 @@ import type { Project } from '../../../../shared/project-types' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill-discovery' import type { RepoSlice } from '../repos/repo-state' -import { getProjectUpdateRuntimeTarget } from './project-host-routing' +import { getProjectSetupRuntimeTarget } from './project-host-routing' import { mergeUpdatedProjectCompatibilityProject } from './project-compatibility-core' import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' @@ -13,9 +13,10 @@ export function createProjectUpdateActions( get: Parameters>[1] ): Pick { return { - updateProject: async (projectId, updates) => { + updateProject: async (projectId, updates, hostId) => { try { - const target = getProjectUpdateRuntimeTarget(get(), projectId) + // Why: the caller names the host whose project row it edits; focus never picks it. + const target = getProjectSetupRuntimeTarget(hostId) const updatedProject = target.kind === 'local' ? await window.api.projects.update({ projectId, updates }) diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index 38ffb530439c..91013b98e355 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -5,7 +5,6 @@ import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' import { retainValidFilterRepoIds } from '../slices/repo-filter-selection' import { readRuntimeWorktreeVisibilitySnapshot } from '../slices/worktree-visibility-owner-settings' import { getRepoHostIdentity } from '../slices/repo-host-identity' -import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' @@ -176,8 +175,8 @@ export function createAllHostRepoCatalogActions( ...state.worktreeVisibilityDefaultsByHost, [hostId]: visibilityDefaults }, - ...(getActiveRuntimeTarget(state.settings).kind === 'environment' && - state.settings?.activeRuntimeEnvironmentId === environment.id + // Why: the settings page mirrors only the default host's source-defaults support. + ...(state.settings?.activeRuntimeEnvironmentId === environment.id ? { worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: visibilitySnapshot.sourceDefaultsSupported ? environment.id : null diff --git a/src/renderer/src/store/repos/owner-routing.ts b/src/renderer/src/store/repos/owner-routing.ts index 52ce1d87f3d9..215a6a00531c 100644 --- a/src/renderer/src/store/repos/owner-routing.ts +++ b/src/renderer/src/store/repos/owner-routing.ts @@ -1,5 +1,4 @@ import type { AppState } from '../types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' import { findRepoForHost } from '../slices/repo-host-identity' @@ -11,7 +10,6 @@ import type { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { AddRepoPathOptions } from './repo-state' export function settingsForRepoOwner( state: Pick, @@ -40,15 +38,6 @@ export function settingsForRepoOwner( return state.settings } -export function getAddRepoPathRouteSettings( - options: AddRepoPathOptions | undefined, - fallbackSettings: GlobalSettings | null -): Pick | null | undefined { - return options && 'runtimeEnvironmentId' in options - ? { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null } - : fallbackSettings -} - export function getRuntimeEnvironmentDisplayName(state: AppState, environmentId: string): string { const environment = state.runtimeEnvironments.find((entry) => entry.id === environmentId) return environment?.name || environmentId diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index 5629a55b0406..67d735781941 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -4,7 +4,9 @@ import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getRepoHostIdentity } from '../slices/repo-host-identity' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' +import { defaultCreationHost } from '@/lib/default-creation-host' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { @@ -14,11 +16,7 @@ import { } from '../../../../shared/execution-host' import type { RepoSlice } from './repo-state' import { ERROR_TOAST_DURATION } from './repo-state' -import { - fetchRuntimeAddProjectPathStatus, - getAddRepoPathRouteSettings, - getRuntimeEnvironmentDisplayName -} from './owner-routing' +import { fetchRuntimeAddProjectPathStatus, getRuntimeEnvironmentDisplayName } from './owner-routing' import { adoptFromEndpoint } from '../adopt-from-endpoint' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' import { warnIfProjectKnownInAnotherProfile } from '../projects/project-profile-presence' @@ -31,7 +29,11 @@ export function createRepoAddActions( return { addRepoPath: async (path, kind = 'git', options) => { try { - const target = getActiveRuntimeTarget(getAddRepoPathRouteSettings(options, get().settings)) + // Why: a new project has no row yet; without a picked host it goes to the default host. + const target = + options && 'runtimeEnvironmentId' in options + ? runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId ?? null) + : defaultCreationHost(get().settings) const displayName = options?.displayName?.trim() || undefined let repo: Repo try { @@ -144,7 +146,8 @@ export function createRepoAddActions( }, addRepo: async () => { - const target = getActiveRuntimeTarget(get().settings) + // Why: the OS folder picker adds a new project on the default host; there is no row yet. + const target = defaultCreationHost(get().settings) if (target.kind !== 'local') { // Why: OS folder pickers return client-local paths; remote environments need an explicit host path (Add Project dialog). toast.error( diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts index e9ff0e484f7f..61ff2b5010d2 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -9,7 +9,7 @@ import { reconcileReadoptedSshRepoRows } from '../slices/superseded-ssh-repo-rows' import { getRepoHostIdentity } from '../slices/repo-host-identity' -import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' @@ -88,9 +88,7 @@ export function createRepoCatalogActions( }), fetchRepos: async (options) => { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) const settleLocalCatalog: (outcome: LocalRepoCatalogFetchOutcome) => void = target.kind === 'local' ? startLocalRepoCatalogFetch(get) : () => undefined let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } @@ -156,7 +154,7 @@ export function createRepoCatalogActions( } catch (err) { localCatalogOutcome = { status: 'rejected', reason: err } console.error('Failed to fetch repos:', err) - if (options?.throwOnError) { + if (options.throwOnError) { throw err } } finally { diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index e1dde15d7cf2..3695dbbe51a5 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -6,11 +6,8 @@ import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency' import { omitSparsePresetsForRepos } from '../slices/sparse-presets' import { repoMatchesHostIdentity } from '../slices/repo-host-identity' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - hasRuntimeRpcErrorCode -} from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc, hasRuntimeRpcErrorCode } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerHostId } from '../../runtime/runtime-client-target' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { translate } from '@/i18n/i18n' import { @@ -22,7 +19,6 @@ import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime import type { RepoSlice } from './repo-state' import { ERROR_TOAST_DURATION } from './repo-state' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' -import { settingsForRepoOwner } from './owner-routing' import { captureWorkspaceChatDraftKeys, deleteWorkspaceChatDrafts @@ -109,7 +105,10 @@ export function createRepoRemovalActions( } } // Why: derive the target from the owner row's host so an SSH host removal never routes repo.rm to the focused runtime. - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId, ownerHostId)) + const target = runtimeTargetForOwnerHostId(ownerHostId) + if (!target) { + throw new Error('The project host is unresolved. Refresh and retry.') + } // Why before the host call: its announcement can start a listing refresh that drops these tabs. const chatDraftKeys = captureWorkspaceChatDraftKeys( get(), diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 003ac6bdcf29..2809893b889f 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -89,11 +89,12 @@ export type FolderWorkspaceUpdates = Partial< export type NestedRepoScanControls = { scanId?: string onProgress?: (scan: NestedRepoScanResult) => void - runtimeEnvironmentId?: string | null + /** The host chosen in Add Project: a server's environment id, or `null` for this app. */ + runtimeEnvironmentId: string | null } export type NestedRepoScanCancelOptions = { - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null } export type FolderWorkspacePathStatusCacheEntry = { @@ -133,7 +134,8 @@ export type DeleteProjectGroupWithContainedProjectsResult = failedProjectRemovals: [] } -export type FolderWorkspacePathStatusRouteOptions = { runtimeEnvironmentId?: string | null } +/** The owner of the folder being probed: its server's environment id, or `null` for this app. */ +export type FolderWorkspacePathStatusRouteOptions = { runtimeEnvironmentId: string | null } export type AddRepoPathOptions = { runtimeEnvironmentId?: string | null @@ -142,7 +144,8 @@ export type AddRepoPathOptions = { } export type RuntimeCatalogFetchOptions = { - runtimeEnvironmentId?: string | null + /** Whose catalog to fetch: a server's environment id, or `null` for this app's own. */ + runtimeEnvironmentId: string | null /** Conversion must retain its source rows until every destination catalog loads. */ throwOnError?: boolean } @@ -162,13 +165,13 @@ export type RepoSlice = { reposFetchGeneration: number pendingSshRepoReadoptions: readonly SshRepoReadoption[] recordSshRepoReadoptions: (readoptions: readonly SshRepoReadoption[]) => void - fetchRepos: (options?: RuntimeCatalogFetchOptions) => Promise + fetchRepos: (options: RuntimeCatalogFetchOptions) => Promise fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise awaitLocalRepoCatalogSettlement: () => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise - fetchProjectGroups: (options?: RuntimeCatalogFetchOptions) => Promise + fetchProjectGroups: (options: RuntimeCatalogFetchOptions) => Promise fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise - fetchFolderWorkspaces: (options?: RuntimeCatalogFetchOptions) => Promise + fetchFolderWorkspaces: (options: RuntimeCatalogFetchOptions) => Promise fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise addRepo: () => Promise addRepoPath: ( @@ -192,20 +195,21 @@ export type RepoSlice = { addNonGitFolder: (path: string, options?: AddRepoPathOptions) => Promise scanNestedRepos: ( path: string, - connectionId?: string, - controls?: NestedRepoScanControls + connectionId: string | undefined, + controls: NestedRepoScanControls ) => Promise - cancelNestedRepoScan: (scanId: string, options?: NestedRepoScanCancelOptions) => Promise + cancelNestedRepoScan: (scanId: string, options: NestedRepoScanCancelOptions) => Promise importNestedRepos: (args: { parentPath: string groupName: string projectPaths: string[] connectionId?: string scanId?: string - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null mode: 'group' | 'separate' }) => Promise - createProjectGroup: (name: string) => Promise + /** `hostId` is the host of the project that seeds the group. */ + createProjectGroup: (name: string, hostId: ExecutionHostId) => Promise createFolderWorkspace: ( args: { projectGroupId: string @@ -218,19 +222,19 @@ export type RepoSlice = { createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }, - options?: FolderWorkspacePathStatusRouteOptions + options: FolderWorkspacePathStatusRouteOptions ) => Promise getFolderWorkspacePathStatusCacheKey: ( request: FolderWorkspacePathStatusRequest, - options?: FolderWorkspacePathStatusRouteOptions + options: FolderWorkspacePathStatusRouteOptions ) => string getFreshFolderWorkspacePathStatus: ( request: FolderWorkspacePathStatusRequest, - options?: FolderWorkspacePathStatusRouteOptions + options: FolderWorkspacePathStatusRouteOptions ) => FolderWorkspacePathStatus | null fetchFolderWorkspacePathStatus: ( request: FolderWorkspacePathStatusRequest, - options?: { force?: boolean } & FolderWorkspacePathStatusRouteOptions + options: { force?: boolean } & FolderWorkspacePathStatusRouteOptions ) => Promise updateFolderWorkspace: ( folderWorkspaceId: string, @@ -255,7 +259,8 @@ export type RepoSlice = { moveProjectToGroup: ( projectId: string, groupId: string | null, - order?: number + order?: number, + hostId?: ExecutionHostId ) => Promise // options.hostId is required: a bare id can resolve to another host's row (#13071). Removes nothing when that host has no row. // options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting. @@ -263,7 +268,11 @@ export type RepoSlice = { projectId: string, options: { hostId: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } ) => Promise - updateProject: (projectId: string, updates: ProjectUpdate) => Promise + updateProject: ( + projectId: string, + updates: ProjectUpdate, + hostId: ExecutionHostId + ) => Promise // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the focused host is assumed. updateRepo: ( projectId: string, diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index 175dcfe827d4..7b3fa975697b 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -9,16 +9,14 @@ import { getRepoHostIdentityForParts, repoMatchesHostIdentity } from '../slices/repo-host-identity' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences } from '../../../../shared/worktree/visibility-sources' import type { RepoSlice, RepoUpdate } from './repo-state' -import { settingsForRepoOwner } from './owner-routing' import { adoptFromEndpoint } from '../adopt-from-endpoint' -import { getRuntimeTargetHostId } from '../runtime-target-host' import { getProjectSetupRuntimeTarget } from '../projects/project-host-routing' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' @@ -113,13 +111,9 @@ export function createRepoUpdateActions( const ownerHasExplicitHost = Boolean( options?.hostId || ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim() ) - const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo) - const ownerTarget = ownerHasExplicitHost - ? getProjectSetupRuntimeTarget(explicitOwnerHostId) - : getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) - const ownerHostId = ownerHasExplicitHost - ? explicitOwnerHostId - : getRuntimeTargetHostId(ownerTarget) + // Why: a row with no stamp is this app's own; ingest stamps every server row. + const ownerHostId = getRepoExecutionHostId(ownerRepo) + const ownerTarget = getProjectSetupRuntimeTarget(ownerHostId) const updateChainKey = getRepoHostIdentityForParts(projectId, ownerHostId) const applyRepoUpdate = async () => { try { diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index efc4da84d8cf..107c5f8526ff 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -6,7 +6,6 @@ import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' import { retainValidFilterRepoIds } from '../slices/repo-filter-selection' import { readRuntimeWorktreeVisibilitySnapshot } from '../slices/worktree-visibility-owner-settings' import { getRepoHostIdentity } from '../slices/repo-host-identity' -import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' @@ -88,8 +87,7 @@ export function createRuntimeRepoCatalogActions( } }), ...(visibilityDefaults !== undefined && s.settings - ? getActiveRuntimeTarget(s.settings).kind === 'environment' && - s.settings.activeRuntimeEnvironmentId === environmentId + ? s.settings.activeRuntimeEnvironmentId === environmentId ? visibilityDefaults ? { settings: { diff --git a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts index 6ba887087361..4c0ec128e22d 100644 --- a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts +++ b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts @@ -308,7 +308,7 @@ describe('folder workspace owner-routed mutations', () => { const pendingUpdate = store .getState() .updateFolderWorkspace(folderWorkspace.id, { isUnread: true }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) resolveUpdate({ ...folderWorkspace, isUnread: true, updatedAt: 2 }) await pendingUpdate @@ -335,7 +335,7 @@ describe('folder workspace owner-routed mutations', () => { const pendingUpdate = store .getState() .updateFolderWorkspace(folderWorkspace.id, { isUnread: true }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) resolveUpdate({ ...folderWorkspace, isUnread: true, updatedAt: 2 }) await pendingUpdate @@ -619,8 +619,20 @@ describe('folder workspace owner-routed mutations', () => { settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: 'env-old' }) }) await expect( - store.getState().createFolderWorkspace({ projectGroupId: projectGroup.id, linkedItems: [] }) + store + .getState() + .createFolderWorkspace( + { projectGroupId: projectGroup.id, linkedItems: [] }, + { runtimeEnvironmentId: 'env-old' } + ) ).rejects.toThrow('Update the remote runtime') expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/project-group-owner-routing.ts b/src/renderer/src/store/slices/project-group-owner-routing.ts index 67d3a793bba2..101b65d53738 100644 --- a/src/renderer/src/store/slices/project-group-owner-routing.ts +++ b/src/renderer/src/store/slices/project-group-owner-routing.ts @@ -7,6 +7,10 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { findIndexedProjectGroupOwner } from '@/lib/worktree-runtime-owner-index' +import { + runtimeTargetForOwnerHostId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' type ProjectGroupHostParts = Pick type ProjectGroupOwnerRecord = Pick @@ -60,29 +64,15 @@ export function resolveProjectGroupOwnerHostId( return getProjectGroupHostId(owner) } -// Why: the sidebar lists groups from every host, so mutations must route to the row's owner -// instead of whichever host currently has focus. Mirrors settingsForRepoOwner. -export function settingsForProjectGroupOwner( - state: ProjectGroupOwnerRoutingState, +/** + * Transport to the group's owner, so a mutation never follows the focused host; `null` when no + * row names the group. Direct-SSH groups live in this app's catalog and ride its IPC. + */ +export function runtimeTargetForProjectGroupOwner( + state: Pick, groupId: string, hostId?: ExecutionHostId -): RoutingSettings { - const ownerHostId = resolveProjectGroupOwnerHostId(state, groupId, hostId) - if (!ownerHostId) { - return state.settings - } - const parsed = parseExecutionHostId(ownerHostId) - if (parsed?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } - : { activeRuntimeEnvironmentId: parsed.environmentId } - } - // Why: direct-SSH groups live in the local main process catalog, so they route through window.api. - if ( - (parsed?.kind === 'local' || parsed?.kind === 'ssh') && - state.settings?.activeRuntimeEnvironmentId - ) { - return { ...state.settings, activeRuntimeEnvironmentId: null } - } - return state.settings +): RuntimeClientTarget | null { + const owner = findIndexedProjectGroupOwner(state.projectGroups, groupId, hostId) + return owner ? runtimeTargetForOwnerHostId(hostId ?? getProjectGroupHostId(owner)) : null } diff --git a/src/renderer/src/store/slices/repos-add-races.test.ts b/src/renderer/src/store/slices/repos-add-races.test.ts index 363e41e5badc..c707d91a24ac 100644 --- a/src/renderer/src/store/slices/repos-add-races.test.ts +++ b/src/renderer/src/store/slices/repos-add-races.test.ts @@ -55,7 +55,7 @@ describe('repo add/catalog races', () => { reposAdd.mockResolvedValue({ repo: localRepo }) const store = createTestStore() - const pendingCatalog = store.getState().fetchRepos() + const pendingCatalog = store.getState().fetchRepos(catalogOwner(store)) await store.getState().addRepoPath(localRepo.path) expect(store.getState().repos).toHaveLength(1) @@ -72,10 +72,17 @@ describe('repo add/catalog races', () => { const store = createTestStore() const pendingAdd = store.getState().addRepoPath(localRepo.path) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) resolveAdd({ repo: localRepo }) await pendingAdd expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts index 2d6e9dfb1b0f..38d0ab353b37 100644 --- a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts @@ -124,7 +124,7 @@ describe('fetchReposForAllHosts generation', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - const remoteRefresh = store.getState().fetchRepos() + const remoteRefresh = store.getState().fetchRepos(catalogOwner(store)) let remoteSettled = false void remoteRefresh.then(() => { remoteSettled = true @@ -525,3 +525,10 @@ describe('fetchReposForAllHosts generation', () => { expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo']) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index 17c9227aa5eb..736b013a61ff 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -437,7 +437,7 @@ describe('fetchReposForAllHosts', () => { const store = createTestStore() await store.getState().fetchReposForAllHosts() - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) expect( store diff --git a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts index 037ed963ec7d..44a482de0d00 100644 --- a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts +++ b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts @@ -112,11 +112,11 @@ describe('catalog merge referential stability', () => { it('keeps the same projectGroups array when a refetch changes nothing', async () => { const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const first = store.getState().projectGroups expect(first).toEqual([{ ...projectGroup, executionHostId: 'local' }]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().projectGroups).toBe(first) }) @@ -125,12 +125,12 @@ describe('catalog merge referential stability', () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) const first = store.getState().folderWorkspaces expect(first).toHaveLength(1) const firstEntry = first[0] - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().folderWorkspaces).toBe(first) expect(store.getState().folderWorkspaces[0]).toBe(firstEntry) @@ -139,14 +139,14 @@ describe('catalog merge referential stability', () => { it('appends new entries and replaces changed ones while keeping order', async () => { const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const first = store.getState().projectGroups projectGroupsList.mockImplementation(async () => [ clone({ ...projectGroup, name: 'Renamed' }), clone(secondProjectGroup) ]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const merged = store.getState().projectGroups expect(merged).not.toBe(first) @@ -160,14 +160,14 @@ describe('catalog merge referential stability', () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) const firstEntry = store.getState().folderWorkspaces[0] folderWorkspacesList.mockImplementation(async () => [ clone(secondFolderWorkspace), clone(folderWorkspace) ]) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) const merged = store.getState().folderWorkspaces expect(merged.map((workspace) => workspace.id)).toEqual(['folder-1', 'folder-2']) @@ -180,7 +180,7 @@ describe('catalog merge referential stability', () => { store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) projectGroupsList.mockImplementation(async () => [clone(secondProjectGroup)]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().projectGroups).toEqual([ { ...secondProjectGroup, executionHostId: 'local' } @@ -191,7 +191,7 @@ describe('catalog merge referential stability', () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) const first = store.getState().folderWorkspaces folderWorkspacesList.mockImplementation(async () => [ @@ -200,7 +200,7 @@ describe('catalog merge referential stability', () => { linkedTask: { ...folderWorkspace.linkedTask!, title: 'Issue 7 renamed' } }) ]) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().folderWorkspaces).not.toBe(first) expect(store.getState().folderWorkspaces[0]?.linkedTask?.title).toBe('Issue 7 renamed') @@ -212,24 +212,24 @@ describe('catalog merge referential stability', () => { describe('folder path status cache retention across catalog fetches', () => { it('keeps cached path statuses when a project-group refetch changes nothing', async () => { const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) }) it('clears cached path statuses when the project-group catalog changes', async () => { const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) projectGroupsList.mockImplementation(async () => [ clone(projectGroup), clone(secondProjectGroup) ]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toEqual({}) }) @@ -237,10 +237,10 @@ describe('folder path status cache retention across catalog fetches', () => { it('keeps cached path statuses when a folder-workspace refetch changes nothing', async () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) }) @@ -248,35 +248,42 @@ describe('folder path status cache retention across catalog fetches', () => { it('clears cached path statuses when a folder workspace path changes', async () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) folderWorkspacesList.mockImplementation(async () => [ clone({ ...folderWorkspace, folderPath: '/parent/folder-1-renamed' }) ]) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toEqual({}) }) it('keeps cached path statuses when a repo refetch changes nothing', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) }) it('clears cached path statuses when the repo catalog changes', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) reposList.mockImplementation(async () => [clone({ ...repo, path: '/repo-1-moved' })]) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toEqual({}) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-catalog-notifications.test.ts b/src/renderer/src/store/slices/repos-catalog-notifications.test.ts index 88fbd339ec3e..cb06b6c788cf 100644 --- a/src/renderer/src/store/slices/repos-catalog-notifications.test.ts +++ b/src/renderer/src/store/slices/repos-catalog-notifications.test.ts @@ -43,7 +43,7 @@ const cases: CatalogCase[] = [ { label: 'selected groups', catalog: 'projectGroups', - refresh: (s) => s.getState().fetchProjectGroups() + refresh: (s) => s.getState().fetchProjectGroups(catalogOwner(s)) }, { label: 'all-host local groups', @@ -53,7 +53,7 @@ const cases: CatalogCase[] = [ { label: 'selected folders', catalog: 'folderWorkspaces', - refresh: (s) => s.getState().fetchFolderWorkspaces() + refresh: (s) => s.getState().fetchFolderWorkspaces(catalogOwner(s)) }, { label: 'all-host local folders', @@ -188,7 +188,7 @@ it('accepts a newer update after an equal catalog response', async () => { const pending = Promise.withResolvers() folderUpdate.mockReturnValueOnce(pending.promise) const update = store.getState().updateFolderWorkspace(folder.id, { isUnread: true }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) pending.resolve({ ...folder, isUnread: true, updatedAt: 4 }) await update expect(store.getState().folderWorkspaces[0]?.isUnread).toBe(true) @@ -227,3 +227,10 @@ it('does not publish an all-host refresh whose restored owners need no cleanup', initial.restoredRuntimeHostIdByWorkspaceSessionKey ) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts index 9169384b55e6..3bc88df2c76f 100644 --- a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts +++ b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts @@ -142,7 +142,7 @@ describe('cross-host project refresh identity', () => { const afterAllHosts = sharedProject(store)?.sourceRepoIds expect(afterAllHosts).toHaveLength(2) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const afterLocal = sharedProject(store)?.sourceRepoIds await store.getState().fetchRuntimeEnvironmentRepos('env-1') @@ -158,13 +158,13 @@ describe('cross-host project refresh identity', () => { await store.getState().fetchReposForAllHosts() // Why: the first local pass lands before the runtime catalog exists, so settle onto the // two-host fixed point before measuring identity. - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const settled = sharedProject(store) await store.getState().fetchRuntimeEnvironmentRepos('env-1') expect(sharedProject(store)).toBe(settled) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(sharedProject(store)).toBe(settled) }) @@ -180,3 +180,10 @@ describe('cross-host project refresh identity', () => { expect(sharedProject(store)).toBe(settled) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts b/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts index 2f75e8c07441..17e6a132463c 100644 --- a/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts +++ b/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts @@ -98,11 +98,18 @@ describe('repo module-lifetime coordinators', () => { const firstStore = createTestStore() const secondStore = createTestStore() - await firstStore.getState().fetchRepos() - await secondStore.getState().fetchRepos() + await firstStore.getState().fetchRepos(catalogOwner(firstStore)) + await secondStore.getState().fetchRepos(catalogOwner(secondStore)) expect(syncFork).toHaveBeenCalledTimes(1) resolveSync() await sync }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts b/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts index e6fdd0b82e8e..0e58e5c6baf2 100644 --- a/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts +++ b/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts @@ -65,6 +65,7 @@ describe('nested import partial catalog refresh failures', () => { parentPath: '/workspace', groupName: 'Workspace', projectPaths: [], + runtimeEnvironmentId: null, mode: 'group' }) ).resolves.toEqual(result) diff --git a/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts b/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts index 9ced00f07fcc..8016f3546481 100644 --- a/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts +++ b/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts @@ -72,7 +72,7 @@ it('preserves distinct SSH execution setups behind the same runtime owner', asyn const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) @@ -145,7 +145,7 @@ it('prefers an authoritative paired-runtime setup over its repo-derived fallback const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).toEqual([ { @@ -157,3 +157,10 @@ it('prefers an authoritative paired-runtime setup over its repo-derived fallback } ]) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-project-group-create-race.test.ts b/src/renderer/src/store/slices/repos-project-group-create-race.test.ts index 02025001385e..442503556799 100644 --- a/src/renderer/src/store/slices/repos-project-group-create-race.test.ts +++ b/src/renderer/src/store/slices/repos-project-group-create-race.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { createCompatibleRuntimeStatusResponseIfNeeded, @@ -67,16 +68,18 @@ function setup(runtimeEnvironmentId: string | null) { settings: { ...getDefaultSettings('/test'), activeRuntimeEnvironmentId: runtimeEnvironmentId }, projectGroups: [otherHostGroup] }) - const ownerHostId = runtimeEnvironmentId ? `runtime:${runtimeEnvironmentId}` : 'local' + const ownerHostId: ExecutionHostId = runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}` + : 'local' return { store, created, createStarted, ownerHostId } } describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvironmentId) => { it('keeps the refreshed row without notifying subscribers when refresh finishes first', async () => { const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) await createStarted.promise - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const refreshedState = store.getState() const listener = vi.fn() const unsubscribe = store.subscribe(listener) @@ -99,26 +102,26 @@ describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvi it('inserts beside another host with the same ID, then accepts the later refresh', async () => { const { store, created, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) created.resolve(projectGroup) await pendingCreate expect(store.getState().projectGroups).toEqual([ otherHostGroup, { ...projectGroup, executionHostId: ownerHostId } ]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().projectGroups).toEqual([ otherHostGroup, { ...refreshedGroup, executionHostId: ownerHostId } ]) const groups = store.getState().projectGroups - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().projectGroups).toBe(groups) }) it('keeps the original owner when the focused host changes during creation', async () => { const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) await createStarted.promise store.setState({ settings: { ...getDefaultSettings('/test'), activeRuntimeEnvironmentId: 'other' } @@ -133,11 +136,11 @@ describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvi }) it('does not roll back a successful refresh if the create response fails', async () => { - const { store, created, createStarted } = setup(runtimeEnvironmentId) + const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) vi.spyOn(console, 'error').mockImplementation(() => {}) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) await createStarted.promise - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const refreshedState = store.getState() created.reject(new Error('Create response lost')) await expect(pendingCreate).resolves.toBeNull() @@ -146,21 +149,21 @@ describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvi }) it('recognizes an unstamped local group without conflating an SSH catalog row', async () => { - const { store, created } = setup(null) + const { store, created, ownerHostId } = setup(null) const sshGroup = { ...projectGroup, connectionId: 'server' } store.setState({ projectGroups: [sshGroup, refreshedGroup] }) const state = store.getState() - const pendingCreate = state.createProjectGroup('Platform') + const pendingCreate = state.createProjectGroup('Platform', ownerHostId) created.resolve(projectGroup) await pendingCreate expect(store.getState()).toBe(state) }) it('does not suppress a local group whose ID matches a direct SSH group', async () => { - const { store, created } = setup(null) + const { store, created, ownerHostId } = setup(null) const sshGroup = { ...projectGroup, connectionId: 'server' } store.setState({ projectGroups: [sshGroup] }) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) created.resolve(projectGroup) await pendingCreate expect(store.getState().projectGroups).toEqual([ @@ -168,3 +171,10 @@ it('does not suppress a local group whose ID matches a direct SSH group', async { ...projectGroup, executionHostId: 'local' } ]) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts index 5e793f03a054..e56eabee30e0 100644 --- a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts @@ -115,17 +115,18 @@ describe('project group deletion store routing', () => { result: { deleted: false }, _meta: { runtimeId: 'runtime-remote' } }) + const serverGroup = { ...projectGroup, executionHostId: 'runtime:env-1' as const } const groupedRepo = { ...remoteRepo, projectGroupId: projectGroup.id } const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projectGroups: [projectGroup], + projectGroups: [serverGroup], repos: [groupedRepo] }) await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(false) - expect(store.getState().projectGroups).toEqual([projectGroup]) + expect(store.getState().projectGroups).toEqual([serverGroup]) expect(store.getState().repos).toEqual([groupedRepo]) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', diff --git a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts index b18fd5743dd2..94711fdc113e 100644 --- a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts @@ -155,7 +155,7 @@ describe('project group mutations route to the owning host', () => { expect(store.getState().projectGroups).toMatchObject([{ executionHostId: 'local' }]) }) - it('keeps the focused host when a colliding id has no unambiguous owner', async () => { + it('refuses, rather than following focus, when a colliding id has no unambiguous owner', async () => { projectGroupsDelete.mockResolvedValue(true) const store = createTestStore() store.setState({ @@ -166,9 +166,9 @@ describe('project group mutations route to the owning host', () => { ] }) - await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(false) - expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(projectGroupsDelete).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/repos-project-groups.test.ts b/src/renderer/src/store/slices/repos-project-groups.test.ts index 281fbde53e8f..e4565e8e91cf 100644 --- a/src/renderer/src/store/slices/repos-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups.test.ts @@ -12,6 +12,8 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { SshConnectionState } from '../../../../shared/ssh-types' +const LOCAL_OWNER = { runtimeEnvironmentId: null } + const remoteRepo: Repo = { id: 'remote-repo', path: '/remote', @@ -121,7 +123,7 @@ describe('project group store routing', () => { projectGroupsCreate.mockResolvedValue(projectGroup) const store = createTestStore() - await expect(store.getState().createProjectGroup('Platform')).resolves.toEqual({ + await expect(store.getState().createProjectGroup('Platform', 'local')).resolves.toEqual({ ...projectGroup, executionHostId: 'local' }) @@ -139,7 +141,7 @@ describe('project group store routing', () => { projectGroupsList.mockResolvedValue([folderGroup]) const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(LOCAL_OWNER) expect(store.getState().projectGroups).toEqual([{ ...folderGroup, executionHostId: 'local' }]) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() @@ -160,7 +162,7 @@ describe('project group store routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().projectGroups).toEqual([ { ...folderGroup, executionHostId: 'runtime:env-1' } @@ -178,7 +180,7 @@ describe('project group store routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().projectGroups).toEqual([ { ...folderGroup, executionHostId: 'runtime:env-1' } @@ -215,9 +217,11 @@ describe('project group store routing', () => { }) ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(store.getState().getFolderWorkspacePathStatusCacheKey(request)).toBe( - `environment:wrong-env:project-group:${folderGroup.id}` - ) + expect( + store + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: 'wrong-env' }) + ).toBe(`environment:wrong-env:project-group:${folderGroup.id}`) expect( store .getState() @@ -342,11 +346,12 @@ describe('project group store routing', () => { const store = createTestStore() await expect( - store.getState().createFolderWorkspace({ - projectGroupId: projectGroup.id, - name: 'Refund fix', - linkedTask - }) + store + .getState() + .createFolderWorkspace( + { projectGroupId: projectGroup.id, name: 'Refund fix', linkedTask }, + LOCAL_OWNER + ) ).resolves.toEqual({ ...folderWorkspace, executionHostId: 'local' }) await expect( store.getState().updateFolderWorkspace(folderWorkspace.id, { comment: 'Ready' }) @@ -380,20 +385,24 @@ describe('project group store routing', () => { store.setState({ projectGroups: [folderGroup] }) await expect( - store.getState().fetchFolderWorkspacePathStatus({ - scope: 'project-group', - projectGroupId: folderGroup.id - }) + store + .getState() + .fetchFolderWorkspacePathStatus( + { scope: 'project-group', projectGroupId: folderGroup.id }, + LOCAL_OWNER + ) ).resolves.toEqual({ path: '/workspace/platform', exists: false, reason: 'missing' }) - const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey({ - scope: 'project-group', - projectGroupId: folderGroup.id - }) + const cacheKey = store + .getState() + .getFolderWorkspacePathStatusCacheKey( + { scope: 'project-group', projectGroupId: folderGroup.id }, + LOCAL_OWNER + ) expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({ path: '/workspace/platform', exists: false, @@ -415,7 +424,7 @@ describe('project group store routing', () => { projectGroups: [{ ...projectGroup, parentPath: '/workspace/old-platform' }] }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) store.setState({ projectGroups: [{ ...projectGroup, parentPath: '/workspace/new-platform' }] @@ -423,7 +432,7 @@ describe('project group store routing', () => { resolveStatus({ path: '/workspace/old-platform', exists: true }) await statusPromise - const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request, LOCAL_OWNER) expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined() }) @@ -441,7 +450,7 @@ describe('project group store routing', () => { repos: [{ ...remoteRepo, id: 'local-repo', path: '/workspace/platform/api' }] }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) store.setState({ repos: [ @@ -456,7 +465,7 @@ describe('project group store routing', () => { resolveStatus({ path: '/workspace/platform', exists: true }) await statusPromise - const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request, LOCAL_OWNER) expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined() }) @@ -468,16 +477,16 @@ describe('project group store routing', () => { projectGroups: [{ ...projectGroup, parentPath: '/workspace/platform' }] }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - await store.getState().fetchFolderWorkspacePathStatus(request) + await store.getState().fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) - expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({ + expect(store.getState().getFreshFolderWorkspacePathStatus(request, LOCAL_OWNER)).toEqual({ path: '/workspace/platform', exists: true }) vi.setSystemTime(Date.now() + 10_001) - expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull() + expect(store.getState().getFreshFolderWorkspacePathStatus(request, LOCAL_OWNER)).toBeNull() } finally { vi.useRealTimers() } @@ -492,9 +501,9 @@ describe('project group store routing', () => { sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]]) }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - await store.getState().fetchFolderWorkspacePathStatus(request) + await store.getState().fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) - expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({ + expect(store.getState().getFreshFolderWorkspacePathStatus(request, LOCAL_OWNER)).toEqual({ path: '/workspace/platform', exists: true }) @@ -503,7 +512,7 @@ describe('project group store routing', () => { sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]]) }) - expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull() + expect(store.getState().getFreshFolderWorkspacePathStatus(request, LOCAL_OWNER)).toBeNull() }) it('ignores stale folder path status responses after SSH connection state changes', async () => { @@ -522,14 +531,16 @@ describe('project group store routing', () => { sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]]) }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const connectedStatusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + const connectedStatusPromise = store + .getState() + .fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) store.setState({ sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]]) }) const disconnectedStatusPromise = store .getState() - .fetchFolderWorkspacePathStatus(request, { force: true }) + .fetchFolderWorkspacePathStatus(request, { force: true, ...LOCAL_OWNER }) resolvers[1]?.({ path: '/workspace/platform', @@ -540,7 +551,7 @@ describe('project group store routing', () => { resolvers[0]?.({ path: '/workspace/platform', exists: true }) await connectedStatusPromise - const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request, LOCAL_OWNER) expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({ path: '/workspace/platform', exists: false, @@ -685,21 +696,16 @@ describe('project group store routing', () => { reposList.mockResolvedValue([importedRepo]) const store = createTestStore() - await expect( - store.getState().importNestedRepos({ - parentPath: '/platform', - groupName: 'Platform', - projectPaths: [importedRepo.path], - mode: 'group' - }) - ).resolves.toEqual(result) - - expect(projectGroupsImportNested).toHaveBeenCalledWith({ + const importArgs = { parentPath: '/platform', groupName: 'Platform', projectPaths: [importedRepo.path], - mode: 'group' - }) + mode: 'group' as const, + ...LOCAL_OWNER + } + await expect(store.getState().importNestedRepos(importArgs)).resolves.toEqual(result) + + expect(projectGroupsImportNested).toHaveBeenCalledWith(importArgs) expect(projectGroupsList).toHaveBeenCalled() expect(folderWorkspacesList).toHaveBeenCalled() expect(reposList).toHaveBeenCalled() @@ -736,6 +742,7 @@ describe('project group store routing', () => { await expect( store.getState().scanNestedRepos('/platform', undefined, { + ...LOCAL_OWNER, scanId: 'scan-1', onProgress: progressCallback }) @@ -759,6 +766,7 @@ describe('project group store routing', () => { await expect( store.getState().scanNestedRepos('/platform', undefined, { + ...LOCAL_OWNER, scanId: 'scan-1', onProgress: vi.fn() }) @@ -771,7 +779,7 @@ describe('project group store routing', () => { projectGroupsCancelNestedScan.mockResolvedValue(true) const store = createTestStore() - await expect(store.getState().cancelNestedRepoScan('scan-1')).resolves.toBe(true) + await expect(store.getState().cancelNestedRepoScan('scan-1', LOCAL_OWNER)).resolves.toBe(true) expect(projectGroupsCancelNestedScan).toHaveBeenCalledWith({ scanId: 'scan-1' }) }) @@ -780,7 +788,9 @@ describe('project group store routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await expect(store.getState().cancelNestedRepoScan('scan-1')).resolves.toBe(false) + await expect( + store.getState().cancelNestedRepoScan('scan-1', { runtimeEnvironmentId: 'env-1' }) + ).resolves.toBe(false) expect(projectGroupsCancelNestedScan).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() @@ -854,10 +864,12 @@ describe('project group store routing', () => { const store = createTestStore() await expect( - store.getState().createFolderWorkspace({ - projectGroupId: projectGroup.id, - name: 'Broken folder' - }) + store + .getState() + .createFolderWorkspace( + { projectGroupId: projectGroup.id, name: 'Broken folder' }, + LOCAL_OWNER + ) ).rejects.toThrow( 'Folder not found. Orca cannot find /srv/app. Remove and re-import the folder.' ) diff --git a/src/renderer/src/store/slices/repos-project-host-capability.test.ts b/src/renderer/src/store/slices/repos-project-host-capability.test.ts index 2bed71803152..c8689034cdee 100644 --- a/src/renderer/src/store/slices/repos-project-host-capability.test.ts +++ b/src/renderer/src/store/slices/repos-project-host-capability.test.ts @@ -76,7 +76,7 @@ describe('repo slice project-host setup runtime capability', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).toEqual([ expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) @@ -145,3 +145,10 @@ describe('repo slice project-host setup runtime capability', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts index 8ce6455754a1..b88c67d5fef2 100644 --- a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts @@ -271,7 +271,7 @@ describe('repo slice project host setup lifecycle', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).toEqual( expect.arrayContaining([ @@ -284,3 +284,10 @@ describe('repo slice project host setup lifecycle', () => { ) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-project-runtime.test.ts b/src/renderer/src/store/slices/repos-project-runtime.test.ts index 6df79a4af589..b59fa4c47a99 100644 --- a/src/renderer/src/store/slices/repos-project-runtime.test.ts +++ b/src/renderer/src/store/slices/repos-project-runtime.test.ts @@ -92,9 +92,13 @@ describe('repo slice project runtime updates', () => { isRefreshingAgents: true }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) expect(store.getState().detectedAgentIds).toBeNull() expect(store.getState().isDetectingAgents).toBe(false) @@ -128,7 +132,7 @@ describe('repo slice project runtime updates', () => { reposList.mockResolvedValue([localRepo]) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).toEqual([project]) expect(store.getState().projectHostSetups).toEqual([setup]) @@ -172,7 +176,7 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [staleProject], projectHostSetups: [setup] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() }) @@ -193,9 +197,13 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [project] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' @@ -207,7 +215,55 @@ describe('repo slice project runtime updates', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - it('updates remote project runtime preferences through the active runtime', async () => { + it('updates a local project on this computer even while a server with a setup is the default host', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + projectsUpdate.mockResolvedValue({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projects: [project], + projectHostSetups: [ + { + id: 'setup-1', + projectId: project.id, + hostId: 'runtime:env-1', + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + await store + .getState() + .updateProject( + project.id, + { localWindowsRuntimePreference: { kind: 'windows-host' } }, + 'local' + ) + + expect(projectsUpdate).toHaveBeenCalledWith({ + projectId: project.id, + updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('updates remote project runtime preferences through the owning runtime', async () => { const project: Project = { id: 'project-1', displayName: 'Project', @@ -247,9 +303,13 @@ describe('repo slice project runtime updates', () => { ] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'runtime:env-1' + ) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', @@ -288,9 +348,13 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [project] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) expect(store.getState().projects[0]?.createdAt).toBe(100) expect(store.getState().projects[0]?.updatedAt).toBe(100) @@ -314,9 +378,13 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [project] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ @@ -345,11 +413,22 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [project] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: undefined - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: undefined + }, + 'local' + ) expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-refresh-identity.test.ts index 78cc36eb0e70..0bb06dddcd5c 100644 --- a/src/renderer/src/store/slices/repos-refresh-identity.test.ts +++ b/src/renderer/src/store/slices/repos-refresh-identity.test.ts @@ -82,13 +82,13 @@ beforeEach(() => { describe('repo catalog refresh identity', () => { it('keeps the projects and host setups arrays and entries across a no-op refetch', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).toBe(projects) expect(store.getState().projects[0]).toBe(projects[0]) @@ -98,11 +98,11 @@ describe('repo catalog refresh identity', () => { it('lets a nested hookSettings change through', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups mockRepos({ ...repo, hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).not.toBe(setups) expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') @@ -110,11 +110,11 @@ describe('repo catalog refresh identity', () => { it('lets a displayName change through on projects', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects mockRepos({ ...repo, displayName: 'Renamed' }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).not.toBe(projects) expect(store.getState().projects[0]?.displayName).toBe('Renamed') @@ -122,11 +122,11 @@ describe('repo catalog refresh identity', () => { it('lets an array-field change through', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const repos = store.getState().repos mockRepos({ ...repo, symlinkPaths: ['.env'] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().repos).not.toBe(repos) expect(store.getState().repos[0]?.symlinkPaths).toEqual(['.env']) @@ -134,14 +134,14 @@ describe('repo catalog refresh identity', () => { it('lets a nested gitRemoteIdentity change through', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects mockRepos({ ...repo, gitRemoteIdentity: { ...repo.gitRemoteIdentity!, remoteName: 'upstream' } }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).not.toBe(projects) expect(store.getState().projects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') @@ -149,7 +149,7 @@ describe('repo catalog refresh identity', () => { it('treats clearing localWindowsRuntimePreference as a change', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projectId = store.getState().projects[0]!.id const withPreference: Project = { ...store.getState().projects[0]!, @@ -157,7 +157,7 @@ describe('repo catalog refresh identity', () => { } store.setState({ projects: [withPreference] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) // Why: a local-host refresh is authoritative; an absent key must not read as unchanged. expect(store.getState().projects[0]).not.toBe(withPreference) @@ -168,12 +168,12 @@ describe('repo catalog refresh identity', () => { it('reuses an unchanged setup element while a sibling changes', async () => { mockRepos(repo, secondRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) mockRepos(repo, { ...secondRepo, displayName: 'Repo 2 renamed' }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const next = store.getState().projectHostSetups expect(next).not.toBe(setups) @@ -183,14 +183,14 @@ describe('repo catalog refresh identity', () => { it('does not reuse a setup that moved to a different execution host', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups[0]?.hostId).toBe('local') // Why: the repo-derived fallback sets setup.id = repo.id, so the same id on a second host is // the case that would silently splice the wrong host's routing metadata into the row. mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const next = store.getState().projectHostSetups expect(next[0]).not.toBe(setups[0]) @@ -204,12 +204,12 @@ describe('repo catalog refresh identity', () => { const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } mockRepos(repo, sshRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) expect(new Set(setups.map((setup) => setup.id)).size).toBe(1) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).toBe(setups) expect(store.getState().projectHostSetups[0]).toBe(setups[0]) @@ -219,11 +219,11 @@ describe('repo catalog refresh identity', () => { it('drops a project and its setup when its repo disappears', async () => { mockRepos(repo, secondRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects mockRepos(repo) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).not.toBe(projects) expect(store.getState().projects).toHaveLength(1) @@ -237,7 +237,7 @@ describe('repo catalog refresh identity', () => { const sshRepo: Repo = { ...secondRepo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } mockRepos(repo, sshRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const sshOwned: Project = { id: 'ssh-owned', displayName: 'SSH Owned', @@ -251,7 +251,7 @@ describe('repo catalog refresh identity', () => { projectHostSetups: [] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned') }) @@ -262,7 +262,7 @@ describe('repo catalog refresh identity', () => { const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } mockRepos(repo, sshRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const dualHostOwned: Project = { id: 'dual-host-owned', displayName: 'Dual Host Owned', @@ -276,18 +276,18 @@ describe('repo catalog refresh identity', () => { projectHostSetups: [] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects.map((project) => project.id)).toContain('dual-host-owned') }) it('adds a project and its setup when a repo appears', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects mockRepos(repo, secondRepo) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).not.toBe(projects) expect(store.getState().projects).toHaveLength(2) @@ -297,7 +297,7 @@ describe('repo catalog refresh identity', () => { it('keeps catalog identity when repo.addedAt is 0', async () => { mockRepos({ ...repo, addedAt: 0 }) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) @@ -305,7 +305,7 @@ describe('repo catalog refresh identity', () => { expect(projects[0]?.createdAt).toBe(0) expect(setups[0]?.createdAt).toBe(0) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).toBe(projects) expect(store.getState().projects[0]).toBe(projects[0]) @@ -316,7 +316,7 @@ describe('repo catalog refresh identity', () => { it('keeps catalog identity when repo.addedAt is omitted', async () => { mockRepos(omitAddedAt(repo)) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) @@ -324,7 +324,7 @@ describe('repo catalog refresh identity', () => { expect(projects[0]?.createdAt).toBe(0) expect(setups[0]?.createdAt).toBe(0) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).toBe(projects) expect(store.getState().projects[0]).toBe(projects[0]) @@ -335,7 +335,7 @@ describe('repo catalog refresh identity', () => { it('lets a nested hookSettings change through when repo.addedAt is 0', async () => { mockRepos({ ...repo, addedAt: 0 }) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups mockRepos({ @@ -343,7 +343,7 @@ describe('repo catalog refresh identity', () => { addedAt: 0, hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).not.toBe(setups) expect(store.getState().projectHostSetups[0]).not.toBe(setups[0]) @@ -357,7 +357,7 @@ describe('repo filter identity across catalog refreshes', () => { store.setState({ filterRepoIds: [repo.id] }) const first = store.getState().filterRepoIds - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) // Why: App.tsx at the root and five sidebar consumers select this array by identity. expect(store.getState().filterRepoIds).toBe(first) @@ -367,7 +367,7 @@ describe('repo filter identity across catalog refreshes', () => { const store = createTestStore() store.setState({ filterRepoIds: [repo.id, 'gone'] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().filterRepoIds).toEqual([repo.id]) }) @@ -378,7 +378,7 @@ describe('repo filter identity across catalog refreshes', () => { store.setState({ filterRepoIds: [repo.id, secondRepo.id] }) const first = store.getState().filterRepoIds - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().filterRepoIds).toEqual([repo.id]) expect(store.getState().filterRepoIds).not.toBe(first) @@ -391,7 +391,7 @@ describe('repo filter identity across catalog refreshes', () => { // Why: the filter is keyed on repo id, not host identity — a rehomed repo is not pruned. mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().filterRepoIds).toBe(first) }) @@ -406,7 +406,7 @@ describe('setup-script dismissal identity across catalog refreshes', () => { }) const first = store.getState().setupScriptPromptDismissedRepoIds - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) // Why: SetupScriptPromptCard Object.is-subscribes to this array. A no-op // catalog refresh must not allocate just because the helper rebuilt next. @@ -417,7 +417,7 @@ describe('setup-script dismissal identity across catalog refreshes', () => { describe('SSH readoption catalog identity', () => { it('keeps projects and host setups across a no-op recordSshRepoReadoptions([])', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const before = store.getState() const projects = before.projects const setups = before.projectHostSetups @@ -438,7 +438,7 @@ describe('SSH readoption catalog identity', () => { it('keeps catalog identity for a pending-only readoption while pending updates', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects const setups = store.getState().projectHostSetups const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } @@ -465,7 +465,7 @@ describe('SSH readoption catalog identity', () => { } mockRepos(oldHostRepo, newHostRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) const oldSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-old') @@ -489,3 +489,10 @@ describe('SSH readoption catalog identity', () => { expect(store.getState().pendingSshRepoReadoptions).toEqual([]) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-runtime-project-groups.test.ts b/src/renderer/src/store/slices/repos-runtime-project-groups.test.ts index c01ccf818b74..1473c6330530 100644 --- a/src/renderer/src/store/slices/repos-runtime-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-runtime-project-groups.test.ts @@ -61,7 +61,7 @@ describe('repo slice runtime project groups', () => { repos: [localOrca] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().repos).toEqual([ localOrca, @@ -73,3 +73,10 @@ describe('repo slice runtime project groups', () => { ]) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-selected-owner-routing.test.ts b/src/renderer/src/store/slices/repos-selected-owner-routing.test.ts index 37922e968d8d..f1d2aa804c81 100644 --- a/src/renderer/src/store/slices/repos-selected-owner-routing.test.ts +++ b/src/renderer/src/store/slices/repos-selected-owner-routing.test.ts @@ -186,8 +186,8 @@ describe('selected Add Project owner routing', () => { projectGroupsList.mockResolvedValue([localGroup]) folderWorkspacesList.mockResolvedValue([localFolder]) store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) - await store.getState().fetchProjectGroups() - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchProjectGroups(catalogOwner(store)) + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().projectGroups).toEqual([ localGroup, @@ -536,3 +536,10 @@ describe('selected Add Project owner routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts b/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts index 6ddcdf99f9fc..08f3709a3e42 100644 --- a/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts +++ b/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts @@ -38,10 +38,17 @@ describe('repo setup script prompt dismissals', () => { ] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([ getSetupScriptPromptDismissalKey(localIdentity) ]) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-stale-fetch.test.ts b/src/renderer/src/store/slices/repos-stale-fetch.test.ts index 8993c823ec05..38e38d78cb9c 100644 --- a/src/renderer/src/store/slices/repos-stale-fetch.test.ts +++ b/src/renderer/src/store/slices/repos-stale-fetch.test.ts @@ -82,8 +82,8 @@ describe('repos slice stale-fetch race (#7020)', () => { // post-removal state (remoteRepo gone) and resolves first. reposList.mockReturnValueOnce(stalePromise).mockResolvedValueOnce([localRepo]) - const stale = store.getState().fetchRepos() - const fresh = store.getState().fetchRepos() + const stale = store.getState().fetchRepos(catalogOwner(store)) + const fresh = store.getState().fetchRepos(catalogOwner(store)) await fresh expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) @@ -105,8 +105,8 @@ describe('repos slice stale-fetch race (#7020)', () => { // the stale one, which must be dropped rather than resurrect remoteRepo. reposList.mockReturnValueOnce(stalePromise).mockRejectedValueOnce(new Error('boom')) - const stale = store.getState().fetchRepos() - await store.getState().fetchRepos() + const stale = store.getState().fetchRepos(catalogOwner(store)) + await store.getState().fetchRepos(catalogOwner(store)) resolveStale([localRepo, remoteRepo]) await stale @@ -119,7 +119,7 @@ describe('repos slice stale-fetch race (#7020)', () => { const store = createTestStore() store.setState({ repos: [localRepo] }) - const pending = store.getState().fetchRepos() + const pending = store.getState().fetchRepos(catalogOwner(store)) await store .getState() .removeProject(localRepo.id, { hostId: getRepoExecutionHostId(localRepo) }) @@ -145,7 +145,7 @@ describe('repos slice stale-fetch race (#7020)', () => { const store = createTestStore() const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - const refresh = store.getState().fetchRepos() + const refresh = store.getState().fetchRepos(catalogOwner(store)) resolveStartup([localRepo]) await startup @@ -184,7 +184,7 @@ describe('repos slice stale-fetch race (#7020)', () => { await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) await store.getState().awaitLocalRepoCatalogSettlement() - const refresh = store.getState().fetchRepos() + const refresh = store.getState().fetchRepos(catalogOwner(store)) let hydrated = false const hydration = (async () => { await store.getState().awaitLocalRepoCatalogSettlement() @@ -210,7 +210,7 @@ describe('repos slice stale-fetch race (#7020)', () => { const store = createTestStore() const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - const refresh = store.getState().fetchRepos() + const refresh = store.getState().fetchRepos(catalogOwner(store)) resolveStartup([localRepo]) await Promise.all([startup, refresh]) @@ -224,3 +224,10 @@ describe('repos slice stale-fetch race (#7020)', () => { expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({}) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 4c9a28972014..a1ecb4863a21 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -37,13 +37,15 @@ vi.mock('sonner', () => ({ installReposRuntimeRoutingHarness() const ownerOf = (repo: Repo) => ({ hostId: getRepoExecutionHostId(repo) }) +// Ingest stamps every server row; an unstamped row is this app's own. +const serverRepo: Repo = { ...remoteRepo, executionHostId: 'runtime:env-1' } describe('repo slice runtime routing', () => { it('fetches repos from local IPC when no remote environment is active', async () => { reposList.mockResolvedValue([localRepo]) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) expect(store.getState().projects).toEqual([ @@ -73,10 +75,10 @@ describe('repo slice runtime routing', () => { }) reposList.mockImplementation(async () => [hydrated()]) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) const reposRef = store.getState().repos - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) // Why: identity-keyed renderer memos (repo lookup index, selectors) rebuild on a new array. expect(store.getState().repos).toBe(reposRef) @@ -86,11 +88,11 @@ describe('repo slice runtime routing', () => { it('replaces the repos array identity when a refetch adds a repo', async () => { reposList.mockResolvedValue([localRepo]) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) const reposRef = store.getState().repos reposList.mockResolvedValue([localRepo, { ...localRepo, id: 'second', path: '/second' }]) - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) expect(store.getState().repos).not.toBe(reposRef) expect(store.getState().repos).toHaveLength(2) @@ -110,7 +112,7 @@ describe('repo slice runtime routing', () => { filterRepoIds: ['remote-repo', 'stale-repo'] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().repos).toEqual([{ ...remoteRepo, executionHostId: 'runtime:env-1' }]) expect(store.getState().projects).toEqual([ @@ -140,14 +142,14 @@ describe('repo slice runtime routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().repos).toEqual([ { ...remoteRepo, connectionId: 'ssh-1', executionHostId: 'runtime:env-1' } ]) }) - it('updates repos through the active remote runtime environment', async () => { + it('updates repos through their owning runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-2', ok: true, @@ -157,7 +159,7 @@ describe('repo slice runtime routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - repos: [remoteRepo] + repos: [serverRepo] }) await store.getState().updateRepo(remoteRepo.id, { displayName: 'Renamed' }) @@ -647,7 +649,7 @@ describe('repo slice runtime routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - it('removes repos through the active remote runtime environment', async () => { + it('removes repos through their owning runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-3', ok: true, @@ -657,11 +659,11 @@ describe('repo slice runtime routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - repos: [remoteRepo], + repos: [serverRepo], activeRepoId: remoteRepo.id }) - await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) + await store.getState().removeProject(remoteRepo.id, ownerOf(serverRepo)) expect(store.getState().repos).toEqual([]) expect(store.getState().activeRepoId).toBeNull() @@ -726,7 +728,7 @@ describe('repo slice runtime routing', () => { const localWorktreeId = `${localRepo.id}::/local/wt` store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - repos: [remoteRepo, localRepo], + repos: [serverRepo, localRepo], activeRepoId: remoteRepo.id, lastVisitedAtByWorktreeId: { [remoteWorktreeId]: 100, @@ -734,7 +736,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) + await store.getState().removeProject(remoteRepo.id, ownerOf(serverRepo)) expect(store.getState().repos).toEqual([localRepo]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktreeId]: 200 }) @@ -784,9 +786,11 @@ describe('repo slice runtime routing', () => { const worktreeId = `${remoteRepo.id}::/remote/wt` store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - repos: [remoteRepo], + repos: [serverRepo], worktreesByRepo: { - [remoteRepo.id]: [makeWorktree({ id: worktreeId, repoId: remoteRepo.id })] + [remoteRepo.id]: [ + makeWorktree({ id: worktreeId, repoId: remoteRepo.id, hostId: 'runtime:env-1' }) + ] }, tabsByWorktree: { [worktreeId]: [{ id: 'tab-1', worktreeId } as never] @@ -796,7 +800,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) + await store.getState().removeProject(remoteRepo.id, ownerOf(serverRepo)) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', diff --git a/src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts b/src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts index 76a28ae26344..d21158a7060e 100644 --- a/src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts +++ b/src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts @@ -110,8 +110,15 @@ describe('environment-catalog merge guard drops catalogs for a removed env', () repo('localStamped', { executionHostId: 'runtime:some-client' }) ]) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().repos.map((r) => r.id)).toContain('localStamped') }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 3f645aec4e08..12835039f678 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -288,7 +288,8 @@ export const createSettingsSlice: StateCreator } // Why: hydration is host-merged by downstream slices. Switching focus // should add/update the selected host without discarding other hosts. - await get().fetchRepos() + // Why: hydrate the newly chosen default host's catalog; other hosts keep theirs. + await get().fetchRepos({ runtimeEnvironmentId: nextId }) await get().fetchAllWorktrees() await get().fetchWorktreeLineage() await get().fetchBrowserSessionProfiles() From 0a73d1e093899fc801b0879c2744fdf3ea36af00 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:09:48 -0700 Subject: [PATCH 009/110] [CI] Reduce repeated test setup and real CLI waits (#27297) Combine related chat, worker-release, and filesystem tests while retaining their contracts. Replace fixed Codex terminal waits with observed readiness and hook completion. Keep runtime database contracts under Node. Local measurements and all CI lifecycle costs are documented in PR #27297. --- .../vitest-sqlite-database-runtime-files.mjs | 1 - ...ex-hook-file-entry-binary-contract.test.ts | 144 ++++++-- .../filesystem-git-commit-dispatch.test.ts | 171 ++++++++++ ...stem-pull-request-field-generation.test.ts | 230 ------------- .../worker/manual-dispatch-release.test.ts | 89 ++++- .../worker/worker-abandon-caller.test.ts | 92 ------ ...MessageList.interrupted-turn-fold.test.tsx | 139 -------- ...atMessageList.provider-retry-runs.test.tsx | 71 ---- ...ChatMessageList.stopping-turn-bar.test.tsx | 126 ------- ...NativeChatMessageList.turn-status.test.tsx | 307 ++++++++++++++++++ 10 files changed, 681 insertions(+), 689 deletions(-) delete mode 100644 src/main/ipc/filesystem-pull-request-field-generation.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index d33348671932..0d2c20aeb30c 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -147,7 +147,6 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts', - 'src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts', diff --git a/src/main/codex/codex-hook-file-entry-binary-contract.test.ts b/src/main/codex/codex-hook-file-entry-binary-contract.test.ts index e0ea77444cbb..47aee2a70bbe 100644 --- a/src/main/codex/codex-hook-file-entry-binary-contract.test.ts +++ b/src/main/codex/codex-hook-file-entry-binary-contract.test.ts @@ -1,5 +1,6 @@ import { execFile, execFileSync } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -11,6 +12,9 @@ import { import { createServer, type Server } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { StringDecoder } from 'node:string_decoder' +import { createCodexComposerReadyScanner } from '../../shared/codex-composer-ready-scanner' +import { createTerminalEscapeScanner } from '../../shared/terminal-escape-scanner' import { promisify } from 'node:util' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { CodexListedHook } from './codex-app-server-client' @@ -277,8 +281,9 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 enabled: true } ]) - const { screen } = await runCodexTui() + const { screen, posts } = await runCodexTui(accountHome(), true) expect(screen).toMatch(/eeds?review/i) + expect(posts).toEqual([]) } ) @@ -403,11 +408,13 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 /** A TUI Codex in a pty: start, type a prompt, quit; the screen without spaces or escapes. */ async function runCodexTui( - codexHome: string | null = accountHome() + codexHome: string | null = accountHome(), + expectReview = false ): Promise<{ screen: string; posts: string[] }> { const codexHomeEnv = codexHome ? { CODEX_HOME: codexHome } : {} const posts: string[] = [] - const receiver = await listen(recordPosts(posts)) + const control = mkdtempSync(join(root, 'tui-control-')) + const receiver = await listen(recordPosts(posts, control)) const model = await startMockResponses() // Why resolved: Codex 0.158 matches the trusted project only by the real path, and a symlinked // HOME (the ~/.codex case) left it untrusted, so the turn never ran and no hook posted. @@ -418,7 +425,7 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 env: { PATH: process.env.PATH, HOME: home, ...codexHomeEnv } }) try { - await execFileAsync( + const run = execFileAsync( 'python3', [ '-c', @@ -426,6 +433,8 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 out, 'say hi', workdir, + control, + expectReview ? 'review' : 'turn', '--', binary!, ...(help.stdout.includes('--no-daemon') ? ['--no-daemon'] : []), @@ -440,6 +449,7 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 ], { timeout: TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, env: { PATH: process.env.PATH, HOME: home, @@ -452,6 +462,40 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 } } ) + const composer = createCodexComposerReadyScanner() + const decoder = new StringDecoder('utf8') + let painted = '' + const text = createTerminalEscapeScanner({ + onCsi: () => {}, + onText: (chunk) => { + painted = (painted + chunk).slice(-16_384) + } + }) + const mark = (name: string): void => { + const file = join(control, name) + if (!existsSync(file)) { + writeFileSync(file, '') + } + } + run.child.stdout?.on('data', (bytes: Buffer) => { + const chunk = decoder.write(bytes) + const ready = composer.observe(chunk).ready + text.observe(chunk) + const folded = painted.replace(/\s+/g, '') + if (/eeds?review/i.test(folded)) { + mark('review-started') + } + if (/\d+hooks?n?eeds?reviewbeforeitcanrun/i.test(folded)) { + mark('review-table') + } + if (ready && /\b(?:default|minimal|low|medium|high|xhigh)\s*·/.test(painted)) { + mark('ready') + } + if (folded.includes('•hi') && ready) { + mark('assistant-painted') + } + }) + await run } finally { model.close() receiver.close() @@ -465,36 +509,57 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 const PTY_DRIVER = ` import os, pty, re, sys, time, select, signal, struct, fcntl, termios out, prompt, cwd = sys.argv[1], sys.argv[2], sys.argv[3] +control, expected = sys.argv[4], sys.argv[5] cmd = sys.argv[sys.argv.index('--') + 1:] -pid, fd = pty.fork() -if pid == 0: - os.chdir(cwd); os.execv(cmd[0], cmd) -fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 40, 120, 0, 0)) +def send(data): + os.write(fd, data) buf = b'' -def pump(sec): +def present(name): + return os.path.isfile(os.path.join(control, name)) +def pump(sec, done=None): global buf - end = time.time() + sec - while time.time() < end: + end = time.monotonic() + sec + while time.monotonic() < end: + if done and done(): return True r, _, _ = select.select([fd], [], [], 0.2) if r: - try: - d = os.read(fd, 65536) - except OSError: - return - if not d: return + try: d = os.read(fd, 65536) + except OSError: return False + if not d: return False buf += d - if b'\\x1b[6n' in d: os.write(fd, b'\\x1b[1;1R') -pump(8) -os.write(fd, prompt.encode()); time.sleep(0.5); os.write(fd, b'\\r'); pump(10) -for _ in range(3): - try: os.write(fd, b'\\x03') - except OSError: break - pump(1) -try: os.kill(pid, signal.SIGKILL) -except ProcessLookupError: pass -buf = re.sub(rb'\\x1b\\[[0-9;?<>=]*[A-Za-z]', b'', buf) -buf = re.sub(rb'\\x1b\\][^\\x07]*\\x07', b'', buf) -open(out, 'wb').write(buf) + os.write(sys.stdout.fileno(), d) + if b'\\x1b[6n' in d: send(b'\\x1b[1;1R') + return bool(done and done()) +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd); os.execv(cmd[0], cmd) +try: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 40, 120, 0, 0)) + start = 'review-started' if expected == 'review' else 'ready' + if not pump(8, lambda: present(start)): + raise RuntimeError('Codex did not paint its expected startup state within 8 seconds') + send(prompt.encode()); time.sleep(0.5); send(b'\\r') + if expected == 'review': + done = lambda: present('review-table') and not present('stop') + else: + done = lambda: present('stop') and present('assistant-painted') + if not pump(10, done): + raise RuntimeError('Codex did not deliver/paint its expected turn or review within 10 seconds') +finally: + try: + for _ in range(3): + try: send(b'\\x03') + except OSError: break + pump(1) + finally: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: pass + finally: + waited_pid, status = os.waitpid(pid, 0) + buf = re.sub(rb'\\x1b\\[[0-9;?<>=]*[A-Za-z]', b'', buf) + buf = re.sub(rb'\\x1b\\][^\\x07]*\\x07', b'', buf) + open(out, 'wb').write(buf) ` function hasPython(): boolean { @@ -506,11 +571,32 @@ function hasPython(): boolean { } } -function recordPosts(posts: string[]): Server { +function recordPosts(posts: string[], control?: string): Server { return createServer((request, response) => { + const chunks: Buffer[] = [] + if (control) { + request.on('data', (chunk: Buffer) => chunks.push(chunk)) + } request.resume() request.on('end', () => { posts.push(request.url ?? '') + if (control && request.url === '/hook/codex') { + const raw = Buffer.concat(chunks).toString('utf8') + const form = new URLSearchParams(raw) + try { + const payload: unknown = JSON.parse(form.get('payload') ?? raw) + if ( + typeof payload === 'object' && + payload !== null && + 'hook_event_name' in payload && + payload.hook_event_name === 'Stop' + ) { + response.once('finish', () => writeFileSync(join(control, 'stop'), raw)) + } + } catch { + /* A malformed owner payload must not satisfy the delivery gate. */ + } + } response.writeHead(204).end() }) }) diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 371a182c5060..223a448e775e 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -1,4 +1,6 @@ +import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as gitRunner from '../git/runner' import { handlers, store, @@ -6,6 +8,11 @@ import { commitChangesMock, bulkDiscardChangesMock, getSshGitProviderMock, + resolveCommitMessageSettingsMock, + generatePullRequestFieldsFromContextMock, + getPullRequestDraftContextMock, + resolveHostedReviewBodyForGenerationMock, + loadPullRequestLinkedIssueMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -200,3 +207,167 @@ describe('registerFilesystemHandlers', () => { expect(sshCommitMock).not.toHaveBeenCalled() }) }) + +describe('registerFilesystemHandlers pull request field generation', () => { + beforeEach(() => { + resetFilesystemIpcMocks() + // Reset module-level auth cache so each test starts with a fresh dirty + // flag — prevents stale worktree data from a prior test's cache rebuild. + invalidateAuthorizedRootsCache() + }) + + describe('git:generatePullRequestFields linked issue', () => { + const PULL_REQUEST_CONTEXT = { + base: 'main', + branch: 'feature/ai', + branchChangedByPreparation: false, + commitSummary: 'a1b2c3d Add generation', + changeSummary: 'README.md | 2 +-', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false + } + const PULL_REQUEST_ARGS = { base: 'main', title: '', body: '', draft: false } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + + beforeEach(() => { + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + resolveHostedReviewBodyForGenerationMock.mockResolvedValue('') + getPullRequestDraftContextMock.mockResolvedValue(PULL_REQUEST_CONTEXT) + generatePullRequestFieldsFromContextMock.mockResolvedValue({ success: true, fields: {} }) + }) + + it('enriches the local pull-request context with a validated worktree linked issue', async () => { + const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` + const linkedIssueDetails = { + provider: 'github', + number: 123, + title: 'Improve PR generation', + description: 'Include issue context.' + } + loadPullRequestLinkedIssueMock.mockResolvedValue(linkedIssueDetails) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId, + provider: 'github' + }) + + expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( + { + ...PULL_REQUEST_CONTEXT, + linkedIssue: 123, + provider: 'github', + linkedIssueDetails + }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + it('enriches the SSH pull-request context from host meta using the remote path', async () => { + const worktreeId = 'repo-1::/remote/repo' + getSshGitProviderMock.mockReturnValue({ + exec: vi.fn(), + executeCommitMessagePlan: vi.fn() + }) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: '/remote/repo', + worktreeId, + connectionId: 'conn-1' + }) + + expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( + { ...PULL_REQUEST_CONTEXT, linkedIssue: 77 }, + params, + expect.objectContaining({ kind: 'remote' }) + ) + }) + + it('ignores a pull-request worktree id that does not own the requested path', async () => { + const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 })) + + registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}` + }) + + expect(getWorktreeMeta).not.toHaveBeenCalled() + // Why: without the length guard the property assertion passes vacuously on `undefined`, + // so an unrelated early return would read as "enrichment correctly suppressed". + expect(generatePullRequestFieldsFromContextMock.mock.calls).toHaveLength(1) + expect(generatePullRequestFieldsFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( + 'linkedIssue' + ) + }) + + it('forwards PR-context command limits through local and SSH adapters', async () => { + const localGitExec = vi + .spyOn(gitRunner, 'gitExecFileAsync') + .mockResolvedValue({ stdout: '', stderr: '' }) + const sshExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + getSshGitProviderMock.mockReturnValue({ + exec: sshExec, + executeCommitMessagePlan: vi.fn() + }) + getPullRequestDraftContextMock.mockImplementation(async (execute) => { + await execute(['show-ref', '--verify'], { maxBuffer: 123, timeoutMs: 456 }) + await execute(['legacy-probe'], { timeout: 789 }) + return PULL_REQUEST_CONTEXT + }) + + try { + registerFilesystemHandlers(store as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH + }) + + expect(localGitExec).toHaveBeenCalledWith(['show-ref', '--verify'], { + cwd: WORKTREE_FEATURE_PATH, + maxBuffer: 123, + timeout: 456 + }) + expect(localGitExec).toHaveBeenCalledWith(['legacy-probe'], { + cwd: WORKTREE_FEATURE_PATH, + timeout: 789 + }) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + + expect(sshExec).toHaveBeenCalledWith(['show-ref', '--verify'], '/remote/repo', { + timeoutMs: 456 + }) + expect(sshExec).toHaveBeenCalledWith(['legacy-probe'], '/remote/repo', { + timeoutMs: 789 + }) + } finally { + localGitExec.mockRestore() + } + }) + }) +}) diff --git a/src/main/ipc/filesystem-pull-request-field-generation.test.ts b/src/main/ipc/filesystem-pull-request-field-generation.test.ts deleted file mode 100644 index b12e5d2b0109..000000000000 --- a/src/main/ipc/filesystem-pull-request-field-generation.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as gitRunner from '../git/runner' -import { - handlers, - store, - WORKTREE_FEATURE_PATH, - resolveCommitMessageSettingsMock, - generatePullRequestFieldsFromContextMock, - getPullRequestDraftContextMock, - resolveHostedReviewBodyForGenerationMock, - loadPullRequestLinkedIssueMock, - getSshGitProviderMock, - resetFilesystemIpcMocks -} from './filesystem-test-harness' - -vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) -vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) -vi.mock( - '../wsl-unc-delete', - async () => (await import('./filesystem-test-harness')).wslUncDeleteMock -) -vi.mock( - '../crash-reporting/crash-breadcrumb-store', - async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock -) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) -vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock -) -vi.mock( - '../git/check-ignored-paths', - async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock -) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock -) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) -vi.mock( - '../text-generation/commit-message-text-generation', - async () => (await import('./filesystem-test-harness')).textGenerationModuleMock -) -vi.mock( - '../text-generation/pull-request-context', - async () => (await import('./filesystem-test-harness')).pullRequestContextMock -) -vi.mock( - '../source-control/pull-request-template', - async () => (await import('./filesystem-test-harness')).pullRequestTemplateMock -) -vi.mock( - '../source-control/pull-request-linked-issue', - async () => (await import('./filesystem-test-harness')).pullRequestLinkedIssueMock -) - -import { registerFilesystemHandlers } from './filesystem' -import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' - -describe('registerFilesystemHandlers', () => { - beforeEach(() => { - resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. - invalidateAuthorizedRootsCache() - }) - - describe('git:generatePullRequestFields linked issue', () => { - const PULL_REQUEST_CONTEXT = { - base: 'main', - branch: 'feature/ai', - branchChangedByPreparation: false, - commitSummary: 'a1b2c3d Add generation', - changeSummary: 'README.md | 2 +-', - patch: '+hello', - currentTitle: '', - currentBody: '', - currentDraft: false - } - const PULL_REQUEST_ARGS = { base: 'main', title: '', body: '', draft: false } - const params = { agentId: 'codex', model: 'gpt-5.4-mini' } - - beforeEach(() => { - resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) - resolveHostedReviewBodyForGenerationMock.mockResolvedValue('') - getPullRequestDraftContextMock.mockResolvedValue(PULL_REQUEST_CONTEXT) - generatePullRequestFieldsFromContextMock.mockResolvedValue({ success: true, fields: {} }) - }) - - it('enriches the local pull-request context with a validated worktree linked issue', async () => { - const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` - const linkedIssueDetails = { - provider: 'github', - number: 123, - title: 'Improve PR generation', - description: 'Include issue context.' - } - loadPullRequestLinkedIssueMock.mockResolvedValue(linkedIssueDetails) - const linkedStore = { - ...store, - getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) - } - - registerFilesystemHandlers(linkedStore as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: WORKTREE_FEATURE_PATH, - worktreeId, - provider: 'github' - }) - - expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( - { - ...PULL_REQUEST_CONTEXT, - linkedIssue: 123, - provider: 'github', - linkedIssueDetails - }, - params, - expect.objectContaining({ kind: 'local' }) - ) - }) - - it('enriches the SSH pull-request context from host meta using the remote path', async () => { - const worktreeId = 'repo-1::/remote/repo' - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn(), - executeCommitMessagePlan: vi.fn() - }) - const linkedStore = { - ...store, - getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) - } - - registerFilesystemHandlers(linkedStore as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: '/remote/repo', - worktreeId, - connectionId: 'conn-1' - }) - - expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( - { ...PULL_REQUEST_CONTEXT, linkedIssue: 77 }, - params, - expect.objectContaining({ kind: 'remote' }) - ) - }) - - it('ignores a pull-request worktree id that does not own the requested path', async () => { - const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 })) - - registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: WORKTREE_FEATURE_PATH, - worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}` - }) - - expect(getWorktreeMeta).not.toHaveBeenCalled() - // Why: without the length guard the property assertion passes vacuously on `undefined`, - // so an unrelated early return would read as "enrichment correctly suppressed". - expect(generatePullRequestFieldsFromContextMock.mock.calls).toHaveLength(1) - expect(generatePullRequestFieldsFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( - 'linkedIssue' - ) - }) - - it('forwards PR-context command limits through local and SSH adapters', async () => { - const localGitExec = vi - .spyOn(gitRunner, 'gitExecFileAsync') - .mockResolvedValue({ stdout: '', stderr: '' }) - const sshExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - getSshGitProviderMock.mockReturnValue({ - exec: sshExec, - executeCommitMessagePlan: vi.fn() - }) - getPullRequestDraftContextMock.mockImplementation(async (execute) => { - await execute(['show-ref', '--verify'], { maxBuffer: 123, timeoutMs: 456 }) - await execute(['legacy-probe'], { timeout: 789 }) - return PULL_REQUEST_CONTEXT - }) - - try { - registerFilesystemHandlers(store as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: WORKTREE_FEATURE_PATH - }) - - expect(localGitExec).toHaveBeenCalledWith(['show-ref', '--verify'], { - cwd: WORKTREE_FEATURE_PATH, - maxBuffer: 123, - timeout: 456 - }) - expect(localGitExec).toHaveBeenCalledWith(['legacy-probe'], { - cwd: WORKTREE_FEATURE_PATH, - timeout: 789 - }) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: '/remote/repo', - connectionId: 'conn-1' - }) - - expect(sshExec).toHaveBeenCalledWith(['show-ref', '--verify'], '/remote/repo', { - timeoutMs: 456 - }) - expect(sshExec).toHaveBeenCalledWith(['legacy-probe'], '/remote/repo', { - timeoutMs: 789 - }) - } finally { - localGitExec.mockRestore() - } - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts index e340e51b01dd..dca1eddadf45 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type Database from '../../../../../sqlite/sync-database' import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import { ORCHESTRATION_METHODS } from '../../orchestration' -import { eraseRpcMethods } from '../../../core' +import { eraseRpcMethods, type RpcContext } from '../../../core' +import { parseOrcaSessionAddress } from '../../../../../../shared/orca-session-address' const COORDINATOR = 'term_coordinator' const TARGET = 'term_target' @@ -195,3 +197,88 @@ function paneKey(handle: string): string { function sqliteFor(db: OrchestrationDb): Database.Database { return (db as unknown as { db: Database.Database }).db } + +describe('orchestration.workerAbandon', () => { + let db: OrchestrationDb + let runtime: OrcaRuntimeService + + beforeEach(() => { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + }) + afterEach(() => db.close()) + + function readyWorker(): string { + const task = db.createTask({ runId: 'run_legacy_local', spec: 'abandon caller' }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + processIncarnation: 'inc_worker', + worktreeId: 'wt', + effects: [], + setupState: 'not_configured' + }) + db.markWorkerDispatchReady(dispatch.id) + return dispatch.id + } + + async function abandon(dispatchId: string, ctx: Partial) { + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (m) => m.name === 'orchestration.workerAbandon' + )! + return method.handler(method.params!.parse({ dispatch: dispatchId }), { runtime, ...ctx }) + } + + it('never records an unverified terminal handle as the one who abandoned the worker', async () => { + const dispatchId = readyWorker() + + await expect( + abandon(dispatchId, { orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } }) + ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) + expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( + 'Abandoned by an unidentified caller.' + ) + }) + + it('records the resolved Orca session as the one who abandoned the worker', async () => { + const dispatchId = readyWorker() + const orcaSessionId = parseOrcaSessionAddress('orca_session_id:chat_1')! + const session: OrchestrationSessionCaller = { + address: 'orca_session_id:chat_1', + terminalHandle: null, + paneKey: null, + orcaSessionId, + sessionId: orcaSessionId, + workspaceId: 'wt' + } + + await abandon(dispatchId, { + orchestrationCaller: session, + orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } + }) + expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( + 'Abandoned by orca_session_id:chat_1.' + ) + }) + + it('reports an already-settled worker as stale and changes nothing', async () => { + const dispatchId = readyWorker() + db.failDispatch(dispatchId, 'tab closed', { workerProcessExited: true }) + const before = db.getWorkerDispatch(dispatchId) + + await expect(abandon(dispatchId, {})).resolves.toMatchObject({ + state: 'failed', + alreadySettled: true, + stale: true + }) + expect(db.getWorkerDispatch(dispatchId)).toEqual(before) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts deleted file mode 100644 index 36ff0a8cd092..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { OrcaRuntimeService } from '../../../../orca-runtime' -import { OrchestrationDb } from '../../../../orchestration/db' -import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' -import { ORCHESTRATION_METHODS } from '../../orchestration' -import { eraseRpcMethods, type RpcContext } from '../../../core' -import { parseOrcaSessionAddress } from '../../../../../../shared/orca-session-address' - -describe('orchestration.workerAbandon', () => { - let db: OrchestrationDb - let runtime: OrcaRuntimeService - - beforeEach(() => { - db = new OrchestrationDb(':memory:') - runtime = new OrcaRuntimeService() - runtime.setOrchestrationDb(db) - }) - afterEach(() => db.close()) - - function readyWorker(): string { - const task = db.createTask({ runId: 'run_legacy_local', spec: 'abandon caller' }) - const { dispatch } = db.createStartingWorkerDispatch({ - taskId: task.id, - startOptions: {}, - creator: { kind: 'system' }, - maxDepth: 9 - }) - db.prepareStartingWorkerAuthority({ - dispatchId: dispatch.id, - handle: 'term_worker', - paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - processIncarnation: 'inc_worker', - worktreeId: 'wt', - effects: [], - setupState: 'not_configured' - }) - db.markWorkerDispatchReady(dispatch.id) - return dispatch.id - } - - async function abandon(dispatchId: string, ctx: Partial) { - const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( - (m) => m.name === 'orchestration.workerAbandon' - )! - return method.handler(method.params!.parse({ dispatch: dispatchId }), { runtime, ...ctx }) - } - - it('never records an unverified terminal handle as the one who abandoned the worker', async () => { - const dispatchId = readyWorker() - - await expect( - abandon(dispatchId, { orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } }) - ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) - expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( - 'Abandoned by an unidentified caller.' - ) - }) - - it('records the resolved Orca session as the one who abandoned the worker', async () => { - const dispatchId = readyWorker() - const orcaSessionId = parseOrcaSessionAddress('orca_session_id:chat_1')! - const session: OrchestrationSessionCaller = { - address: 'orca_session_id:chat_1', - terminalHandle: null, - paneKey: null, - orcaSessionId, - sessionId: orcaSessionId, - workspaceId: 'wt' - } - - await abandon(dispatchId, { - orchestrationCaller: session, - orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } - }) - expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( - 'Abandoned by orca_session_id:chat_1.' - ) - }) - - it('reports an already-settled worker as stale and changes nothing', async () => { - const dispatchId = readyWorker() - db.failDispatch(dispatchId, 'tab closed', { workerProcessExited: true }) - const before = db.getWorkerDispatch(dispatchId) - - await expect(abandon(dispatchId, {})).resolves.toMatchObject({ - state: 'failed', - alreadySettled: true, - stale: true - }) - expect(db.getWorkerDispatch(dispatchId)).toEqual(before) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx deleted file mode 100644 index f9d2ee5e3a14..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import { cleanup, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalRenderItem, - AgentJournalTurnItem -} from '../../../../shared/agent-session-journal-types' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' -import type { NativeChatLiveSession } from './use-native-chat-live-session' -import { NativeChatMessageList } from './NativeChatMessageList' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -const TURN_MS = 12_000 - -// Each turn ends differently. The journal is where each end is recorded, and the lifecycle is -// seeded rather than produced, since a crash settles as `interrupted` only once the host proves it. -const TURNS: { id: string; end: Pick }[] = [ - { id: 'user-finished', end: { state: 'completed', outcome: 'success' } }, - { id: 'user-crashed', end: { state: 'interrupted' } }, - { id: 'user-stopped', end: { state: 'interrupted', outcome: 'cancellation' } } -] - -function turnMessages(userId: string, at: number): NativeChatMessage[] { - return [ - { - id: userId, - role: 'user', - blocks: [{ type: 'text', text: `prompt ${userId}` }], - timestamp: at, - source: 'transcript' - }, - { - id: `${userId}-narration`, - role: 'assistant', - blocks: [{ type: 'text', text: `narration ${userId}` }], - timestamp: at + 1, - source: 'transcript' - }, - { - id: `${userId}-work`, - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'completed' }, - { type: 'tool-result', output: 'ok' } - ], - timestamp: at + 2, - source: 'transcript' - }, - { - id: `${userId}-answer`, - role: 'assistant', - blocks: [{ type: 'text', text: `answer ${userId}` }], - timestamp: at + 3, - source: 'transcript' - } - ] -} - -function journal(): AgentJournalRenderItem[] { - return TURNS.flatMap(({ id, end }, index) => { - const at = 1_000_000 + index * 100_000 - return [ - { - itemId: id, - sequence: index * 2 + 1, - revision: 1, - observedAt: at, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } - }, - { - itemId: `${id}-turn`, - sequence: index * 2 + 2, - revision: 1, - observedAt: at + TURN_MS, - body: { - kind: 'turn', - turnId: `${id}-turn`, - userItemId: id, - startedAt: at, - requestedAt: at, - completedAt: at + TURN_MS, - ...end - } - } - ] - }) -} - -function session(): NativeChatLiveSession { - return { - messages: TURNS.flatMap(({ id }, index) => turnMessages(id, index * 10)), - status: 'ready', - sessionId: 'session-1', - agent: 'codex', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' - } -} - -describe('NativeChatMessageList folded turn headers', () => { - // A crash-cut turn reads like a finished one; the chat's notice row is what says it stopped. - it('says a crash-cut turn worked like a finished one and a stopped turn was interrupted', () => { - render( - - ) - - const headers = screen - .getAllByRole('button', { name: 'Toggle turn details' }) - .map((button) => [button.textContent, button.getAttribute('aria-expanded')]) - expect(headers).toEqual([ - ['Worked for 12s', 'false'], - ['Worked for 12s', 'false'], - ['Interrupted after 12s', 'false'] - ]) - // The turn's detail stays inside the fold. - expect(screen.queryByText('narration user-crashed')).toBeNull() - expect(screen.getByText('answer user-crashed')).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx deleted file mode 100644 index 06bc89d8c16c..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import { cleanup, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import { NativeChatMessageList } from './NativeChatMessageList' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -function retry(sequence: number, attempt: number): AgentJournalRenderItem { - return { - itemId: `retry-${sequence}`, - revision: 1, - sequence, - observedAt: sequence, - body: { - kind: 'status', - tone: 'warning', - ...agentSessionFailureWords( - { - kind: 'providerRetrying', - detail: { text: `Reconnecting... ${attempt}/5`, audience: 'person' }, - retry: { cause: 'stream disconnected before completion' } - }, - { surface: 'row', agentName: 'Codex' } - ) - } - } -} - -function transcript(items: AgentJournalRenderItem[]) { - return ( - - ) -} - -describe('a Codex retrying a dropped stream', () => { - it('draws one warning for the run, with what failed on its second line', () => { - render(transcript([retry(1, 1), retry(2, 2), retry(3, 3)])) - - const rows = screen.getAllByText(/Codex is retrying/) - expect(rows).toHaveLength(1) - expect(rows[0]?.textContent).toBe( - 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected before completion' - ) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx deleted file mode 100644 index 65e7f5303965..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import { cleanup, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { NativeChatLiveSession } from './use-native-chat-live-session' -import { NativeChatMessageList } from './NativeChatMessageList' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../../../shared/agent-session-journal-types' - -const turnItem: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } - -function journalItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { - return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } -} - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -const session: NativeChatLiveSession = { - messages: [ - { - id: 'user-stop', - role: 'user', - blocks: [{ type: 'text', text: 'Start the task' }], - timestamp: Date.now(), - source: 'transcript' - } - ], - status: 'working', - sessionId: 'session-1', - agent: 'codex', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' -} - -// The turn still runs while a Stop ends it, so its bar keeps the running clock; only the tail line -// says the turn is stopping, and the bar settles to "Interrupted after" once the turn ends. -describe("the turn bar while a person's Stop ends the turn", () => { - it('keeps the running clock above the Stopping tail line', () => { - render( - - ) - - const bar = screen.getByText('Working for 0s') - const stopping = screen.getByText('Stopping…') - expect(bar.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(screen.getAllByText(/Stopping/)).toHaveLength(1) - }) -}) - -// Where the host does not queue sends, one made while Stopping is held until the turn ends: it is -// drawn after the Stopping line, not inside the turn being stopped. -describe('a message sent while Stopping, before the host has handed it over', () => { - const pending = { - id: 'pending-send', - role: 'user' as const, - sentWhileStopping: true as const, - blocks: [{ type: 'text' as const, text: 'Run this after the stop' }], - timestamp: Date.now(), - source: 'transcript' as const - } - - function renderList(stopping: boolean): void { - render( - - ) - } - - it('draws after the Stopping line', () => { - renderList(true) - - const stopping = screen.getByText('Stopping…') - const sent = screen.getByText('Run this after the stop') - expect(stopping.compareDocumentPosition(sent)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - }) - - it('stays in the turn while nothing is stopping', () => { - renderList(false) - - const sent = screen.getByText('Run this after the stop') - const working = screen.getByText('Working…') - expect(sent.compareDocumentPosition(working)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - }) - - // Sent just before the Stop, the host steers it into the turn: it stays there, never jumping. - it('keeps a send made before the Stop in the turn', () => { - const { sentWhileStopping: _made, ...beforeStop } = pending - render( - - ) - - const sent = screen.getByText('Run this after the stop') - const stopping = screen.getByText('Stopping…') - expect(sent.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx new file mode 100644 index 000000000000..b9d3949300d7 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx @@ -0,0 +1,307 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnItem +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +{ + const TURN_MS = 12_000 + + // Each turn ends differently. The journal is where each end is recorded, and the lifecycle is + // seeded rather than produced, since a crash settles as `interrupted` only once the host proves it. + const TURNS: { id: string; end: Pick }[] = [ + { id: 'user-finished', end: { state: 'completed', outcome: 'success' } }, + { id: 'user-crashed', end: { state: 'interrupted' } }, + { id: 'user-stopped', end: { state: 'interrupted', outcome: 'cancellation' } } + ] + + function turnMessages(userId: string, at: number): NativeChatMessage[] { + return [ + { + id: userId, + role: 'user', + blocks: [{ type: 'text', text: `prompt ${userId}` }], + timestamp: at, + source: 'transcript' + }, + { + id: `${userId}-narration`, + role: 'assistant', + blocks: [{ type: 'text', text: `narration ${userId}` }], + timestamp: at + 1, + source: 'transcript' + }, + { + id: `${userId}-work`, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'completed' }, + { type: 'tool-result', output: 'ok' } + ], + timestamp: at + 2, + source: 'transcript' + }, + { + id: `${userId}-answer`, + role: 'assistant', + blocks: [{ type: 'text', text: `answer ${userId}` }], + timestamp: at + 3, + source: 'transcript' + } + ] + } + + function journal(): AgentJournalRenderItem[] { + return TURNS.flatMap(({ id, end }, index) => { + const at = 1_000_000 + index * 100_000 + return [ + { + itemId: id, + sequence: index * 2 + 1, + revision: 1, + observedAt: at, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } + }, + { + itemId: `${id}-turn`, + sequence: index * 2 + 2, + revision: 1, + observedAt: at + TURN_MS, + body: { + kind: 'turn', + turnId: `${id}-turn`, + userItemId: id, + startedAt: at, + requestedAt: at, + completedAt: at + TURN_MS, + ...end + } + } + ] + }) + } + + function session(): NativeChatLiveSession { + return { + messages: TURNS.flatMap(({ id }, index) => turnMessages(id, index * 10)), + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + } + + describe('NativeChatMessageList folded turn headers', () => { + // A crash-cut turn reads like a finished one; the chat's notice row is what says it stopped. + it('says a crash-cut turn worked like a finished one and a stopped turn was interrupted', () => { + render( + + ) + + const headers = screen + .getAllByRole('button', { name: 'Toggle turn details' }) + .map((button) => [button.textContent, button.getAttribute('aria-expanded')]) + expect(headers).toEqual([ + ['Worked for 12s', 'false'], + ['Worked for 12s', 'false'], + ['Interrupted after 12s', 'false'] + ]) + // The turn's detail stays inside the fold. + expect(screen.queryByText('narration user-crashed')).toBeNull() + expect(screen.getByText('answer user-crashed')).toBeInTheDocument() + }) + }) +} + +{ + function retry(sequence: number, attempt: number): AgentJournalRenderItem { + return { + itemId: `retry-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords( + { + kind: 'providerRetrying', + detail: { text: `Reconnecting... ${attempt}/5`, audience: 'person' }, + retry: { cause: 'stream disconnected before completion' } + }, + { surface: 'row', agentName: 'Codex' } + ) + } + } + } + + function transcript(items: AgentJournalRenderItem[]) { + return ( + + ) + } + + describe('a Codex retrying a dropped stream', () => { + it('draws one warning for the run, with what failed on its second line', () => { + render(transcript([retry(1, 1), retry(2, 2), retry(3, 3)])) + + const rows = screen.getAllByText(/Codex is retrying/) + expect(rows).toHaveLength(1) + expect(rows[0]?.textContent).toBe( + 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected before completion' + ) + }) + }) +} + +{ + const turnItem: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } + + function journalItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } + } + + const session: NativeChatLiveSession = { + messages: [ + { + id: 'user-stop', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: Date.now(), + source: 'transcript' + } + ], + status: 'working', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + + // The turn still runs while a Stop ends it, so its bar keeps the running clock; only the tail line + // says the turn is stopping, and the bar settles to "Interrupted after" once the turn ends. + describe("the turn bar while a person's Stop ends the turn", () => { + it('keeps the running clock above the Stopping tail line', () => { + render( + + ) + + const bar = screen.getByText('Working for 0s') + const stopping = screen.getByText('Stopping…') + expect(bar.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.getAllByText(/Stopping/)).toHaveLength(1) + }) + }) + + // Where the host does not queue sends, one made while Stopping is held until the turn ends: it is + // drawn after the Stopping line, not inside the turn being stopped. + describe('a message sent while Stopping, before the host has handed it over', () => { + const pending = { + id: 'pending-send', + role: 'user' as const, + sentWhileStopping: true as const, + blocks: [{ type: 'text' as const, text: 'Run this after the stop' }], + timestamp: Date.now(), + source: 'transcript' as const + } + + function renderList(stopping: boolean): void { + render( + + ) + } + + it('draws after the Stopping line', () => { + renderList(true) + + const stopping = screen.getByText('Stopping…') + const sent = screen.getByText('Run this after the stop') + expect(stopping.compareDocumentPosition(sent)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + + it('stays in the turn while nothing is stopping', () => { + renderList(false) + + const sent = screen.getByText('Run this after the stop') + const working = screen.getByText('Working…') + expect(sent.compareDocumentPosition(working)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + + // Sent just before the Stop, the host steers it into the turn: it stays there, never jumping. + it('keeps a send made before the Stop in the turn', () => { + const { sentWhileStopping: _made, ...beforeStop } = pending + render( + + ) + + const sent = screen.getByText('Run this after the stop') + const stopping = screen.getByText('Stopping…') + expect(sent.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + }) +} From 853c25de3e5894966aeb488dbc6461fda4f0a9c0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 14:07:55 -0700 Subject: [PATCH 010/110] refactor(git): git calls route by the worktree's owner (#27291) * refactor(git): git calls route by the worktree's owner RuntimeGitContext carries the owner's transport (target) instead of a settings object, so raw focus settings no longer compile at a git call. Source control, the checks panel, diff loaders, fork sync, lineage, worktree metadata, review push targets and diff-comment persistence derive it from the repo or worktree rows (resolveOwner) or a captured owner. Editor-slice remote actions take an explicit runtimeTarget and otherwise resolve the worktree owner; an unresolved owner is refused, never sent to the focused server. Generation preferences ride a separate prefs field. * fix(work-items): one owner row routes direct-launch preflight, PR start point and create * fix(review): PR dialog effects key on the host, not the target object A fresh target literal per render re-ran base-ref discovery forever and ran the test worker out of memory. --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 86 ++----------- config/owner-routing-baseline.txt | 13 -- .../editor/MonacoGutterContextMenu.tsx | 11 +- .../fetch-combined-diff-section.ts | 15 ++- .../editor/useEditorPanelDiffContentLoader.ts | 18 +-- ...tive-chat-session-option-discovery.test.ts | 2 +- .../native-chat-session-option-discovery.ts | 17 ++- ...ive-chat-session-option-enrichment.test.ts | 23 ++-- ...eControl.commit-generation-records.test.ts | 13 +- ...ourceControl.pr-generation-records.test.ts | 11 +- .../SourceControl.preview-open.test.tsx | 11 +- .../manual-refresh-dependencies.ts | 2 +- .../checks-panel/review-state-dependencies.ts | 1 + .../use-checks-panel-branch-actions.tsx | 22 ++-- .../use-checks-panel-composer-state.tsx | 3 + .../use-checks-panel-controller-state.tsx | 4 + .../use-checks-panel-generation.test.tsx | 12 +- .../use-checks-panel-generation.tsx | 21 ++-- ...e-checks-panel-git-status-effects.test.tsx | 3 +- .../use-checks-panel-git-status-effects.tsx | 9 +- .../use-checks-panel-manual-refresh.test.tsx | 2 +- .../use-checks-panel-manual-refresh.tsx | 8 +- .../use-checks-panel-review-state.tsx | 7 +- .../create-pull-request-dialog-field-model.ts | 4 + ...t-status-refresh-branch-line-total.test.ts | 10 ++ .../right-sidebar/git-status-refresh.test.ts | 20 ++- .../right-sidebar/git-status-refresh.ts | 51 ++++---- ...push-target-upstream-refresh-cache.test.ts | 10 +- .../push-target-upstream-refresh-cache.ts | 26 ++-- .../source-control/ai/controller-types.ts | 3 + .../right-sidebar/source-control/ai/use-ai.ts | 9 +- .../discard-all-failure-description.test.tsx | 2 +- ...urce-control-discard-editor-owner.test.tsx | 13 +- ...e-control-entry-mutation-failures.test.tsx | 4 +- .../use-bulk-actions-capped-stage.test.tsx | 2 +- .../source-control/commit/use-bulk-actions.ts | 25 ++-- .../commit/use-commit-action.ts | 11 +- .../source-control/commit/use-commit-flows.ts | 10 +- .../commit/use-commit-message-generation.ts | 26 ++-- .../commit/use-discard-confirmation.ts | 12 +- .../commit/use-entry-mutations.ts | 24 ++-- .../listing/operation-target.ts | 14 ++- .../listing/use-file-listing.ts | 10 +- .../listing/use-submodule-status.ts | 15 ++- .../listing/use-worktree-context.ts | 28 ++--- .../panel/use-panel-foundation.ts | 7 +- .../source-control/panel/use-panel-model.ts | 10 +- .../source-control/panel/use-panel-state.ts | 4 +- .../review/create-pr-intent-run-snapshot.ts | 3 +- .../use-create-pr-intent-commit-message.ts | 10 +- .../review/use-create-pr-intent-flows.ts | 4 +- .../review/use-create-pr-intent-probes.ts | 11 +- .../review/use-create-pr-intent-review.ts | 15 ++- .../review/use-create-pr-intent-target.ts | 8 +- .../review/use-create-review-composer.ts | 7 +- .../use-hosted-review-creation.test.tsx | 3 +- .../use-pull-request-generation.test.tsx | 6 +- .../review/use-pull-request-generation.ts | 21 ++-- .../review/use-review-context.ts | 6 +- .../source-control/review/use-review-flows.ts | 6 +- .../source-control/sync/use-branch-compare.ts | 12 +- .../source-control/sync/use-conflict-abort.ts | 9 +- .../sync/use-git-history-commit-actions.ts | 17 +-- .../source-control/sync/use-git-history.ts | 15 ++- .../sync/use-remote-action-runner.ts | 24 ++-- .../use-status-refresh.local-ignore.test.tsx | 3 +- .../source-control/sync/use-status-refresh.ts | 19 +-- .../sync/use-upstream-status-fetch.ts | 8 +- .../stale-conflict-operation-poll.ts | 11 +- ...-create-pull-request-base-ref-discovery.ts | 26 ++-- ...se-create-pull-request-field-generation.ts | 11 +- .../use-file-explorer-ignored-paths.ts | 8 +- .../use-git-status-upstream-ref-watch.test.ts | 11 +- .../use-git-status-upstream-ref-watch.ts | 15 +-- .../use-source-control-ai.test.ts | 18 ++- ...use-source-control-branch-compare.test.tsx | 2 +- .../use-source-control-git-history.test.tsx | 15 +-- ...llRequestDialogFields.repo-default.test.ts | 1 + .../useCreatePullRequestDialogFields.test.ts | 1 + .../useCreatePullRequestDialogFields.ts | 14 ++- ...orerVisibleRowProjection.debounce.test.tsx | 4 + .../right-sidebar/useGitStatusPolling.test.ts | 19 ++- .../right-sidebar/useGitStatusPolling.ts | 9 +- .../useSourceControlSubmoduleStatus.test.tsx | 17 ++- .../worktree-git-owner-target.ts | 52 ++++++++ .../settings/CommitMessageAiPane.test.tsx | 9 +- .../settings/CommitMessageAiPane.tsx | 5 +- .../settings/RepositoryForkSyncSection.tsx | 12 +- .../use-delete-worktree-status-hydration.ts | 26 ++-- .../use-workspace-space-git-refresh-action.ts | 17 ++- .../github-provider-selection.ts | 11 +- .../github-submit-resolution.ts | 15 +-- .../src/lib/github-pr-start-point.test.ts | 6 +- src/renderer/src/lib/github-pr-start-point.ts | 15 +-- .../launch-work-item-direct-owner-row.test.ts | 118 ++++++++++++++++++ .../lib/launch-work-item-direct-preflight.ts | 44 +++++-- .../src/lib/launch-work-item-direct.test.ts | 22 +++- .../src/lib/launch-work-item-direct.ts | 17 ++- ...tigravity-generation-compatibility.test.ts | 8 +- .../antigravity-generation-compatibility.ts | 8 +- ...ntime-git-client-branch-line-total.test.ts | 8 +- .../src/runtime/runtime-git-client-context.ts | 29 +++-- .../runtime/runtime-git-client-merge.test.ts | 8 +- .../src/runtime/runtime-git-client.test.ts | 63 +++++----- .../src/runtime/runtime-git-diff-client.ts | 12 +- .../runtime/runtime-git-generation-client.ts | 20 +-- .../src/runtime/runtime-git-status-client.ts | 14 +-- .../src/runtime/runtime-git-sync-client.ts | 22 ++-- .../runtime-git-working-tree-client.ts | 20 +-- .../runtime-git-worktree-scope-client.test.ts | 4 +- .../repos/all-host-repo-catalog-actions.ts | 2 +- .../src/store/repos/repo-catalog-actions.ts | 2 +- .../repos/runtime-repo-catalog-actions.ts | 2 +- .../store/repos/safe-auto-fork-sync.test.ts | 18 +-- .../src/store/repos/safe-auto-fork-sync.ts | 11 +- .../store/slices/commit-message-generation.ts | 10 +- .../store/slices/diff-comment-persistence.ts | 25 ++-- .../src/store/slices/diffComments.test.ts | 1 + .../editor-remote-branch-actions.test.ts | 18 ++- .../editor/actions/git-operation-target.ts | 33 +++++ .../editor/actions/git-remote-push-pull.ts | 23 ++-- .../editor/actions/git-remote-status.ts | 11 +- .../slices/editor/actions/git-remote-sync.ts | 19 +-- .../editor/types/git-runtime-operation.ts | 5 +- ...tion-records-worktree-removal-leak.test.ts | 11 +- .../store/slices/pull-request-generation.ts | 10 +- .../worktrees-activity-persistence.test.ts | 7 +- .../slices/worktrees-lineage-state.test.ts | 10 +- ...orktrees-linked-review-push-target.test.ts | 3 +- .../worktrees-metadata-persistence.test.ts | 7 +- .../worktrees/listing/fetch-worktrees.ts | 3 +- .../listing/worktree-owner-settings.ts | 7 +- .../listing/worktree-owner-target.ts | 30 +++++ .../worktrees/listing/worktree-slice-types.ts | 4 +- .../hosted-review-push-target-ensure.ts | 8 +- .../metadata/hosted-review-push-target.ts | 16 ++- .../metadata/update-worktree-meta.ts | 19 +-- .../metadata/update-worktrees-meta.ts | 10 +- .../metadata/worktree-git-identity-update.ts | 6 +- .../metadata/worktree-lineage-actions.ts | 39 +++--- .../metadata/worktree-lineage-refresh.ts | 41 +++--- .../metadata/worktree-meta-persist.test.ts | 24 ++-- .../metadata/worktree-meta-persist.ts | 10 +- .../session/worktree-unread-activity.ts | 8 +- 144 files changed, 1256 insertions(+), 835 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/worktree-git-owner-target.ts create mode 100644 src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts create mode 100644 src/renderer/src/store/slices/editor/actions/git-operation-target.ts create mode 100644 src/renderer/src/store/slices/worktrees/listing/worktree-owner-target.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 81b66780de21..3df156508098 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -46,10 +46,7 @@ 1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx 1 src/renderer/src/components/editor/EditorEditFileSurface.tsx 1 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx -1 src/renderer/src/components/editor/MonacoGutterContextMenu.tsx 1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx -4 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts -1 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts 1 src/renderer/src/components/editor/csv/csv-link-routing.ts 2 src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts 1 src/renderer/src/components/editor/editor-model-lifetime-fixture.ts @@ -57,8 +54,7 @@ 1 src/renderer/src/components/editor/markdown-preview-system-link-action.ts 1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts 2 src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts -4 src/renderer/src/components/editor/useEditorPanelContentState.ts -5 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts +2 src/renderer/src/components/editor/useEditorPanelContentState.ts 1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts 2 src/renderer/src/components/editor/useMarkdownDocuments.ts 2 src/renderer/src/components/feature-wall/ConnectIntegrationsList.tsx @@ -82,7 +78,6 @@ 2 src/renderer/src/components/native-chat-resume-modal.test-support.tsx 1 src/renderer/src/components/native-chat-resume-tree.test-support.ts 1 src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx -1 src/renderer/src/components/native-chat/NativeChatComposer.tsx 1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx 1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx 1 src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx @@ -93,11 +88,9 @@ 1 src/renderer/src/components/native-chat/native-chat-file-link.ts 3 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts 2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts -2 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts 1 src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts 1 src/renderer/src/components/native-chat/use-native-chat-file-drops.ts 1 src/renderer/src/components/native-chat/use-native-chat-link-actions.ts -1 src/renderer/src/components/native-chat/use-native-chat-session-options.ts 2 src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts 1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts 4 src/renderer/src/components/new-workspace/use-linear-source-connection.ts @@ -107,7 +100,7 @@ 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts 1 src/renderer/src/components/quick-open-file-list-test-harness.ts 3 src/renderer/src/components/repo/CreateFromPicker.tsx -4 src/renderer/src/components/right-sidebar/ChecksPanel.tsx +1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx 2 src/renderer/src/components/right-sidebar/FileExplorer.tsx 1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx 1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx @@ -116,63 +109,25 @@ 3 src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx -2 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx -3 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx -3 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx -1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx 1 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts 1 src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx 1 src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts -5 src/renderer/src/components/right-sidebar/git-status-refresh.ts -1 src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts -2 src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts -5 src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts -1 src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts -2 src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts -2 src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts -1 src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts -7 src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts 1 src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx -3 src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts -2 src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts -1 src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts -1 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts -3 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts -2 src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts -1 src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-run-snapshot.ts -1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts -1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts -1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts -1 src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts -2 src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts -1 src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts 1 src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx 1 src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts -1 src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts -2 src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts -2 src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts -2 src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts -1 src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts -1 src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts 2 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts -2 src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts -2 src/renderer/src/components/right-sidebar/use-create-pull-request-field-seeding.ts -1 src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts 2 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts -3 src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts 1 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts 4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts 2 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts 2 src/renderer/src/components/right-sidebar/useFileSearchScope.ts -3 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts 3 src/renderer/src/components/settings/BaseRefPicker.tsx 2 src/renderer/src/components/settings/BrowserPane.tsx 1 src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx -1 src/renderer/src/components/settings/CommitMessageAiPane.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/IntegrationsPane.tsx 3 src/renderer/src/components/settings/LinearAgentSkillPane.tsx -1 src/renderer/src/components/settings/RepositoryForkSyncSection.tsx 2 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryHooksSection.tsx 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -226,7 +181,7 @@ 2 src/renderer/src/components/sidebar/sidebar-host-options.ts 1 src/renderer/src/components/sidebar/sidebar-project-drop.ts 2 src/renderer/src/components/sidebar/use-add-repo-host-selection.ts -2 src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts +1 src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts 1 src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts 1 src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts 1 src/renderer/src/components/sidebar/use-workspace-kanban-board-projection.ts @@ -259,7 +214,7 @@ 1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts 7 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts 1 src/renderer/src/components/status-bar/use-status-bar-controller.ts -2 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts +1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts 1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts 1 src/renderer/src/components/status-bar/workspace-space-decision-details.ts 1 src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -337,8 +292,6 @@ 2 src/renderer/src/hooks/composer-state/composer-source-state.ts 1 src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts 2 src/renderer/src/hooks/composer-state/composer-target-state.ts -1 src/renderer/src/hooks/composer-state/github-provider-selection.ts -2 src/renderer/src/hooks/composer-state/github-submit-resolution.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts @@ -389,7 +342,6 @@ 2 src/renderer/src/lib/execution-host-facts.ts 6 src/renderer/src/lib/file-preview.ts 2 src/renderer/src/lib/folder-workspace-runtime-owner.ts -1 src/renderer/src/lib/github-pr-start-point.ts 1 src/renderer/src/lib/host-mirrored-pane-liveness.ts 6 src/renderer/src/lib/http-link-destinations.ts 3 src/renderer/src/lib/http-link-routing.ts @@ -399,8 +351,7 @@ 2 src/renderer/src/lib/launch-agent-web-host-tab.ts 2 src/renderer/src/lib/launch-ai-vault-session.ts 1 src/renderer/src/lib/launch-structured-agent-session.ts -1 src/renderer/src/lib/launch-work-item-direct-preflight.ts -2 src/renderer/src/lib/launch-work-item-direct.ts +1 src/renderer/src/lib/launch-work-item-direct.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts 1 src/renderer/src/lib/linear-issue-url-lookup.ts 2 src/renderer/src/lib/local-preflight-context.ts @@ -448,12 +399,6 @@ 1 src/renderer/src/runtime/close-mirrored-editor-tab.ts 1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts 1 src/renderer/src/runtime/runtime-client-target.ts -2 src/renderer/src/runtime/runtime-git-client-context.ts -5 src/renderer/src/runtime/runtime-git-diff-client.ts -5 src/renderer/src/runtime/runtime-git-generation-client.ts -6 src/renderer/src/runtime/runtime-git-status-client.ts -10 src/renderer/src/runtime/runtime-git-sync-client.ts -9 src/renderer/src/runtime/runtime-git-working-tree-client.ts 3 src/renderer/src/runtime/runtime-hooks-client.ts 17 src/renderer/src/runtime/runtime-jira-client.ts 2 src/renderer/src/runtime/runtime-jira-summary-client.ts @@ -493,7 +438,6 @@ 2 src/renderer/src/store/repos/repo-add-actions.ts 1 src/renderer/src/store/repos/repo-update.ts 1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts -2 src/renderer/src/store/repos/safe-auto-fork-sync.ts 1 src/renderer/src/store/slices/browser-slice-test-harness.ts 1 src/renderer/src/store/slices/browser/browser-close-actions.ts 16 src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts @@ -507,11 +451,8 @@ 1 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts 1 src/renderer/src/store/slices/browser/create-browser-slice.ts 1 src/renderer/src/store/slices/browser/paired-browser-tab-creator.ts -5 src/renderer/src/store/slices/diff-comment-persistence.ts +3 src/renderer/src/store/slices/diff-comment-persistence.ts 2 src/renderer/src/store/slices/editor-slice-test-harness.ts -4 src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts -1 src/renderer/src/store/slices/editor/actions/git-remote-status.ts -8 src/renderer/src/store/slices/editor/actions/git-remote-sync.ts 1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts 1 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts 1 src/renderer/src/store/slices/editor/actions/open-file-action.ts @@ -555,7 +496,7 @@ 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts 3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts -15 src/renderer/src/store/slices/worktrees.ts +12 src/renderer/src/store/slices/worktrees.ts 3 src/renderer/src/store/slices/worktrees/create/create-worktree.ts 1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts 2 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts @@ -563,18 +504,15 @@ 7 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts 4 src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts 4 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts -7 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +6 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts 1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts 2 src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts 2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts 3 src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts -2 src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts -4 src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts -5 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts -3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts +2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts 3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts -8 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts -1 src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts +1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts 1 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts 3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts 1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 15568aaff917..a054597471b1 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -2,8 +2,6 @@ # getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file. # This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead. # Prune after removing sites: pnpm check:owner-routing-ratchet --prune -1 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts -1 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts 1 src/renderer/src/components/linear-api-key-dialog.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx @@ -11,17 +9,9 @@ 1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts -1 src/renderer/src/lib/github-pr-start-point.ts 1 src/renderer/src/lib/worktree-creation-flow-startup.ts -1 src/renderer/src/runtime/runtime-git-client-context.ts -5 src/renderer/src/runtime/runtime-git-diff-client.ts -5 src/renderer/src/runtime/runtime-git-generation-client.ts -6 src/renderer/src/runtime/runtime-git-status-client.ts -10 src/renderer/src/runtime/runtime-git-sync-client.ts -9 src/renderer/src/runtime/runtime-git-working-tree-client.ts 3 src/renderer/src/runtime/runtime-hooks-client.ts 3 src/renderer/src/runtime/runtime-repo-client.ts -1 src/renderer/src/store/slices/diff-comment-persistence.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts @@ -29,9 +19,6 @@ 1 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts 2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts 1 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts -2 src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts -3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts -1 src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts 1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts 1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts 1 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts diff --git a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx index 81fc40367dee..c29c11bb3e1c 100644 --- a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx +++ b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { Copy, ExternalLink } from 'lucide-react' import { DropdownMenu, @@ -73,9 +74,17 @@ export function MonacoGutterContextMenu({ return } const connectionId = getConnectionId(activeFile?.worktreeId ?? null) ?? undefined + const target = getRuntimeTargetForFileOwner( + state, + activeFile.worktreeId, + activeFile.runtimeEnvironmentId + ) + if (!target) { + return + } const url = await getRuntimeGitRemoteFileUrl( { - settings: state.settings, + target, worktreeId: activeFile.worktreeId, worktreePath: worktree.path, connectionId diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts b/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts index 7ea5a406a71c..680db610cf7d 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { getRuntimeGitBranchDiff, getRuntimeGitCommitDiff, @@ -36,13 +36,16 @@ export function fetchCombinedDiffSection({ file.filePath, entry.path ) - const state = useAppStore.getState() - const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) + const fileTarget = requireRuntimeTargetForFileOwner( + useAppStore.getState(), + file.worktreeId, + file.runtimeEnvironmentId + ) if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) { return withDiffSectionLoadTimeout( getRuntimeGitBranchDiff( { - settings: fileSettings, + target: fileTarget, worktreeId: file.worktreeId, worktreePath: file.filePath, connectionId @@ -64,7 +67,7 @@ export function fetchCombinedDiffSection({ return withDiffSectionLoadTimeout( getRuntimeGitCommitDiff( { - settings: fileSettings, + target: fileTarget, worktreeId: file.worktreeId, worktreePath: file.filePath, connectionId @@ -81,7 +84,7 @@ export function fetchCombinedDiffSection({ return withDiffSectionLoadTimeout( getRuntimeGitDiff( { - settings: fileSettings, + target: fileTarget, worktreeId: file.worktreeId, worktreePath: file.filePath, connectionId diff --git a/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts index 4c9f62f9b757..e254a1928e3c 100644 --- a/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts @@ -1,8 +1,8 @@ import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import type { OpenFile } from '@/store/slices/editor' import { getConnectionIdForFile } from '@/lib/connection-context' import { useAppStore } from '@/store' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { getRuntimeGitBranchDiff, getRuntimeGitCommitDiff, @@ -69,9 +69,12 @@ export function useEditorPanelDiffContentLoader({ : null const commitCompare = file.commitCompare?.commitOid ? file.commitCompare : null const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined - const activeSettings = useAppStore.getState().settings - const fileSettings = settingsForRuntimeOwner(activeSettings, file.runtimeEnvironmentId) - const gitScope = getRuntimeGitScope(fileSettings, connectionId) + const fileTarget = requireRuntimeTargetForFileOwner( + useAppStore.getState(), + file.worktreeId, + file.runtimeEnvironmentId + ) + const gitScope = getRuntimeGitScope(fileTarget, connectionId) const effectiveDiffSource: typeof file.diffSource = file.mode === 'edit' ? 'unstaged' : file.diffSource const compareAgainstHead = file.mode === 'edit' @@ -92,12 +95,13 @@ export function useEditorPanelDiffContentLoader({ } let pending = inFlightDiffReads.get(key) if (!pending) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every branch resolves a git diff result, which DiffContent mirrors. const promise = ( effectiveDiffSource === 'commit' ? commitCompare ? getRuntimeGitCommitDiff( { - settings: fileSettings, + target: fileTarget, worktreeId: file.worktreeId, worktreePath, connectionId @@ -113,7 +117,7 @@ export function useEditorPanelDiffContentLoader({ : effectiveDiffSource === 'branch' && branchCompare ? getRuntimeGitBranchDiff( { - settings: fileSettings, + target: fileTarget, worktreeId: file.worktreeId, worktreePath, connectionId @@ -131,7 +135,7 @@ export function useEditorPanelDiffContentLoader({ ) : getRuntimeGitDiff( { - settings: fileSettings, + target: fileTarget, worktreeId: file.worktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.test.ts index 018136e09500..3b6f2adb3f38 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.test.ts @@ -29,7 +29,7 @@ describe('resolveNativeChatModelDiscoveryContext', () => { runtime: { worktreeId: 'wt-local', worktreePath: '/repo/local', - settings: { activeRuntimeEnvironmentId: null } + target: { kind: 'local' } } }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index 021d8bb9b050..1b06b5953be6 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -9,7 +9,7 @@ import { getCommitMessageModelDiscoveryHostKeyForScope, LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../../../shared/commit-message-host-key' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' import { hasExplicitTuiLaunchCommand } from '../../../../shared/tui-agent-launch-command-override' import { getConnectionIdFromState } from '@/lib/connection-context' import { @@ -63,13 +63,20 @@ export function resolveNativeChatModelDiscoveryContext( if (worktreeId && connectionId === undefined) { return null } - const settings = getSettingsForWorktreeRuntimeOwner(state, worktreeId) + // Why: discovery asks the worktree's owner; an owner the rows can't name gets no models. + const target = worktreeId + ? runtimeTargetForWorkspaceOwner(state, { workspaceId: worktreeId }) + : ({ kind: 'local' } as const) + if (!target) { + return null + } const worktreePath = worktreeId ? (findKnownWorktreeById(state, worktreeId)?.path ?? '') : '' - const scope = getRuntimeGitScope(settings, connectionId) + const scope = getRuntimeGitScope(target, connectionId) return { hostKey: resolveNativeChatModelDiscoveryHostKey(state, worktreeId, worktreePath, scope), runtime: { - settings, + target, + prefs: state.settings, worktreeId, worktreePath, ...(connectionId ? { connectionId } : {}) @@ -132,7 +139,7 @@ export async function discoverNativeChatCatalogModels( if ( hostCatalogAgent && hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY && - !hasExplicitTuiLaunchCommand(context.settings, hostCatalogAgent) + !hasExplicitTuiLaunchCommand(context.prefs, hostCatalogAgent) ) { const fromHost = await readLocalHostCatalogModels(hostCatalogAgent) if (fromHost) { diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index aaa8c7c04a83..2efa39cd5507 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -45,7 +45,11 @@ describe('native chat session option enrichment', () => { catalogOrigin: 'probe', models: [{ id: 'gpt-cli', label: 'GPT CLI' }] }) - const context = { settings: {}, worktreeId: 'repo::/worktree', worktreePath: '/worktree' } + const context = { + target: { kind: 'local' as const }, + worktreeId: 'repo::/worktree', + worktreePath: '/worktree' + } const local = await discoverNativeChatCatalogModels('codex', context, 'local') expect(local?.map(({ id }) => id)).toEqual(['gpt-host']) @@ -66,7 +70,8 @@ describe('native chat session option enrichment', () => { models: [{ id: 'gpt-custom', label: 'GPT Custom' }] }) const context = { - settings: { agentCmdOverrides: { codex: 'codex-nightly' } }, + target: { kind: 'local' as const }, + prefs: { agentCmdOverrides: { codex: 'codex-nightly' } }, worktreeId: 'repo::/worktree', worktreePath: '/worktree' } @@ -179,7 +184,7 @@ describe('native chat session option enrichment', () => { ] }) const models = await discoverNativeChatCatalogModels('omp', { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'repo::/worktree', worktreePath: '/worktree' }) @@ -220,7 +225,7 @@ describe('native chat session option enrichment', () => { }) const discover = vi.fn(() => discoverNativeChatCatalogModels('claude', { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'repo::/worktree', worktreePath: '/worktree' }) @@ -268,7 +273,7 @@ describe('native chat session option enrichment', () => { }) const discover = vi.fn(() => discoverNativeChatCatalogModels('grok', { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'repo::/worktree', worktreePath: '/worktree' }) @@ -303,7 +308,7 @@ describe('native chat session option enrichment', () => { }) const discover = vi.fn(() => discoverNativeChatCatalogModels('grok', { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'repo::/worktree', worktreePath: '/worktree' }) @@ -330,7 +335,7 @@ describe('native chat session option enrichment', () => { await expect( discoverNativeChatCatalogModels('grok', { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'repo::/worktree', worktreePath: '/worktree' }) @@ -348,7 +353,7 @@ describe('native chat session option enrichment', () => { await expect( discoverNativeChatCatalogModels('cursor', { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'repo::/worktree', worktreePath: '/worktree' }) @@ -408,7 +413,7 @@ describe('native chat session option enrichment', () => { await expect( discoverNativeChatCatalogModels('claude', { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'repo::/worktree', worktreePath: '/worktree' }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts index 7581d393ade1..faa1e52085a2 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts @@ -18,7 +18,8 @@ function runningRecord(overrides: Partial = {}) { worktreeId: 'wt-a', worktreePath: '/repo/a', connectionId: 'conn-a', - requestId: 3 + requestId: 3, + runtimeTarget: { kind: 'local' as const } }, status: 'running' as const, message: null, @@ -117,7 +118,7 @@ describe('SourceControl commit message generation records', () => { worktreePath: '/repo/a', connectionId: 'conn-a', requestId, - runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } + runtimeTarget: { kind: 'environment' as const, environmentId: 'runtime-a' } }) ) @@ -126,7 +127,7 @@ describe('SourceControl commit message generation records', () => { requestId, worktreeId: 'wt-a', worktreePath: '/repo/a', - runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } + runtimeTarget: { kind: 'environment' as const, environmentId: 'runtime-a' } }, status: 'running' }) @@ -139,7 +140,8 @@ describe('SourceControl commit message generation records', () => { createRunningCommitMessageGenerationRecord({ worktreeId: 'wt-a', worktreePath: '/repo/a', - requestId: 1 + requestId: 1, + runtimeTarget: { kind: 'local' as const } }) ) store.getState().setCommitMessageGenerationRecord( @@ -147,7 +149,8 @@ describe('SourceControl commit message generation records', () => { createRunningCommitMessageGenerationRecord({ worktreeId: 'wt-b', worktreePath: '/repo/b', - requestId: 2 + requestId: 2, + runtimeTarget: { kind: 'local' as const } }) ) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts index 0781faa3033b..c16f6191083d 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts @@ -39,6 +39,7 @@ function runningRecord(overrides: Partial = {}) { worktreePath: '/repo/a', connectionId: 'conn-a', requestId: 3, + runtimeTarget: { kind: 'local' as const }, repoId: 'repo-1', branch: 'feature-a' }, @@ -175,6 +176,7 @@ describe('SourceControl pull request generation records', () => { worktreePath: '/repo/a', connectionId: 'conn-a', requestId: 1, + runtimeTarget: { kind: 'local' as const }, repoId: 'repo-1', branch: 'feature-a' }, @@ -333,7 +335,7 @@ describe('SourceControl pull request generation records', () => { requestId: 1, repoId: 'repo-1', branch: 'feature-a', - runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } + runtimeTarget: { kind: 'environment' as const, environmentId: 'runtime-a' } }, seed, fieldRevisions @@ -355,7 +357,7 @@ describe('SourceControl pull request generation records', () => { ) expect(store.getState().pullRequestGenerationRecords[key!]).toMatchObject({ - context: { runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } }, + context: { runtimeTarget: { kind: 'environment' as const, environmentId: 'runtime-a' } }, status: 'succeeded', result: generated, hydrated: false @@ -369,6 +371,7 @@ describe('SourceControl pull request generation records', () => { worktreePath: '/repo/a', connectionId: 'conn-a', requestId: 1, + runtimeTarget: { kind: 'local' as const }, repoId: 'repo-1', branch: 'feature-a' }, @@ -423,6 +426,7 @@ describe('SourceControl pull request generation records', () => { worktreeId: 'wt-a', worktreePath: '/repo/a', requestId: 1, + runtimeTarget: { kind: 'local' as const }, repoId: 'repo-1', branch: 'feature-a' }, @@ -437,6 +441,7 @@ describe('SourceControl pull request generation records', () => { worktreeId: 'wt-b', worktreePath: '/repo/b', requestId: 2, + runtimeTarget: { kind: 'local' as const }, repoId: 'repo-1', branch: 'feature-b' }, @@ -468,6 +473,7 @@ describe('SourceControl pull request generation records', () => { worktreePath: '/repo/a', connectionId: 'conn-a', requestId: firstRequestId, + runtimeTarget: { kind: 'local' as const }, repoId: 'repo-1', branch: 'feature-a' }, @@ -487,6 +493,7 @@ describe('SourceControl pull request generation records', () => { worktreePath: '/repo/a', connectionId: 'conn-a', requestId: secondRequestId, + runtimeTarget: { kind: 'local' as const }, repoId: 'repo-1', branch: 'feature-a' }, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index 0afa6769ea16..ff6af37c05c4 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { GitBranchChangeEntry, @@ -386,12 +386,17 @@ describe('SourceControl preview row opens', () => { ) }) - it('scopes discard autosave quiesce and reload notifications to the active runtime', async () => { + it('scopes discard autosave quiesce and reload notifications to the repo owner runtime', async () => { resetState({ gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts' })] } }) + // Why: the owner stamp routes the discard; a different focused server must not. + Object.assign(mocks.activeRepo, { executionHostId: 'runtime:runtime-remote' }) + onTestFinished(() => { + Reflect.deleteProperty(mocks.activeRepo, 'executionHostId') + }) renderSourceControl() - mocks.state.settings = { activeRuntimeEnvironmentId: 'runtime-remote' } + mocks.state.settings = { activeRuntimeEnvironmentId: 'runtime-other' } const row = container.querySelector('[data-source-control-path="src/file.ts"]') const discardButton = row?.querySelector( diff --git a/src/renderer/src/components/right-sidebar/checks-panel/manual-refresh-dependencies.ts b/src/renderer/src/components/right-sidebar/checks-panel/manual-refresh-dependencies.ts index a0098722862f..ca26485043c5 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/manual-refresh-dependencies.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/manual-refresh-dependencies.ts @@ -17,7 +17,7 @@ export type ChecksPanelManualRefreshInput = Pick< | 'fetchPRComments' | 'fetchPRForBranch' | 'gitStatusSnapshot' - | 'ownerSettings' + | 'ownerTarget' | 'panelContextKey' | 'panelContextKeyRef' | 'pollIntervalRef' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/review-state-dependencies.ts b/src/renderer/src/components/right-sidebar/checks-panel/review-state-dependencies.ts index c681e90ab9cc..4756fcfc37d0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/review-state-dependencies.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/review-state-dependencies.ts @@ -36,6 +36,7 @@ export type ChecksPanelReviewStateInput = Pick< | 'remoteStatusInvalidation' | 'repo' | 'repoConnectionId' + | 'ownerTarget' | 'runtimeEnvironmentId' | 'settings' | 'updatePullRequestGenerationRecord' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-branch-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-branch-actions.tsx index a1a8d28429df..f1858123475b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-branch-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-branch-actions.tsx @@ -11,7 +11,7 @@ type ChecksPanelBranchActionsInput = Pick< | 'isPublishingBranch' | 'isRemoteOperationActive' | 'isSyncingBranch' - | 'ownerSettings' + | 'ownerTarget' | 'pushBranch' | 'setGitStatusRefreshNonce' | 'setIsPublishingBranch' @@ -28,7 +28,7 @@ export function useChecksPanelBranchActions(model: ChecksPanelBranchActionsInput isPublishingBranch, isRemoteOperationActive, isSyncingBranch, - ownerSettings, + ownerTarget, pushBranch, setGitStatusRefreshNonce, setIsPublishingBranch, @@ -47,10 +47,10 @@ export function useChecksPanelBranchActions(model: ChecksPanelBranchActionsInput false, connectionId, activeWorktree.pushTarget, - { runtimeTargetSettings: ownerSettings } + { runtimeTarget: ownerTarget } ) await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId, undefined, { - runtimeTargetSettings: ownerSettings + runtimeTarget: ownerTarget }) return true } catch { @@ -61,7 +61,7 @@ export function useChecksPanelBranchActions(model: ChecksPanelBranchActionsInput activeWorktree, activeWorktreeId, fetchUpstreamStatus, - ownerSettings, + ownerTarget, pushBranch ]) @@ -83,14 +83,14 @@ export function useChecksPanelBranchActions(model: ChecksPanelBranchActionsInput true, connectionId, activeWorktree.pushTarget, - { runtimeTargetSettings: ownerSettings } + { runtimeTarget: ownerTarget } ) await fetchUpstreamStatus( activeWorktreeId, activeWorktree.path, connectionId, activeWorktree.pushTarget, - { runtimeTargetSettings: ownerSettings } + { runtimeTarget: ownerTarget } ) } catch { // Store remote actions already surface the publish failure toast. @@ -106,7 +106,7 @@ export function useChecksPanelBranchActions(model: ChecksPanelBranchActionsInput fetchUpstreamStatus, isPublishingBranch, isRemoteOperationActive, - ownerSettings, + ownerTarget, pushBranch, setIsPublishingBranch, setGitStatusRefreshNonce @@ -126,7 +126,7 @@ export function useChecksPanelBranchActions(model: ChecksPanelBranchActionsInput connectionId, activeWorktree.pushTarget, { - runtimeTargetSettings: ownerSettings + runtimeTarget: ownerTarget } ) await fetchUpstreamStatus( @@ -134,7 +134,7 @@ export function useChecksPanelBranchActions(model: ChecksPanelBranchActionsInput activeWorktree.path, connectionId, activeWorktree.pushTarget, - { runtimeTargetSettings: ownerSettings } + { runtimeTarget: ownerTarget } ) } catch { // Store remote actions already surface the sync failure toast. @@ -150,7 +150,7 @@ export function useChecksPanelBranchActions(model: ChecksPanelBranchActionsInput fetchUpstreamStatus, isSyncingBranch, isRemoteOperationActive, - ownerSettings, + ownerTarget, syncBranch, setIsSyncingBranch, setGitStatusRefreshNonce diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-composer-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-composer-state.tsx index 2da3a2158ecd..b2ab637c1bf9 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-composer-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-composer-state.tsx @@ -44,6 +44,7 @@ type ChecksPanelComposerStateInput = Pick< | 'setAgentComposerState' | 'setCreatePrError' | 'ownerSettings' + | 'ownerTarget' | 'pendingCommentResolutionRef' | 'claimedCommentResolutionRef' | 'commentResolutionLaunchAcceptedRef' @@ -85,6 +86,7 @@ export function useChecksPanelComposerState(model: ChecksPanelComposerStateInput setCreatePrError, sourceControlAiActionsVisible, ownerSettings, + ownerTarget, pendingCommentResolutionRef, claimedCommentResolutionRef, commentResolutionLaunchAcceptedRef, @@ -126,6 +128,7 @@ export function useChecksPanelComposerState(model: ChecksPanelComposerStateInput eligibility: hostedReviewCreation, repo, settings: ownerSettings, + target: ownerTarget, submitting: isCreatingPr, prCreationDefaults, sourceControlAiActionsVisible, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx index 42872c4cdadc..a40bc2e109d7 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef, useState, type SetStateAction } from 'react' +import { useWorktreeGitOwnerTarget } from '../worktree-git-owner-target' import { useAppStore, type AppState } from '@/store' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from '../use-checks-panel-terminal-worktree' @@ -236,6 +237,8 @@ export function useChecksPanelControllerState() { : { ...settings, activeRuntimeEnvironmentId: null }, [runtimeEnvironmentId, settings] ) + // Why: git calls route by the worktree's owner rows, never the focused server. + const ownerTarget = useWorktreeGitOwnerTarget(activeWorktreeId) const repoConnectionId = repo?.connectionId?.trim() || null // Local execution host variant (wsl:{distro} vs host); applies only when local — remote contexts are scoped by runtimeEnvironmentId/connectionId. const localExecutionScope = useMemo(() => { @@ -389,6 +392,7 @@ export function useChecksPanelControllerState() { activeSourceControlLaunchPlatform, runtimeEnvironmentId, ownerSettings, + ownerTarget, repoConnectionId, localExecutionScope, sshConnectionStatus, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx index 5f537afafefa..e9964cd00ab2 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx @@ -43,7 +43,7 @@ describe('useChecksPanelGeneration cancellation ownership', () => { connectionId: 'ssh-owner', repoId: 'repo-1', branch: 'feature', - runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-owner' } + runtimeTarget: { kind: 'environment' as const, environmentId: 'runtime-owner' } }, seed: { base: 'main', title: '', body: '', draft: false }, seedFieldRevisions: { base: 0, title: 0, body: 0, draft: 0 }, @@ -62,7 +62,7 @@ describe('useChecksPanelGeneration cancellation ownership', () => { branch: 'feature', handleBranchChangedByPullRequestGeneration: vi.fn(), hostedReviewCreateProvider: 'github', - ownerSettings: null, + ownerTarget: { kind: 'local' }, prCreationDefaults: { draft: false, generateDetailsOnOpen: false, @@ -79,7 +79,7 @@ describe('useChecksPanelGeneration cancellation ownership', () => { act(() => result.current.handleCancelGeneratePullRequestFieldsForActive()) expect(runtime.cancel).toHaveBeenCalledWith({ - settings: record.context.runtimeTargetSettings, + target: record.context.runtimeTarget, worktreeId: 'owner-worktree', worktreePath: '/workspace/owner', connectionId: 'ssh-owner' @@ -118,7 +118,7 @@ describe('useChecksPanelGeneration outcome', () => { branch: 'feature', handleBranchChangedByPullRequestGeneration: vi.fn(), hostedReviewCreateProvider: 'github', - ownerSettings: null, + ownerTarget: { kind: 'local' }, prCreationDefaults: { draft: false, generateDetailsOnOpen: false, @@ -175,7 +175,7 @@ describe('useChecksPanelGeneration outcome', () => { branch: 'feature', handleBranchChangedByPullRequestGeneration: vi.fn(), hostedReviewCreateProvider: 'github', - ownerSettings: null, + ownerTarget: { kind: 'local' }, prCreationDefaults: { draft: false, generateDetailsOnOpen: false, @@ -239,7 +239,7 @@ describe('useChecksPanelGeneration outcome', () => { branch: 'feature', handleBranchChangedByPullRequestGeneration: vi.fn(), hostedReviewCreateProvider: 'github', - ownerSettings: null, + ownerTarget: { kind: 'local' }, prCreationDefaults: { draft: false, generateDetailsOnOpen: false, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx index 48cd276016a7..b744f8d24a03 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.tsx @@ -37,7 +37,7 @@ type ChecksPanelGenerationInput = Pick< | 'activeWorktreePath' | 'allocatePullRequestGenerationRequestId' | 'branch' - | 'ownerSettings' + | 'ownerTarget' | 'prGenerationRecords' | 'repo' | 'setPullRequestGenerationRecord' @@ -54,7 +54,7 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { branch, handleBranchChangedByPullRequestGeneration, hostedReviewCreateProvider, - ownerSettings, + ownerTarget, prCreationDefaults, prGenerationRecords, repo, @@ -68,7 +68,13 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { overrides?: RuntimeGeneratePullRequestFieldsOverrides, options?: PullRequestGenerationOptions ): Promise => { - if (!repo || !activePullRequestGenerationKey || !activeWorktreePath || !branch) { + if ( + !repo || + !activePullRequestGenerationKey || + !activeWorktreePath || + !branch || + !ownerTarget + ) { return undefined } const generationKey = activePullRequestGenerationKey @@ -85,7 +91,7 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { requestId, repoId: repo.id, branch, - runtimeTargetSettings: ownerSettings + runtimeTarget: ownerTarget } const seed = { ...fields } const previousRequiresPushBeforeCreate = @@ -110,7 +116,8 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { const result = await generateRuntimePullRequestFields( { // Why: route generation by the worktree owner captured at click time. - settings: context.runtimeTargetSettings, + target: context.runtimeTarget, + prefs: useAppStore.getState().settings, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId @@ -172,7 +179,7 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { branch, handleBranchChangedByPullRequestGeneration, hostedReviewCreateProvider, - ownerSettings, + ownerTarget, prCreationDefaults.useTemplate, repo, setPullRequestGenerationRecord, @@ -196,7 +203,7 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) { }) void cancelRuntimeGeneratePullRequestFields({ // Why: Stop must target the request owner, not the currently focused worktree. - settings: record.context.runtimeTargetSettings, + target: record.context.runtimeTarget, worktreeId: record.context.worktreeId, worktreePath: record.context.worktreePath, connectionId: record.context.connectionId diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx index 231f835619de..7ff5f1c13967 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx @@ -39,6 +39,7 @@ function Probe({ worktreePath?: string }): null { panelContextKeyRef.current = contextKey + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the probe passes only the fields this effect reads. useChecksPanelGitStatusEffects({ activeConnectionId: repoConnectionId, activeWorktreeId: worktreeId, @@ -63,7 +64,7 @@ function Probe({ linkedPR: null, fallbackGitHubPRNumber: null, localExecutionScope: 'host', - ownerSettings: null, + ownerTarget: { kind: 'local' }, panelContextKey: contextKey, panelContextKeyRef, remoteStatus: undefined, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx index ba362915ddd4..df494deeb093 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx @@ -24,7 +24,7 @@ type ChecksPanelGitStatusEffectsInput = Pick< | 'gitStatusSnapshotRetryTimerRef' | 'isPanelVisible' | 'localExecutionScope' - | 'ownerSettings' + | 'ownerTarget' | 'panelContextKey' | 'panelContextKeyRef' | 'remoteStatusInvalidation' @@ -90,7 +90,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect linkedPR, fallbackGitHubPRNumber, localExecutionScope, - ownerSettings, + ownerTarget, panelContextKey, panelContextKeyRef, remoteStatus, @@ -143,6 +143,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect !isPanelVisible || !activeWorktreeId || !activeWorktreePath || + !ownerTarget || (!runtimeEnvironmentId && repoConnectionId && sshConnectionStatus !== 'connected') ) { if (gitStatusSnapshotRetryTimerRef.current) { @@ -165,7 +166,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect shouldClearChecksPanelGitStatusSnapshot(snapshot, requestContextKey) ? null : snapshot ) const context = { - settings: ownerSettings, + target: ownerTarget, worktreeId: activeWorktreeId, worktreePath: activeWorktreePath, connectionId @@ -259,7 +260,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect gitStatusRefreshNonce, isFolder, isPanelVisible, - ownerSettings, + ownerTarget, panelContextKey, repo, repoConnectionId, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx index 94c07d7171d3..58dff3ad4491 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx @@ -74,7 +74,7 @@ describe('useChecksPanelManualRefresh ordering', () => { linkedGiteaPR: null, linkedGitLabMR: null, linkedPR: null, - ownerSettings: null, + ownerTarget: { kind: 'local' }, panelContextKey: 'repo-1::worktree-1::main', panelContextKeyRef: { current: 'repo-1::worktree-1::main' }, pollIntervalRef: { current: 30_000 }, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx index 796140405ef5..1e3a419dfdab 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx @@ -39,7 +39,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput linkedGiteaPR, linkedGitLabMR, linkedPR, - ownerSettings, + ownerTarget, panelContextKey, panelContextKeyRef, pollIntervalRef, @@ -95,7 +95,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput refreshState: prCacheKey ? useAppStore.getState().prRefreshStates[prCacheKey] : null }) try { - if (activeWorktreeId && activeWorktreePath && !isFolder) { + if (activeWorktreeId && activeWorktreePath && !isFolder && ownerTarget) { const snapshotIdentity = readChecksPanelRefreshGitIdentitySnapshot({ snapshot: gitStatusSnapshot, contextKey: panelContextKey, @@ -112,7 +112,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput } try { const statusContext = { - settings: ownerSettings, + target: ownerTarget, worktreeId: activeWorktreeId, worktreePath: activeWorktreePath, connectionId: activeConnectionId ?? undefined @@ -380,7 +380,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput fetchHostedReviewForBranch, expireGitHubPRRefreshState, isCurrentAsyncResult, - ownerSettings, + ownerTarget, updateWorktreeGitIdentity, panelContextKeyRef, asyncResultKeyRef, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx index 055d6438de82..44166f2565a7 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx @@ -53,6 +53,7 @@ export function useChecksPanelReviewState(model: ChecksPanelReviewStateInput) { linkedPR, linkedReviewNumber, localExecutionScope, + ownerTarget, panelContextKey, pr, prCacheKey, @@ -263,7 +264,7 @@ export function useChecksPanelReviewState(model: ChecksPanelReviewStateInput) { context.connectionId, undefined, { - runtimeTargetSettings: context.runtimeTargetSettings + runtimeTarget: context.runtimeTarget } ) } catch (error) { @@ -277,7 +278,7 @@ export function useChecksPanelReviewState(model: ChecksPanelReviewStateInput) { return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS } const hostKey = getCommitMessageModelDiscoveryHostKeyForScope( - getRuntimeGitScope(settings, repo?.connectionId) + getRuntimeGitScope(ownerTarget ?? { kind: 'local' }, repo?.connectionId) ) const resolved = resolveSourceControlAiForOperation({ settings, @@ -293,7 +294,7 @@ export function useChecksPanelReviewState(model: ChecksPanelReviewStateInput) { repo, prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS }) - }, [repo, settings]) + }, [ownerTarget, repo, settings]) const sourceControlAiActionsVisible = useMemo( () => (settings ? resolveSourceControlAiEnabled({ settings, repo }) : false), [repo, settings] diff --git a/src/renderer/src/components/right-sidebar/create-pull-request-dialog-field-model.ts b/src/renderer/src/components/right-sidebar/create-pull-request-dialog-field-model.ts index bb081a35c391..9ddaa300dc46 100644 --- a/src/renderer/src/components/right-sidebar/create-pull-request-dialog-field-model.ts +++ b/src/renderer/src/components/right-sidebar/create-pull-request-dialog-field-model.ts @@ -1,4 +1,5 @@ import type { AppState } from '@/store' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RuntimeGeneratePullRequestFieldsOverrides, RuntimeGitContext @@ -30,7 +31,10 @@ export type UseCreatePullRequestDialogFieldsOptions = { eligibility: HostedReviewCreationEligibility | null currentBaseRef?: string | null repo?: Pick | null + /** Generation preferences; routing comes from `target`. */ settings: AppState['settings'] + /** The worktree owner's transport. */ + target: RuntimeClientTarget | null submitting: boolean prCreationDefaults?: SourceControlAiPrCreationDefaults sourceControlAiActionsVisible?: boolean diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts index 2bb1f45d6ff5..e7acf7e9ad1c 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts @@ -52,6 +52,7 @@ describe('branch line total request gate on git status refreshes', () => { const gitStatus = stubGitStatus() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-hidden', worktreePath: '/repo', connectionId: 'ssh-1', @@ -73,6 +74,7 @@ describe('branch line total request gate on git status refreshes', () => { setBranchLineTotalMergeBase('wt-1', null) await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', deps: makeDeps() @@ -90,6 +92,7 @@ describe('branch line total request gate on git status refreshes', () => { setBranchLineTotalMergeBase('wt-visible', MERGE_BASE) await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-background', worktreePath: '/other-repo', deps: makeDeps() @@ -109,6 +112,7 @@ describe('branch line total request gate on git status refreshes', () => { // Why: this path used to build no options object at all, so the gate was // silently dropped on the most common (fs-watcher) refresh. await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', @@ -128,6 +132,7 @@ describe('branch line total request gate on git status refreshes', () => { setBranchLineTotalMergeBase('wt-1', MERGE_BASE) await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', deps: makeDeps(), @@ -149,6 +154,7 @@ describe('branch line total request gate on git status refreshes', () => { const controller = new AbortController() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', deps: makeDeps(), @@ -168,12 +174,14 @@ describe('branch line total request gate on git status refreshes', () => { setBranchLineTotalMergeBase('wt-1', MERGE_BASE) await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', deps: makeDeps() }) setBranchLineTotalMergeBase('wt-1', 'rebased-merge-base') await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', deps: makeDeps() @@ -197,6 +205,7 @@ describe('branch line total request gate on git status refreshes', () => { const gitStatus = stubGitStatus() await refreshGitStatusForWorktreeStrict({ + target: { kind: 'local' }, worktreeId: 'wt-strict', worktreePath: '/repo', deps: { ...makeDeps(), fetchUpstreamStatus: undefined } @@ -217,6 +226,7 @@ describe('branch line total request gate on git status refreshes', () => { setBranchLineTotalMergeBase('wt-strict', MERGE_BASE) await refreshGitStatusForWorktreeStrict({ + target: { kind: 'local' }, worktreeId: 'wt-strict', worktreePath: '/repo', deps: { ...makeDeps(), fetchUpstreamStatus: undefined } diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index dc540909121a..83aabf1817ba 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -55,6 +55,7 @@ describe('refreshGitStatusForWorktree', () => { const deps = makeDeps() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', @@ -91,6 +92,7 @@ describe('refreshGitStatusForWorktree', () => { const deps = makeDeps() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', deps @@ -98,7 +100,7 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, { - runtimeTargetSettings: undefined, + runtimeTarget: { kind: 'local' }, applyUpstreamStatus: false }) }) @@ -115,6 +117,7 @@ describe('refreshGitStatusForWorktree', () => { const deps = makeDeps() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-2', worktreePath: '/repo', connectionId: 'ssh-2', @@ -128,7 +131,7 @@ describe('refreshGitStatusForWorktree', () => { }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, { - runtimeTargetSettings: undefined, + runtimeTarget: { kind: 'local' }, applyUpstreamStatus: false }) }) @@ -144,11 +147,13 @@ describe('refreshGitStatusForWorktree', () => { const deps = makeDeps() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-normal', worktreePath: '/repo', deps }) await refreshGitStatusForWorktreeStrict({ + target: { kind: 'local' }, worktreeId: 'wt-strict', worktreePath: '/repo', deps @@ -192,6 +197,7 @@ describe('refreshGitStatusForWorktree', () => { const deps = makeDeps() const automatic = refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-race', worktreePath: '/repo', deps @@ -199,6 +205,7 @@ describe('refreshGitStatusForWorktree', () => { await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) await refreshGitStatusForWorktreeStrict({ + target: { kind: 'local' }, worktreeId: 'wt-race', worktreePath: '/repo', deps @@ -228,12 +235,14 @@ describe('refreshGitStatusForWorktree', () => { const deps = makeDeps() const olderRefresh = refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-automatic-race', worktreePath: '/repo', deps }) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-automatic-race', worktreePath: '/repo', deps @@ -259,6 +268,7 @@ describe('refreshGitStatusForWorktree', () => { const deps = makeDeps() const olderRefresh = refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-failed-veto', worktreePath: '/repo', deps @@ -266,6 +276,7 @@ describe('refreshGitStatusForWorktree', () => { await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) await expect( refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-failed-veto', worktreePath: '/repo', deps @@ -294,6 +305,7 @@ describe('refreshGitStatusForWorktree', () => { const onStatusAccepted = vi.fn() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-stale', worktreePath: '/repo', deps, @@ -317,6 +329,7 @@ describe('refreshGitStatusForWorktree', () => { const onStatusAccepted = vi.fn() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-current', worktreePath: '/repo', deps, @@ -352,6 +365,7 @@ describe('refreshGitStatusForWorktree', () => { vi.mocked(deps.fetchUpstreamStatus).mockReturnValueOnce(automaticFetch.promise) const automatic = refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-fetch-race', worktreePath: '/repo', deps @@ -359,6 +373,7 @@ describe('refreshGitStatusForWorktree', () => { await vi.waitFor(() => expect(deps.fetchUpstreamStatus).toHaveBeenCalledTimes(1)) await refreshGitStatusForWorktreeStrict({ + target: { kind: 'local' }, worktreeId: 'wt-fetch-race', worktreePath: '/repo', deps @@ -384,6 +399,7 @@ describe('refreshGitStatusForWorktree', () => { const deps = makeDeps() await refreshGitStatusForWorktree({ + target: { kind: 'local' }, worktreeId: 'wt-detached', worktreePath: '/repo', deps diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index ade0f1d76334..7a8978a21930 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -7,7 +7,7 @@ import { storeCachedAutomaticPushTargetUpstreamStatus } from './push-target-upstream-refresh-cache' import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/git-status-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GitPushTarget } from '../../../../shared/worktree/types' import { beginAutomaticUpstreamRefresh, @@ -32,14 +32,14 @@ export type GitStatusRefreshDeps = { connectionId?: string, pushTarget?: GitPushTarget, options?: { - runtimeTargetSettings?: Pick | null + runtimeTarget?: RuntimeClientTarget | null applyUpstreamStatus?: boolean } ) => Promise } async function fetchAndApplyAutomaticUpstreamStatus({ - settings, + target, worktreeId, worktreePath, connectionId, @@ -48,7 +48,8 @@ async function fetchAndApplyAutomaticUpstreamStatus({ order, shouldApply }: { - settings?: Pick | null + /** The worktree owner's transport. */ + target: RuntimeClientTarget worktreeId: string worktreePath: string connectionId?: string @@ -66,7 +67,7 @@ async function fetchAndApplyAutomaticUpstreamStatus({ connectionId, pushTarget, { - runtimeTargetSettings: settings, + runtimeTarget: target, applyUpstreamStatus: false } ) @@ -75,7 +76,7 @@ async function fetchAndApplyAutomaticUpstreamStatus({ // Why: failed publish-target refreshes must not let an older automatic // cache entry suppress the next recovery poll for the same target. invalidateAutomaticPushTargetUpstreamStatusCache({ - settings, + target, worktreeId, worktreePath, connectionId, @@ -97,7 +98,7 @@ export function clearGitStatusRefreshOrderingForTests(): void { } export async function refreshGitStatusForWorktree({ - settings, + target, worktreeId, worktreePath, connectionId, @@ -105,7 +106,8 @@ export async function refreshGitStatusForWorktree({ deps, request }: { - settings?: Pick | null + /** The worktree owner's transport. */ + target: RuntimeClientTarget worktreeId: string worktreePath: string connectionId?: string @@ -124,9 +126,9 @@ export async function refreshGitStatusForWorktree({ // whichever poll happened to omit it. const branchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) try { - const status = (await getRuntimeGitStatus( + const status = await getRuntimeGitStatus( { - settings, + target, worktreeId, worktreePath, connectionId @@ -137,7 +139,7 @@ export async function refreshGitStatusForWorktree({ ...(request?.signal ? { signal: request.signal } : {}), ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) } - )) as GitStatusResult + ) if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { return @@ -158,7 +160,7 @@ export async function refreshGitStatusForWorktree({ // actions for PR-created worktrees must instead reconcile with Orca's // explicit publish target. const cachedUpstreamStatus = getCachedAutomaticPushTargetUpstreamStatus({ - settings, + target, worktreeId, worktreePath, connectionId, @@ -171,7 +173,7 @@ export async function refreshGitStatusForWorktree({ return } const upstreamStatus = await fetchAndApplyAutomaticUpstreamStatus({ - settings, + target, worktreeId, worktreePath, connectionId, @@ -184,7 +186,7 @@ export async function refreshGitStatusForWorktree({ // Why: explicit publish-target comparison can spawn several git // subprocesses; unchanged automatic polls should reuse it briefly. storeCachedAutomaticPushTargetUpstreamStatus( - { settings, worktreeId, worktreePath, connectionId, pushTarget, status }, + { target, worktreeId, worktreePath, connectionId, pushTarget, status }, upstreamStatus ) } @@ -200,7 +202,7 @@ export async function refreshGitStatusForWorktree({ // upstream commits from real remote work. Writing it first makes the // primary action flicker between Sync and Force Push on every poll. await fetchAndApplyAutomaticUpstreamStatus({ - settings, + target, worktreeId, worktreePath, connectionId, @@ -216,7 +218,7 @@ export async function refreshGitStatusForWorktree({ return } await fetchAndApplyAutomaticUpstreamStatus({ - settings, + target, worktreeId, worktreePath, connectionId, @@ -231,14 +233,15 @@ export async function refreshGitStatusForWorktree({ } export async function refreshGitStatusForWorktreeStrict({ - settings, + target, worktreeId, worktreePath, connectionId, pushTarget, deps }: { - settings?: Pick | null + /** The worktree owner's transport. */ + target: RuntimeClientTarget worktreeId: string worktreePath: string connectionId?: string @@ -250,9 +253,9 @@ export async function refreshGitStatusForWorktreeStrict({ beginStrictUpstreamRefresh(worktreeId) clearAutomaticPushTargetUpstreamStatusCache() const strictBranchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) - const status = (await getRuntimeGitStatus( + const status = await getRuntimeGitStatus( { - settings, + target, worktreeId, worktreePath, connectionId @@ -268,7 +271,7 @@ export async function refreshGitStatusForWorktreeStrict({ ? { branchLineTotalMergeBase: strictBranchLineTotalMergeBase } : {}) } - )) as GitStatusResult + ) deps.setGitStatus(worktreeId, status) // Why: branch switches can happen inside a terminal. `git status --branch` @@ -284,7 +287,7 @@ export async function refreshGitStatusForWorktreeStrict({ // actions for PR-created worktrees must instead reconcile with Orca's // explicit publish target. const upstreamStatus = await getRuntimeGitUpstreamStatus( - { settings, worktreeId, worktreePath, connectionId }, + { target, worktreeId, worktreePath, connectionId }, pushTarget ) deps.setUpstreamStatus(worktreeId, upstreamStatus) @@ -300,7 +303,7 @@ export async function refreshGitStatusForWorktreeStrict({ // upstream commits from real remote work. Writing it first makes the // primary action flicker between Sync and Force Push on every poll. const upstreamStatus = await getRuntimeGitUpstreamStatus( - { settings, worktreeId, worktreePath, connectionId }, + { target, worktreeId, worktreePath, connectionId }, undefined ) deps.setUpstreamStatus(worktreeId, upstreamStatus) @@ -310,7 +313,7 @@ export async function refreshGitStatusForWorktreeStrict({ return { status, upstreamStatus: status.upstreamStatus } } const upstreamStatus = await getRuntimeGitUpstreamStatus( - { settings, worktreeId, worktreePath, connectionId }, + { target, worktreeId, worktreePath, connectionId }, undefined ) deps.setUpstreamStatus(worktreeId, upstreamStatus) diff --git a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts index 50b76dd829bb..0d07dc7f06eb 100644 --- a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts +++ b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { clearGitStatusRefreshOrderingForTests, refreshGitStatusForWorktree, @@ -69,7 +70,7 @@ async function refreshAutomatically(options: { pushTarget?: GitPushTarget }): Promise { await refreshGitStatusForWorktree({ - settings: { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null }, + target: runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId ?? null), worktreeId: 'wt-1', worktreePath: '/repo', connectionId: options.connectionId, @@ -128,7 +129,7 @@ describe('push-target upstream refresh cache', () => { await refreshAutomatically({ deps }) invalidateAutomaticPushTargetUpstreamStatusCache({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', pushTarget @@ -204,7 +205,7 @@ describe('push-target upstream refresh cache', () => { storeCachedAutomaticPushTargetUpstreamStatus( { - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + target: { kind: 'environment' as const, environmentId: 'runtime-1' }, worktreeId: 'wt-1', worktreePath: '/repo', pushTarget, @@ -215,7 +216,7 @@ describe('push-target upstream refresh cache', () => { expect( getCachedAutomaticPushTargetUpstreamStatus({ - settings: { activeRuntimeEnvironmentId: 'runtime-2' }, + target: { kind: 'environment' as const, environmentId: 'runtime-2' }, worktreeId: 'wt-1', worktreePath: '/repo', pushTarget, @@ -230,6 +231,7 @@ describe('push-target upstream refresh cache', () => { await refreshAutomatically({ deps }) await refreshGitStatusForWorktreeStrict({ + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', pushTarget, diff --git a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts index e8f0a8c39fb8..a9bec776e2e1 100644 --- a/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts +++ b/src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.ts @@ -1,5 +1,5 @@ import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/git-status-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GitPushTarget } from '../../../../shared/worktree/types' const AUTOMATIC_PUSH_TARGET_UPSTREAM_REFRESH_TTL_MS = 60_000 @@ -16,10 +16,8 @@ const automaticPushTargetUpstreamRefreshCache = new Map< PushTargetUpstreamRefreshCacheEntry >() -function getRuntimeEnvironmentKey( - settings?: Pick | null -): string | null { - return settings?.activeRuntimeEnvironmentId ?? null +function getRuntimeEnvironmentKey(target?: RuntimeClientTarget | null): string | null { + return target?.kind === 'environment' ? target.environmentId : null } function getPushTargetKey(pushTarget: GitPushTarget): readonly unknown[] { @@ -36,13 +34,13 @@ function getStatusIdentityKey(status: GitStatusResult): readonly unknown[] { } function getCacheScopeKey({ - settings, + target, worktreeId, worktreePath, connectionId, pushTarget }: { - settings?: Pick | null + target?: RuntimeClientTarget | null worktreeId: string worktreePath: string connectionId?: string @@ -52,20 +50,20 @@ function getCacheScopeKey({ worktreeId, worktreePath, connectionId ?? null, - getRuntimeEnvironmentKey(settings), + getRuntimeEnvironmentKey(target), getPushTargetKey(pushTarget) ]) } function getCacheKey({ - settings, + target, worktreeId, worktreePath, connectionId, pushTarget, status }: { - settings?: Pick | null + target?: RuntimeClientTarget | null worktreeId: string worktreePath: string connectionId?: string @@ -73,7 +71,7 @@ function getCacheKey({ status: GitStatusResult }): string { return JSON.stringify([ - getCacheScopeKey({ settings, worktreeId, worktreePath, connectionId, pushTarget }), + getCacheScopeKey({ target, worktreeId, worktreePath, connectionId, pushTarget }), getStatusIdentityKey(status) ]) } @@ -91,7 +89,7 @@ function trimAutomaticPushTargetUpstreamRefreshCache(): void { } export function getCachedAutomaticPushTargetUpstreamStatus(input: { - settings?: Pick | null + target?: RuntimeClientTarget | null worktreeId: string worktreePath: string connectionId?: string @@ -112,7 +110,7 @@ export function getCachedAutomaticPushTargetUpstreamStatus(input: { export function storeCachedAutomaticPushTargetUpstreamStatus( input: { - settings?: Pick | null + target?: RuntimeClientTarget | null worktreeId: string worktreePath: string connectionId?: string @@ -130,7 +128,7 @@ export function storeCachedAutomaticPushTargetUpstreamStatus( } export function invalidateAutomaticPushTargetUpstreamStatusCache(input: { - settings?: Pick | null + target?: RuntimeClientTarget | null worktreeId: string worktreePath: string connectionId?: string diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts b/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts index fc71c62ce752..521eb0035970 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts @@ -6,12 +6,15 @@ import type { } from '../../../../../../shared/git-status-types' import type { GlobalSettings } from '../../../../../../shared/global-settings-types' import type { Repo } from '../../../../../../shared/repo-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' export type SourceControlAiStoreSnapshot = Pick & WorktreeAgentInventoryState export type SourceControlAiControllerParams = { settings: GlobalSettings | null + /** The repo owner's transport; it scopes model discovery. */ + target: RuntimeClientTarget | null activeRepo: Repo | null activeWorktreeId: string | null | undefined activeConnectionId: string | null | undefined diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts index 65544be0a914..f90ab131d679 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts @@ -28,16 +28,17 @@ import { useSourceControlRecoveryAi } from './use-recovery-ai' import { translate } from '@/i18n/i18n' export function getSourceControlAiControllerDiscoveryHostKey( - settings: SourceControlAiControllerParams['settings'], + target: SourceControlAiControllerParams['target'], activeConnectionId: string | null | undefined ): string { return getCommitMessageModelDiscoveryHostKeyForScope( - getRuntimeGitScope(settings, activeConnectionId) + getRuntimeGitScope(target ?? { kind: 'local' }, activeConnectionId) ) } export function useSourceControlAi({ settings, + target, activeRepo, activeWorktreeId, activeConnectionId, @@ -61,8 +62,8 @@ export function useSourceControlAi({ const [pullRequestGenerationDialogOpen, setPullRequestGenerationDialogOpen] = useState(false) const sourceControlAiDiscoveryHostKey = useMemo( - () => getSourceControlAiControllerDiscoveryHostKey(settings, activeConnectionId), - [activeConnectionId, settings] + () => getSourceControlAiControllerDiscoveryHostKey(target, activeConnectionId), + [activeConnectionId, target] ) const sourceControlAiActionsVisible = useMemo( () => (settings ? resolveSourceControlAiEnabled({ settings, repo: activeRepo }) : false), diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx index 46760efae814..07f2c52608ff 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx @@ -36,7 +36,7 @@ function lastDescription(): string | undefined { function renderDiscard() { return renderHook(() => useSourceControlDiscardConfirmation({ - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: 'wt-1', worktreePath: '/repo', grouped: EMPTY_GROUPS, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-discard-editor-owner.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-discard-editor-owner.test.tsx index d3901174f87a..ff440933757c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-discard-editor-owner.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-discard-editor-owner.test.tsx @@ -1,8 +1,11 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { beforeEach, expect, it, vi } from 'vitest' import type { EditorPathMutationTarget } from '@/components/editor/editor-autosave' -import type { RuntimeGitContext } from '@/runtime/runtime-git-client' const mocks = vi.hoisted(() => { const focus: { value: string | null } = { value: null } @@ -51,12 +54,10 @@ for (const bulk of [false, true]) { mocks.quiesce.mockImplementation((target) => target.runtimeEnvironmentId === owner ? pendingSave : Promise.resolve() ) - const activeRepoSettings: RuntimeGitContext['settings'] = { - activeRuntimeEnvironmentId: owner - } + const activeRepoTarget: RuntimeClientTarget | null = runtimeTargetForOwnerEnvironment(owner) const { result } = renderHook(() => useSourceControlEntryMutations({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId: 'wt-owner', worktreePath: '/repo', refreshActiveGitStatusAfterMutation: async () => {} @@ -95,7 +96,7 @@ for (const bulk of [false, true]) { ) const mutation = bulk ? mocks.bulkDiscard : mocks.discard expect(mutation).toHaveBeenCalledTimes(1) - expect(mutation.mock.calls[0]?.[0]).toMatchObject({ settings: activeRepoSettings }) + expect(mutation.mock.calls[0]?.[0]).toMatchObject({ target: activeRepoTarget }) } ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx index 4381102ee04d..b477c25beb25 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx @@ -61,7 +61,7 @@ function clickRetry(): void { function renderMutations() { return renderHook(() => useSourceControlEntryMutations({ - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: 'wt-1', worktreePath: '/repo', refreshActiveGitStatusAfterMutation: async () => {} @@ -72,7 +72,7 @@ function renderMutations() { function renderDiscard(discardSingle: (path: string) => Promise) { return renderHook(() => useSourceControlDiscardConfirmation({ - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: 'wt-1', worktreePath: '/repo', grouped: EMPTY_GROUPS, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions-capped-stage.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions-capped-stage.test.tsx index 8c8d6d7efd9a..ee10c5b68d44 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions-capped-stage.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions-capped-stage.test.tsx @@ -26,7 +26,7 @@ function renderBulkActions(grouped: SourceControlEntryGroups, isStatusTruncated: useSourceControlBulkActions({ selectedKeys: new Set(), flatEntriesByKey: new Map(), - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: 'wt-1', worktreePath: '/repo', grouped, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts index da330f756fee..e474bd56e105 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts @@ -1,4 +1,6 @@ import { useCallback, useMemo, useState } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { translate } from '@/i18n/i18n' @@ -6,8 +8,7 @@ import { readIpcErrorMessage } from '@/lib/ipc-error' import { bulkStageRuntimeGitPaths, bulkUnstageRuntimeGitPaths, - stageRuntimeGitWorktreeScope, - type RuntimeGitContext + stageRuntimeGitWorktreeScope } from '@/runtime/runtime-git-client' import { getStageAllPaths, @@ -36,7 +37,7 @@ function reportBulkMutationFailure(error: unknown): void { export function useSourceControlBulkActions({ selectedKeys, flatEntriesByKey, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, grouped, @@ -46,7 +47,7 @@ export function useSourceControlBulkActions({ }: { selectedKeys: ReadonlySet flatEntriesByKey: ReadonlyMap - activeRepoSettings: RuntimeGitContext['settings'] + activeRepoTarget: RuntimeClientTarget | null activeWorktreeId: string | null worktreePath: string | null grouped: SourceControlEntryGroups @@ -95,7 +96,7 @@ export function useSourceControlBulkActions({ await bulkStageRuntimeGitPaths( { // Why: route staging by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -110,7 +111,7 @@ export function useSourceControlBulkActions({ setIsExecutingBulk(false) } }, [ - activeRepoSettings, + activeRepoTarget, worktreePath, bulkStagePaths, clearSelection, @@ -129,7 +130,7 @@ export function useSourceControlBulkActions({ await bulkUnstageRuntimeGitPaths( { // Why: route unstaging by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -144,7 +145,7 @@ export function useSourceControlBulkActions({ setIsExecutingBulk(false) } }, [ - activeRepoSettings, + activeRepoTarget, worktreePath, bulkUnstagePaths, clearSelection, @@ -163,7 +164,7 @@ export function useSourceControlBulkActions({ try { const context = { // Why: route staging by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId: getConnectionId(activeWorktreeId ?? null) ?? undefined @@ -180,7 +181,7 @@ export function useSourceControlBulkActions({ } }, [ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, clearSelection, isExecutingBulk, @@ -212,7 +213,7 @@ export function useSourceControlBulkActions({ await bulkUnstageRuntimeGitPaths( { // Why: route unstaging by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -228,7 +229,7 @@ export function useSourceControlBulkActions({ } }, [ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, clearSelection, isExecutingBulk, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts index c2b697796266..e44bfaba2674 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' import { getConnectionId } from '@/lib/connection-context' import { commitRuntimeGit } from '@/runtime/runtime-git-client' import type { SourceControlOperationTarget } from '../listing/operation-target' @@ -13,7 +14,7 @@ import { writeCommitDraftForWorktree } from './commit-drafts' * can keep committing to the worktree it started on after the user navigates away. */ export function useSourceControlCommitAction({ - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, beginGitBranchCompareRequest, @@ -30,7 +31,7 @@ export function useSourceControlCommitAction({ updateCommitDrafts, worktreePath }: { - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktree: SourceControlWorktreeContext['activeWorktree'] activeWorktreeId: string | null beginGitBranchCompareRequest: SourceControlStoreActions['beginGitBranchCompareRequest'] @@ -60,7 +61,7 @@ export function useSourceControlCommitAction({ options?.target ?? (activeWorktreeId && worktreePath ? { - settings: activeRepoSettings, + target: activeRepoTarget, worktreeId: activeWorktreeId, worktreePath, connectionId: getConnectionId(activeWorktreeId) ?? undefined, @@ -90,7 +91,7 @@ export function useSourceControlCommitAction({ const commitResult = await commitRuntimeGit( { // Why: route the commit by the repo OWNER host, not the focused runtime. - settings: target.settings, + target: requireGitOwnerTarget(target.target), worktreeId: target.worktreeId, worktreePath: target.worktreePath, connectionId: target.connectionId @@ -140,7 +141,7 @@ export function useSourceControlCommitAction({ } }, [ - activeRepoSettings, + activeRepoTarget, activeWorktree?.pushTarget, activeWorktreeId, beginGitBranchCompareRequest, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts index 4cbb113ed197..c2628b3d9dc7 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-flows.ts @@ -12,7 +12,7 @@ import { useSourceControlCommitMessageGeneration } from './use-commit-message-ge export function useSourceControlCommitFlows(foundation: SourceControlPanelFoundation) { const { activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, allocateCommitMessageGenerationRequestId, @@ -60,7 +60,7 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda } = foundation const commitAction = useSourceControlCommitAction({ - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, beginGitBranchCompareRequest, @@ -79,7 +79,7 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda }) const commitMessageGeneration = useSourceControlCommitMessageGeneration({ activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, allocateCommitMessageGenerationRequestId, commitMessageGenerationRecords, @@ -107,7 +107,7 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda settings }) const remoteActionRunner = useSourceControlRemoteActionRunner({ - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, branchName, @@ -130,7 +130,7 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda worktreePath }) const conflictAbort = useSourceControlConflictAbort({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, conflictOperation, isAbortingOperation, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts index 99906c2bbf6f..a9e6f86c851c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-message-generation.ts @@ -29,7 +29,7 @@ import { writeCommitDraftForWorktree } from './commit-drafts' */ export function useSourceControlCommitMessageGeneration({ activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, allocateCommitMessageGenerationRequestId, commitMessageGenerationRecords, @@ -48,7 +48,7 @@ export function useSourceControlCommitMessageGeneration({ worktreePath }: { activeRepo: SourceControlWorktreeContext['activeRepo'] - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktreeId: string | null allocateCommitMessageGenerationRequestId: SourceControlStoreActions['allocateCommitMessageGenerationRequestId'] commitMessageGenerationRecords: SourceControlStoreActions['commitMessageGenerationRecords'] @@ -82,7 +82,12 @@ export function useSourceControlCommitMessageGeneration({ const handleGenerate = useCallback( async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise => { - if (!activeWorktreeId || !worktreePath || !activeCommitMessageGenerationKey) { + if ( + !activeWorktreeId || + !worktreePath || + !activeCommitMessageGenerationKey || + !activeRepoTarget + ) { return } if (generateInFlightRef.current[activeWorktreeId]) { @@ -118,7 +123,7 @@ export function useSourceControlCommitMessageGeneration({ worktreePath, connectionId, requestId, - runtimeTargetSettings: activeRepoSettings + runtimeTarget: activeRepoTarget }) ) setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) @@ -127,7 +132,8 @@ export function useSourceControlCommitMessageGeneration({ const result = await generateRuntimeCommitMessage( { // Why: route generation by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: activeRepoTarget, + prefs: settings, worktreeId: activeWorktreeId, worktreePath, connectionId @@ -200,7 +206,7 @@ export function useSourceControlCommitMessageGeneration({ }, [ activeCommitMessageGenerationKey, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, allocateCommitMessageGenerationRequestId, generateInFlightRef, @@ -208,6 +214,7 @@ export function useSourceControlCommitMessageGeneration({ setCommitMessageGenerationRecord, setGenerateErrors, setGenerateInFlightByWorktree, + settings, updateCommitDrafts, updateCommitMessageGenerationRecord, worktreePath @@ -246,17 +253,20 @@ export function useSourceControlCommitMessageGeneration({ resolveCommitMessageGenerationCancel(record) ) const connectionId = getConnectionId(activeWorktreeId) ?? undefined + if (!activeRepoTarget) { + return + } // Why: fire-and-forget; the in-flight promise resolves {canceled: true} where the spinner clears, so awaiting would just delay UI feedback. void cancelRuntimeGenerateCommitMessage({ // Why: route the cancel by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: activeRepoTarget, worktreeId: activeWorktreeId, worktreePath, connectionId }) }, [ activeCommitMessageGenerationKey, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, generateInFlightRef, updateCommitMessageGenerationRecord, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts index ae1c47398c56..d3f164b2f348 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts @@ -1,8 +1,10 @@ import { useCallback, useState } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { basename } from '@/lib/path' -import { bulkUnstageRuntimeGitPaths, type RuntimeGitContext } from '@/runtime/runtime-git-client' +import { bulkUnstageRuntimeGitPaths } from '@/runtime/runtime-git-client' import { translate } from '@/i18n/i18n' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' import { @@ -20,7 +22,7 @@ import type { PendingDiscardConfirmation } from './discard-dialog' import type { SourceControlEntryGroups } from '../listing/section-order' export function useSourceControlDiscardConfirmation({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, grouped, @@ -31,7 +33,7 @@ export function useSourceControlDiscardConfirmation({ discardSingle, refreshActiveGitStatusAfterMutation }: { - activeRepoSettings: RuntimeGitContext['settings'] + activeRepoTarget: RuntimeClientTarget | null activeWorktreeId: string | null worktreePath: string | null grouped: SourceControlEntryGroups @@ -96,7 +98,7 @@ export function useSourceControlDiscardConfirmation({ bulkUnstageRuntimeGitPaths( { // Why: route unstaging by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -149,7 +151,7 @@ export function useSourceControlDiscardConfirmation({ } }, [ - activeRepoSettings, + activeRepoTarget, worktreePath, activeWorktreeId, grouped, diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts index d1dc8d4bd4a6..cd3b3525e837 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts @@ -1,4 +1,6 @@ import { useCallback } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { notifyEditorExternalFileChange, requestEditorSaveQuiesce @@ -19,12 +21,12 @@ import { } from './source-control-entry-failure-toast' export function useSourceControlEntryMutations({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, refreshActiveGitStatusAfterMutation }: { - activeRepoSettings: RuntimeGitContext['settings'] + activeRepoTarget: RuntimeClientTarget | null activeWorktreeId: string | null worktreePath: string | null refreshActiveGitStatusAfterMutation: () => Promise @@ -44,7 +46,7 @@ export function useSourceControlEntryMutations({ await mutate( { // Why: route the mutation by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -71,7 +73,7 @@ export function useSourceControlEntryMutations({ // Why: refreshing outside the try keeps a refresh failure from being reported as "Failed to stage"; the refresher reports its own. await refreshActiveGitStatusAfterMutation() }, - [activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] + [activeRepoTarget, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] ) const handleStage = useCallback( @@ -97,7 +99,8 @@ export function useSourceControlEntryMutations({ if (!worktreePath || !activeWorktreeId) { return } - const runtimeEnvironmentId = activeRepoSettings?.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = + activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : null // Why: quiesce pending editor autosaves first so a delayed save can't recreate the discarded edits after git restores the file. await requestEditorSaveQuiesce({ worktreeId: activeWorktreeId, @@ -109,7 +112,7 @@ export function useSourceControlEntryMutations({ await discardRuntimeGitPath( { // Why: route the discard by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -123,7 +126,7 @@ export function useSourceControlEntryMutations({ runtimeEnvironmentId }) }, - [activeRepoSettings, activeWorktreeId, worktreePath] + [activeRepoTarget, activeWorktreeId, worktreePath] ) const discardMany = useCallback( @@ -131,7 +134,8 @@ export function useSourceControlEntryMutations({ if (!worktreePath || !activeWorktreeId) { return } - const runtimeEnvironmentId = activeRepoSettings?.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = + activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : null // Why: quiesce matching editor autosaves first so a delayed save can't recreate edits after git mutates the files. await Promise.all( filePaths.map((relativePath) => @@ -147,7 +151,7 @@ export function useSourceControlEntryMutations({ await bulkDiscardRuntimeGitPaths( { // Why: route the discard by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -163,7 +167,7 @@ export function useSourceControlEntryMutations({ }) } }, - [activeRepoSettings, activeWorktreeId, worktreePath] + [activeRepoTarget, activeWorktreeId, worktreePath] ) return { handleStage, handleUnstage, discardSingle, discardMany } diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/operation-target.ts b/src/renderer/src/components/right-sidebar/source-control/listing/operation-target.ts index 66c334b9958a..ba0aa7db7203 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/operation-target.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/operation-target.ts @@ -1,11 +1,23 @@ import type { RuntimeGitContext } from '@/runtime/runtime-git-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' import type { GitConflictOperation } from '../../../../../../shared/git-status-types' import type { GitPushTarget } from '../../../../../../shared/worktree/types' export type AbortConflictOperation = Extract // Why: source-control operations outlive the focused worktree, so each one pins the host it started on. -export type SourceControlOperationTarget = RuntimeGitContext & { +export type SourceControlOperationTarget = Omit & { + /** The repo owner's transport; `null` when no repo row names one, which refuses the call. */ + target: RuntimeClientTarget | null worktreeId: string pushTarget?: GitPushTarget } + +/** The git-client context for an operation; an unresolved owner throws the refusal. */ +export function operationGitContext( + operation: SourceControlOperationTarget, + prefs?: RuntimeGitContext['prefs'] +): RuntimeGitContext { + return { ...operation, target: requireGitOwnerTarget(operation.target), prefs } +} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts index 76224909a400..8f65b796e3a4 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts @@ -19,7 +19,7 @@ import type { SourceControlWorktreeContext } from './use-worktree-context' */ export function useSourceControlFileListing({ activeRemoteActionSequence, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, branchEntries, branchName, @@ -44,7 +44,7 @@ export function useSourceControlFileListing({ worktreePath }: { activeRemoteActionSequence: number | null - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktreeId: string | null branchEntries: SourceControlWorktreeContext['branchEntries'] branchName: string @@ -72,7 +72,7 @@ export function useSourceControlFileListing({ useSourceControlSubmoduleStatus({ activeWorktreeId, worktreePath, - activeRepoSettings, + activeRepoTarget, entries }) const { @@ -103,7 +103,7 @@ export function useSourceControlFileListing({ collapsedSections }) const { gitHistoryState, refreshGitHistory, refreshGitHistoryRef } = useSourceControlGitHistory({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, compareBaseRef, @@ -166,7 +166,7 @@ export function useSourceControlFileListing({ } = useSourceControlBulkActions({ selectedKeys, flatEntriesByKey, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, grouped, diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts index 350cd45fc0b6..acbc35c083dd 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-submodule-status.ts @@ -1,7 +1,9 @@ import { useCallback, useEffect, useState } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' import { getConnectionId } from '@/lib/connection-context' -import { getRuntimeGitSubmoduleStatus, type RuntimeGitContext } from '@/runtime/runtime-git-client' +import { getRuntimeGitSubmoduleStatus } from '@/runtime/runtime-git-client' import { getSubmoduleExpansionKey, isExpandableSubmoduleEntry, @@ -12,7 +14,7 @@ import { export type UseSourceControlSubmoduleStatusInput = { activeWorktreeId: string | null | undefined worktreePath: string | null - activeRepoSettings: RuntimeGitContext['settings'] + activeRepoTarget: RuntimeClientTarget | null // Why: re-fetch expanded children whenever the parent status poll refreshes // its entries, so an expanded submodule's inner changes stay fresh. entries: readonly GitStatusEntry[] @@ -34,9 +36,10 @@ export type UseSourceControlSubmoduleStatusResult = { export function useSourceControlSubmoduleStatus( input: UseSourceControlSubmoduleStatusInput ): UseSourceControlSubmoduleStatusResult { - const { activeWorktreeId, worktreePath, activeRepoSettings, entries } = input + const { activeWorktreeId, worktreePath, activeRepoTarget, entries } = input const [expandedSubmoduleKeys, setExpandedSubmoduleKeys] = useState>(() => new Set()) - const activeRuntimeRouteKey = activeRepoSettings?.activeRuntimeEnvironmentId?.trim() ?? '' + const activeRuntimeRouteKey = + activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : '' const activeConnectionRouteKey = getConnectionId(activeWorktreeId ?? null) ?? '' const submoduleStatusScopeKey = `${activeConnectionRouteKey}\0${activeRuntimeRouteKey}\0${activeWorktreeId ?? ''}\0${worktreePath ?? ''}` // Why: scope lives in the status store so a late response from a previous @@ -78,7 +81,7 @@ export function useSourceControlSubmoduleStatus( const result = await getRuntimeGitSubmoduleStatus( { // Why: route by the repo OWNER host, matching the rest of this panel. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -118,7 +121,7 @@ export function useSourceControlSubmoduleStatus( ) } }, - [activeRepoSettings, activeWorktreeId, submoduleStatusScopeKey, worktreePath] + [activeRepoTarget, activeWorktreeId, submoduleStatusScopeKey, worktreePath] ) const toggleSubmodule = useCallback((entry: Pick) => { diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts index 94fc5879f855..3cb377fbeb4c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { getConnectionId } from '@/lib/connection-context' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { useAppStore } from '@/store' @@ -11,6 +11,7 @@ import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-iden import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' import { isFolderRepo } from '../../../../../../shared/repo-kind' +import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' import { selectReviewCacheData, selectReviewCacheEntry } from '../../review-cache-entry-selection' const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = [] @@ -106,22 +107,21 @@ export function useSourceControlWorktreeContext() { ) const hostedReviewEntryData = hostedReviewEntry?.data ?? null const activePrFromQueue = useAppStore((s) => selectReviewCacheData(s.prCache, activePrCacheKey)) - // Why: git/file mutations and repo metadata belong to the repo OWNER host, not the currently focused sidebar host. - const activeRepoSettings = useMemo( + // Why: git calls and repo metadata go to the repo's OWNER host; `null` (no repo row) runs nothing. + const activeRepoTarget = useMemo( () => - getRepoOwnerRoutedSettings( - settings, - activeRepoId - ? { - id: activeRepoId, + activeRepoId + ? runtimeTargetForOwnerHostId( + getRepoExecutionHostId({ connectionId: activeRepoConnectionId, executionHostId: activeRepoExecutionHostId - } - : null - ), - [activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, settings] + }) + ) + : null, + [activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId] ) - const activeRepoRuntimeEnvironmentId = activeRepoSettings?.activeRuntimeEnvironmentId ?? null + const activeRepoRuntimeEnvironmentId = + activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : null const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const isFolder = activeRepo ? isFolderRepo(activeRepo) : false @@ -152,7 +152,7 @@ export function useSourceControlWorktreeContext() { activeRepoId, activeRepoPath, activeRepoRuntimeEnvironmentId, - activeRepoSettings, + activeRepoTarget, activeSourceControlLaunchPlatform, activeWorktree, activeWorktreeId, diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts index e517fb29e1ff..e037259b2502 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-foundation.ts @@ -17,7 +17,7 @@ export function useSourceControlPanelFoundation() { activeGroupId, activeRemoteActionSequence, activeRepo, - activeRepoSettings, + activeRepoTarget, activeSourceControlLaunchPlatform, activeWorktreeId, branchEntries, @@ -51,7 +51,7 @@ export function useSourceControlPanelFoundation() { const listing = useSourceControlFileListing({ activeRemoteActionSequence, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, branchEntries, branchName, @@ -76,7 +76,8 @@ export function useSourceControlPanelFoundation() { worktreePath }) const ai = useSourceControlAi({ - settings: activeRepoSettings, + settings: panelState.settings, + target: activeRepoTarget, activeRepo: activeRepo ?? null, activeWorktreeId, activeConnectionId, diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts index 42095b70ae9b..24980954fed5 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts @@ -26,7 +26,7 @@ export function useSourceControlPanelModel() { reviewFlows ) const { - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, branchEntries, @@ -135,7 +135,7 @@ export function useSourceControlPanelModel() { runRemoteAction }) useSourceControlUpstreamStatusFetch({ - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, fetchUpstreamStatus, @@ -146,7 +146,7 @@ export function useSourceControlPanelModel() { const gitHistoryCommitActions = useGitHistoryCommitActions({ activeWorktreeId, worktreePath, - activeRepoSettings, + activeRepoTarget, resolveSplitTargetGroupId }) const noteOpening = useSourceControlNoteOpening({ @@ -160,13 +160,13 @@ export function useSourceControlPanelModel() { sourceControlRef }) const entryMutations = useSourceControlEntryMutations({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, refreshActiveGitStatusAfterMutation }) const discardConfirmation = useSourceControlDiscardConfirmation({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, grouped, diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts index 08e79582aa2c..529b1b524b09 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-state.ts @@ -15,7 +15,7 @@ export function useSourceControlPanelState() { const storeActions = useSourceControlStoreActions() const { activeConnectionId, - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, activeWorktreeInstanceId, @@ -51,7 +51,7 @@ export function useSourceControlPanelState() { isFolder }) const statusRefresh = useSourceControlStatusRefresh({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, activePushTarget: activeWorktree?.pushTarget, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-run-snapshot.ts b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-run-snapshot.ts index 3e77c30e777b..b5d41234ce8a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-run-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-run-snapshot.ts @@ -1,4 +1,5 @@ import { bulkStageRuntimeGitPaths } from '@/runtime/runtime-git-client' +import { operationGitContext } from '../listing/operation-target' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' import { createPrIntentGitStatusMatchesToken, @@ -79,7 +80,7 @@ export function createCreatePrIntentRunSnapshot({ } setIsExecutingBulk(true) try { - await bulkStageRuntimeGitPaths(operationTarget, stagePaths) + await bulkStageRuntimeGitPaths(operationGitContext(operationTarget), stagePaths) } finally { setIsExecutingBulk(false) } diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts index 7c918a1c9acb..f82a15edc68c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-commit-message.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { operationGitContext } from '../listing/operation-target' import { generateRuntimeCommitMessage } from '@/runtime/runtime-git-client' import { useAppStore } from '@/store' import { isCustomAgentId } from '../../../../../../shared/commit-message-agent-spec' @@ -59,9 +60,12 @@ export function useSourceControlCreatePrIntentCommitMessage({ setGenerateInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true })) setGenerateErrors((prev) => ({ ...prev, [target.worktreeId]: null })) try { - const result = await generateRuntimeCommitMessage(target, { - sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params - }) + const result = await generateRuntimeCommitMessage( + operationGitContext(target, useAppStore.getState().settings), + { + sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params + } + ) if (!result.success) { if (!result.canceled) { setGenerateErrors((prev) => ({ ...prev, [target.worktreeId]: result.error })) diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts index 76572dbcc3cd..54bd189a8526 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-flows.ts @@ -16,7 +16,7 @@ export function useSourceControlCreatePrIntentFlows( ) { const { activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, beginGitBranchCompareRequest, branchName, @@ -84,7 +84,7 @@ export function useSourceControlCreatePrIntentFlows( }) const createPrIntentProbes = useSourceControlCreatePrIntentProbes({ activeRepo, - activeRepoSettings, + activeRepoTarget, beginGitBranchCompareRequest, fallbackGitHubPRNumber, getCreatePrIntentOperationTarget, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts index 9a145f1fc2eb..af517cf691b7 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-probes.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitBranchCompare } from '@/runtime/runtime-git-client' import type { HostedReviewCreationEligibility } from '../../../../../../shared/hosted-review' @@ -17,7 +18,7 @@ import type { SourceControlLinkedReviews } from './use-linked-reviews' */ export function useSourceControlCreatePrIntentProbes({ activeRepo, - activeRepoSettings, + activeRepoTarget, beginGitBranchCompareRequest, fallbackGitHubPRNumber, getCreatePrIntentOperationTarget, @@ -35,7 +36,7 @@ export function useSourceControlCreatePrIntentProbes({ updateWorktreeGitIdentity }: { activeRepo: SourceControlWorktreeContext['activeRepo'] - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] beginGitBranchCompareRequest: SourceControlStoreActions['beginGitBranchCompareRequest'] fallbackGitHubPRNumber: SourceControlLinkedReviews['fallbackGitHubPRNumber'] getCreatePrIntentOperationTarget: SourceControlCreatePrIntentTarget['getCreatePrIntentOperationTarget'] @@ -63,7 +64,7 @@ export function useSourceControlCreatePrIntentProbes({ const result = await getRuntimeGitBranchCompare( { // Why: intent may continue after a worktree switch; use the token's original host target, not whatever is focused later. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: token.worktreeId, worktreePath: token.worktreePath, connectionId: getConnectionId(token.worktreeId) ?? undefined @@ -73,7 +74,7 @@ export function useSourceControlCreatePrIntentProbes({ setGitBranchCompareResult(token.worktreeId, requestKey, result) return result.summary.status === 'ready' ? (result.summary.commitsAhead ?? 0) : undefined }, - [activeRepoSettings, beginGitBranchCompareRequest, setGitBranchCompareResult] + [activeRepoTarget, beginGitBranchCompareRequest, setGitBranchCompareResult] ) const readHostedReviewCreationEligibilityForIntent = useCallback( @@ -155,7 +156,7 @@ export function useSourceControlCreatePrIntentProbes({ const target = getCreatePrIntentOperationTarget(token) return await refreshGitStatusForWorktreeStrict({ // Why: intent can finish in the background after navigation; branch-safety checks must inspect the worktree that started it. - settings: target.settings, + target: requireGitOwnerTarget(target.target), worktreeId: target.worktreeId, worktreePath: target.worktreePath, connectionId: target.connectionId, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts index 373148e4fc2c..330dd0b21e39 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts @@ -1,4 +1,6 @@ import { useCallback } from 'react' +import { useAppStore } from '@/store' +import { operationGitContext } from '../listing/operation-target' import { translate } from '@/i18n/i18n' import { generateRuntimePullRequestFields } from '@/runtime/runtime-git-client' import type { HostedReviewCreationEligibility } from '../../../../../../shared/hosted-review' @@ -113,11 +115,14 @@ export function useSourceControlCreatePrIntentReview({ }) const target = getCreatePrIntentOperationTarget(token) try { - const generated = await generateRuntimePullRequestFields(target, { - ...fields, - provider: eligibility.provider, - useTemplate: resolvedPrCreationDefaults.useTemplate - }) + const generated = await generateRuntimePullRequestFields( + operationGitContext(target, useAppStore.getState().settings), + { + ...fields, + provider: eligibility.provider, + useTemplate: resolvedPrCreationDefaults.useTemplate + } + ) if (generated.branchChangedByPreparation) { setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'muted', diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-target.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-target.ts index c4d66bb45568..51e637aa2e6f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-target.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-target.ts @@ -11,7 +11,7 @@ import type { SourceControlWorktreeOperationState } from '../panel/use-worktree- */ export function useSourceControlCreatePrIntentTarget({ activeRepoId, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, branchName, createPrIntentCurrentTargetRef, @@ -20,7 +20,7 @@ export function useSourceControlCreatePrIntentTarget({ worktreePath }: { activeRepoId: string | null - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktreeId: string | null branchName: string createPrIntentCurrentTargetRef: SourceControlWorktreeOperationState['createPrIntentCurrentTargetRef'] @@ -48,13 +48,13 @@ export function useSourceControlCreatePrIntentTarget({ const getCreatePrIntentOperationTarget = useCallback( (token: CreatePrIntentRunToken): SourceControlOperationTarget => ({ // Why: Create PR intent continues after navigation; pin git commands to the worktree/host that started the sequence. - settings: activeRepoSettings, + target: activeRepoTarget, worktreeId: token.worktreeId, worktreePath: token.worktreePath, connectionId: getConnectionId(token.worktreeId) ?? undefined, pushTarget: worktreeMap.get(token.worktreeId)?.pushTarget }), - [activeRepoSettings, worktreeMap] + [activeRepoTarget, worktreeMap] ) return { getCreatePrIntentOperationTarget } diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts index 3e25db500a55..5b1ddd3e2e57 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-review-composer.ts @@ -18,7 +18,7 @@ export function useSourceControlCreateReviewComposer({ activePullRequestGenerationRecord, activePullRequestGenerationSeedRestoreKey, activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, branchName, effectiveBaseRef, @@ -41,7 +41,7 @@ export function useSourceControlCreateReviewComposer({ activePullRequestGenerationRecord: SourceControlPullRequestGeneration['activePullRequestGenerationRecord'] activePullRequestGenerationSeedRestoreKey: SourceControlPullRequestGeneration['activePullRequestGenerationSeedRestoreKey'] activeRepo: SourceControlWorktreeContext['activeRepo'] - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktreeId: string | null branchName: string effectiveBaseRef: string | null @@ -96,7 +96,8 @@ export function useSourceControlCreateReviewComposer({ eligibility: hostedReviewCreation, currentBaseRef: effectiveBaseRef, repo: activeRepo ?? null, - settings: activeRepoSettings, + settings, + target: activeRepoTarget, submitting: isCreatingPr, prCreationDefaults: resolvedPrCreationDefaults, sourceControlAiActionsVisible, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx index e950caad5bbf..1e5757bfced6 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx @@ -56,6 +56,7 @@ const runningRecordFor = (options: PullRequestGenerationOptions = {}) => worktreeId: 'wt-1', worktreePath: '/repo', requestId: 7, + runtimeTarget: { kind: 'local' as const }, repoId: 'repo-1', branch: 'fix-readme-typo' }, @@ -475,7 +476,7 @@ describe('useSourceControlHostedReviewCreation', () => { const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) const generation = useSourceControlPullRequestGeneration({ activeRepo: base.activeRepo, - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: 'wt-1', allocatePullRequestGenerationRequestId: useAppStore.getState().allocatePullRequestGenerationRequestId, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx index 9d7f95d37d38..12a667c38e5a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx @@ -48,7 +48,7 @@ describe('useSourceControlPullRequestGeneration outcome', () => { badgeColor: '#000', addedAt: 0 }, - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: 'wt-1', allocatePullRequestGenerationRequestId: vi.fn(() => 5), branchName: 'feature', @@ -92,7 +92,7 @@ describe('useSourceControlPullRequestGeneration outcome', () => { badgeColor: '#000', addedAt: 0 }, - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: 'wt-1', allocatePullRequestGenerationRequestId: vi.fn(() => 5), branchName: 'feature', @@ -158,7 +158,7 @@ describe('useSourceControlPullRequestGeneration outcome', () => { badgeColor: '#000', addedAt: 0 }, - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: 'wt-1', allocatePullRequestGenerationRequestId: vi.fn(() => 5), branchName: 'feature', diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts index e1c11cba0737..566104fdfa91 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.ts @@ -34,7 +34,7 @@ import type { SourceControlStatusRefresh } from '../sync/use-status-refresh' */ export function useSourceControlPullRequestGeneration({ activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, allocatePullRequestGenerationRequestId, branchName, @@ -47,7 +47,7 @@ export function useSourceControlPullRequestGeneration({ worktreePath }: { activeRepo: SourceControlWorktreeContext['activeRepo'] - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktreeId: string | null allocatePullRequestGenerationRequestId: SourceControlStoreActions['allocatePullRequestGenerationRequestId'] branchName: string @@ -86,7 +86,13 @@ export function useSourceControlPullRequestGeneration({ overrides?: RuntimeGeneratePullRequestFieldsOverrides, options?: PullRequestGenerationOptions ): Promise => { - if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) { + if ( + !activeRepo || + !activeRepoTarget || + !activePullRequestGenerationKey || + !worktreePath || + !branchName + ) { return undefined } const generationKey = activePullRequestGenerationKey @@ -103,7 +109,7 @@ export function useSourceControlPullRequestGeneration({ requestId, repoId: activeRepo.id, branch: branchName, - runtimeTargetSettings: activeRepoSettings + runtimeTarget: activeRepoTarget } const seed = { ...fields } // Why: SourceControl can unmount on tab switches; the persisted record lets the PR composer resume on return. @@ -117,7 +123,8 @@ export function useSourceControlPullRequestGeneration({ const result = await generateRuntimePullRequestFields( { // Why: route generation by the repo OWNER host, not the focused runtime. - settings: context.runtimeTargetSettings, + target: context.runtimeTarget, + prefs: useAppStore.getState().settings, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId @@ -177,7 +184,7 @@ export function useSourceControlPullRequestGeneration({ [ activePullRequestGenerationKey, activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, allocatePullRequestGenerationRequestId, branchName, @@ -207,7 +214,7 @@ export function useSourceControlPullRequestGeneration({ }) void cancelRuntimeGeneratePullRequestFields({ // Why: the user can switch hosts while generation runs; cancel the original request owner, not the focused host. - settings: record.context.runtimeTargetSettings, + target: record.context.runtimeTarget, worktreeId: record.context.worktreeId, worktreePath: record.context.worktreePath, connectionId: record.context.connectionId diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts index 5629998eb6a3..581045013f42 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts @@ -21,7 +21,7 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat activeRepoExecutionHostId, activeRepoId, activeRepoRuntimeEnvironmentId, - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, branchName, @@ -69,7 +69,7 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat }) const { compareBaseRef, effectiveBaseRef } = baseRefs const branchCompare = useSourceControlBranchCompare({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, compareBaseRef, @@ -81,7 +81,7 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat }) const createPrIntentTarget = useSourceControlCreatePrIntentTarget({ activeRepoId, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, branchName, createPrIntentCurrentTargetRef, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts index f2bab0d15666..561c48171b21 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-flows.ts @@ -16,7 +16,7 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda activeRepoExecutionHostId, activeRepoId, activeRepoPath, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, allocatePullRequestGenerationRequestId, branchName, @@ -82,7 +82,7 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda }) const pullRequestGeneration = useSourceControlPullRequestGeneration({ activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, allocatePullRequestGenerationRequestId, branchName, @@ -100,7 +100,7 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda activePullRequestGenerationSeedRestoreKey: pullRequestGeneration.activePullRequestGenerationSeedRestoreKey, activeRepo, - activeRepoSettings, + activeRepoTarget, activeWorktreeId, branchName, effectiveBaseRef, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts index e6159cfa9cdb..858c3355e284 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { getConnectionId } from '@/lib/connection-context' -import { getRuntimeGitBranchCompare, type RuntimeGitContext } from '@/runtime/runtime-git-client' +import { getRuntimeGitBranchCompare } from '@/runtime/runtime-git-client' import { useAppStore } from '@/store' import { createLoadingBranchCompareSummary } from '@/store/slices/editor/git/branch-compare-state' import type { GitUpstreamStatus } from '../../../../../../shared/git-status-types' @@ -17,7 +19,7 @@ const BRANCH_REFRESH_MAX_INTERVAL_MS = 5 * 60_000 type BranchCompareRefreshKind = 'immediate' | 'interval' export function useSourceControlBranchCompare({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, compareBaseRef, @@ -27,7 +29,7 @@ export function useSourceControlBranchCompare({ activeGitStatusHead, remoteStatus }: { - activeRepoSettings: RuntimeGitContext['settings'] + activeRepoTarget: RuntimeClientTarget | null activeWorktreeId: string | null worktreePath: string | null compareBaseRef: string | null @@ -69,7 +71,7 @@ export function useSourceControlBranchCompare({ const result = await getRuntimeGitBranchCompare( { // Why: route the branch compare by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId: getConnectionId(activeWorktreeId) ?? undefined @@ -91,7 +93,7 @@ export function useSourceControlBranchCompare({ } }, [ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, beginGitBranchCompareRequest, branchName, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts index 6e7a16ca040f..5e7c99a2371f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-conflict-abort.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' import { toast } from 'sonner' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { translate } from '@/i18n/i18n' @@ -15,7 +16,7 @@ import type { SourceControlStatusRefresh } from './use-status-refresh' * Aborts an in-progress merge or rebase behind a destructive confirmation. */ export function useSourceControlConflictAbort({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, conflictOperation, isAbortingOperation, @@ -26,7 +27,7 @@ export function useSourceControlConflictAbort({ setRemoteActionErrors, worktreePath }: { - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktreeId: string | null conflictOperation: GitConflictOperation isAbortingOperation: boolean @@ -72,7 +73,7 @@ export function useSourceControlConflictAbort({ try { const context = { // Why: route the abort by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -107,7 +108,7 @@ export function useSourceControlConflictAbort({ } }, [ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, confirmAction, conflictOperation, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts index 639a20c079a5..800e6a2c5933 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts @@ -1,10 +1,11 @@ import { useCallback, useEffect, useRef } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { toast } from 'sonner' import { useAppStore } from '@/store' import { getRuntimeGitCommitCompare, - getRuntimeGitRemoteCommitUrl, - type RuntimeGitContext + getRuntimeGitRemoteCommitUrl } from '@/runtime/runtime-git-client' import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' @@ -43,12 +44,12 @@ type GitHistoryCommitActions = { export function useGitHistoryCommitActions({ activeWorktreeId, worktreePath, - activeRepoSettings, + activeRepoTarget, resolveSplitTargetGroupId }: { activeWorktreeId: string | null | undefined worktreePath: string | null - activeRepoSettings: RuntimeGitContext['settings'] + activeRepoTarget: RuntimeClientTarget | null resolveSplitTargetGroupId: (event?: SourceControlRowOpenEvent) => string | undefined }): GitHistoryCommitActions { const openCommitAllDiffs = useAppStore((s) => s.openCommitAllDiffs) @@ -78,7 +79,7 @@ export function useGitHistoryCommitActions({ const result = await getRuntimeGitCommitCompare( { // Why: route the commit compare by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId @@ -97,7 +98,7 @@ export function useGitHistoryCommitActions({ commitCompareCacheRef.current.set(item.id, result) return result.entries }, - [activeRepoSettings, activeWorktreeId, worktreePath] + [activeRepoTarget, activeWorktreeId, worktreePath] ) const openHistoryCommitDiff = useCallback( @@ -199,7 +200,7 @@ export function useGitHistoryCommitActions({ // real origin remote (the renderer has no reliable origin identity). void getRuntimeGitRemoteCommitUrl( { - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId: activeWorktreeId, worktreePath, connectionId: getConnectionId(activeWorktreeId) ?? undefined @@ -285,7 +286,7 @@ export function useGitHistoryCommitActions({ promptDelivery: 'submit-after-ready' }) }, - [activeRepoSettings, activeWorktreeId, copyCommitText, worktreePath] + [activeRepoTarget, activeWorktreeId, copyCommitText, worktreePath] ) return { loadCommitFiles, openHistoryCommitDiff, openCommitFile, handleCommitAction } diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts index ed3f1f9e00b9..96a4808106f7 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react' +import { requireGitOwnerTarget } from '../../worktree-git-owner-target' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { getConnectionId } from '@/lib/connection-context' -import { getRuntimeGitHistory, type RuntimeGitContext } from '@/runtime/runtime-git-client' +import { getRuntimeGitHistory } from '@/runtime/runtime-git-client' import type { GitHistoryPanelState } from './git-history-panel' const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' } @@ -16,7 +18,7 @@ const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' } * whenever the callback identity changes. */ export function useSourceControlGitHistory({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, compareBaseRef, @@ -26,7 +28,7 @@ export function useSourceControlGitHistory({ isGitHistoryVisible, worktreeMap }: { - activeRepoSettings: RuntimeGitContext['settings'] + activeRepoTarget: RuntimeClientTarget | null activeWorktreeId: string | null worktreePath: string | null compareBaseRef: string | null @@ -49,7 +51,8 @@ export function useSourceControlGitHistory({ ? (gitHistoryByWorktree[activeWorktreeId] ?? EMPTY_GIT_HISTORY_STATE) : EMPTY_GIT_HISTORY_STATE // Why: the read is routed by owner host, so track it as a stable string — a new settings object alone must not refetch. - const ownerHostKey = activeRepoSettings?.activeRuntimeEnvironmentId?.trim() ?? '' + const ownerHostKey = + activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : '' useEffect(() => { setGitHistoryByWorktree((prev) => { @@ -100,7 +103,7 @@ export function useSourceControlGitHistory({ const result = await getRuntimeGitHistory( { // Why: route the history read by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: requireGitOwnerTarget(activeRepoTarget), worktreeId, worktreePath, connectionId @@ -130,7 +133,7 @@ export function useSourceControlGitHistory({ }) } }, [ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, compareBaseRef, isBranchVisible, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-remote-action-runner.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-remote-action-runner.ts index f2818ea5c46d..e91b47e38e84 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-remote-action-runner.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-remote-action-runner.ts @@ -38,7 +38,7 @@ export type RunRemoteActionResult = * surface actionable toasts. */ export function useSourceControlRemoteActionRunner({ - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, branchName, @@ -60,7 +60,7 @@ export function useSourceControlRemoteActionRunner({ syncBranch, worktreePath }: { - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktree: SourceControlWorktreeContext['activeWorktree'] activeWorktreeId: string | null branchName: string @@ -94,7 +94,7 @@ export function useSourceControlRemoteActionRunner({ options?.target ?? (activeWorktreeId && worktreePath ? { - settings: activeRepoSettings, + target: activeRepoTarget, worktreeId: activeWorktreeId, worktreePath, connectionId: getConnectionId(activeWorktreeId) ?? undefined, @@ -126,7 +126,7 @@ export function useSourceControlRemoteActionRunner({ true, target.connectionId, target.pushTarget, - { runtimeTargetSettings: target.settings } + { runtimeTarget: target.target } ) return { status: 'ok' } } @@ -138,7 +138,7 @@ export function useSourceControlRemoteActionRunner({ false, target.connectionId, target.pushTarget, - { runtimeTargetSettings: target.settings } + { runtimeTarget: target.target } ) return { status: 'ok' } } @@ -149,7 +149,7 @@ export function useSourceControlRemoteActionRunner({ false, target.connectionId, target.pushTarget, - { forceWithLease: true, runtimeTargetSettings: target.settings } + { forceWithLease: true, runtimeTarget: target.target } ) return { status: 'ok' } } @@ -160,7 +160,7 @@ export function useSourceControlRemoteActionRunner({ target.connectionId, target.pushTarget, { - runtimeTargetSettings: target.settings + runtimeTarget: target.target } ) return { status: 'ok' } @@ -171,7 +171,7 @@ export function useSourceControlRemoteActionRunner({ target.worktreePath, target.connectionId, target.pushTarget, - { runtimeTargetSettings: target.settings } + { runtimeTarget: target.target } ) return { status: 'ok' } } @@ -182,7 +182,7 @@ export function useSourceControlRemoteActionRunner({ target.connectionId, target.pushTarget, { - runtimeTargetSettings: target.settings + runtimeTarget: target.target } ) return { status: 'ok' } @@ -198,7 +198,7 @@ export function useSourceControlRemoteActionRunner({ baseRef, target.connectionId, target.pushTarget, - { runtimeTargetSettings: target.settings } + { runtimeTarget: target.target } ) return { status: 'ok' } } @@ -208,7 +208,7 @@ export function useSourceControlRemoteActionRunner({ target.connectionId, target.pushTarget, { - runtimeTargetSettings: target.settings + runtimeTarget: target.target } ) if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] === sequence) { @@ -244,7 +244,7 @@ export function useSourceControlRemoteActionRunner({ } }, [ - activeRepoSettings, + activeRepoTarget, activeWorktree?.pushTarget, activeWorktreeId, branchName, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.local-ignore.test.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.local-ignore.test.tsx index c936795f86c0..9d185ab82876 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.local-ignore.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.local-ignore.test.tsx @@ -1,5 +1,6 @@ /** @vitest-environment happy-dom */ import { act } from 'react' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { clearHugeRepoWarningDismissalsForTests } from '@/lib/source-control-huge-repo-warning-dismissals' @@ -53,7 +54,7 @@ async function renderOwner( ) { function Probe() { useSourceControlStatusRefresh({ - activeRepoSettings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, + activeRepoTarget: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), activeWorktreeId: 'repo::/same/path', worktreePath: '/same/path', isFolder: false, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts index 9deaf18d1cd3..406077ea349f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { getLocalPathOpenOwnerForRoute, isLocalPathOpenBlocked } from '@/lib/local-path-open-guard' @@ -8,7 +9,6 @@ import { markHugeRepoWarningDismissed } from '@/lib/source-control-huge-repo-warning-dismissals' import { translate } from '@/i18n/i18n' -import type { RuntimeGitContext } from '@/runtime/runtime-git-client' import { useAppStore } from '@/store' import type { GitPushTarget } from '../../../../../../shared/worktree/types' import type { PullRequestGenerationContext } from '@/store/slices/pull-request-generation' @@ -23,7 +23,7 @@ export type SourceControlStatusRefresh = { } export function useSourceControlStatusRefresh({ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, worktreePath, activePushTarget, @@ -33,7 +33,7 @@ export function useSourceControlStatusRefresh({ activeWorktreeInstanceId, worktreeMap }: { - activeRepoSettings: RuntimeGitContext['settings'] + activeRepoTarget: RuntimeClientTarget | null activeWorktreeId: string | null worktreePath: string | null activePushTarget?: GitPushTarget @@ -47,22 +47,23 @@ export function useSourceControlStatusRefresh({ const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus) const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) - // Why: activeRepoSettings is pinned to the repo owner's runtime. + // Why: activeRepoTarget is pinned to the repo owner's runtime. const localIgnoreBlocked = isLocalPathOpenBlocked( getLocalPathOpenOwnerForRoute({ - runtimeEnvironmentId: activeRepoSettings?.activeRuntimeEnvironmentId, + runtimeEnvironmentId: + activeRepoTarget?.kind === 'environment' ? activeRepoTarget.environmentId : null, connectionId: activeConnectionId }) ) const refreshActiveGitStatus = useCallback( async (signal?: AbortSignal): Promise => { - if (!activeWorktreeId || !worktreePath || isFolder) { + if (!activeWorktreeId || !worktreePath || isFolder || !activeRepoTarget) { return } const connectionId = getConnectionId(activeWorktreeId) ?? undefined await refreshGitStatusForWorktree({ // Why: route git status by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, + target: activeRepoTarget, worktreeId: activeWorktreeId, worktreePath, connectionId, @@ -77,7 +78,7 @@ export function useSourceControlStatusRefresh({ }) }, [ - activeRepoSettings, + activeRepoTarget, activeWorktreeId, activePushTarget, fetchUpstreamStatus, @@ -166,7 +167,7 @@ export function useSourceControlStatusRefresh({ try { await refreshGitStatusForWorktree({ // Why: generation can finish after a host switch; refresh the host that owned the generation request. - settings: context.runtimeTargetSettings, + target: context.runtimeTarget, worktreeId: context.worktreeId, worktreePath: context.worktreePath, connectionId: context.connectionId, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-upstream-status-fetch.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-upstream-status-fetch.ts index 424a73367191..189f5da3e4ff 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-upstream-status-fetch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-upstream-status-fetch.ts @@ -5,7 +5,7 @@ import type { SourceControlWorktreeContext } from '../listing/use-worktree-conte /** Keeps the upstream ahead/behind status for the visible branch fresh. */ export function useSourceControlUpstreamStatusFetch({ - activeRepoSettings, + activeRepoTarget, activeWorktree, activeWorktreeId, fetchUpstreamStatus, @@ -13,7 +13,7 @@ export function useSourceControlUpstreamStatusFetch({ isFolder, worktreePath }: { - activeRepoSettings: SourceControlWorktreeContext['activeRepoSettings'] + activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget'] activeWorktree: SourceControlWorktreeContext['activeWorktree'] activeWorktreeId: string | null fetchUpstreamStatus: SourceControlStoreActions['fetchUpstreamStatus'] @@ -32,10 +32,10 @@ export function useSourceControlUpstreamStatusFetch({ worktreePath, connectionId, activeWorktree?.pushTarget, - { runtimeTargetSettings: activeRepoSettings } + { runtimeTarget: activeRepoTarget } ) }, [ - activeRepoSettings, + activeRepoTarget, activeWorktree?.pushTarget, activeWorktreeId, fetchUpstreamStatus, diff --git a/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts index 413f1c51c0a2..0bd794ea6b2f 100644 --- a/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts +++ b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts @@ -6,7 +6,7 @@ import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { createCoalescedPollRunner, type SlowTaskBackoffOptions } from './coalesced-poll-runner' import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' -import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' +import { getWorktreeGitOwnerTarget } from './worktree-git-owner-target' const CONFLICT_POLL_INTERVAL_MS = 3000 @@ -79,15 +79,16 @@ export function useStaleConflictOperationPolling(args: { const connectionId = getConnectionId(id) ?? undefined // Why: after explicit SSH disconnect the provider is intentionally // gone; keep remote polling quiet until the target reconnects. - if (!isConnectionReady(connectionId)) { + const target = getWorktreeGitOwnerTarget(id) + if (!isConnectionReady(connectionId) || !target) { continue } - const op = (await getRuntimeGitConflictOperation({ - settings: getRightSidebarWorktreeRuntimeSettings(id), + const op = await getRuntimeGitConflictOperation({ + target, worktreeId: id, worktreePath: path, connectionId - })) as GitConflictOperation + }) if (!active) { return } diff --git a/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts b/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts index 84fcb526fda6..bf2c30658b3c 100644 --- a/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts +++ b/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts @@ -1,5 +1,5 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' -import type { AppState } from '@/store' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefDetails @@ -12,7 +12,8 @@ import { type CreatePullRequestBaseRefDiscoveryOptions = { open: boolean repoId: string - settings: AppState['settings'] + /** The repo owner's transport; `null` skips discovery. */ + target: RuntimeClientTarget | null base: string baseQuery: string setBase: Dispatch> @@ -21,10 +22,17 @@ type CreatePullRequestBaseRefDiscoveryOptions = { setBaseSearchError: Dispatch> } +// Why: the repo client still takes the settings shape; build it from the owner, never from focus. +function repoClientRoute(target: RuntimeClientTarget) { + return { + activeRuntimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null + } +} + export function useCreatePullRequestBaseRefDiscovery({ open, repoId, - settings, + target, base, baseQuery, setBase, @@ -43,11 +51,11 @@ export function useCreatePullRequestBaseRefDiscovery({ useEffect(() => { // Why: the repo default doesn't move while a repo stays open, so skip the probe // once it is known — on a remote runtime it is an RPC round-trip per composer open. - if (!open || repoDefaultBaseRef) { + if (!open || repoDefaultBaseRef || !target) { return } let stale = false - void getRuntimeRepoBaseRefDefault(settings, repoId) + void getRuntimeRepoBaseRefDefault(repoClientRoute(target), repoId) .then((result) => { if (!stale && result.defaultBaseRef) { setRepoDefault({ repoId, baseRef: stripBaseRef(result.defaultBaseRef) }) @@ -57,7 +65,7 @@ export function useCreatePullRequestBaseRefDiscovery({ return () => { stale = true } - }, [open, repoDefaultBaseRef, repoId, settings]) + }, [open, repoDefaultBaseRef, repoId, target]) useEffect(() => { if (!open || base || !repoDefaultBaseRef) { @@ -67,7 +75,7 @@ export function useCreatePullRequestBaseRefDiscovery({ }, [base, open, repoDefaultBaseRef, setBase]) useEffect(() => { - if (!open || baseQuery.trim().length < 2) { + if (!open || baseQuery.trim().length < 2 || !target) { setBaseResults([]) setBaseSearchPending(false) setBaseSearchError(null) @@ -76,7 +84,7 @@ export function useCreatePullRequestBaseRefDiscovery({ let stale = false setBaseSearchPending(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefDetails(settings, repoId, baseQuery.trim(), 20) + void searchRuntimeRepoBaseRefDetails(repoClientRoute(target), repoId, baseQuery.trim(), 20) .then((results) => { if (!stale) { setBaseResults(normalizeCreateReviewBaseSearchResults(results)) @@ -99,7 +107,7 @@ export function useCreatePullRequestBaseRefDiscovery({ stale = true window.clearTimeout(timer) } - }, [baseQuery, open, repoId, settings, setBaseResults, setBaseSearchError, setBaseSearchPending]) + }, [baseQuery, open, repoId, target, setBaseResults, setBaseSearchError, setBaseSearchPending]) return repoDefaultBaseRef } diff --git a/src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts b/src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts index 092ae2c52b67..8bf1ab25f0b4 100644 --- a/src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts +++ b/src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { getConnectionId } from '@/lib/connection-context' import { useAppStore, type AppState } from '@/store' import { @@ -30,6 +31,7 @@ type CreatePullRequestFieldGenerationOptions = { worktreeId: string | null worktreePath: string settings: AppState['settings'] + target: RuntimeClientTarget | null eligibility: HostedReviewCreationEligibility | null resolvedPrDefaults: Required generation: UseCreatePullRequestDialogFieldsOptions['generation'] @@ -73,6 +75,7 @@ export function useCreatePullRequestFieldGeneration({ worktreeId, worktreePath, settings, + target, eligibility, resolvedPrDefaults, generation, @@ -119,13 +122,18 @@ export function useCreatePullRequestFieldGeneration({ options ) } + if (!target) { + setGenerateError('The workspace host is unresolved. Refresh the workspace and retry.') + return + } const requestId = generationRequestIdRef.current + 1 generationRequestIdRef.current = requestId const connectionId = getConnectionId(worktreeId) ?? undefined const requestContext = { // Why: PR generation belongs to the visible worktree owner. Global // focused-host changes must not retarget an in-flight generation. - settings, + target, + prefs: settings, worktreeId, worktreePath, connectionId @@ -210,6 +218,7 @@ export function useCreatePullRequestFieldGeneration({ setGenerateError, setGenerating, settings, + target, title, worktreeId, worktreePath diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts index 9f420b6aba00..0625ef54f10b 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client' -import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' +import { getWorktreeGitOwnerTarget } from './worktree-git-owner-target' const EMPTY_IGNORED_PATHS: readonly string[] = [] export const FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS = 300 @@ -60,9 +60,13 @@ export function useFileExplorerIgnoredPaths({ let canceled = false const refresh = (): void => { const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const target = getWorktreeGitOwnerTarget(activeWorktreeId) + if (!target) { + return + } void getRuntimeGitIgnoredPaths( { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + target, worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts b/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts index 61d5c2d1f06e..824aeeacf0b9 100644 --- a/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts +++ b/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts @@ -15,10 +15,11 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) -vi.mock('./file-explorer-runtime-owner', () => ({ - getRightSidebarWorktreeRuntimeSettings: () => ({ - activeRuntimeEnvironmentId: runtimeState.environmentId - }) +vi.mock('./worktree-git-owner-target', () => ({ + getWorktreeGitOwnerTarget: () => + runtimeState.environmentId + ? { kind: 'environment', environmentId: runtimeState.environmentId } + : { kind: 'local' } })) vi.mock('react', async () => { @@ -54,7 +55,7 @@ describe('useGitStatusUpstreamRefWatch', () => { expect(setWatch).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, worktreeId: 'repo-1::/repo', worktreePath: '/repo', connectionId: undefined diff --git a/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts b/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts index 8508d61f5e7a..e6c1021da963 100644 --- a/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts +++ b/src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts @@ -2,7 +2,8 @@ import { useCallback, useEffect } from 'react' import { getConnectionId } from '@/lib/connection-context' import { setRuntimeGitStatusUpstreamRefWatch } from '@/runtime/runtime-git-client' import type { GitStatusResult } from '../../../../shared/git-status-types' -import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' +import { getWorktreeGitOwnerTarget } from './worktree-git-owner-target' export function useGitStatusUpstreamRefWatch(args: { enabled: boolean @@ -10,12 +11,12 @@ export function useGitStatusUpstreamRefWatch(args: { worktreeId: string | null worktreePath: string | null }): (status: GitStatusResult) => void { - const runtimeEnvironmentId = args.worktreeId - ? getRightSidebarWorktreeRuntimeSettings(args.worktreeId).activeRuntimeEnvironmentId - : null + const owner = getWorktreeGitOwnerTarget(args.worktreeId) + // Why a string: it keys the effect, and a fresh target object each render would re-arm it. + const runtimeEnvironmentId = owner?.kind === 'environment' ? owner.environmentId : null const connectionId = args.worktreeId ? (getConnectionId(args.worktreeId) ?? undefined) : undefined const scope = - args.enabled && args.executionHostId + args.enabled && args.executionHostId && owner ? `${args.executionHostId}\0${runtimeEnvironmentId}\0${args.worktreeId}\0${args.worktreePath}` : null @@ -29,7 +30,7 @@ export function useGitStatusUpstreamRefWatch(args: { : undefined void setRuntimeGitStatusUpstreamRefWatch( { - settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), worktreeId: args.worktreeId, worktreePath: args.worktreePath, connectionId @@ -61,7 +62,7 @@ export function useGitStatusUpstreamRefWatch(args: { return () => { void setRuntimeGitStatusUpstreamRefWatch( { - settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), worktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts b/src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts index f5ce96bc6d00..86ef3d3b1338 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts +++ b/src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts @@ -1,22 +1,18 @@ import { describe, expect, it } from 'vitest' -import { getDefaultSettings } from '../../../../shared/constants' import { getSourceControlAiControllerDiscoveryHostKey } from './source-control/ai/use-ai' describe('getSourceControlAiControllerDiscoveryHostKey', () => { it('keys generation settings by the active workspace connection', () => { - const settings = getDefaultSettings('/tmp') + const local = { kind: 'local' } as const - expect(getSourceControlAiControllerDiscoveryHostKey(settings, null)).toBe('local') - expect(getSourceControlAiControllerDiscoveryHostKey(settings, undefined)).toBe('unknown') - expect(getSourceControlAiControllerDiscoveryHostKey(settings, 'ssh-1')).toBe('ssh:ssh-1') + expect(getSourceControlAiControllerDiscoveryHostKey(local, null)).toBe('local') + expect(getSourceControlAiControllerDiscoveryHostKey(local, undefined)).toBe('unknown') + expect(getSourceControlAiControllerDiscoveryHostKey(local, 'ssh-1')).toBe('ssh:ssh-1') }) - it('uses the active runtime environment before SSH connection scope', () => { - const settings = { - ...getDefaultSettings('/tmp'), - activeRuntimeEnvironmentId: 'env-1' - } + it('uses the repo owner runtime before SSH connection scope', () => { + const owner = { kind: 'environment', environmentId: 'env-1' } as const - expect(getSourceControlAiControllerDiscoveryHostKey(settings, 'ssh-1')).toBe('runtime:env-1') + expect(getSourceControlAiControllerDiscoveryHostKey(owner, 'ssh-1')).toBe('runtime:env-1') }) }) diff --git a/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx b/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx index 1fb21b7613c3..b7e2387129b5 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx @@ -54,7 +54,7 @@ function Probe(props: { remoteStatus?: GitUpstreamStatus | undefined }): null { latest = useSourceControlBranchCompare({ - activeRepoSettings: null, + activeRepoTarget: { kind: 'local' }, activeWorktreeId: props.worktreeId === undefined ? 'A' : props.worktreeId, worktreePath: '/a', compareBaseRef: props.compareBaseRef === undefined ? 'origin/main' : props.compareBaseRef, diff --git a/src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx b/src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx index 96e890a842de..5811d4ee659e 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { act } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -35,7 +36,7 @@ type Api = ReturnType let latest: Api | null = null function Probe(props: { - activeRepoSettings?: { activeRuntimeEnvironmentId: string | null } + activeRepoTarget?: RuntimeClientTarget worktreeId?: string | null worktreePath?: string compareBaseRef?: string | null @@ -46,7 +47,7 @@ function Probe(props: { worktreeMap?: ReadonlyMap }): null { latest = useSourceControlGitHistory({ - activeRepoSettings: props.activeRepoSettings ?? null, + activeRepoTarget: props.activeRepoTarget ?? { kind: 'local' }, activeWorktreeId: props.worktreeId === undefined ? 'A' : props.worktreeId, worktreePath: props.worktreePath ?? '/a', compareBaseRef: props.compareBaseRef === undefined ? 'origin/main' : props.compareBaseRef, @@ -238,28 +239,28 @@ describe('useSourceControlGitHistory stale completion', () => { it('re-fetches when the owner host changes but the worktree and path stay put', async () => { const root = await mount({ - activeRepoSettings: { activeRuntimeEnvironmentId: null } + activeRepoTarget: { kind: 'local' } }) await flush() expect(mocks.getRuntimeGitHistory).toHaveBeenCalledTimes(1) expect(mocks.getRuntimeGitHistory).toHaveBeenLastCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null } }), + expect.objectContaining({ target: { kind: 'local' } }), expect.anything() ) await act(async () => { - root.render() + root.render() }) await flush() expect(mocks.getRuntimeGitHistory).toHaveBeenCalledTimes(2) expect(mocks.getRuntimeGitHistory).toHaveBeenLastCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'env-1' } }), + expect.objectContaining({ target: { kind: 'environment', environmentId: 'env-1' } }), expect.anything() ) // A new settings object with the same owner host must not trigger another git read. await act(async () => { - root.render() + root.render() }) await flush() expect(mocks.getRuntimeGitHistory).toHaveBeenCalledTimes(2) diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts index 3121d5534239..ffc092f2abd2 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts @@ -52,6 +52,7 @@ function renderFields(initialRepoId: string): { branch: 'feature/child', eligibility, settings: null, + target: { kind: 'local' }, submitting: false }) return null diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index 4e09a248a171..9c98e8ca1040 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -94,6 +94,7 @@ function renderDialogFields(input: DialogFieldsRenderInput): { eligibility: currentInput.eligibility, currentBaseRef: currentInput.currentBaseRef, settings: currentInput.settings ?? null, + target: { kind: 'local' }, submitting: false, generation: currentInput.generation }) diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index c9b217e3587d..dbe37c1823a4 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, resolveSourceControlAiForOperation @@ -21,6 +21,7 @@ import { import { useCreatePullRequestFieldSeeding } from './use-create-pull-request-field-seeding' import { useCreatePullRequestBaseRefDiscovery } from './use-create-pull-request-base-ref-discovery' import { useCreatePullRequestFieldGeneration } from './use-create-pull-request-field-generation' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export function useCreatePullRequestDialogFields({ open, @@ -32,6 +33,7 @@ export function useCreatePullRequestDialogFields({ currentBaseRef, repo, settings, + target: targetInput, submitting, prCreationDefaults, sourceControlAiActionsVisible = true, @@ -39,6 +41,13 @@ export function useCreatePullRequestDialogFields({ onBranchChangedByGeneration, generation }: UseCreatePullRequestDialogFieldsOptions) { + // Why: effects key on the host, not on the object, so a caller's fresh literal can't loop them. + const hasTarget = targetInput !== null + const targetEnvironmentId = targetInput?.kind === 'environment' ? targetInput.environmentId : null + const target = useMemo( + () => (hasTarget ? runtimeTargetForOwnerEnvironment(targetEnvironmentId) : null), + [hasTarget, targetEnvironmentId] + ) const resolvedPullRequestAi = settings ? resolveSourceControlAiForOperation({ settings, @@ -189,7 +198,7 @@ export function useCreatePullRequestDialogFields({ const repoDefaultBaseRef = useCreatePullRequestBaseRefDiscovery({ open, repoId, - settings, + target, base, baseQuery, setBase, @@ -216,6 +225,7 @@ export function useCreatePullRequestDialogFields({ worktreeId, worktreePath, settings, + target, eligibility, resolvedPrDefaults, generation, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx index 04d980b68ba1..a52c0b8894e1 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx @@ -8,6 +8,10 @@ import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRow import { FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS } from './use-file-explorer-ignored-paths' const getRuntimeGitIgnoredPathsMock = vi.hoisted(() => vi.fn()) +vi.mock('./worktree-git-owner-target', () => ({ + getWorktreeGitOwnerTarget: () => ({ kind: 'local' }) +})) + vi.mock('@/runtime/runtime-git-client', () => ({ getRuntimeGitIgnoredPaths: getRuntimeGitIgnoredPathsMock })) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index a6245c0f1f9d..66777a105050 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -94,6 +94,9 @@ async function usePollingOnce( } }) + vi.doMock('./worktree-git-owner-target', () => ({ + getWorktreeGitOwnerTarget: () => ({ kind: 'local' }) + })) vi.doMock('@/store', () => ({ useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), { getState: () => ({ settings: null }) @@ -217,7 +220,7 @@ describe('useGitStatusPolling', () => { undefined, undefined, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + runtimeTarget: { kind: 'local' }, applyUpstreamStatus: false } ) @@ -241,7 +244,7 @@ describe('useGitStatusPolling', () => { undefined, pushTarget, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + runtimeTarget: { kind: 'local' }, applyUpstreamStatus: false } ) @@ -375,6 +378,9 @@ describe('useGitStatusPolling', () => { useRef: (initial: T) => ({ current: initial }) } }) + vi.doMock('./worktree-git-owner-target', () => ({ + getWorktreeGitOwnerTarget: () => ({ kind: 'local' }) + })) vi.doMock('@/store', () => ({ useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), { getState: () => ({ settings: null }) @@ -488,6 +494,9 @@ describe('useGitStatusPolling', () => { useRef: (initial: T) => ({ current: initial }) } }) + vi.doMock('./worktree-git-owner-target', () => ({ + getWorktreeGitOwnerTarget: () => ({ kind: 'local' }) + })) vi.doMock('@/store', () => ({ useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), { getState: () => ({ settings: null }) @@ -612,6 +621,9 @@ describe('useGitStatusPolling', () => { } } }) + vi.doMock('./worktree-git-owner-target', () => ({ + getWorktreeGitOwnerTarget: () => ({ kind: 'local' }) + })) vi.doMock('@/store', () => ({ useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), { getState: () => ({ settings: null }) @@ -794,6 +806,9 @@ describe('useGitStatusPolling', () => { } }) + vi.doMock('./worktree-git-owner-target', () => ({ + getWorktreeGitOwnerTarget: () => ({ kind: 'local' }) + })) vi.doMock('@/store', () => ({ useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), { getState: () => ({ settings: null }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index ca0bef6be7e5..ef8503b40f45 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -10,7 +10,7 @@ import { hasInteractiveActiveGitStatusConsumer, shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access' -import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' +import { getWorktreeGitOwnerTarget } from './worktree-git-owner-target' import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' import { useStaleConflictOperationPolling } from './stale-conflict-operation-poll' @@ -135,9 +135,12 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { } try { const connectionId = getConnectionId(activeWorktreeId) ?? undefined - const runtimeSettings = getRightSidebarWorktreeRuntimeSettings(activeWorktreeId) + const target = getWorktreeGitOwnerTarget(activeWorktreeId) + if (!target) { + return + } await refreshGitStatusForWorktree({ - settings: runtimeSettings, + target, worktreeId: activeWorktreeId, worktreePath, connectionId, diff --git a/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx b/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx index ad088b687dfb..58ec40b7dc75 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx +++ b/src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx @@ -1,6 +1,9 @@ // @vitest-environment happy-dom import { act } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' + +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -51,12 +54,12 @@ function Probe({ worktreeId: string worktreePath: string entries: GitStatusEntry[] - settings?: { activeRuntimeEnvironmentId: string | null } | null + settings?: RuntimeClientTarget | null }): null { latest = useSourceControlSubmoduleStatus({ activeWorktreeId: worktreeId, worktreePath, - activeRepoSettings: settings, + activeRepoTarget: settings ?? LOCAL_TARGET, entries }) return null @@ -177,8 +180,10 @@ describe('useSourceControlSubmoduleStatus', () => { const envA = deferred<{ entries: GitStatusEntry[] }>() const envB = deferred<{ entries: GitStatusEntry[] }>() mocks.getRuntimeGitSubmoduleStatus.mockImplementation( - (ctx: { settings?: { activeRuntimeEnvironmentId?: string | null } | null }) => - ctx.settings?.activeRuntimeEnvironmentId === 'env-b' ? envB.promise : envA.promise + (ctx: { target?: RuntimeClientTarget }) => + ctx.target?.kind === 'environment' && ctx.target.environmentId === 'env-b' + ? envB.promise + : envA.promise ) const container = document.createElement('div') @@ -190,7 +195,7 @@ describe('useSourceControlSubmoduleStatus', () => { ) @@ -205,7 +210,7 @@ describe('useSourceControlSubmoduleStatus', () => { ) diff --git a/src/renderer/src/components/right-sidebar/worktree-git-owner-target.ts b/src/renderer/src/components/right-sidebar/worktree-git-owner-target.ts new file mode 100644 index 000000000000..83e151057d3e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/worktree-git-owner-target.ts @@ -0,0 +1,52 @@ +import { useMemo } from 'react' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { UNRESOLVED_GIT_HOST_ERROR } from '@/store/slices/editor/actions/git-operation-target' + +function ownerTargetFor( + state: AppState, + worktreeId: string | null | undefined +): RuntimeClientTarget | null { + return worktreeId ? runtimeTargetForWorkspaceOwner(state, { workspaceId: worktreeId }) : null +} + +/** + * Transport to the worktree's owner for right-sidebar git work, or `null` when its rows name none + * or disagree. Never the focused server, which can change while a poll is in flight. + */ +export function getWorktreeGitOwnerTarget( + worktreeId: string | null | undefined +): RuntimeClientTarget | null { + return ownerTargetFor(useAppStore.getState(), worktreeId) +} + +/** {@link getWorktreeGitOwnerTarget} as a hook with a stable identity per owner. */ +export function useWorktreeGitOwnerTarget( + worktreeId: string | null | undefined +): RuntimeClientTarget | null { + // Why a key: the resolver returns a fresh object per call, which would re-render every write. + const ownerKey = useAppStore((state) => { + const target = ownerTargetFor(state, worktreeId) + return target ? (target.kind === 'environment' ? `env:${target.environmentId}` : 'local') : null + }) + return useMemo( + () => + ownerKey === null + ? null + : runtimeTargetForOwnerEnvironment(ownerKey === 'local' ? null : ownerKey.slice(4)), + [ownerKey] + ) +} + +/** The owner transport for a git call, or the unresolved-host refusal the caller already surfaces. */ +export function requireGitOwnerTarget(target: RuntimeClientTarget | null): RuntimeClientTarget { + if (!target) { + throw new Error(UNRESOLVED_GIT_HOST_ERROR) + } + return target +} diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx index 95c3e2567b89..6343188b35e1 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx @@ -449,13 +449,12 @@ describe('CommitMessageAiPane', () => { }) it('keeps local active worktree discovery scoped to local, not unknown', () => { - expect(getCommitMessageSettingsPaneDiscoveryHostKey(buildSettings(), null, true)).toBe('local') - expect(getCommitMessageSettingsPaneDiscoveryHostKey(buildSettings(), undefined, true)).toBe( - 'unknown' - ) + const local = { kind: 'local' } as const + expect(getCommitMessageSettingsPaneDiscoveryHostKey(local, null, true)).toBe('local') + expect(getCommitMessageSettingsPaneDiscoveryHostKey(local, undefined, true)).toBe('unknown') expect( getCommitMessageSettingsPaneDiscoveryHostKey( - buildSettings({ activeRuntimeEnvironmentId: 'env-1' }), + { kind: 'environment', environmentId: 'env-1' }, null, true ) diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 22f71f71cf75..e12ed8e7c59b 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -1,6 +1,7 @@ import { useRef } from 'react' import type React from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAiSettingsPatch, @@ -82,12 +83,12 @@ export function mergeDiscoveredModelsIntoCommitMessageConfig( } export function getCommitMessageSettingsPaneDiscoveryHostKey( - settings: GlobalSettings, + target: RuntimeClientTarget, activeConnectionId: string | null | undefined, hasActiveWorktree: boolean ): string { const runtimeScope = hasActiveWorktree - ? getRuntimeGitScope(settings, activeConnectionId) + ? getRuntimeGitScope(target, activeConnectionId) : activeConnectionId return getCommitMessageModelDiscoveryHostKeyForScope(runtimeScope) } diff --git a/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx b/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx index a541a82dd241..9d517d1d7221 100644 --- a/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx +++ b/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx @@ -8,8 +8,9 @@ import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { SettingsSegmentedControl } from './SettingsFormControls' import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client' -import { useAppStore } from '../../store' -import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' +import { UNRESOLVED_GIT_HOST_ERROR } from '@/store/slices/editor/actions/git-operation-target' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { searchKeywords } from './settings-search-keywords' @@ -99,7 +100,6 @@ export function RepositoryForkSyncSection({ updateRepo, forceVisible }: RepositoryForkSyncSectionProps): React.JSX.Element | null { - const settings = useAppStore((state) => state.settings) const upstream = repo.upstream const [syncing, setSyncing] = useState(false) const syncInFlightRef = useRef(false) @@ -126,9 +126,13 @@ export function RepositoryForkSyncSection({ syncInFlightRef.current = true setSyncing(true) try { + const target = runtimeTargetForOwnerHostId(getRepoExecutionHostId(repo)) + if (!target) { + throw new Error(UNRESOLVED_GIT_HOST_ERROR) + } const result = await syncRuntimeGitForkDefaultBranch( { - settings: getRepoOwnerRoutedSettings(settings, repo), + target, worktreeId: getRepoMainWorktreeId(repo), worktreePath: repo.path, connectionId: repo.connectionId ?? undefined diff --git a/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts b/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts index 5ed3fd6efd0e..f6f8d874bd01 100644 --- a/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts +++ b/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts @@ -1,13 +1,13 @@ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' import { findRepoForHost } from '@/store/slices/repo-host-identity' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { GitStatusResult } from '../../../../shared/git-status-types' -import { parseExecutionHostId } from '../../../../shared/execution-host' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy' import { orderDeleteWorktreeStatusHydrationTargets } from './delete-worktree-dirty-change-counts' @@ -26,7 +26,6 @@ export function useDeleteWorktreeStatusHydration({ repoMap: ReadonlyMap }): ReadonlyMap { const repos = useAppStore((state) => state.repos) - const settings = useAppStore((state) => state.settings) const generation = isOpen ? deleteTargets.map(getWorktreeHostIdentity).join('\n') : '' const generationRef = useRef(generation) const [statusByIdentity, setStatusByIdentity] = useState< @@ -62,19 +61,16 @@ export function useDeleteWorktreeStatusHydration({ const owner = target.hostId ? findRepoForHost(repos, target.repoId, { hostId: target.hostId }) : undefined - const parsedHost = parseExecutionHostId(target.hostId) - const runtimeEnvironmentId = parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null - const runtimeSettings = target.hostId - ? settings - ? { ...settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } - : { activeRuntimeEnvironmentId: runtimeEnvironmentId } - : getSettingsForWorktreeRuntimeOwner( - { repos, settings, worktreesByRepo: useAppStore.getState().worktreesByRepo }, - target.id - ) + const ownerTarget = target.hostId + ? runtimeTargetForOwnerHostId(target.hostId) + : runtimeTargetForWorkspaceOwner(currentState, { workspaceId: target.id }) + if (!ownerTarget) { + setStatusByIdentity((current) => new Map(current).set(identity, null)) + continue + } void getRuntimeGitStatus( { - settings: runtimeSettings, + target: ownerTarget, worktreeId: target.id, worktreePath: target.path, connectionId: target.hostId @@ -97,7 +93,7 @@ export function useDeleteWorktreeStatusHydration({ return () => { controller.abort() } - }, [deleteTargets, generation, isOpen, repoMap, repos, settings, visibleTargets]) + }, [deleteTargets, generation, isOpen, repoMap, repos, visibleTargets]) return currentStatusByIdentity } diff --git a/src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts b/src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts index 353ab14edb8c..03d11bc374fd 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' import type { GitStatusResult } from '../../../../shared/git-status-types' -import { parseExecutionHostId } from '../../../../shared/execution-host' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' import { getWorkspaceSpaceWorktreeIdentity } from './workspace-space-delete-selection' import { useAppStore } from '../../store' @@ -47,18 +47,15 @@ export function useWorkspaceSpaceGitRefreshAction(bindings: WorkspaceSpaceManage hostId: worktree.executionHostId, settings }) - const host = parseExecutionHostId(worktree.executionHostId) - const ownerSettings = settings - ? { - ...settings, - activeRuntimeEnvironmentId: host?.kind === 'runtime' ? host.environmentId : null - } - : { activeRuntimeEnvironmentId: host?.kind === 'runtime' ? host.environmentId : null } + // Why: a scan row without a host id came from this app's own scan. + const ownerTarget = worktree.executionHostId + ? runtimeTargetForOwnerHostId(worktree.executionHostId) + : ({ kind: 'local' } as const) return ( - owner + owner && ownerTarget ? getRuntimeGitStatus({ - settings: ownerSettings, + target: ownerTarget, worktreeId: worktree.worktreeId, worktreePath: worktree.path, connectionId: owner.connectionId ?? undefined diff --git a/src/renderer/src/hooks/composer-state/github-provider-selection.ts b/src/renderer/src/hooks/composer-state/github-provider-selection.ts index cfa5ebbd3cdb..0c72a6725865 100644 --- a/src/renderer/src/hooks/composer-state/github-provider-selection.ts +++ b/src/renderer/src/hooks/composer-state/github-provider-selection.ts @@ -38,7 +38,8 @@ import { getLinkedItemDisplayName } from '@/components/sidebar/folder-workspace-composer-helpers' import { shouldApplyWorkspaceSourceAutoName } from '../../../../shared/new-workspace/workspace-source' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point' import { getForkPushWarning } from '../fork-push-warning' import { toast } from 'sonner' @@ -71,7 +72,6 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) setName, setPushTarget, setStartFromResetHint, - settings, smartGitHubPrStartPointSelectionRef } = input @@ -129,14 +129,10 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) item: normalizedItem } smartGitHubPrStartPointSelectionRef.current = startPointSelection - const itemRepoSettings = getSettingsForRepoRuntimeOwner( - { repos: [runRepo], settings }, - runRepo.id - ) const resolvePrBase = resolveGitHubPrStartPointForRepo({ repoId: runRepo.id, prNumber: identity.number, - settings: itemRepoSettings, + target: runtimeTargetForOwnerHostId(getRepoExecutionHostId(runRepo)), ...(normalizedItem.branchName ? { headRefName: normalizedItem.branchName } : {}), ...(normalizedItem.baseRefName ? { baseRefName: normalizedItem.baseRefName } : {}), ...(normalizedItem.isCrossRepository !== undefined @@ -182,7 +178,6 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) name, selectedRepo, selectedRepoGitHubSourceContext, - settings, branchAutoNameRef, lastAutoNameRef, setBaseBranch, diff --git a/src/renderer/src/hooks/composer-state/github-submit-resolution.ts b/src/renderer/src/hooks/composer-state/github-submit-resolution.ts index bab2db999b3e..32337b938dda 100644 --- a/src/renderer/src/hooks/composer-state/github-submit-resolution.ts +++ b/src/renderer/src/hooks/composer-state/github-submit-resolution.ts @@ -34,7 +34,8 @@ import { useCallback } from 'react' import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' import { getLinkedWorkItemProvider } from '@/lib/new-workspace' import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { getSmartGitHubSubmitResolution, getSmartGitHubSubmitIntent, @@ -77,7 +78,6 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { setName, setPushTarget, setStartFromResetHint, - settings, smartGitHubPrStartPointSelectionRef } = input @@ -104,10 +104,7 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { (await resolveGitHubPrStartPointForRepo({ repoId: selectedRepo.id, prNumber: startPointIdentity.number, - settings: getSettingsForRepoRuntimeOwner( - { repos: [selectedRepo], settings }, - selectedRepo.id - ), + target: runtimeTargetForOwnerHostId(getRepoExecutionHostId(selectedRepo)), ...(startPointSelection.item.branchName ? { headRefName: startPointSelection.item.branchName } : {}), @@ -197,10 +194,7 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { ? await resolveGitHubPrStartPointForRepo({ repoId: selectedRepo.id, prNumber: itemIdentity.number, - settings: getSettingsForRepoRuntimeOwner( - { repos: [selectedRepo], settings }, - selectedRepo.id - ), + target: runtimeTargetForOwnerHostId(getRepoExecutionHostId(selectedRepo)), ...(item.branchName ? { headRefName: item.branchName } : {}), ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), ...(item.isCrossRepository !== undefined @@ -263,7 +257,6 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { selectedRepo, selectedRepoGitHubSourceContext, selectedRepoIsGit, - settings, branchAutoNameRef, lastAutoNameRef, setBaseBranch, diff --git a/src/renderer/src/lib/github-pr-start-point.test.ts b/src/renderer/src/lib/github-pr-start-point.test.ts index 23c41f837d3b..28acc7f63bb4 100644 --- a/src/renderer/src/lib/github-pr-start-point.test.ts +++ b/src/renderer/src/lib/github-pr-start-point.test.ts @@ -35,7 +35,7 @@ describe('resolveGitHubPrStartPointForRepo', () => { resolveGitHubPrStartPointForRepo({ repoId: 'repo-1', prNumber: 42, - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, headRefName: 'feature/fix', baseRefName: 'main', isCrossRepository: false @@ -67,7 +67,7 @@ describe('resolveGitHubPrStartPointForRepo', () => { resolveGitHubPrStartPointForRepo({ repoId: 'repo-runtime', prNumber: 7, - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, headRefName: 'feature/runtime', baseRefName: 'develop', isCrossRepository: true @@ -101,7 +101,7 @@ describe('resolveGitHubPrStartPointForRepo', () => { resolveGitHubPrStartPointForRepo({ repoId: 'repo-1', prNumber: 99, - settings: null + target: { kind: 'local' } }) ).rejects.toThrow('Could not resolve PR head.') }) diff --git a/src/renderer/src/lib/github-pr-start-point.ts b/src/renderer/src/lib/github-pr-start-point.ts index 3984bf32cf3b..6c6bf7c41ab3 100644 --- a/src/renderer/src/lib/github-pr-start-point.ts +++ b/src/renderer/src/lib/github-pr-start-point.ts @@ -1,13 +1,12 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GlobalSettings } from '../../../shared/global-settings-types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GitHubPrStartPoint } from '../../../shared/worktree/types' -type PrStartPointSettings = Pick | null | undefined - export type GitHubPrStartPointInput = { repoId: string prNumber: number - settings: PrStartPointSettings + /** The repo owner's transport; `null` (no routable owner) is refused. */ + target: RuntimeClientTarget | null headRefName?: string baseRefName?: string isCrossRepository?: boolean @@ -16,12 +15,14 @@ export type GitHubPrStartPointInput = { export async function resolveGitHubPrStartPointForRepo({ repoId, prNumber, - settings, + target, headRefName, baseRefName, isCrossRepository }: GitHubPrStartPointInput): Promise { - const target = getActiveRuntimeTarget(settings) + if (!target) { + throw new Error('The project host is unresolved. Refresh the project and retry.') + } const prFields = { prNumber, ...(headRefName ? { headRefName } : {}), diff --git a/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts b/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts new file mode 100644 index 000000000000..9310ca91768b --- /dev/null +++ b/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const store: Record = {} + return { + createWorktree: vi.fn(), + resolvePrBase: vi.fn(), + checkRuntimeHooks: vi.fn(), + callRuntimeRpc: vi.fn(), + store + } +}) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) +vi.mock('@/lib/ensure-hooks-confirmed', () => ({ + ensureHooksConfirmed: vi.fn().mockResolvedValue('run') +})) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/runtime/runtime-hooks-client', () => ({ checkRuntimeHooks: mocks.checkRuntimeHooks })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: (a: string) => a })) + +import { launchWorkItemDirect } from './launch-work-item-direct' + +// Why: preflight, the PR start point and `git worktree add` must agree on one host per launch. +describe('launchWorkItemDirect owner row', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { api: { worktrees: { resolvePrBase: mocks.resolvePrBase } } }) + mocks.checkRuntimeHooks.mockResolvedValue({ + hasHooks: false, + hooks: null, + mayNeedUpdate: false + }) + // Why: stop after create; the agent launch that follows is covered elsewhere. + mocks.createWorktree.mockRejectedValue(new Error('stop after create')) + mocks.store = { + repos: [], + worktreesByRepo: {}, + settings: {}, + ensureDetectedAgents: vi.fn().mockResolvedValue([]), + ensureRemoteDetectedAgents: vi.fn().mockResolvedValue([]), + createWorktree: mocks.createWorktree + } + }) + + it('runs setup preflight, the PR start point and createWorktree on the one owner row', async () => { + mocks.store.settings = { activeRuntimeEnvironmentId: 'env-a' } + mocks.store.repos = [ + { + id: 'repo-1', + path: '/srv/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'runtime:env-b' + } + ] + mocks.callRuntimeRpc.mockResolvedValue({ baseBranch: 'abc123', headSha: 'abc123' }) + + await launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + telemetrySource: 'sidebar', + openModalFallback: vi.fn(), + item: { type: 'pr', number: 7, title: 'Fix', url: 'https://github.com/o/r/pull/7' } + }) + + expect(mocks.checkRuntimeHooks).toHaveBeenCalledWith(null, 'repo-1', 'runtime:env-b') + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-b' }, + 'worktree.resolvePrBase', + expect.objectContaining({ repo: 'repo-1', prNumber: 7 }), + expect.anything() + ) + expect(mocks.resolvePrBase).not.toHaveBeenCalled() + expect(mocks.createWorktree.mock.calls[0]?.at(-1)).toEqual({ executionHostId: 'runtime:env-b' }) + }) + + it('asks the user instead of tie-breaking by focus when two hosts share the repo id', async () => { + mocks.store.settings = { activeRuntimeEnvironmentId: 'env-b' } + mocks.store.repos = [ + { + id: 'repo-1', + path: '/srv/a', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'runtime:env-a' + }, + { + id: 'repo-1', + path: '/srv/b', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'runtime:env-b' + } + ] + const openModalFallback = vi.fn() + + await expect( + launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + telemetrySource: 'sidebar', + openModalFallback, + item: { type: 'pr', number: 7, title: 'Fix', url: 'https://github.com/o/r/pull/7' } + }) + ).resolves.toBe(false) + + expect(openModalFallback).toHaveBeenCalled() + expect(mocks.checkRuntimeHooks).not.toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.createWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-preflight.ts b/src/renderer/src/lib/launch-work-item-direct-preflight.ts index 84cc3033f097..2c7478d6d2fa 100644 --- a/src/renderer/src/lib/launch-work-item-direct-preflight.ts +++ b/src/renderer/src/lib/launch-work-item-direct-preflight.ts @@ -1,19 +1,18 @@ import { getSetupConfig } from '@/lib/new-workspace' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point' -import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' +import type { Repo } from '../../../shared/repo-types' import type { OrcaHooks, RepoHookSettings } from '../../../shared/orca-yaml-hook-types' import type { SetupDecision } from '../../../shared/worktree/create-types' import type { GitHubPrStartPoint } from '../../../shared/worktree/types' -// Why: preflight routes by the repo's owner host, which `getSettingsForRepoRuntimeOwner` -// hands back as a narrow runtime-scope pick rather than the full GlobalSettings. -type PreflightSettings = Pick | null | undefined - export async function resolveDirectPrStartPoint( repoId: string, prNumber: number, - settings: PreflightSettings, + target: RuntimeClientTarget | null, hints: { branchName?: string headRefName?: string @@ -24,7 +23,7 @@ export async function resolveDirectPrStartPoint( return resolveGitHubPrStartPointForRepo({ repoId, prNumber, - settings, + target, headRefName: hints.headRefName ?? hints.branchName, baseRefName: hints.baseRefName, isCrossRepository: hints.isCrossRepository @@ -34,13 +33,12 @@ export async function resolveDirectPrStartPoint( export async function resolveDirectSetupDecision( repoId: string, repo: { hookSettings?: RepoHookSettings }, - settings: PreflightSettings + ownerHostId: ExecutionHostId ): Promise<{ kind: 'decided'; decision: SetupDecision } | { kind: 'needs-modal' }> { let yamlHooks: OrcaHooks | null = null try { - // Why: route the hooks probe by the repo's owner host (passed in) so preflight - // and the subsequent owner-routed createWorktree hit the same host. - const result = await checkRuntimeHooks(settings, repoId) + // Why: the same owner host as the PR start point and createWorktree, never focus. + const result = await checkRuntimeHooks(null, repoId, ownerHostId) yamlHooks = (result.hooks as OrcaHooks | null) ?? null } catch { yamlHooks = null @@ -60,3 +58,27 @@ export async function resolveDirectSetupDecision( decision: policy === 'run-by-default' ? 'run' : 'skip' } } + +export type DirectLaunchOwnerRow = { + repo: Repo + executionHostId: ExecutionHostId + target: RuntimeClientTarget +} + +/** + * The one repo row a direct launch runs on: setup preflight, the PR start point, hook trust and + * createWorktree all take its host. `null` when no row or several hosts share the id, which the + * caller sends to the create modal instead of breaking the tie by focus. + */ +export function resolveDirectLaunchOwnerRow( + repos: readonly Repo[], + repoId: string +): DirectLaunchOwnerRow | null { + const rows = repos.filter((r) => r.id === repoId) + if (rows.length !== 1) { + return null + } + const executionHostId = getRepoExecutionHostId(rows[0]) + const target = runtimeTargetForOwnerHostId(executionHostId) + return target ? { repo: rows[0], executionHostId, target } : null +} diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 333c4ea3b4fe..ab4e4361a896 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -60,15 +60,18 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionId: mocks.getConnectionId })) +const hostMocks = vi.hoisted(() => ({ + checkRuntimeHooks: vi.fn(), + callRuntimeRpc: vi.fn() +})) + vi.mock('@/runtime/runtime-hooks-client', () => ({ - checkRuntimeHooks: vi - .fn() - .mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) + checkRuntimeHooks: hostMocks.checkRuntimeHooks })) vi.mock('@/runtime/runtime-rpc-client', () => ({ getActiveRuntimeTarget: vi.fn().mockReturnValue({ kind: 'local' }), - callRuntimeRpc: vi.fn() + callRuntimeRpc: hostMocks.callRuntimeRpc })) vi.mock('@/lib/new-workspace', () => ({ @@ -149,6 +152,11 @@ describe('launchWorkItemDirect', () => { } } }) + hostMocks.checkRuntimeHooks.mockResolvedValue({ + hasHooks: false, + hooks: null, + mayNeedUpdate: false + }) mocks.resolvePrBase.mockResolvedValue({ baseBranch: 'abc123', compareBaseRef: 'refs/remotes/origin/main', @@ -284,7 +292,8 @@ describe('launchWorkItemDirect', () => { undefined, undefined, undefined, - 'refs/remotes/origin/main' + 'refs/remotes/origin/main', + { executionHostId: 'local' } ) }) @@ -365,7 +374,8 @@ describe('launchWorkItemDirect', () => { undefined, undefined, undefined, - undefined + undefined, + { executionHostId: 'local' } ) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index debf385c3be8..7c7197f95e74 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -28,11 +28,11 @@ import { import { getDirectWorkItemDraftContent } from '@/lib/launch-work-item-direct-draft' import { resolveDirectPrStartPoint, + resolveDirectLaunchOwnerRow, resolveDirectSetupDecision } from '@/lib/launch-work-item-direct-preflight' import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' import { prepareDirectWorkItemAgentLaunch } from '@/lib/launch-work-item-direct-route-preparation' @@ -66,16 +66,14 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom agentArgs } = args const store = useAppStore.getState() - const repo = store.repos.find((r) => r.id === repoId) - if (!repo) { + const owner = resolveDirectLaunchOwnerRow(store.repos, repoId) + if (!owner) { openModalFallback() return false } + const { repo, executionHostId: repoOwnerHostId, target: repoOwnerTarget } = owner const settings = store.settings - // Why: preflight (PR base + hooks probe) must run on the repo's owner host so it - // matches the owner-routed createWorktree below, not the focused runtime. - const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, repoId) const promptDelivery = args.promptDelivery ?? 'draft' const repoConnectionId = repo.connectionId?.trim() || null const githubIdentity = @@ -114,7 +112,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom ? store.ensureRemoteDetectedAgents(repoConnectionId) : store.ensureDetectedAgents() - const setupResolution = await resolveDirectSetupDecision(repoId, repo, repoOwnerSettings) + const setupResolution = await resolveDirectSetupDecision(repoId, repo, repoOwnerHostId) if (setupResolution.kind === 'needs-modal') { openModalFallback() return false @@ -147,7 +145,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom try { // Why: direct "Use PR" launches bypass the Start-from picker, so they // must still resolve the PR head before `git worktree add`. - const result = await resolveDirectPrStartPoint(repoId, itemNumber, repoOwnerSettings, item) + const result = await resolveDirectPrStartPoint(repoId, itemNumber, repoOwnerTarget, item) resolvedBaseBranch = result.baseBranch resolvedPushTarget = result.pushTarget resolvedBranchNameOverride = result.branchNameOverride @@ -195,7 +193,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom undefined, undefined, undefined, - resolvedCompareBaseRef + resolvedCompareBaseRef, + { executionHostId: repoOwnerHostId } ) worktreeId = result.worktree.id worktreePath = result.worktree.path diff --git a/src/renderer/src/runtime/antigravity-generation-compatibility.test.ts b/src/renderer/src/runtime/antigravity-generation-compatibility.test.ts index f2b44417f8a7..8bb19c82f54b 100644 --- a/src/renderer/src/runtime/antigravity-generation-compatibility.test.ts +++ b/src/renderer/src/runtime/antigravity-generation-compatibility.test.ts @@ -23,9 +23,13 @@ for (const operation of ['commitMessage', 'pullRequest'] as const) { describe(operation, () => { function generate(model: string, resolved = true, agentArgs?: string) { const settings = getDefaultSettings('/tmp') - settings.activeRuntimeEnvironmentId = 'remote-test' settings.sourceControlAi = { ...settings.sourceControlAi!, agentId: 'antigravity' } - const context = { settings, worktreeId: 'wt-1', worktreePath: '/remote/workspace' } + const context = { + target: { kind: 'environment' as const, environmentId: 'remote-test' }, + prefs: settings, + worktreeId: 'wt-1', + worktreePath: '/remote/workspace' + } const overrides = resolved ? { sourceControlAiResolvedParams: { diff --git a/src/renderer/src/runtime/antigravity-generation-compatibility.ts b/src/renderer/src/runtime/antigravity-generation-compatibility.ts index 6c7d101a0cfd..62d3d84e212f 100644 --- a/src/renderer/src/runtime/antigravity-generation-compatibility.ts +++ b/src/renderer/src/runtime/antigravity-generation-compatibility.ts @@ -17,11 +17,15 @@ export async function antigravityGenerationCompatibilityError( ): Promise { let params = overrides?.sourceControlAiResolvedParams if (!params) { - const settings = getRuntimeCommitMessageSettings(context.settings, context.connectionId) + const settings = getRuntimeCommitMessageSettings( + context.target, + context.prefs, + context.connectionId + ) const resolved = resolveSourceControlAiForOperation({ settings: { ...settings, - defaultTuiAgent: context.settings?.defaultTuiAgent ?? null, + defaultTuiAgent: context.prefs?.defaultTuiAgent ?? null, sourceControlAi: overrides?.sourceControlAi ?? settings.sourceControlAi, agentCmdOverrides: overrides?.agentCmdOverrides ?? settings.agentCmdOverrides ?? {} }, diff --git a/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts b/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts index 07d2db533a98..ec0df9320509 100644 --- a/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts +++ b/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts @@ -45,7 +45,7 @@ afterEach(() => { describe('branch line total merge base on git status requests', () => { it('forwards the merge base to local git status only when the chip asked for it', async () => { const context = { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' } @@ -72,7 +72,7 @@ describe('branch line total merge base on git status requests', () => { it('forwards the merge base through the active runtime environment', async () => { await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -91,7 +91,7 @@ describe('branch line total merge base on git status requests', () => { // Why: Rule-1 wire safety — an old server must see the exact params it // already understands, and a hidden chip must not cost a remote diff. await getRuntimeGitStatus({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }) @@ -107,7 +107,7 @@ describe('branch line total merge base on git status requests', () => { it('keeps the merge base alongside reuse and cache-bypass flags', async () => { await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, diff --git a/src/renderer/src/runtime/runtime-git-client-context.ts b/src/renderer/src/runtime/runtime-git-client-context.ts index 076978357ead..736402f74338 100644 --- a/src/renderer/src/runtime/runtime-git-client-context.ts +++ b/src/renderer/src/runtime/runtime-git-client-context.ts @@ -7,7 +7,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { HostedReviewProvider } from '../../../shared/hosted-review' import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' -import { getActiveRuntimeTarget } from './runtime-rpc-client' +import type { RuntimeClientTarget } from './runtime-client-target' export type RuntimeGenerateCommitMessageResult = | { success: true; message: string; agentLabel?: string } @@ -31,13 +31,13 @@ export type RuntimePullRequestGenerationInput = { useTemplate?: boolean } -export type RuntimeGitSettings = Pick & - Partial< - Pick< - GlobalSettings, - 'commitMessageAi' | 'sourceControlAi' | 'agentCmdOverrides' | 'defaultTuiAgent' - > +/** Generation preferences only; routing comes from the context's `target`. */ +export type RuntimeGitPrefs = Partial< + Pick< + GlobalSettings, + 'commitMessageAi' | 'sourceControlAi' | 'agentCmdOverrides' | 'defaultTuiAgent' > +> export type RuntimeDiscoverCommitMessageModelsResult = | { @@ -51,7 +51,9 @@ export type RuntimeDiscoverCommitMessageModelsResult = | { success: false; error: string } export type RuntimeGitContext = { - settings: RuntimeGitSettings | null | undefined + /** Transport to the worktree's owner. */ + target: RuntimeClientTarget + prefs?: RuntimeGitPrefs | null worktreeId: string | null | undefined worktreePath: string connectionId?: string @@ -72,15 +74,15 @@ export function resolveLocalWorktreePath(context: RuntimeGitContext): string { } export function getRuntimeGitScope( - settings: Pick | null | undefined, + target: RuntimeClientTarget, connectionId: string | null | undefined ): string | null | undefined { - const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId } export function getRuntimeCommitMessageSettings( - settings: RuntimeGitSettings | null | undefined, + target: RuntimeClientTarget, + settings: RuntimeGitPrefs | null | undefined, connectionId?: string ): Partial< Pick< @@ -90,10 +92,11 @@ export function getRuntimeCommitMessageSettings( > & { commitMessageDiscoveryHostKey?: string } { + const scope = getRuntimeGitScope(target, connectionId) if (!settings) { - return {} + // Why: the host key comes from the route, so a call without prefs still keys its models. + return { commitMessageDiscoveryHostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope) } } - const scope = getRuntimeGitScope(settings, connectionId) return { ...(settings.commitMessageAi !== undefined ? { commitMessageAi: settings.commitMessageAi } diff --git a/src/renderer/src/runtime/runtime-git-client-merge.test.ts b/src/renderer/src/runtime/runtime-git-client-merge.test.ts index 826ff417a008..9fd157f13d89 100644 --- a/src/renderer/src/runtime/runtime-git-client-merge.test.ts +++ b/src/renderer/src/runtime/runtime-git-client-merge.test.ts @@ -36,7 +36,7 @@ describe('runtime git client merge operations', () => { gitAbortMerge.mockResolvedValue(undefined) await abortRuntimeGitMerge({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo' }) @@ -54,7 +54,7 @@ describe('runtime git client merge operations', () => { }) await abortRuntimeGitMerge({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }) @@ -72,7 +72,7 @@ describe('runtime git client merge operations', () => { gitAbortRebase.mockResolvedValue(undefined) await abortRuntimeGitRebase({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -91,7 +91,7 @@ describe('runtime git client merge operations', () => { }) await abortRuntimeGitRebase({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }) diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 62085575b64c..59289816be9b 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -108,7 +108,7 @@ describe('runtime git client', () => { await getRuntimeGitBranchCompare( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -129,7 +129,7 @@ describe('runtime git client', () => { gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) await getRuntimeGitStatus({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -144,7 +144,7 @@ describe('runtime git client', () => { const workspaceId = '123e4567-e89b-12d3-a456-426614174000' await getRuntimeGitStatus({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: `folder-repo::/home/user::workspace:${workspaceId}`, worktreePath: `/home/user::workspace:${workspaceId}` }) @@ -163,7 +163,7 @@ describe('runtime git client', () => { gitSubmoduleStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) const workspaceId = '123e4567-e89b-12d3-a456-426614174000' const context = { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: `folder-repo::/home/user::workspace:${workspaceId}`, worktreePath: `/home/user::workspace:${workspaceId}` } @@ -182,7 +182,7 @@ describe('runtime git client', () => { await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -190,7 +190,7 @@ describe('runtime git client', () => { ) await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -217,7 +217,7 @@ describe('runtime git client', () => { const result = await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -237,7 +237,7 @@ describe('runtime git client', () => { await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -245,7 +245,7 @@ describe('runtime git client', () => { ) await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -275,7 +275,7 @@ describe('runtime git client', () => { const request = getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -303,7 +303,7 @@ describe('runtime git client', () => { const result = await getRuntimeGitIgnoredPaths( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -325,7 +325,7 @@ describe('runtime git client', () => { await getRuntimeGitSubmoduleStatus( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -354,7 +354,7 @@ describe('runtime git client', () => { await getRuntimeGitHistory( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -380,13 +380,13 @@ describe('runtime git client', () => { }) await getRuntimeGitStatus({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }) await getRuntimeGitDiff( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -394,7 +394,7 @@ describe('runtime git client', () => { ) await getRuntimeGitHistory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -436,7 +436,7 @@ describe('runtime git client', () => { await getRuntimeGitSubmoduleStatus( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -462,7 +462,7 @@ describe('runtime git client', () => { await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -491,7 +491,7 @@ describe('runtime git client', () => { const result = await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -517,7 +517,7 @@ describe('runtime git client', () => { await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -543,7 +543,7 @@ describe('runtime git client', () => { await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -568,7 +568,7 @@ describe('runtime git client', () => { await getRuntimeGitStatus( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -593,7 +593,7 @@ describe('runtime git client', () => { const result = await getRuntimeGitIgnoredPaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -617,7 +617,7 @@ describe('runtime git client', () => { _meta: { runtimeId: 'remote-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' } @@ -719,8 +719,8 @@ describe('runtime git client', () => { }) await generateRuntimeCommitMessage({ - settings: { - activeRuntimeEnvironmentId: 'env-1', + target: { kind: 'environment', environmentId: 'env-1' }, + prefs: { defaultTuiAgent: 'codex', commitMessageAi, agentCmdOverrides @@ -759,7 +759,7 @@ describe('runtime git client', () => { await generateRuntimeCommitMessage( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'repo-1::/repo', worktreePath: '/repo' }, @@ -767,7 +767,7 @@ describe('runtime git client', () => { ) await generateRuntimeCommitMessage( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -804,7 +804,8 @@ describe('runtime git client', () => { await discoverRuntimeCommitMessageModels( { - settings: { activeRuntimeEnvironmentId: 'env-1', agentCmdOverrides }, + target: { kind: 'environment', environmentId: 'env-1' }, + prefs: { agentCmdOverrides }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -825,7 +826,7 @@ describe('runtime git client', () => { const workspaceId = '123e4567-e89b-12d3-a456-426614174000' const worktreeId = `folder-repo::/home/user::workspace:${workspaceId}` const context = { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId, worktreePath: `/home/user::workspace:${workspaceId}` } @@ -848,7 +849,7 @@ describe('runtime git client', () => { it('omits worktreeId from local generation IPC when the context has none', async () => { const context = { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: null, worktreePath: '/repo' } diff --git a/src/renderer/src/runtime/runtime-git-diff-client.ts b/src/renderer/src/runtime/runtime-git-diff-client.ts index ea44571ecded..2d8a7cc2c001 100644 --- a/src/renderer/src/runtime/runtime-git-diff-client.ts +++ b/src/renderer/src/runtime/runtime-git-diff-client.ts @@ -4,14 +4,14 @@ import type { GitDiffResult } from '../../../shared/git-diff-compare-types' import { resolveLocalWorktreePath, type RuntimeGitContext } from './runtime-git-client-context' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' export async function getRuntimeGitDiff( context: RuntimeGitContext, args: { filePath: string; staged: boolean; compareAgainstHead?: boolean } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.diff({ worktreePath: resolveLocalWorktreePath(context), @@ -34,7 +34,7 @@ export async function getRuntimeGitBranchCompare( baseRef: string, admissionTier: 'interactive' | 'background' = 'interactive' ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.branchCompare({ worktreePath: resolveLocalWorktreePath(context), @@ -55,7 +55,7 @@ export async function getRuntimeGitCommitCompare( context: RuntimeGitContext, commitId: string ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commitCompare({ worktreePath: resolveLocalWorktreePath(context), @@ -79,7 +79,7 @@ export async function getRuntimeGitBranchDiff( oldPath?: string } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.branchDiff({ worktreePath: resolveLocalWorktreePath(context), @@ -106,7 +106,7 @@ export async function getRuntimeGitCommitDiff( oldPath?: string } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commitDiff({ worktreePath: resolveLocalWorktreePath(context), diff --git a/src/renderer/src/runtime/runtime-git-generation-client.ts b/src/renderer/src/runtime/runtime-git-generation-client.ts index 3d823778ff9d..b27bac4d95f0 100644 --- a/src/renderer/src/runtime/runtime-git-generation-client.ts +++ b/src/renderer/src/runtime/runtime-git-generation-client.ts @@ -11,14 +11,14 @@ import { type RuntimeGitContext, type RuntimePullRequestGenerationInput } from './runtime-git-client-context' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' export async function generateRuntimeCommitMessage( context: RuntimeGitContext, overrides?: RuntimeGenerateCommitMessageOverrides ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.generateCommitMessage({ worktreePath: resolveLocalWorktreePath(context), @@ -47,7 +47,7 @@ export async function generateRuntimeCommitMessage( 'git.generateCommitMessage', { worktree: toRuntimeWorktreeSelector(context.worktreeId), - ...getRuntimeCommitMessageSettings(context.settings, context.connectionId), + ...getRuntimeCommitMessageSettings(context.target, context.prefs, context.connectionId), ...(overrides?.sourceControlAiResolvedParams ? { sourceControlAiResolvedParams: overrides.sourceControlAiResolvedParams } : {}), @@ -62,7 +62,7 @@ export async function discoverRuntimeCommitMessageModels( context: RuntimeGitContext, agentId: string ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.discoverCommitMessageModels({ agentId, @@ -76,8 +76,8 @@ export async function discoverRuntimeCommitMessageModels( { worktree: toRuntimeWorktreeSelector(context.worktreeId), agentId, - ...(context.settings?.agentCmdOverrides - ? { agentCmdOverrides: context.settings.agentCmdOverrides } + ...(context.prefs?.agentCmdOverrides + ? { agentCmdOverrides: context.prefs.agentCmdOverrides } : {}) }, { timeoutMs: 75_000 } @@ -87,7 +87,7 @@ export async function discoverRuntimeCommitMessageModels( export async function cancelRuntimeGenerateCommitMessage( context: RuntimeGitContext ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.cancelGenerateCommitMessage({ worktreePath: resolveLocalWorktreePath(context), @@ -108,7 +108,7 @@ export async function generateRuntimePullRequestFields( input: RuntimePullRequestGenerationInput, overrides?: RuntimeGeneratePullRequestFieldsOverrides ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.generatePullRequestFields({ worktreePath: resolveLocalWorktreePath(context), @@ -139,7 +139,7 @@ export async function generateRuntimePullRequestFields( { worktree: toRuntimeWorktreeSelector(context.worktreeId), ...input, - ...getRuntimeCommitMessageSettings(context.settings, context.connectionId), + ...getRuntimeCommitMessageSettings(context.target, context.prefs, context.connectionId), ...(overrides?.sourceControlAiResolvedParams ? { sourceControlAiResolvedParams: overrides.sourceControlAiResolvedParams } : {}), @@ -153,7 +153,7 @@ export async function generateRuntimePullRequestFields( export async function cancelRuntimeGeneratePullRequestFields( context: RuntimeGitContext ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.cancelGeneratePullRequestFields({ worktreePath: resolveLocalWorktreePath(context), diff --git a/src/renderer/src/runtime/runtime-git-status-client.ts b/src/renderer/src/runtime/runtime-git-status-client.ts index 048211bd1374..c417177359ad 100644 --- a/src/renderer/src/runtime/runtime-git-status-client.ts +++ b/src/renderer/src/runtime/runtime-git-status-client.ts @@ -5,7 +5,7 @@ import type { GitStatusResult } from '../../../shared/git-status-types' import { resolveLocalWorktreePath, type RuntimeGitContext } from './runtime-git-client-context' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' export async function getRuntimeGitStatus( @@ -20,7 +20,7 @@ export async function getRuntimeGitStatus( signal?: AbortSignal } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} const admissionTierArgs = options?.admissionTier ? { admissionTier: options.admissionTier } : {} const includeLineStatsArgs = @@ -71,7 +71,7 @@ export async function setRuntimeGitStatusUpstreamRefWatch( context: RuntimeGitContext, args: { executionHostId: string; branch?: string; upstreamName?: string } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'local' || !context.worktreeId) { return } @@ -125,7 +125,7 @@ export async function getRuntimeGitSubmoduleStatus( submodulePath: string, area: GitStagingArea = 'unstaged' ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.submoduleStatus({ worktreePath: resolveLocalWorktreePath(context), @@ -146,7 +146,7 @@ export async function getRuntimeGitIgnoredPaths( context: RuntimeGitContext, paths: string[] ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (paths.length === 0) { return [] } @@ -169,7 +169,7 @@ export async function getRuntimeGitHistory( context: RuntimeGitContext, options: GitHistoryOptions = {} ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.history({ worktreePath: resolveLocalWorktreePath(context), @@ -188,7 +188,7 @@ export async function getRuntimeGitHistory( export async function getRuntimeGitConflictOperation( context: RuntimeGitContext ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.conflictOperation({ worktreePath: resolveLocalWorktreePath(context), diff --git a/src/renderer/src/runtime/runtime-git-sync-client.ts b/src/renderer/src/runtime/runtime-git-sync-client.ts index 5f1652a38932..aa2692be0234 100644 --- a/src/renderer/src/runtime/runtime-git-sync-client.ts +++ b/src/renderer/src/runtime/runtime-git-sync-client.ts @@ -3,11 +3,11 @@ import type { GitUpstreamStatus } from '../../../shared/git-status-types' import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../../shared/git-rebase-source' import type { GitPushTarget } from '../../../shared/worktree/types' import { resolveLocalWorktreePath, type RuntimeGitContext } from './runtime-git-client-context' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' export async function abortRuntimeGitMerge(context: RuntimeGitContext): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.abortMerge({ worktreePath: resolveLocalWorktreePath(context), @@ -24,7 +24,7 @@ export async function abortRuntimeGitMerge(context: RuntimeGitContext): Promise< } export async function abortRuntimeGitRebase(context: RuntimeGitContext): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.abortRebase({ worktreePath: resolveLocalWorktreePath(context), @@ -44,7 +44,7 @@ export async function getRuntimeGitUpstreamStatus( context: RuntimeGitContext, pushTarget?: GitPushTarget ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.upstreamStatus({ worktreePath: resolveLocalWorktreePath(context), @@ -67,7 +67,7 @@ export async function fetchRuntimeGit( context: RuntimeGitContext, pushTarget?: GitPushTarget ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.fetch({ worktreePath: resolveLocalWorktreePath(context), @@ -92,7 +92,7 @@ export async function syncRuntimeGitForkDefaultBranch( context: RuntimeGitContext, expectedUpstream: GitForkSyncExpectedUpstream ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.syncFork({ worktreePath: resolveLocalWorktreePath(context), @@ -112,7 +112,7 @@ export async function pullRuntimeGit( context: RuntimeGitContext, pushTarget?: GitPushTarget ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.pull({ worktreePath: resolveLocalWorktreePath(context), @@ -137,7 +137,7 @@ export async function fastForwardRuntimeGit( context: RuntimeGitContext, pushTarget?: GitPushTarget ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.fastForward({ worktreePath: resolveLocalWorktreePath(context), @@ -162,7 +162,7 @@ export async function rebaseRuntimeGitFromBase( context: RuntimeGitContext, baseRef: string ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.rebaseFromBase({ worktreePath: resolveLocalWorktreePath(context), @@ -183,7 +183,7 @@ export async function pushRuntimeGit( context: RuntimeGitContext, args: { publish?: boolean; pushTarget?: GitPushTarget; forceWithLease?: boolean } = {} ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.push({ worktreePath: resolveLocalWorktreePath(context), @@ -212,7 +212,7 @@ export async function commitRuntimeGit( context: RuntimeGitContext, message: string ): Promise<{ success: boolean; error?: string }> { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commit({ worktreePath: resolveLocalWorktreePath(context), diff --git a/src/renderer/src/runtime/runtime-git-working-tree-client.ts b/src/renderer/src/runtime/runtime-git-working-tree-client.ts index b2c5245012e0..6ecc17198a56 100644 --- a/src/renderer/src/runtime/runtime-git-working-tree-client.ts +++ b/src/renderer/src/runtime/runtime-git-working-tree-client.ts @@ -3,14 +3,14 @@ import { type GitStageWorktreeScope } from '../../../shared/git-stage-worktree-scope' import { resolveLocalWorktreePath, type RuntimeGitContext } from './runtime-git-client-context' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' export async function stageRuntimeGitPath( context: RuntimeGitContext, filePath: string ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.stage({ worktreePath: resolveLocalWorktreePath(context), @@ -31,7 +31,7 @@ export async function bulkStageRuntimeGitPaths( context: RuntimeGitContext, filePaths: string[] ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkStage({ worktreePath: resolveLocalWorktreePath(context), @@ -53,7 +53,7 @@ export async function stageRuntimeGitWorktreeScope( context: RuntimeGitContext, scope: GitStageWorktreeScope ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target // Why no paths: an older host strips `scope` and stages `filePaths`, so an empty list makes that a no-op. if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkStage({ @@ -77,7 +77,7 @@ export async function unstageRuntimeGitPath( context: RuntimeGitContext, filePath: string ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.unstage({ worktreePath: resolveLocalWorktreePath(context), @@ -98,7 +98,7 @@ export async function bulkUnstageRuntimeGitPaths( context: RuntimeGitContext, filePaths: string[] ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkUnstage({ worktreePath: resolveLocalWorktreePath(context), @@ -119,7 +119,7 @@ export async function bulkDiscardRuntimeGitPaths( context: RuntimeGitContext, filePaths: string[] ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkDiscard({ worktreePath: resolveLocalWorktreePath(context), @@ -140,7 +140,7 @@ export async function discardRuntimeGitPath( context: RuntimeGitContext, filePath: string ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { await window.api.git.discard({ worktreePath: resolveLocalWorktreePath(context), @@ -161,7 +161,7 @@ export async function getRuntimeGitRemoteFileUrl( context: RuntimeGitContext, args: { relativePath: string; line: number } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.remoteFileUrl({ worktreePath: resolveLocalWorktreePath(context), @@ -186,7 +186,7 @@ export async function getRuntimeGitRemoteCommitUrl( context: RuntimeGitContext, args: { sha: string } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind === 'local' || !context.worktreeId) { return window.api.git.remoteCommitUrl({ worktreePath: resolveLocalWorktreePath(context), diff --git a/src/renderer/src/runtime/runtime-git-worktree-scope-client.test.ts b/src/renderer/src/runtime/runtime-git-worktree-scope-client.test.ts index 558910c1fa61..ce8023195497 100644 --- a/src/renderer/src/runtime/runtime-git-worktree-scope-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-worktree-scope-client.test.ts @@ -13,7 +13,7 @@ vi.mock('./runtime-rpc-client', async (importActual) => ({ import { stageRuntimeGitWorktreeScope } from './runtime-git-working-tree-client' const REMOTE = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' } @@ -50,7 +50,7 @@ describe('stageRuntimeGitWorktreeScope', () => { mocks.bulkStage.mockResolvedValue(undefined) await stageRuntimeGitWorktreeScope( - { ...REMOTE, settings: { activeRuntimeEnvironmentId: null }, connectionId: 'ssh-1' }, + { ...REMOTE, target: { kind: 'local' }, connectionId: 'ssh-1' }, 'tracked' ) diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index 91013b98e355..f2078a6c5b4b 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -101,7 +101,7 @@ export function createAllHostRepoCatalogActions( } }) // Why: keep the safe-auto fork sync (as fetchRepos does) so cold-start, which now routes here, still updates safe-auto forks. - scheduleSafeAutoForkSync(get, hostRepos) + scheduleSafeAutoForkSync(hostRepos) return true } const validateRepoScopedUi = (): void => { diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts index 61ff2b5010d2..75c304b978fc 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -150,7 +150,7 @@ export function createRepoCatalogActions( ) } }) - scheduleSafeAutoForkSync(get, finalizedHostRepos) + scheduleSafeAutoForkSync(finalizedHostRepos) } catch (err) { localCatalogOutcome = { status: 'rejected', reason: err } console.error('Failed to fetch repos:', err) diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index 107c5f8526ff..a797b7584c70 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -121,7 +121,7 @@ export function createRuntimeRepoCatalogActions( ) } }) - scheduleSafeAutoForkSync(get, finalizedHostRepos) + scheduleSafeAutoForkSync(finalizedHostRepos) return finalizedHostRepos } catch (err) { console.error(`Failed to fetch repos for runtime environment ${environmentId}:`, err) diff --git a/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts b/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts index 5bf4732cc338..c957d51411e3 100644 --- a/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts +++ b/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' import { createCompatibleRuntimeStatusResponseIfNeeded, @@ -24,13 +23,6 @@ const RUNTIME_REPO: Repo = { upstream: { owner: 'up', repo: 'r' } } -function stateWith(repo: Repo): AppState { - return { - repos: [repo], - settings: { activeRuntimeEnvironmentId: 'env-1' } - } as unknown as AppState -} - async function flushScheduledSyncs(): Promise { await Promise.all([...safeAutoForkSyncAttempts.values()].map((attempt) => attempt.promise)) } @@ -62,7 +54,7 @@ beforeEach(() => { describe('scheduleSafeAutoForkSync', () => { it('addresses a runtime-hosted repo by its main worktree id, not the bare repo id', async () => { // Why: the runtime rejects `id:` with worktree_id_requires_full_path (#16447). - scheduleSafeAutoForkSync(() => stateWith(RUNTIME_REPO), [RUNTIME_REPO]) + scheduleSafeAutoForkSync([RUNTIME_REPO]) await flushScheduledSyncs() expect(runtimeEnvironmentCall).toHaveBeenCalledWith( @@ -81,11 +73,7 @@ describe('scheduleSafeAutoForkSync', () => { executionHostId: 'local' } - scheduleSafeAutoForkSync( - () => - ({ ...stateWith(localRepo), settings: { activeRuntimeEnvironmentId: null } }) as AppState, - [localRepo] - ) + scheduleSafeAutoForkSync([localRepo]) await flushScheduledSyncs() expect(gitSyncFork).toHaveBeenCalledWith({ @@ -103,7 +91,7 @@ describe('scheduleSafeAutoForkSync', () => { path: `/srv/repo-${index}` })) - scheduleSafeAutoForkSync(() => stateWith(RUNTIME_REPO), repos) + scheduleSafeAutoForkSync(repos) await flushScheduledSyncs() expect(safeAutoForkSyncAttempts.size).toBeLessThanOrEqual(512) diff --git a/src/renderer/src/store/repos/safe-auto-fork-sync.ts b/src/renderer/src/store/repos/safe-auto-fork-sync.ts index 2a992a89c920..38285d0d155b 100644 --- a/src/renderer/src/store/repos/safe-auto-fork-sync.ts +++ b/src/renderer/src/store/repos/safe-auto-fork-sync.ts @@ -1,9 +1,8 @@ -import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { getRepoMainWorktreeId } from '../../../../shared/worktree/id' -import { settingsForRepoOwner } from './owner-routing' +import { runtimeTargetForOwnerHostId } from '../../runtime/runtime-client-target' export const SAFE_AUTO_FORK_SYNC_COOLDOWN_MS = 10 * 60 * 1000 @@ -32,7 +31,7 @@ export function getSafeAutoForkSyncKey(repo: Repo): string { return `${getRepoExecutionHostId(repo)}:${repo.id}:${repo.path}` } -export function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Repo[]): void { +export function scheduleSafeAutoForkSync(repos: readonly Repo[]): void { const now = Date.now() pruneSafeAutoForkSyncAttempts(now) for (const repo of repos) { @@ -47,9 +46,13 @@ export function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Re ) { continue } + const target = runtimeTargetForOwnerHostId(getRepoExecutionHostId(repo)) + if (!target) { + continue + } const promise = syncRuntimeGitForkDefaultBranch( { - settings: settingsForRepoOwner(get(), repo.id), + target, worktreeId: getRepoMainWorktreeId(repo), worktreePath: repo.path, connectionId: repo.connectionId ?? undefined diff --git a/src/renderer/src/store/slices/commit-message-generation.ts b/src/renderer/src/store/slices/commit-message-generation.ts index 4e7ef3e722d9..76ec6692098d 100644 --- a/src/renderer/src/store/slices/commit-message-generation.ts +++ b/src/renderer/src/store/slices/commit-message-generation.ts @@ -1,18 +1,14 @@ import type { StateCreator } from 'zustand' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AppState } from '../types' - -export type CommitMessageGenerationRuntimeTargetSettings = Pick< - GlobalSettings, - 'activeRuntimeEnvironmentId' -> +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' export type CommitMessageGenerationContext = { worktreeId: string worktreePath: string connectionId?: string requestId: number - runtimeTargetSettings?: CommitMessageGenerationRuntimeTargetSettings | null + /** The worktree owner's transport, captured when the request started. */ + runtimeTarget: RuntimeClientTarget } export type CommitMessageGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded' diff --git a/src/renderer/src/store/slices/diff-comment-persistence.ts b/src/renderer/src/store/slices/diff-comment-persistence.ts index e0f97a2ce790..cb71bbda2ac9 100644 --- a/src/renderer/src/store/slices/diff-comment-persistence.ts +++ b/src/renderer/src/store/slices/diff-comment-persistence.ts @@ -4,9 +4,9 @@ import type { DiffComment } from '../../../../shared/diff-comment-types' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { Worktree } from '../../../../shared/worktree/types' import { getRepoIdFromWorktreeId } from './worktree-helpers' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, @@ -53,7 +53,6 @@ export function normalizeDiffComment(comment: DiffComment): DiffComment { async function persist( state: AppState, - settings: AppState['settings'], worktreeId: string, diffComments: DiffComment[], folderExecutionHostId?: ReturnType @@ -89,7 +88,10 @@ async function persist( } return } - const target = getActiveRuntimeTarget(settings) + const target = runtimeTargetForWorkspaceOwner(state, { workspaceId: worktreeId }) + if (!target) { + throw new Error('The workspace host is unresolved; review notes were not saved.') + } if (target.kind === 'local') { await window.api.worktrees.updateMeta({ worktreeId, @@ -105,13 +107,6 @@ async function persist( ) } -function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] { - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) -} - // Why: IPC writes aren't ordered, so serialize per worktree to stop an older snapshot from overwriting a newer one on disk. const persistQueueByWorktree = new Map>() @@ -179,7 +174,6 @@ export function enqueueDiffCommentPersist( stateList = folderWorkspace?.diffComments await persist( state, - state.settings, worktreeId, (stateList ?? []).map(normalizeDiffComment), folderExecutionHostId @@ -189,12 +183,7 @@ export function enqueueDiffCommentPersist( const target = get().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId) stateList = target?.diffComments const state = get() - await persist( - state, - settingsForWorktreeOwner(state, worktreeId), - worktreeId, - (stateList ?? []).map(normalizeDiffComment) - ) + await persist(state, worktreeId, (stateList ?? []).map(normalizeDiffComment)) } } catch (err) { // Why: converge to what actually landed; rollback's identity guard no-ops when a later mutation owns the array. diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 23e12d15ab66..730d3feb68c3 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -150,6 +150,7 @@ function seed( worktreeOverrides: Partial = {} ): void { store.setState({ + repos: [{ id: REPO, path: '/path/repo', displayName: 'repo', badgeColor: '#000', addedAt: 0 }], worktreesByRepo: { [REPO]: [{ ...makeWorktree(comments), ...worktreeOverrides }] } }) } diff --git a/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts b/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts index 9521dda3a103..aebf640ed2c0 100644 --- a/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts +++ b/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts @@ -144,12 +144,24 @@ describe('createEditorSlice remote branch actions', () => { expect(toastErrorMock).not.toHaveBeenCalled() }) + it('refuses a git operation whose worktree no row places, instead of using the focused server', async () => { + const store = createEditorStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) + + await expect(store.getState().pushBranch('wt-unknown', '/elsewhere')).rejects.toThrow( + 'The workspace host is unresolved' + ) + + expect(gitPushMock).not.toHaveBeenCalled() + expect(store.getState().isRemoteOperationActive).toBe(false) + }) + it('routes git operations through the explicit runtime owner instead of ambient focus', async () => { const store = createEditorStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) await store.getState().pushBranch('wt-1', '/repo', false, undefined, undefined, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTarget: { kind: 'local' } }) expect(gitPushMock).toHaveBeenCalledWith({ @@ -660,7 +672,9 @@ describe('createEditorSlice remote branch actions', () => { await Promise.resolve() expect(store.getState().isRemoteOperationActive).toBe(true) - const pushB = store.getState().pushBranch('wt-2', '/b') + const pushB = store.getState().pushBranch('wt-2', '/b', false, undefined, undefined, { + runtimeTarget: { kind: 'local' } + }) await Promise.resolve() expect(store.getState().isRemoteOperationActive).toBe(true) diff --git a/src/renderer/src/store/slices/editor/actions/git-operation-target.ts b/src/renderer/src/store/slices/editor/actions/git-operation-target.ts new file mode 100644 index 000000000000..7f586d5c27cd --- /dev/null +++ b/src/renderer/src/store/slices/editor/actions/git-operation-target.ts @@ -0,0 +1,33 @@ +import { toast } from 'sonner' +import type { AppState } from '../../../types' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { GitRuntimeOperationOptions } from '../types/git-runtime-operation' + +export const UNRESOLVED_GIT_HOST_ERROR = + 'The workspace host is unresolved. Refresh the workspace and retry.' + +/** The caller's owner transport, else the worktree's own owner; never the focused server. */ +export function tryResolveGitOperationTarget( + state: AppState, + worktreeId: string, + options?: Pick +): RuntimeClientTarget | null { + return ( + options?.runtimeTarget ?? runtimeTargetForWorkspaceOwner(state, { workspaceId: worktreeId }) + ) +} + +/** {@link tryResolveGitOperationTarget} for user actions: an unresolved host is refused out loud. */ +export function resolveGitOperationTarget( + state: AppState, + worktreeId: string, + options?: Pick +): RuntimeClientTarget { + const target = tryResolveGitOperationTarget(state, worktreeId, options) + if (!target) { + toast.error(UNRESOLVED_GIT_HOST_ERROR) + throw new Error(UNRESOLVED_GIT_HOST_ERROR) + } + return target +} diff --git a/src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts b/src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts index 34c57e33e239..1afda076aa8b 100644 --- a/src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts +++ b/src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts @@ -1,5 +1,6 @@ import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' +import { resolveGitOperationTarget } from './git-operation-target' import { toast } from 'sonner' import { fetchRuntimeGit, @@ -25,15 +26,15 @@ export function createGitRemotePushPull( pushTarget, options = {} ) => { + const runtimeTarget = resolveGitOperationTarget(get(), worktreeId, options) // Why: fire-and-forget the upstream refresh (don't await) so compound flows aren't delayed, but the "Push"→"Commit" label still rotates faster than the 3s poll. get().beginRemoteOperation( publish ? 'publish' : options.forceWithLease === true ? 'force_push' : 'push' ) let shouldRefreshAfterRejectedPush = false - const runtimeSettings = options.runtimeTargetSettings ?? get().settings try { await pushRuntimeGit( - { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, + { target: runtimeTarget, worktreeId, worktreePath, connectionId }, { publish, pushTarget, forceWithLease: options.forceWithLease } ) } catch (error) { @@ -49,19 +50,19 @@ export function createGitRemotePushPull( } finally { get().endRemoteOperation() if (shouldRefreshAfterRejectedPush) { - const context = { settings: runtimeSettings, worktreeId, worktreePath, connectionId } + const context = { target: runtimeTarget, worktreeId, worktreePath, connectionId } // Why: the rejected push proved the branch moved; fetch first so legacy base-tracking worktrees discover origin/, then refresh ahead/behind. void fetchRuntimeGit(context, pushTarget) .catch(() => undefined) .then(() => get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: runtimeSettings + runtimeTarget }) ) } } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: runtimeSettings + runtimeTarget }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { @@ -69,11 +70,11 @@ export function createGitRemotePushPull( } }, pullBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { + const runtimeTarget = resolveGitOperationTarget(get(), worktreeId, options) get().beginRemoteOperation('pull') - const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await pullRuntimeGit( - { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, + { target: runtimeTarget, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -83,7 +84,7 @@ export function createGitRemotePushPull( get().endRemoteOperation() } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: runtimeSettings + runtimeTarget }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { @@ -91,11 +92,11 @@ export function createGitRemotePushPull( } }, fastForwardBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { + const runtimeTarget = resolveGitOperationTarget(get(), worktreeId, options) get().beginRemoteOperation('fast_forward') - const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await fastForwardRuntimeGit( - { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, + { target: runtimeTarget, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -105,7 +106,7 @@ export function createGitRemotePushPull( get().endRemoteOperation() } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: runtimeSettings + runtimeTarget }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { diff --git a/src/renderer/src/store/slices/editor/actions/git-remote-status.ts b/src/renderer/src/store/slices/editor/actions/git-remote-status.ts index 7ab94b136a4c..70552373a68c 100644 --- a/src/renderer/src/store/slices/editor/actions/git-remote-status.ts +++ b/src/renderer/src/store/slices/editor/actions/git-remote-status.ts @@ -1,5 +1,6 @@ import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' +import { tryResolveGitOperationTarget } from './git-operation-target' import { getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' import { invalidateAutomaticPushTargetUpstreamStatusCache } from '@/components/right-sidebar/push-target-upstream-refresh-cache' import { areUpstreamStatusesEqual } from '../git/git-status-reconciliation' @@ -53,11 +54,15 @@ export function createGitRemoteStatus( } }), fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { - const runtimeSettings = options?.runtimeTargetSettings ?? get().settings + const runtimeTarget = tryResolveGitOperationTarget(get(), worktreeId, options) + if (!runtimeTarget) { + console.warn('fetchUpstreamStatus skipped: workspace host is unresolved', worktreeId) + return null + } try { const status = await getRuntimeGitUpstreamStatus( { - settings: runtimeSettings, + target: runtimeTarget, worktreeId, worktreePath, connectionId @@ -73,7 +78,7 @@ export function createGitRemoteStatus( if (pushTarget) { // Why: don't let an old automatic-poll cache entry suppress the next retry after a transient refresh failure. invalidateAutomaticPushTargetUpstreamStatusCache({ - settings: runtimeSettings, + target: runtimeTarget, worktreeId, worktreePath, connectionId, diff --git a/src/renderer/src/store/slices/editor/actions/git-remote-sync.ts b/src/renderer/src/store/slices/editor/actions/git-remote-sync.ts index a1caa1b412a0..f90936a0a8e0 100644 --- a/src/renderer/src/store/slices/editor/actions/git-remote-sync.ts +++ b/src/renderer/src/store/slices/editor/actions/git-remote-sync.ts @@ -1,5 +1,6 @@ import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' +import { resolveGitOperationTarget } from './git-operation-target' import { toast } from 'sonner' import { fetchRuntimeGit, @@ -20,14 +21,14 @@ export function createGitRemoteSync( ): Pick { return { syncBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { + const runtimeTarget = resolveGitOperationTarget(get(), worktreeId, options) // Why: like pushBranch — fire-and-forget the post-op upstream refresh so the primary button label rotates immediately. get().beginRemoteOperation('sync') // Why: the inner push stage toasts as Sync and marks the error so the outer catch skips toasting, avoiding a double-toast. let pushStageToastShown = false let pushed = false - const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { - const context = { settings: runtimeSettings, worktreeId, worktreePath, connectionId } + const context = { target: runtimeTarget, worktreeId, worktreePath, connectionId } await fetchRuntimeGit(context, pushTarget) const upstreamStatusBeforePull = await getRuntimeGitUpstreamStatus(context, pushTarget) if (shouldForcePushWithLeaseForUpstream(upstreamStatusBeforePull)) { @@ -75,7 +76,7 @@ export function createGitRemoteSync( get().endRemoteOperation() } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: runtimeSettings + runtimeTarget }) if (pushed) { const refreshGitHubForWorktree = get().refreshGitHubForWorktree @@ -92,11 +93,11 @@ export function createGitRemoteSync( pushTarget, options ) => { + const runtimeTarget = resolveGitOperationTarget(get(), worktreeId, options) get().beginRemoteOperation('rebase') - const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await rebaseRuntimeGitFromBase( - { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, + { target: runtimeTarget, worktreeId, worktreePath, connectionId }, baseRef ) } catch (error) { @@ -106,7 +107,7 @@ export function createGitRemoteSync( get().endRemoteOperation() } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: runtimeSettings + runtimeTarget }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { @@ -114,12 +115,12 @@ export function createGitRemoteSync( } }, fetchBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { + const runtimeTarget = resolveGitOperationTarget(get(), worktreeId, options) // Why: like pushBranch — fire-and-forget the upstream refresh after the busy flag clears so new ahead/behind counts surface. get().beginRemoteOperation('fetch') - const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await fetchRuntimeGit( - { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, + { target: runtimeTarget, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -129,7 +130,7 @@ export function createGitRemoteSync( get().endRemoteOperation() } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: runtimeSettings + runtimeTarget }) } } diff --git a/src/renderer/src/store/slices/editor/types/git-runtime-operation.ts b/src/renderer/src/store/slices/editor/types/git-runtime-operation.ts index 175e8cb2b123..84df4842900c 100644 --- a/src/renderer/src/store/slices/editor/types/git-runtime-operation.ts +++ b/src/renderer/src/store/slices/editor/types/git-runtime-operation.ts @@ -1,6 +1,7 @@ -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' export type GitRuntimeOperationOptions = { - runtimeTargetSettings?: Pick | null + /** The worktree owner's transport; absent resolves it from the worktree's rows. */ + runtimeTarget?: RuntimeClientTarget | null applyUpstreamStatus?: boolean } diff --git a/src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts b/src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts index f1a94840acc7..f0dcbd5e6c54 100644 --- a/src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts +++ b/src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts @@ -60,7 +60,14 @@ const BRANCH = 'refs/heads/feature' function prRecord(worktreeId: string, worktreePath: string): PullRequestGenerationRecord { return { - context: { worktreeId, worktreePath, requestId: 1, repoId: REPO, branch: BRANCH }, + context: { + worktreeId, + worktreePath, + requestId: 1, + repoId: REPO, + branch: BRANCH, + runtimeTarget: { kind: 'local' } + }, seed: { base: 'main', title: 'Title', body: 'Body', draft: false }, seedFieldRevisions: { base: 0, title: 0, body: 0, draft: 0 }, requiresPushBeforeCreate: false, @@ -73,7 +80,7 @@ function prRecord(worktreeId: string, worktreePath: string): PullRequestGenerati function commitRecord(worktreeId: string, worktreePath: string): CommitMessageGenerationRecord { return { - context: { worktreeId, worktreePath, requestId: 1 }, + context: { worktreeId, worktreePath, requestId: 1, runtimeTarget: { kind: 'local' } }, status: 'succeeded', message: 'a generated commit message', error: null, diff --git a/src/renderer/src/store/slices/pull-request-generation.ts b/src/renderer/src/store/slices/pull-request-generation.ts index 377e1b746c6b..5be6c7bf7781 100644 --- a/src/renderer/src/store/slices/pull-request-generation.ts +++ b/src/renderer/src/store/slices/pull-request-generation.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AppState } from '../types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { resolveGeneratedFields } from './pull-request-generation-auto-submit' export type PullRequestFieldName = 'base' | 'title' | 'body' | 'draft' @@ -13,11 +13,6 @@ export type PullRequestGenerationFields = { draft: boolean } -export type PullRequestGenerationRuntimeTargetSettings = Pick< - GlobalSettings, - 'activeRuntimeEnvironmentId' -> - export type PullRequestGenerationContext = { worktreeId: string | null worktreePath: string @@ -25,7 +20,8 @@ export type PullRequestGenerationContext = { requestId: number repoId: string branch: string - runtimeTargetSettings?: PullRequestGenerationRuntimeTargetSettings | null + /** The worktree owner's transport, captured when the request started. */ + runtimeTarget: RuntimeClientTarget } export type PullRequestGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded' diff --git a/src/renderer/src/store/slices/worktrees-activity-persistence.test.ts b/src/renderer/src/store/slices/worktrees-activity-persistence.test.ts index f1fdbfc2a5e0..ac33d0dc16b4 100644 --- a/src/renderer/src/store/slices/worktrees-activity-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-activity-persistence.test.ts @@ -39,7 +39,12 @@ describe('worktree remote runtime mutations', () => { it('does not surface remote selector misses while persisting activity timestamps', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const store = createTestStore() - const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + hostId: 'runtime:env-1' + }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-set', ok: false, diff --git a/src/renderer/src/store/slices/worktrees-lineage-state.test.ts b/src/renderer/src/store/slices/worktrees-lineage-state.test.ts index 288fce9596f6..13daf38a84fe 100644 --- a/src/renderer/src/store/slices/worktrees-lineage-state.test.ts +++ b/src/renderer/src/store/slices/worktrees-lineage-state.test.ts @@ -423,7 +423,9 @@ describe('worktree lineage state', () => { const child = makeWorktree({ id: lineage.worktreeId, repoId: 'repo1', - path: '/remote/child' + path: '/remote/child', + // Why: ingest stamps a server's rows; routing follows the stamp, not focus. + hostId: 'runtime:env-1' }) const updatedChild = { ...child, lineage } runtimeEnvironmentCall.mockResolvedValue({ @@ -511,7 +513,9 @@ describe('worktree lineage state', () => { const child = makeWorktree({ id: lineage.worktreeId, repoId: 'repo1', - path: '/remote/child' + path: '/remote/child', + // Why: ingest stamps a server's rows; routing follows the stamp, not focus. + hostId: 'runtime:env-1' }) const updatedChild = { ...child, lineage } runtimeEnvironmentCall.mockResolvedValueOnce({ @@ -686,6 +690,8 @@ describe('worktree lineage state', () => { path: '/remote/child', lineage } as Partial & { id: string; repoId: string }) + // Why: ingest stamps a server's rows; routing follows the stamp, not focus. + child.hostId = 'runtime:env-1' const updatedChild = { ...child, lineage: null } runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-clear-lineage', diff --git a/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts b/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts index 61279218ef75..d34e309a76c1 100644 --- a/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts +++ b/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts @@ -475,7 +475,8 @@ describe('worktree remote runtime mutations', () => { id: 'repo1::/path/runtime-wt', repoId: 'repo1', path: '/path/runtime-wt', - linkedPR: 5571 + linkedPR: 5571, + hostId: 'runtime:env-1' }) runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => { if (method === 'worktree.resolvePrBase') { diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index ac3d5eca571e..9b1579b7ee5f 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -39,7 +39,12 @@ describe('worktree remote runtime mutations', () => { it('persists worktree metadata through the active remote runtime environment', async () => { const store = createTestStore() - const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + hostId: 'runtime:env-1' + }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-set', ok: true, diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts index 0a60c63d2875..3c2c91877ec6 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts @@ -1,3 +1,4 @@ +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' import type { DirectSshWorktreeFetchOptions, WorktreeFetchOptions, @@ -140,7 +141,7 @@ export function createFetchWorktrees( // Direct SSH lineage requires its own qualified authority result. // Bulk runtime callers apply one final host-wide snapshot after all repo merges. if (!directSshAuthority && !options?.suppressRemoteLineageRefresh) { - await refreshRemoteWorktreeLineageBestEffort(settings, set, get) + await refreshRemoteWorktreeLineageBestEffort(runtimeTargetForOwnerHostId(hostId), set, get) } return directCallerAuthority ? refresh.providerResult! : refresh.result.authoritative } catch (err) { diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts index 0953d681b339..7b745fba253f 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts @@ -1,4 +1,5 @@ import type { AppState } from '../../../types' +import { tryRuntimeTargetForWorktreeOwner } from './worktree-owner-target' import type { Worktree } from '../../../../../../shared/worktree/types' import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' import { getRepoIdFromWorktreeId } from '../../worktree-helpers' @@ -147,12 +148,12 @@ export function persistPassiveWorktreeMetaForOwner( updates: Partial, errorLabel: string ): void { - const ownerSettings = trySettingsForWorktreeOwner(get(), worktreeId) - if (!ownerSettings) { + const ownerTarget = tryRuntimeTargetForWorktreeOwner(get(), worktreeId) + if (!ownerTarget) { warnAmbiguousOwnerOnce(worktreeId, errorLabel) return } - void persistWorktreeMeta(ownerSettings, worktreeId, updates).catch((err) => { + void persistWorktreeMeta(ownerTarget, worktreeId, updates).catch((err) => { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) return diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-target.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-target.ts new file mode 100644 index 000000000000..d7eb8814b6ae --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-target.ts @@ -0,0 +1,30 @@ +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { AppState } from '../../../types' +import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from './worktree-slice-constants' + +/** Transport to the worktree's owner (on `executionHostId` when given), or `null` when unresolved. */ +export function tryRuntimeTargetForWorktreeOwner( + state: AppState, + worktreeId: string, + executionHostId?: ExecutionHostId +): RuntimeClientTarget | null { + return runtimeTargetForWorkspaceOwner(state, { + workspaceId: worktreeId, + ...(executionHostId ? { hostId: executionHostId } : {}) + }) +} + +/** {@link tryRuntimeTargetForWorktreeOwner}, refusing an unresolved owner. */ +export function runtimeTargetForWorktreeOwner( + state: AppState, + worktreeId: string, + executionHostId?: ExecutionHostId +): RuntimeClientTarget { + const target = tryRuntimeTargetForWorktreeOwner(state, worktreeId, executionHostId) + if (!target) { + throw new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) + } + return target +} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-slice-types.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-slice-types.ts index 54d106bd8f8d..77b740054edd 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-slice-types.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-slice-types.ts @@ -12,7 +12,7 @@ import type { DirectSshAuthority } from '../../../../../../shared/ssh-types' import type { HostQualifiedDetectedWorktreeResult } from '../../../../../../shared/detected-worktree-provider-contract' import type { ProjectHostSetup } from '../../../../../../shared/project-types' import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import type { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target' export type WorktreeSliceGet = Parameters>[1] export type WorktreeSliceSet = Parameters>[0] @@ -72,7 +72,7 @@ export type RepoHostSummary = { } export type WorktreeLineageUpdateResult = { - target: ReturnType + target: RuntimeClientTarget lineage: WorktreeLineage | null updatedRemoteWorktree?: WorktreeWithLineage } diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts index a590ff7185ea..b4a93fe74981 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts @@ -4,7 +4,7 @@ import { getHostedReviewPushTargetLookup, hostedReviewPushTargetLookupsInFlight } from './hosted-review-push-target' -import { trySettingsForWorktreeOwner } from '../listing/worktree-owner-settings' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' export function createEnsureHostedReviewPushTarget( _set: WorktreeSliceSet, @@ -22,11 +22,11 @@ export function createEnsureHostedReviewPushTarget( hostedReviewPushTargetLookupsInFlight.add(lookup.key) try { // Why: an ambiguous owner is a skip, not a crash — this runs as fire-and-forget background restoration. - const ownerSettings = trySettingsForWorktreeOwner(get(), worktreeId) - if (!ownerSettings) { + const ownerTarget = runtimeTargetForWorkspaceOwner(get(), { workspaceId: worktreeId }) + if (!ownerTarget) { return } - const resolvedPushTarget = await lookup.resolve(ownerSettings) + const resolvedPushTarget = await lookup.resolve(ownerTarget) if (!resolvedPushTarget) { return } diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts index 4aec39272d1e..59166f2a00b9 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts @@ -1,6 +1,6 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target' import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review' -import type { AppState } from '../../../types' import type { GitHubPrStartPoint, GitPushTarget, @@ -10,12 +10,11 @@ import type { export const hostedReviewPushTargetLookupsInFlight = new Set() export async function resolveGitHubReviewPushTarget( - settings: AppState['settings'], + target: RuntimeClientTarget, repoId: string, prNumber: number ): Promise { try { - const target = getActiveRuntimeTarget(settings) const result = target.kind === 'local' ? await window.api.worktrees.resolvePrBase({ repoId, prNumber }) @@ -40,12 +39,11 @@ export async function resolveGitHubReviewPushTarget( } export async function resolveGitLabReviewPushTarget( - settings: AppState['settings'], + target: RuntimeClientTarget, repoId: string, mrIid: number ): Promise { try { - const target = getActiveRuntimeTarget(settings) const result = target.kind === 'local' ? await window.api.worktrees.resolveMrBase({ repoId, mrIid }) @@ -71,21 +69,21 @@ export async function resolveGitLabReviewPushTarget( export function getHostedReviewPushTargetLookup(worktree: Worktree): { key: string - resolve: (settings: AppState['settings']) => Promise + resolve: (target: RuntimeClientTarget) => Promise } | null { const hostScope = worktree.hostId ?? '' if (isPositiveHostedReviewNumber(worktree.linkedPR)) { const prNumber = worktree.linkedPR return { key: `${worktree.id}:${hostScope}:github:${prNumber}`, - resolve: (settings) => resolveGitHubReviewPushTarget(settings, worktree.repoId, prNumber) + resolve: (target) => resolveGitHubReviewPushTarget(target, worktree.repoId, prNumber) } } if (isPositiveHostedReviewNumber(worktree.linkedGitLabMR)) { const mrIid = worktree.linkedGitLabMR return { key: `${worktree.id}:${hostScope}:gitlab:${mrIid}`, - resolve: (settings) => resolveGitLabReviewPushTarget(settings, worktree.repoId, mrIid) + resolve: (target) => resolveGitLabReviewPushTarget(target, worktree.repoId, mrIid) } } return null diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index de6eed0b05c4..6ab8967102f5 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -33,10 +33,8 @@ import { } from './hosted-review-push-target' import { persistWorktreeMeta } from './worktree-meta-persist' import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors' -import { - settingsForWorktreeOwner, - trySettingsForWorktreeOwner -} from '../listing/worktree-owner-settings' +import { runtimeTargetForWorktreeOwner } from '../listing/worktree-owner-target' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' import { findRepoForHost } from '../../repo-host-identity' export function createUpdateWorktreeMeta( @@ -98,17 +96,20 @@ export function createUpdateWorktreeMeta( ? normalizedUpdates.linkedPR : null // Why: an ambiguous owner must not throw past this update's { ok, error } contract — skip the lookup instead. - const pushTargetOwnerSettings = + const pushTargetOwner = linkedPrForPushTarget !== null && normalizedUpdates.pushTarget === undefined && existingWorktree && !existingWorktree.pushTarget - ? trySettingsForWorktreeOwner(get(), worktreeId, executionHostId) + ? runtimeTargetForWorkspaceOwner(get(), { + workspaceId: worktreeId, + ...(executionHostId ? { hostId: executionHostId } : {}) + }) : null const resolvedPushTarget = - pushTargetOwnerSettings && existingWorktree && linkedPrForPushTarget !== null + pushTargetOwner && existingWorktree && linkedPrForPushTarget !== null ? await resolveGitHubReviewPushTarget( - pushTargetOwnerSettings, + pushTargetOwner, existingWorktree.repoId, linkedPrForPushTarget ) @@ -271,7 +272,7 @@ export function createUpdateWorktreeMeta( try { await persistWorktreeMeta( - settingsForWorktreeOwner(get(), worktreeId, executionHostId), + runtimeTargetForWorktreeOwner(get(), worktreeId, executionHostId), worktreeId, getWorkspaceReviewPersistenceUpdates(mutationUpdates, enriched), executionHostId ?? existingWorktree?.hostId, diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts index 8ae7be3ea8b6..11a8e84d852c 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts @@ -1,4 +1,5 @@ import type { WorktreeMetaBatchUpdate, WorktreeSlice } from '../../worktree-helpers' +import { runtimeTargetForWorktreeOwner } from '../listing/worktree-owner-target' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' import { @@ -7,7 +8,6 @@ import { } from '../listing/detected-worktree-meta' import { persistWorktreeMeta } from './worktree-meta-persist' import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' import { getIndexedWorktreesById } from '../../../worktree-repo-index' @@ -95,14 +95,18 @@ export function createUpdateWorktreesMeta( ? [executionHostId] : getKnownOwnerHostIds(state, worktreeId) await (ownerHostIds.length === 0 - ? persistWorktreeMeta(settingsForWorktreeOwner(state, worktreeId), worktreeId, updates) + ? persistWorktreeMeta( + runtimeTargetForWorktreeOwner(state, worktreeId), + worktreeId, + updates + ) : Promise.all( ownerHostIds.map((hostId) => { const worktree = getIndexedWorktreesById(state.worktreesByRepo, worktreeId).find( (candidate) => candidate.hostId === hostId ) return persistWorktreeMeta( - settingsForWorktreeOwner(state, worktreeId, hostId), + runtimeTargetForWorktreeOwner(state, worktreeId, hostId), worktreeId, updates, hostId, diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts index 26b1b4428a94..8c58b1ac2b6d 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts @@ -1,4 +1,5 @@ import type { WorktreeSlice } from '../../worktree-helpers' +import { runtimeTargetForWorktreeOwner } from '../listing/worktree-owner-target' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import type { Worktree } from '../../../../../../shared/worktree/types' import { getRepoIdFromWorktreeId } from '../../worktree-helpers' @@ -18,7 +19,6 @@ import { } from './hosted-review-link-mutation' import { persistWorktreeMeta } from './worktree-meta-persist' import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' export function createUpdateWorktreeGitIdentity( set: WorktreeSliceSet, @@ -153,7 +153,7 @@ export function createUpdateWorktreeGitIdentity( } } await persistWorktreeMeta( - settingsForWorktreeOwner(get(), currentWorktreeId), + runtimeTargetForWorktreeOwner(get(), currentWorktreeId), currentWorktreeId, CLEARED_HOSTED_REVIEW_LINK_UPDATES ) @@ -175,7 +175,7 @@ export function createUpdateWorktreeGitIdentity( if (getHostedReviewLinkMutationGeneration(currentWorktreeId) !== clearGeneration) { // Why: a delayed branch-switch clear must not win over a newer manual relink. await persistWorktreeMeta( - settingsForWorktreeOwner(get(), currentWorktreeId), + runtimeTargetForWorktreeOwner(get(), currentWorktreeId), currentWorktreeId, getHostedReviewLinkUpdates(latest as Worktree) ) diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts index f560f35f159c..89cc0abc12c6 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts @@ -1,22 +1,25 @@ import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { parseExecutionHostId } from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '../../../../runtime/runtime-client-target' import { applyWorktreeLineageUpdate, - refreshWorktreeLineageForSettings, + refreshWorktreeLineageForTarget, setWorktreeLineageForRuntime } from './worktree-lineage-refresh' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' +import { runtimeTargetForWorktreeOwner } from '../listing/worktree-owner-target' // Why: this runs inside a catch, so letting the refresh reject would replace the failure it recovers from. async function refreshWorktreeLineageBestEffort( - ownerSettings: AppState['settings'], + ownerTarget: RuntimeClientTarget, set: WorktreeSliceSet, get: WorktreeSliceGet ): Promise { try { - await refreshWorktreeLineageForSettings(ownerSettings, set, get) + await refreshWorktreeLineageForTarget(ownerTarget, set, get) } catch (err) { console.error('Failed to refresh worktree lineage after a failed write:', err) } @@ -39,12 +42,14 @@ export function createFetchWorktreeLineage( : parsedHost || options?.forceLocalOwner ? null : ownerSettings?.activeRuntimeEnvironmentId - const settings = ownerSettings - ? { ...ownerSettings, activeRuntimeEnvironmentId } - : ({ activeRuntimeEnvironmentId } as AppState['settings']) - await refreshWorktreeLineageForSettings(settings, set, get, { - reuseRecentCompatibilityFailure: true - }) + await refreshWorktreeLineageForTarget( + runtimeTargetForOwnerEnvironment(activeRuntimeEnvironmentId ?? null), + set, + get, + { + reuseRecentCompatibilityFailure: true + } + ) } catch (err) { console.error('Failed to fetch worktree lineage:', err) } @@ -58,16 +63,16 @@ export function createUpdateWorktreeLineage( return async (worktreeId, args) => { // Why: an unresolvable owner route (ambiguous or missing) rejects rather than skipping — this is a // user-initiated action, and both callers toast the failure. Don't swallow it into a silent no-op. - const ownerSettings = settingsForWorktreeOwner(get(), worktreeId) + const ownerTarget = runtimeTargetForWorktreeOwner(get(), worktreeId) try { applyWorktreeLineageUpdate( set, worktreeId, - await setWorktreeLineageForRuntime(ownerSettings, worktreeId, args) + await setWorktreeLineageForRuntime(ownerTarget, worktreeId, args) ) } catch (err) { console.error('Failed to update worktree lineage:', err) - await refreshWorktreeLineageBestEffort(ownerSettings, set, get) + await refreshWorktreeLineageBestEffort(ownerTarget, set, get) throw err } } @@ -78,17 +83,17 @@ export function createAssignWorktreeParent( get: WorktreeSliceGet ): WorktreeSlice['assignWorktreeParent'] { return async (worktreeId, args) => { - const ownerSettings = settingsForWorktreeOwner(get(), worktreeId) + const ownerTarget = runtimeTargetForWorktreeOwner(get(), worktreeId) try { applyWorktreeLineageUpdate( set, worktreeId, - await setWorktreeLineageForRuntime(ownerSettings, worktreeId, args) + await setWorktreeLineageForRuntime(ownerTarget, worktreeId, args) ) } catch (err) { console.error('Failed to assign worktree parent:', err) // Unlike the update path this rethrows, so the recovery refresh must not mask the original cause. - await refreshWorktreeLineageBestEffort(ownerSettings, set, get) + await refreshWorktreeLineageBestEffort(ownerTarget, set, get) throw err } } diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts index 309f39603a1e..4f1f2fd1666d 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts @@ -5,13 +5,12 @@ import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../../../shared/worktree/types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target' +import { getRuntimeTargetHostId } from '../../../runtime-target-host' import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { - getSettingsFocusedExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { getRepoIdFromWorktreeId } from '../../worktree-helpers' import { replaceWorktreeInRepoLists } from '../listing/worktree-owner-settings' import { repoHostId, withRepoHostOwnership } from '../listing/worktree-host-ownership' @@ -38,13 +37,12 @@ function captureLineageAtRequestStart( } export async function listWorktreeLineageForRuntime( - settings: AppState['settings'], + target: RuntimeClientTarget, options: BackgroundRuntimeRefreshOptions = {} ): Promise<{ worktreeLineageById: Readonly> workspaceLineageByChildKey: Readonly> }> { - const target = getActiveRuntimeTarget(settings) type LineageListResponse = { lineage?: Record workspaceLineage?: Record @@ -105,11 +103,10 @@ export function projectWorktreeLineageToWorkspaceLineage( } export async function setWorktreeLineageForRuntime( - settings: AppState['settings'], + target: RuntimeClientTarget, worktreeId: string, args: { parentWorktreeId?: string; noParent?: boolean } ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { return { target, @@ -249,41 +246,31 @@ export function applyHostLineageRefresh( }) } -export async function refreshWorktreeLineageForSettings( - settings: AppState['settings'], +export async function refreshWorktreeLineageForTarget( + target: RuntimeClientTarget, set: Parameters>[0], getState: () => Pick, options: BackgroundRuntimeRefreshOptions = {} ): Promise { const lineageAtRequestStart = captureLineageAtRequestStart(getState()) - const lineage = await listWorktreeLineageForRuntime(settings, options) - applyHostLineageRefresh( - set, - getSettingsFocusedExecutionHostId(settings), - lineage, - lineageAtRequestStart - ) + const lineage = await listWorktreeLineageForRuntime(target, options) + applyHostLineageRefresh(set, getRuntimeTargetHostId(target), lineage, lineageAtRequestStart) } export async function refreshRemoteWorktreeLineageBestEffort( - settings: AppState['settings'], + target: RuntimeClientTarget | null, set: (partial: Partial | ((state: AppState) => Partial)) => void, getState: () => Pick ): Promise { - if (getActiveRuntimeTarget(settings).kind === 'local') { + if (target?.kind !== 'environment') { return } try { const lineageAtRequestStart = captureLineageAtRequestStart(getState()) - const lineage = await listWorktreeLineageForRuntime(settings, { + const lineage = await listWorktreeLineageForRuntime(target, { reuseRecentCompatibilityFailure: true }) - applyHostLineageRefresh( - set, - getSettingsFocusedExecutionHostId(settings), - lineage, - lineageAtRequestStart - ) + applyHostLineageRefresh(set, getRuntimeTargetHostId(target), lineage, lineageAtRequestStart) } catch (err) { // Why: lineage is supplemental, so a remote timeout here must not discard a successful worktree refresh. console.error('Failed to fetch worktree lineage:', err) diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts index c928e88e6fef..d6f8258b47de 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts @@ -6,7 +6,6 @@ import { WORKTREE_LINKED_ITEMS_DELTA_RUNTIME_CAPABILITY } from '../../../../../../shared/workspace-attachment-capabilities' import { persistWorktreeMeta } from './worktree-meta-persist' -import { createGlobalSettingsFixture } from '../../../../../../shared/global-settings-test-fixture' import { normalizeWorkspaceAttachmentUpdate } from '../../../../../../shared/workspace-attachments' const mocks = vi.hoisted(() => ({ @@ -19,7 +18,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../../../runtime/runtime-rpc-client', () => ({ assertRuntimeEnvironmentCapability: mocks.assertCapability, callRuntimeRpc: mocks.callRuntimeRpc, - getActiveRuntimeTarget: () => mocks.target, runtimeEnvironmentSupportsCapability: mocks.supportsCapability })) @@ -48,7 +46,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { { linkedIssue: null } ])('does not require a collection capability for scalar writes: %j', async (updates) => { mocks.assertCapability.mockRejectedValue(new Error('collection unsupported')) - await persistWorktreeMeta(createGlobalSettingsFixture(), 'repo::/feature', updates) + await persistWorktreeMeta(mocks.target, 'repo::/feature', updates) expect(mocks.assertCapability).not.toHaveBeenCalled() expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( mocks.target, @@ -62,7 +60,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { 'keeps review selection exclusive when collection support is %s', async (supportsCollections) => { mocks.supportsCapability.mockResolvedValue(supportsCollections) - await persistWorktreeMeta(createGlobalSettingsFixture(), 'repo::/feature', { + await persistWorktreeMeta(mocks.target, 'repo::/feature', { linkedGitLabMR: 7 }) const wire = mocks.callRuntimeRpc.mock.lastCall?.[2] @@ -87,7 +85,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { mocks.assertCapability.mockRejectedValue(new Error('update required')) await expect( - persistWorktreeMeta({} as never, 'repo::/feature', { suppressedGitHubPR: 42 }) + persistWorktreeMeta(mocks.target, 'repo::/feature', { suppressedGitHubPR: 42 }) ).rejects.toThrow('update required') expect(mocks.assertCapability).toHaveBeenCalledWith( @@ -101,7 +99,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { it('rejects attachment writes before an older host can silently drop them', async () => { mocks.assertCapability.mockRejectedValue(new Error('update required')) await expect( - persistWorktreeMeta(createGlobalSettingsFixture(), 'repo::/feature', { linkedItems: [] }) + persistWorktreeMeta(mocks.target, 'repo::/feature', { linkedItems: [] }) ).rejects.toThrow('update required') expect(mocks.assertCapability).toHaveBeenCalledWith( 'env-1', @@ -116,7 +114,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { { provider: 'github', type: 'pr', number: 42 }, { provider: 'gitlab', type: 'mr', number: 7 } ] as const - await persistWorktreeMeta(createGlobalSettingsFixture(), 'repo::/feature', { + await persistWorktreeMeta(mocks.target, 'repo::/feature', { linkedItems: [...linkedItems] }) expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( @@ -128,7 +126,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { }) it('sends positive suppression writes to capable hosts', async () => { - await persistWorktreeMeta({} as never, 'repo::/feature', { suppressedGitHubPR: 42 }) + await persistWorktreeMeta(mocks.target, 'repo::/feature', { suppressedGitHubPR: 42 }) expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( mocks.target, @@ -141,7 +139,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { it('strips null clears for older hosts while preserving compatible updates', async () => { mocks.supportsCapability.mockResolvedValue(false) - await persistWorktreeMeta({} as never, 'repo::/feature', { + await persistWorktreeMeta(mocks.target, 'repo::/feature', { linkedPR: 42, suppressedGitHubPR: null }) @@ -162,7 +160,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { }) it('keeps null clears for capable hosts', async () => { - await persistWorktreeMeta({} as never, 'repo::/feature', { + await persistWorktreeMeta(mocks.target, 'repo::/feature', { linkedPR: 42, suppressedGitHubPR: null }) @@ -181,7 +179,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { vi.stubGlobal('window', { api: { worktrees: { updateMeta } } }) await persistWorktreeMeta( - {} as never, + mocks.target, 'repo::/feature', { suppressedGitHubPR: 42 }, 'ssh:build-box' @@ -195,7 +193,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { expect(mocks.assertCapability).not.toHaveBeenCalled() }) it('gates and forwards the atomic collection snapshot', async () => { - await persistWorktreeMeta(createGlobalSettingsFixture(), 'repo::/feature', { + await persistWorktreeMeta(mocks.target, 'repo::/feature', { linkedItems: [], linkedItemsBase: [], linkedItemsSelectionChanged: false @@ -219,7 +217,7 @@ describe('persistWorktreeMeta GitHub PR suppression compatibility', () => { mocks.callRuntimeRpc.mockClear() mocks.assertCapability.mockRejectedValue(new Error('atomic support required')) await expect( - persistWorktreeMeta(createGlobalSettingsFixture(), 'repo::/feature', { + persistWorktreeMeta(mocks.target, 'repo::/feature', { linkedItems: [], linkedItemsBase: [], linkedItemsSelectionChanged: false diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts index 6c9e9cef5275..dbc151d49184 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts @@ -5,7 +5,6 @@ import { WORKTREE_LINKED_ITEMS_RUNTIME_CAPABILITY } from '../../../../../../shar import { assertRuntimeEnvironmentCapability, callRuntimeRpc, - getActiveRuntimeTarget, runtimeEnvironmentSupportsCapability } from '../../../../runtime/runtime-rpc-client' import { @@ -15,7 +14,7 @@ import { } from '../../../../../../shared/protocol-version' import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' import { translate } from '@/i18n/i18n' -import type { AppState } from '../../../types' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target' import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { encodePushTargetClearForRuntimeRpc } from './hosted-review-link-mutation' @@ -53,14 +52,14 @@ export function isDisplayNamePersistencePending( } export function persistWorktreeMeta( - settings: AppState['settings'], + target: RuntimeClientTarget, worktreeId: string, updates: Partial & WorkspaceAttachmentMutation, executionHostId?: ExecutionHostId, identityKey?: string ): Promise { const operation = persistWorktreeMetaUntracked( - settings, + target, worktreeId, updates, executionHostId, @@ -82,13 +81,12 @@ export function persistWorktreeMeta( } async function persistWorktreeMetaUntracked( - settings: AppState['settings'], + target: RuntimeClientTarget, worktreeId: string, updates: Partial & WorkspaceAttachmentMutation, executionHostId?: ExecutionHostId, identityKey?: string ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { await window.api.worktrees.updateMeta({ worktreeId, diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts index fc8515ab5c3e..2675e634e4d4 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts @@ -1,4 +1,5 @@ import type { WorktreeSlice } from '../../worktree-helpers' +import { tryRuntimeTargetForWorktreeOwner } from '../listing/worktree-owner-target' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' @@ -9,7 +10,6 @@ import { import { getFolderWorkspaceActivityPersistence } from './folder-workspace-activity' import { persistPassiveWorktreeMetaForOwner, - trySettingsForWorktreeOwner, warnAmbiguousOwnerOnce } from '../listing/worktree-owner-settings' import { persistWorktreeMeta } from '../metadata/worktree-meta-persist' @@ -224,12 +224,12 @@ export function createBumpWorktreeActivity( return } - const ownerSettings = trySettingsForWorktreeOwner(get(), worktreeId) - if (!ownerSettings) { + const ownerTarget = tryRuntimeTargetForWorktreeOwner(get(), worktreeId) + if (!ownerTarget) { warnAmbiguousOwnerOnce(worktreeId, 'persist worktree activity timestamp') return } - void persistWorktreeMeta(ownerSettings, worktreeId, { + void persistWorktreeMeta(ownerTarget, worktreeId, { lastActivityAt: now }).catch((err) => { if (isRuntimeSelectorNotFoundError(err)) { From 26ee9bbfabe1334241e32defa6d27a89aec66f41 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:26:04 -0700 Subject: [PATCH 011/110] [CI] Reduce repeated test setup and unnecessary packaging (#27308) * [CI] Reduce repeated test setup and remove source text checks * [CI] Consolidate transcript tests and preserve fixture-only CI routing * [CI] Retarget reliability gate to consolidated transcript suite --- config/reliability-gates.jsonc | 10 +- config/scripts/pr-code-change-scope.mjs | 7 +- .../scripts/pr-unit-support-routing.test.mjs | 56 +- ...rees-create-execution-host-routing.test.ts | 229 ---- src/main/ipc/worktrees-forget-local.test.ts | 424 +++++++- .../worktrees-issue-command-overrides.test.ts | 144 ++- ...trees-preserved-branch-fork-remote.test.ts | 327 ------ ...rktrees-remove-host-disambiguation.test.ts | 513 --------- ...trees-setup-launch-sparse-checkout.test.ts | 320 +++++- ...rees-ssh-branch-conflict-suffixing.test.ts | 334 ------ ...ktrees-ssh-fork-push-target-remote.test.ts | 244 ++++- .../ipc/worktrees-ssh-pr-head-fetch.test.ts | 248 ++++- .../ipc/worktrees-ssh-setup-launch.test.ts | 406 -------- .../antigravity-readiness-transcripts.test.ts | 542 ++++++---- ...avity-screen-readiness-transcripts.test.ts | 151 --- .../claude-task-wakeup-indexed-read.test.ts | 83 -- ...cline-screen-readiness-transcripts.test.ts | 492 +++++++-- ...codex-header-readiness-transcripts.test.ts | 805 +++++++++----- .../runtime/codex-quiet-ready-screen.test.ts | 980 +++++++++++------- .../codex-startup-dialog-transcripts.test.ts | 205 ---- .../freebuff-status-transcript.test.ts | 146 --- .../hermes-readiness-transcript.test.ts | 128 --- ...hed-agent-composer-readiness-codex.test.ts | 129 --- .../launched-agent-worker-start-grok.test.ts | 63 -- .../runtime/muse-readiness-transcript.test.ts | 44 - .../runtime/omp-readiness-transcripts.test.ts | 692 +++++++++---- ...pencode-worker-composer-transcript.test.ts | 169 --- src/main/runtime/qoder-transcripts.test.ts | 80 -- .../messaging/check-delivery-history.test.ts | 46 - .../check-superseded-terminal.test.ts | 135 --- .../check-worker-consumer-fencing.test.ts | 384 ++++++- .../dispatch-recipient-identity.test.ts | 137 --- .../run-bound-mailbox-safety.test.ts | 360 +++++++ .../run-bound-recipient-mail.test.ts | 189 ---- .../messaging/send-dispatch-authority.test.ts | 249 +++++ .../messaging/send-receipt-plumbing.test.ts | 107 -- .../messaging/send-unbound-terminals.test.ts | 31 - .../messaging/settled-dispatch-mail.test.ts | 124 --- .../messaging/worker-report-authority.test.ts | 253 ----- .../antigravity-worker-lifecycle.test.ts | 102 -- .../context-only-dispatch-retry.test.ts | 58 -- .../worker/legacy-dispatch-projection.test.ts | 122 --- .../self-dispatch-nesting-depth.test.ts | 71 -- .../worker-brief-agent-in-front.test.ts | 50 - .../worker/worker-list-run-scope-rpc.test.ts | 280 +++++ .../worker/worker-read-projection.test.ts | 38 - .../worker-release-ownership-guard.test.ts | 102 +- .../worker-start-terminal-target.test.ts | 100 ++ .../worker/zcode-worker-readiness.test.ts | 54 - .../screen-ruled-readiness-scope.test.ts | 32 - .../runtime/stale-title-display-clear.test.ts | 170 --- ...rminal-interactive-wait-visibility.test.ts | 733 ++++++------- .../tui-idle-hook-lane-runtime.test.ts | 576 ++++++---- ...-idle-launch-readiness-shell-title.test.ts | 95 -- .../zcode-readiness-transcript.test.ts | 100 -- ...ession.approval-and-start-failure.test.tsx | 277 +++++ ...turedSession.command-refusal-line.test.tsx | 96 -- ...ucturedSession.final-read-failure.test.tsx | 117 --- ...eChatStructuredSession.first-read.test.tsx | 148 --- ...ucturedSession.history-read-state.test.tsx | 238 +++++ ...tStructuredSession.newer-approval.test.tsx | 142 --- ...eChatStructuredSession.read-error.test.tsx | 169 --- ...StructuredSession.refusal-notices.test.tsx | 241 +++++ ...turedSession.start-failure-notice.test.tsx | 162 --- ...tiveChatStructuredSession.test-harness.tsx | 5 + .../lib/github-source-runtime-context.test.ts | 23 - .../src/lib/local-file-access.test.ts | 111 -- 67 files changed, 6831 insertions(+), 7567 deletions(-) delete mode 100644 src/main/ipc/worktrees-create-execution-host-routing.test.ts delete mode 100644 src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts delete mode 100644 src/main/ipc/worktrees-remove-host-disambiguation.test.ts delete mode 100644 src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts delete mode 100644 src/main/ipc/worktrees-ssh-setup-launch.test.ts delete mode 100644 src/main/runtime/antigravity-screen-readiness-transcripts.test.ts delete mode 100644 src/main/runtime/claude-task-wakeup-indexed-read.test.ts delete mode 100644 src/main/runtime/codex-startup-dialog-transcripts.test.ts delete mode 100644 src/main/runtime/freebuff-status-transcript.test.ts delete mode 100644 src/main/runtime/hermes-readiness-transcript.test.ts delete mode 100644 src/main/runtime/launched-agent-composer-readiness-codex.test.ts delete mode 100644 src/main/runtime/launched-agent-worker-start-grok.test.ts delete mode 100644 src/main/runtime/muse-readiness-transcript.test.ts delete mode 100644 src/main/runtime/opencode-worker-composer-transcript.test.ts delete mode 100644 src/main/runtime/qoder-transcripts.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts delete mode 100644 src/main/runtime/screen-ruled-readiness-scope.test.ts delete mode 100644 src/main/runtime/stale-title-display-clear.test.ts delete mode 100644 src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts delete mode 100644 src/main/runtime/zcode-readiness-transcript.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.first-read.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index db244cabe768..936a10b4f23a 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -337,12 +337,12 @@ "invariant": "An ended tracked task stays pending until its wake-up and finishing turn end; missing wake-ups expire only after 60 idle seconds. Cancellation remains owned by the executing host, duplicate delivery cannot create a fresh completion veto, and fresh native rest can recover a lost finishing Stop.", "oracle": "Replay captured hook ordering through production HTTP entry points and native ready bytes through the actual automation observer. Check local/relay state, cancellation/new-turn fencing, fresh versus retained rest, bounded task/timer ownership, and zero global status reads during indexed runtime waits.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/tui-idle-hook-lane-runtime.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" ], "testFiles": [ "src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts", "src/main/runtime/tui-idle-claude-task-wakeup.test.ts", - "src/main/runtime/claude-task-wakeup-indexed-read.test.ts", + "src/main/runtime/tui-idle-hook-lane-runtime.test.ts", "src/shared/claude-owed-notification-resource-contract.test.ts", "src/shared/agent-hook-listener-claude-task-notification.test.ts", "src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts", @@ -358,7 +358,7 @@ ] }, { - "file": "src/main/runtime/claude-task-wakeup-indexed-read.test.ts", + "file": "src/main/runtime/tui-idle-hook-lane-runtime.test.ts", "assertions": [ "reads only its pane with 512 unrelated agents, including an empty index: pending=%s" ] @@ -376,10 +376,10 @@ "date": "2026-10-06", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/tui-idle-hook-lane-runtime.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts", "result": "passed", "durationSeconds": 8.62, - "summary": "Exact stored command with repository Vitest configuration passes 148 tests across eight files including out-of-order launch/end/delivery controls. Includes real HTTP/relay ingress, 12 blank/padded Agent/Bash/Monitor IDs, captured-native-rest automation completion, duplicate/cancellation controls, owner/phase gates, timer/task bounds and indexed reads. Three upstream interrupt suites pass 28 more tests separately." + "summary": "Exact stored command with repository Vitest configuration passes 148 tests across eight files including out-of-order launch/end/delivery controls. Includes real HTTP/relay ingress, 12 blank/padded Agent/Bash/Monitor IDs, captured-native-rest automation completion, duplicate/cancellation controls, owner/phase gates, timer/task bounds and indexed reads. Three upstream interrupt suites pass 28 more tests separately. This dated receipt is unchanged; command now names the relocated test selector, not a new run. Original 2026-10-06 command: ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts src/main/runtime/tui-idle-claude-task-wakeup.test.ts src/main/runtime/claude-task-wakeup-indexed-read.test.ts src/shared/claude-owed-notification-resource-contract.test.ts src/shared/agent-hook-listener-claude-task-notification.test.ts src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" } ], "runtimeBudget": { diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index abb759c69f1b..ddc84b4efb62 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -574,9 +574,12 @@ function isProductBundlePath(file, extraPrefixes) { return matchesPrefix(file, extraPrefixes) } -// This shared fixture is consumed only by unit suites and their placement rig. +// These shared fixtures are consumed only by unit tests. export function isUnitTestSupportSource(file) { - return file === 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' + return ( + file === 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' || + file === 'src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx' + ) } function isTestFile(file) { diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs index 318b74bc7800..21fbdfd452c9 100644 --- a/config/scripts/pr-unit-support-routing.test.mjs +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -3,30 +3,44 @@ import { classifyPrJobs } from './pr-code-change-scope.mjs' import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const harness = 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts' +const structuredSessionHarness = + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx' -describe('shared web-session unit support routing', () => { - it('keeps unit checks and lint while excluding packaging for the exact harness', () => { - const result = classifyPrJobs([harness]) - expect(result.should_run).toBe(true) - expect(result.static_analysis).toBe(true) - expect(result.typecheck).toBe(true) - expect(result.test).toBe(true) - expect(result.package).toBe(false) - expect(result.package_windows).toBe(false) - }) +describe('unit support routing', () => { + it.each([harness, structuredSessionHarness])( + 'keeps unit checks and lint while excluding packaging for %s', + (unitHarness) => { + const result = classifyPrJobs([unitHarness]) + expect(result.should_run).toBe(true) + expect(result.static_analysis).toBe(true) + expect(result.typecheck).toBe(true) + expect(result.test).toBe(true) + expect(result.package).toBe(false) + expect(result.package_windows).toBe(false) + } + ) - it('retains packaging for unknown helpers and mixed product changes', () => { - for (const file of [ - 'src/renderer/src/runtime/web-session-tabs-sync-next-test-harness.ts', - 'src/renderer/src/runtime/web-session-tabs-sync.ts' - ]) { - const result = classifyPrJobs([harness, file]) - expect(result.should_run, file).toBe(true) - expect(result.test, file).toBe(true) - expect(result.package, file).toBe(true) - expect(result.package_windows, file).toBe(true) + it.each([harness, structuredSessionHarness])( + 'retains packaging for unknown helpers and mixed product changes with %s', + (unitHarness) => { + for (const file of [ + 'src/renderer/src/runtime/web-session-tabs-sync-next-test-harness.ts', + 'src/renderer/src/runtime/web-session-tabs-sync.ts', + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.next-test-harness.tsx', + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx', + 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', + 'src/main/browser/browser-route-tcp-egress.electron.test.ts' + ]) { + const result = classifyPrJobs([unitHarness, file]) + expect(result.should_run, file).toBe(true) + expect(result.test, file).toBe(true) + expect(result.package, file).toBe( + file !== 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts' + ) + expect(result.package_windows, file).toBe(true) + } } - }) + ) it('does not turn the exact unit harness into a two-app E2E', () => { expect(selectPrE2eSpecs([harness])).toEqual([]) diff --git a/src/main/ipc/worktrees-create-execution-host-routing.test.ts b/src/main/ipc/worktrees-create-execution-host-routing.test.ts deleted file mode 100644 index 36f1e816ea65..000000000000 --- a/src/main/ipc/worktrees-create-execution-host-routing.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - addWorktreeMock, - getActiveMultiplexerMock, - getSshGitProviderMock, - listWorktreesMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -const REMOTE_REPO_PATH = '/remote/repo' - -function makeRepo(fields: Record) { - return { - id: 'repo-1', - path: REMOTE_REPO_PATH, - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: 'origin/main', - ...fields - } -} - -function makeProvider(worktreePath: string) { - return { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - // A hit here reads as "branch already exists"; the create loop would then rename. - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: worktreePath, - head: 'abc123', - branch: 'refs/heads/wt', - isBare: false, - isMainWorktree: false - } - ]) - } -} - -function useRepo(repo: ReturnType): void { - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) -} - -describe('worktrees:create execution host routing', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('creates on the SSH host for a row that names it only as executionHostId', async () => { - // No `connectionId`: the raw read answered "local" and ran `git worktree add` on the client - // against `/remote/repo`. The runtime sibling already resolved this row remotely. - useRepo(makeRepo({ executionHostId: 'ssh:target-a' })) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(provider.addWorktree).toHaveBeenCalledTimes(1) - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('keeps two simultaneously registered SSH hosts apart', async () => { - useRepo(makeRepo({ executionHostId: 'ssh:target-b' })) - const providerA = makeProvider('/remote/repo-wt-a') - const providerB = makeProvider('/remote/repo-wt-b') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? providerA : connectionId === 'target-b' ? providerB : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(providerB.addWorktree).toHaveBeenCalledTimes(1) - expect(providerA.addWorktree).not.toHaveBeenCalled() - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a runtime row with no nested SSH target instead of creating locally', async () => { - useRepo(makeRepo({ executionHostId: 'runtime:env-1' })) - - await expect( - handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - ).rejects.toThrow('not dispatched by this process') - - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { - // `target-a` names a target inside env-1. A same-named one registered here is another machine, - // so creating through it lands the checkout on the wrong host. - useRepo(makeRepo({ executionHostId: 'runtime:env-1', connectionId: 'target-a' })) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await expect( - handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - ).rejects.toThrow('not dispatched by this process') - - expect(provider.addWorktree).not.toHaveBeenCalled() - expect(addWorktreeMock).not.toHaveBeenCalled() - }) - - it('answers local for a row that declares itself local while carrying a connection', async () => { - // A contradictory row: `getRepoSshConnectionId` lets `local` win, and the runtime sibling has - // always read it that way. The raw field sent it remote, so the two entry points disagreed. - useRepo( - makeRepo({ path: '/workspace/repo', executionHostId: 'local', connectionId: 'target-a' }) - ) - listWorktreesMock.mockResolvedValue([ - { - path: '/workspace/wt', - head: 'abc123', - branch: 'wt', - isBare: false, - isMainWorktree: false - } - ]) - const provider = makeProvider('/remote/repo-wt') - getSshGitProviderMock.mockImplementation((connectionId: string) => - connectionId === 'target-a' ? provider : undefined - ) - - await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) - - expect(addWorktreeMock).toHaveBeenCalledTimes(1) - expect(provider.addWorktree).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index 95fdcc27b308..77e2557f0c6f 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -14,7 +14,9 @@ import { killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock, - getSshPtyProviderMock + getSshPtyProviderMock, + parseOrcaYamlMock, + hasHooksFileMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' @@ -355,3 +357,423 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree execution-host disambiguation', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + } + const sshRepo = { + ...localRepo, + path: '/remote/repo', + displayName: 'ssh', + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: sshRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(localRepo) + store.getRepos.mockReturnValue([localRepo, sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-shared::/remote/feature-wt', + hostId: 'ssh:conn-1' + }) + + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a row whose execution host and connection id name different machines', async () => { + // #11163: everything below the handler picks the filesystem from `repo.connectionId` while the + // prune, the archive-hook route and the home guard come from the resolved execution host. This + // row would have listed a remote checkout on this client and deleted a same-named local path. + const brokenRepo = { + id: 'repo-host-only', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: null, + executionHostId: 'ssh:conn-1' as const + } + const provider = { + listWorktrees: vi.fn(), + removeWorktree: vi.fn(), + worktreeIsClean: vi.fn() + } + store.getRepo.mockReturnValue(brokenRepo) + store.getRepos.mockReturnValue([brokenRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-host-only::/remote/feature-wt', + force: true + }) + ).rejects.toThrow( + 'Refusing to delete worktree: repo repo-host-only names execution host ssh:conn-1, but its checkout is only reachable as local.' + ) + + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(provider.listWorktrees).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses the mirror row that names local while carrying a connection id', async () => { + const brokenRepo = { + id: 'repo-local-spelled', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + executionHostId: 'local' as const + } + store.getRepo.mockReturnValue(brokenRepo) + store.getRepos.mockReturnValue([brokenRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-local-spelled::/remote/feature-wt', + force: true + }) + ).rejects.toThrow( + 'Refusing to delete worktree: repo repo-local-spelled names execution host local, but its checkout is only reachable as ssh:conn-1.' + ) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a row owned by a runtime environment this process does not execute', async () => { + // `runtime:` deletes on that environment's own server. Its repo row looks local here — + // `connectionId` is null — so a host id that fell back to "local" would delete a same-named + // path on this client. + const runtimeRepo = { + id: 'repo-runtime-owned', + path: '/env/repo', + displayName: 'env', + badgeColor: '#000', + addedAt: 0, + connectionId: null, + executionHostId: 'runtime:env-1' as const + } + store.getRepo.mockReturnValue(runtimeRepo) + store.getRepos.mockReturnValue([runtimeRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-runtime-owned::/env/feature-wt', + force: true + }) + ).rejects.toThrow('Refusing to delete worktree: repo repo-runtime-owned names execution host') + + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('accepts a row whose two spellings differ only by percent-escaping', async () => { + // `getRepoExecutionHostId` returns the stored text while the connection id is re-encoded, so a + // byte compare would refuse this consistent row over a `%20`. + const repo = { + id: 'repo-escaped', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn one', + executionHostId: 'ssh:conn one' as const + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: repo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(repo) + store.getRepos.mockReturnValue([repo]) + getSshGitProviderMock.mockReturnValue(provider) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-escaped::/remote/feature-wt' + }) + + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + }) + + it('tears down the remote session when an ownerless remote worktree is deleted', async () => { + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: sshRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(sshRepo) + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + // Why: no WorktreeMeta means removal cannot read the owner back; the repo is the only host source. + store.getWorktreeMeta.mockReturnValue(undefined) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/remote/feature-wt' + }) + + // The whole point: without the repo's host the ownerless row would clear the local session instead. + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/remote/feature-wt', + 'ssh:conn-1' + ) + }) + + it('fails closed when duplicate repo ids are deleted without a host', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-shared::/remote/feature-wt' + }) + ).rejects.toThrow('Repo not found: repo-shared') + + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('inspects hooks on the requested host when repo ids collide', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n archive: remote-cleanup', + isBinary: false + }) + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) + + await expect( + handlers['hooks:check'](null, { + repoId: 'repo-shared', + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({ + status: 'ok', + hasHooks: true, + hooks: { scripts: { archive: 'remote-cleanup' } }, + mayNeedUpdate: false + }) + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(hasHooksFileMock).not.toHaveBeenCalled() + }) + + it('fails hook inspection closed when duplicate repo ids omit the host', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + + await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({ + status: 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + }) + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + expect(hasHooksFileMock).not.toHaveBeenCalled() + }) + + it('inspects setup-script imports on the requested host when repo ids collide', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + const fsProvider = { + readFile: vi.fn(async (filePath: string) => { + if (filePath === '/remote/repo/.superset/config.json') { + return { content: '{"setup":"remote setup"}', isBinary: false } + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }), + stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + store.getRepo.mockReturnValue(localRepo) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + + await expect( + handlers['hooks:inspectSetupScriptImports'](null, { + repoId: 'repo-shared', + hostId: 'ssh:conn-1' + }) + ).resolves.toContainEqual( + expect.objectContaining({ + provider: 'superset', + setup: 'remote setup' + }) + ) + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json') + }) + + it('does not coalesce forget requests for the same id on different hosts', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + let finishFirst!: () => void + killAllProcessesForWorktreeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + }) + ) + .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + const first = handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-shared::/same/path', + hostId: 'local' + }) as Promise + await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-shared::/same/path', + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) + + finishFirst() + await expect(first).resolves.toEqual({}) + }) + + // The repos are gone (host removed), so only the single global meta key is left to + // key on — and it names one host. It must not merge two hosts' forgets into one. + it('does not let stale metadata coalesce an explicit host forget onto another host', async () => { + const worktreeId = 'repo-gone::/same/path' + store.getRepos.mockReturnValue([]) + store.getRepo.mockReturnValue(undefined) + store.getWorktreeMeta.mockReturnValue({ hostId: 'local' }) + let finishFirst!: () => void + killAllProcessesForWorktreeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + }) + ) + .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + const first = handlers['worktrees:forgetLocal'](null, { + worktreeId, + hostId: 'local' + }) as Promise + await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId, + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) + + finishFirst() + await expect(first).resolves.toEqual({}) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') + }) +}) diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index 05287f3d713c..07818ed7d308 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -7,7 +7,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createIssueCommandRunnerScriptMock, getSshFilesystemProviderMock, - getSshGitProviderMock + getSshGitProviderMock, + addWorktreeMock, + getActiveMultiplexerMock, + listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -93,6 +96,56 @@ vi.mock('../runtime/worktree-teardown', async () => ) vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) +const REMOTE_REPO_PATH = '/remote/repo' + +function makeRepo(fields: Record) { + return { + id: 'repo-1', + path: REMOTE_REPO_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main', + ...fields + } +} + +function makeProvider(worktreePath: string) { + return { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + // A hit here reads as "branch already exists"; the create loop would then rename. + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: worktreePath, + head: 'abc123', + branch: 'refs/heads/wt', + isBare: false, + isMainWorktree: false + } + ]) + } +} + +function useRepo(repo: ReturnType): void { + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) +} + describe('registerWorktreeHandlers', () => { beforeEach(() => { setupWorktreeHandlers() @@ -369,3 +422,92 @@ describe('registerWorktreeHandlers', () => { ) }) }) + +describe('worktrees:create execution host routing', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('creates on the SSH host for a row that names it only as executionHostId', async () => { + // No `connectionId`: the raw read answered "local" and ran `git worktree add` on the client + // against `/remote/repo`. The runtime sibling already resolved this row remotely. + useRepo(makeRepo({ executionHostId: 'ssh:target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(provider.addWorktree).toHaveBeenCalledTimes(1) + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('keeps two simultaneously registered SSH hosts apart', async () => { + useRepo(makeRepo({ executionHostId: 'ssh:target-b' })) + const providerA = makeProvider('/remote/repo-wt-a') + const providerB = makeProvider('/remote/repo-wt-b') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? providerA : connectionId === 'target-b' ? providerB : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(providerB.addWorktree).toHaveBeenCalledTimes(1) + expect(providerA.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row with no nested SSH target instead of creating locally', async () => { + useRepo(makeRepo({ executionHostId: 'runtime:env-1' })) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { + // `target-a` names a target inside env-1. A same-named one registered here is another machine, + // so creating through it lands the checkout on the wrong host. + useRepo(makeRepo({ executionHostId: 'runtime:env-1', connectionId: 'target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(provider.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('answers local for a row that declares itself local while carrying a connection', async () => { + // A contradictory row: `getRepoSshConnectionId` lets `local` win, and the runtime sibling has + // always read it that way. The raw field sent it remote, so the two entry points disagreed. + useRepo( + makeRepo({ path: '/workspace/repo', executionHostId: 'local', connectionId: 'target-a' }) + ) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/wt', + head: 'abc123', + branch: 'wt', + isBare: false, + isMainWorktree: false + } + ]) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(addWorktreeMock).toHaveBeenCalledTimes(1) + expect(provider.addWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts deleted file mode 100644 index bd3501abbe7f..000000000000 --- a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - removeWorktreeMock, - forceDeleteLocalBranchMock, - gitExecFileAsyncMock, - getSshGitProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' -import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('preserves the branch on remove for worktrees created from an existing local branch', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true })) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(removeWorktreeMock).toHaveBeenCalledWith( - '/workspace/repo', - '/workspace/feature-wt', - false, - expect.objectContaining({ - deleteBranch: false, - knownRemovedWorktree: expect.objectContaining({ - branch: 'feature', - head: 'feature', - path: '/workspace/feature-wt' - }) - }) - ) - }) - - it('force-deletes a branch that was preserved by safe worktree removal', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId: 'repo-1::/workspace/feature-wt', - branchName: 'feature/test', - expectedHead: 'def456' - }) - - expect(result).toMatchObject({ deleted: true }) - expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( - '/workspace/repo', - 'feature/test', - 'def456' - ) - }) - - it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const worktreeId = 'repo-ssh::/remote/feature-wt' - const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined } - const provider = { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'def456', - branch: 'feature/test', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'def456' } - }), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepos.mockReturnValue([localRepo, repo]) - store.getRepo.mockReturnValue(localRepo) - store.getWorktreeMeta.mockReturnValue( - makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true }) - ) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() }) - - await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' }) - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId, - branchName: 'feature/test', - expectedHead: 'def456', - hostId: 'ssh:conn-1' - }) - - expect(result).toMatchObject({ deleted: true }) - expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( - '/remote/repo', - 'feature/test', - 'def456' - ) - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - }) - - it('rejects stale preserved-branch cleanup actions with an old head', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue({ - preservedBranch: { branchName: 'feature/test', head: 'new456' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - await expect( - handlers['worktrees:forceDeletePreservedBranch'](null, { - worktreeId: 'repo-1::/workspace/feature-wt', - branchName: 'feature/test', - expectedHead: 'old123' - }) - ).rejects.toThrow('No preserved branch cleanup is pending') - expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() - }) - - it('removes an unused Orca-created fork remote after deleting its worktree', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }) - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'config') { - throw new Error('no branch config') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { - cwd: '/workspace/repo' - }) - }) - - it('keeps an Orca-created fork remote while another worktree still uses it', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), - 'repo-1::/workspace/other-wt': makeWorktreeMeta({ - pushTarget: { - ...pushTarget, - branchName: 'other-branch' - } - }) - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( - ['remote', 'remove', 'pr-contributor-orca'], - expect.any(Object) - ) - }) - - it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => { - mockKnownFeatureWorktree() - removeWorktreeMock.mockResolvedValue(undefined) - const pushTarget = { - remoteName: 'pr-contributor-orca', - branchName: 'feature/from-fork', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), - 'repo-2::/workspace/other-wt': makeWorktreeMeta({ - pushTarget: { - ...pushTarget, - branchName: 'other-branch' - } - }) - }) - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'config') { - throw new Error('no branch config') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/workspace/feature-wt' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { - cwd: '/workspace/repo' - }) - }) -}) diff --git a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts deleted file mode 100644 index ed10ef8f6ade..000000000000 --- a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - removeWorktreeMock, - listWorktreesMock, - parseOrcaYamlMock, - hasHooksFileMock, - getSshGitProviderMock, - getSshFilesystemProviderMock, - killAllProcessesForWorktreeMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: null - } - const sshRepo = { - ...localRepo, - path: '/remote/repo', - displayName: 'ssh', - connectionId: 'conn-1' - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: sshRepo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(localRepo) - store.getRepos.mockReturnValue([localRepo, sshRepo]) - getSshGitProviderMock.mockReturnValue(provider) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-shared::/remote/feature-wt', - hostId: 'ssh:conn-1' - }) - - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a row whose execution host and connection id name different machines', async () => { - // #11163: everything below the handler picks the filesystem from `repo.connectionId` while the - // prune, the archive-hook route and the home guard come from the resolved execution host. This - // row would have listed a remote checkout on this client and deleted a same-named local path. - const brokenRepo = { - id: 'repo-host-only', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: null, - executionHostId: 'ssh:conn-1' as const - } - const provider = { - listWorktrees: vi.fn(), - removeWorktree: vi.fn(), - worktreeIsClean: vi.fn() - } - store.getRepo.mockReturnValue(brokenRepo) - store.getRepos.mockReturnValue([brokenRepo]) - getSshGitProviderMock.mockReturnValue(provider) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-host-only::/remote/feature-wt', - force: true - }) - ).rejects.toThrow( - 'Refusing to delete worktree: repo repo-host-only names execution host ssh:conn-1, but its checkout is only reachable as local.' - ) - - expect(listWorktreesMock).not.toHaveBeenCalled() - expect(provider.listWorktrees).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses the mirror row that names local while carrying a connection id', async () => { - const brokenRepo = { - id: 'repo-local-spelled', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - executionHostId: 'local' as const - } - store.getRepo.mockReturnValue(brokenRepo) - store.getRepos.mockReturnValue([brokenRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-local-spelled::/remote/feature-wt', - force: true - }) - ).rejects.toThrow( - 'Refusing to delete worktree: repo repo-local-spelled names execution host local, but its checkout is only reachable as ssh:conn-1.' - ) - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('refuses a row owned by a runtime environment this process does not execute', async () => { - // `runtime:` deletes on that environment's own server. Its repo row looks local here — - // `connectionId` is null — so a host id that fell back to "local" would delete a same-named - // path on this client. - const runtimeRepo = { - id: 'repo-runtime-owned', - path: '/env/repo', - displayName: 'env', - badgeColor: '#000', - addedAt: 0, - connectionId: null, - executionHostId: 'runtime:env-1' as const - } - store.getRepo.mockReturnValue(runtimeRepo) - store.getRepos.mockReturnValue([runtimeRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-runtime-owned::/env/feature-wt', - force: true - }) - ).rejects.toThrow('Refusing to delete worktree: repo repo-runtime-owned names execution host') - - expect(listWorktreesMock).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - }) - - it('accepts a row whose two spellings differ only by percent-escaping', async () => { - // `getRepoExecutionHostId` returns the stored text while the connection id is re-encoded, so a - // byte compare would refuse this consistent row over a `%20`. - const repo = { - id: 'repo-escaped', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn one', - executionHostId: 'ssh:conn one' as const - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(repo) - store.getRepos.mockReturnValue([repo]) - getSshGitProviderMock.mockReturnValue(provider) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-escaped::/remote/feature-wt' - }) - - expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) - }) - - it('tears down the remote session when an ownerless remote worktree is deleted', async () => { - const sshRepo = { - id: 'repo-1', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: sshRepo.path, - head: 'main', - branch: 'main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/feature-wt', - head: 'feature', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ]), - removeWorktree: vi.fn().mockResolvedValue(undefined), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) - } - store.getRepo.mockReturnValue(sshRepo) - store.getRepos.mockReturnValue([sshRepo]) - getSshGitProviderMock.mockReturnValue(provider) - // Why: no WorktreeMeta means removal cannot read the owner back; the repo is the only host source. - store.getWorktreeMeta.mockReturnValue(undefined) - - await handlers['worktrees:remove'](null, { - worktreeId: 'repo-1::/remote/feature-wt' - }) - - // The whole point: without the repo's host the ownerless row would clear the local session instead. - expect(store.removeWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/remote/feature-wt', - 'ssh:conn-1' - ) - }) - - it('fails closed when duplicate repo ids are deleted without a host', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - - await expect( - handlers['worktrees:remove'](null, { - worktreeId: 'repo-shared::/remote/feature-wt' - }) - ).rejects.toThrow('Repo not found: repo-shared') - - expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() - }) - - it('inspects hooks on the requested host when repo ids collide', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n archive: remote-cleanup', - isBinary: false - }) - } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) - - await expect( - handlers['hooks:check'](null, { - repoId: 'repo-shared', - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({ - status: 'ok', - hasHooks: true, - hooks: { scripts: { archive: 'remote-cleanup' } }, - mayNeedUpdate: false - }) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') - expect(hasHooksFileMock).not.toHaveBeenCalled() - }) - - it('fails hook inspection closed when duplicate repo ids omit the host', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - - await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({ - status: 'error', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - }) - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() - expect(hasHooksFileMock).not.toHaveBeenCalled() - }) - - it('inspects setup-script imports on the requested host when repo ids collide', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath === '/remote/repo/.superset/config.json') { - return { content: '{"setup":"remote setup"}', isBinary: false } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), - stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - store.getRepo.mockReturnValue(localRepo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:inspectSetupScriptImports'](null, { - repoId: 'repo-shared', - hostId: 'ssh:conn-1' - }) - ).resolves.toContainEqual( - expect.objectContaining({ - provider: 'superset', - setup: 'remote setup' - }) - ) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json') - }) - - it('does not coalesce forget requests for the same id on different hosts', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - let finishFirst!: () => void - killAllProcessesForWorktreeMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => - resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - }) - ) - .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - - const first = handlers['worktrees:forgetLocal'](null, { - worktreeId: 'repo-shared::/same/path', - hostId: 'local' - }) as Promise - await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) - - await expect( - handlers['worktrees:forgetLocal'](null, { - worktreeId: 'repo-shared::/same/path', - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({}) - expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) - - finishFirst() - await expect(first).resolves.toEqual({}) - }) - - // The repos are gone (host removed), so only the single global meta key is left to - // key on — and it names one host. It must not merge two hosts' forgets into one. - it('does not let stale metadata coalesce an explicit host forget onto another host', async () => { - const worktreeId = 'repo-gone::/same/path' - store.getRepos.mockReturnValue([]) - store.getRepo.mockReturnValue(undefined) - store.getWorktreeMeta.mockReturnValue({ hostId: 'local' }) - let finishFirst!: () => void - killAllProcessesForWorktreeMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => - resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - }) - ) - .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) - - const first = handlers['worktrees:forgetLocal'](null, { - worktreeId, - hostId: 'local' - }) as Promise - await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) - - await expect( - handlers['worktrees:forgetLocal'](null, { - worktreeId, - hostId: 'ssh:conn-1' - }) - ).resolves.toEqual({}) - expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) - - finishFirst() - await expect(first).resolves.toEqual({}) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:conn-1') - }) -}) diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index cbf4c904096a..bfb857da5f4c 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -6,7 +6,12 @@ import { getEffectiveHooksMock, createSetupRunnerScriptMock, getEffectiveHooksFromConfigMock, - shouldRunSetupForCreateMock + shouldRunSetupForCreateMock, + parseOrcaYamlMock, + resolveSetupRunnerShellMock, + getSshGitProviderMock, + getSshFilesystemProviderMock, + getActiveMultiplexerMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { createdWorktreeList } from './worktrees-test-fixtures' @@ -404,3 +409,316 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree SSH setup launch', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--git-path') { + return { + stdout: '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh\n', + stderr: '' + } + } + if (args[0] === 'rev-parse') { + throw new Error('missing local branch') + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/old-improve-dashboard', + head: 'old123', + branch: 'refs/heads/archive/improve-dashboard', + isBare: false, + isMainWorktree: false + }, + { + path: '/remote/repo-improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n', + isBinary: false + }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + parseOrcaYamlMock.mockReturnValue({ + scripts: { setup: 'pnpm install' }, + setupAgentStartupPolicy: 'wait-for-setup' + }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo-improve-dashboard/orca.yaml') + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + '/remote/repo-improve-dashboard' + ) + expect(fsProvider.createDir).toHaveBeenCalledWith( + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n' + ) + expect(result).toEqual( + expect.objectContaining({ + setup: { + runnerScriptPath: + '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/remote/repo', + ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard' + }), + waitForAgentStartup: true + } + }) + ) + }) + + it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => { + const repo = { + id: 'repo-ssh', + path: 'C:\\remote\\repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--git-path') { + return { + stdout: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n', + stderr: '' + } + } + if (args[0] === 'rev-parse') { + throw new Error('missing local branch') + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: 'C:\\remote\\improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n setup: pnpm install\n', + isBinary: false + }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + terminalWindowsShell: 'git-bash', + workspaceDir: 'C:\\workspace' + }) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + 'C:\\remote\\improve-dashboard' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + 'pnpm install' + ) + expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled() + expect(result).toEqual( + expect.objectContaining({ + setup: { + runnerScriptPath: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\remote\\repo', + ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard' + }) + } + }) + ) + }) + + it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-sparse-dashboard', + head: 'abc123', + branch: 'refs/heads/sparse-dashboard', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-ssh', + name: 'App', + directories: ['apps/mobile', 'packages/shared'], + createdAt: 1, + updatedAt: 1 + } + ]) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'sparse-dashboard', + sparseCheckout: { + directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'], + presetId: 'preset-1' + } + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'sparse-dashboard', + '/remote/repo-sparse-dashboard', + { base: 'origin/main', noCheckout: true } + ) + expect(provider.exec).toHaveBeenCalledWith( + ['sparse-checkout', 'init', '--cone'], + '/remote/repo-sparse-dashboard' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], + '/remote/repo-sparse-dashboard' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['checkout', 'sparse-dashboard'], + '/remote/repo-sparse-dashboard' + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-ssh::/remote/repo-sparse-dashboard', + expect.objectContaining({ + sparseDirectories: ['apps/mobile', 'packages/shared'], + baseRef: 'refs/remotes/origin/main', + sparseBaseRef: 'refs/remotes/origin/main', + sparsePresetId: 'preset-1' + }) + ) + expect(result).toMatchObject({ + worktree: expect.objectContaining({ + isSparse: true, + sparseDirectories: ['apps/mobile', 'packages/shared'], + sparseBaseRef: 'refs/remotes/origin/main', + sparsePresetId: 'preset-1' + }) + }) + }) +}) diff --git a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts deleted file mode 100644 index 03e69583e99c..000000000000 --- a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getSshGitProviderMock, - getSshFilesystemProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'abc123' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { - return { stdout: 'abc123\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { - return { stdout: 'abc123\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi - .fn() - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo-fix-title-2', - head: 'abc123', - branch: 'refs/heads/feature/fix', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - stat: vi.fn().mockImplementation(async (pathValue: string) => { - if (pathValue === '/remote/repo-fix-title') { - return { size: 0, type: 'directory', mtime: 0 } - } - const error = new Error('missing') as Error & { code: string } - error.code = 'ENOENT' - throw error - }), - readFile: vi.fn().mockRejectedValue(new Error('missing')) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'fix-title', - baseBranch: 'abc123', - branchNameOverride: 'feature/fix' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/fix', - '/remote/repo-fix-title-2', - { checkoutExistingBranch: true } - ) - expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { - rootPath: '/remote/repo-fix-title-2' - }) - }) - - it('suffixes SSH worktree creation when the requested branch already exists on a remote', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'branch' && args.includes('feature/something')) { - return { stdout: '', stderr: '' } - } - if (args[0] === 'show-ref') { - const ref = 'refs/remotes/origin/feature/something' - if (args.includes(ref)) { - return { stdout: `abc ${ref}\n`, stderr: '' } - } - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { - throw new Error('missing local branch') - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { - throw new Error('missing local branch') - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-feature-something-2', - head: 'abc123', - branch: 'refs/heads/feature/something-2', - isBare: false, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'feature-something', - branchNameOverride: 'feature/something' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/something-2', - '/remote/repo-feature-something-2', - { base: 'origin/main' } - ) - }) - - it('suffixes SSH worktree creation when a slashed remote owns the requested branch', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'for-each-ref') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\nfoo/bar\n', stderr: '' } - } - if (args[0] === 'show-ref') { - const ref = 'refs/remotes/foo/bar/feature/something' - if (args.includes(ref)) { - return { stdout: `abc ${ref}\n`, stderr: '' } - } - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { - throw new Error('missing local branch') - } - if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { - throw new Error('missing local branch') - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-feature-something-2', - head: 'abc123', - branch: 'refs/heads/feature/something-2', - isBare: false, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'feature-something', - branchNameOverride: 'feature/something' - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'feature/something-2', - '/remote/repo-feature-something-2', - { base: 'origin/main' } - ) - }) -}) diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts index fe80219fa8f9..19b1c903aada 100644 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts @@ -1,9 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { validateGitExecArgs } from '../../relay/git-exec-validator' -import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks' +import { + getSshGitProviderMock, + getActiveMultiplexerMock, + removeWorktreeMock, + forceDeleteLocalBranchMock, + gitExecFileAsyncMock +} from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote' import type { SshGitProvider } from '../providers/ssh-git-provider' +import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -225,3 +232,238 @@ describe('registerWorktreeHandlers', () => { // first sync. Coverage for all three moved with it to // worktree-remote-push-target-materialization.test.ts, which calls that function directly. }) + +describe('worktree preserved branch fork remotes', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('preserves the branch on remove for worktrees created from an existing local branch', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true })) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(removeWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + false, + expect.objectContaining({ + deleteBranch: false, + knownRemovedWorktree: expect.objectContaining({ + branch: 'feature', + head: 'feature', + path: '/workspace/feature-wt' + }) + }) + ) + }) + + it('force-deletes a branch that was preserved by safe worktree removal', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + branchName: 'feature/test', + expectedHead: 'def456' + }) + + expect(result).toMatchObject({ deleted: true }) + expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith( + '/workspace/repo', + 'feature/test', + 'def456' + ) + }) + + it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const worktreeId = 'repo-ssh::/remote/feature-wt' + const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined } + const provider = { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: repo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'def456', + branch: 'feature/test', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepos.mockReturnValue([localRepo, repo]) + store.getRepo.mockReturnValue(localRepo) + store.getWorktreeMeta.mockReturnValue( + makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true }) + ) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() }) + + await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' }) + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + const result = await handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId, + branchName: 'feature/test', + expectedHead: 'def456', + hostId: 'ssh:conn-1' + }) + + expect(result).toMatchObject({ deleted: true }) + expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith( + '/remote/repo', + 'feature/test', + 'def456' + ) + expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() + }) + + it('rejects stale preserved-branch cleanup actions with an old head', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'new456' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + await expect( + handlers['worktrees:forceDeletePreservedBranch'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + branchName: 'feature/test', + expectedHead: 'old123' + }) + ).rejects.toThrow('No preserved branch cleanup is pending') + expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled() + }) + + it('removes an unused Orca-created fork remote after deleting its worktree', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }) + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + throw new Error('no branch config') + } + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { + cwd: '/workspace/repo' + }) + }) + + it('keeps an Orca-created fork remote while another worktree still uses it', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), + 'repo-1::/workspace/other-wt': makeWorktreeMeta({ + pushTarget: { + ...pushTarget, + branchName: 'other-branch' + } + }) + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['remote', 'remove', 'pr-contributor-orca'], + expect.any(Object) + ) + }) + + it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), + 'repo-2::/workspace/other-wt': makeWorktreeMeta({ + pushTarget: { + ...pushTarget, + branchName: 'other-branch' + } + }) + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + throw new Error('no branch config') + } + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { + cwd: '/workspace/repo' + }) + }) +}) diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index 8d4966dd187e..034bd890bd18 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -4,7 +4,8 @@ import { getPullRequestPushTargetMock, gitExecFileAsyncMock, getSshGitProviderMock, - getActiveMultiplexerMock + getActiveMultiplexerMock, + getSshFilesystemProviderMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { ORIGIN_HEAD_COMPONENT, ORIGIN_REMOTE_URL } from './worktrees-test-fixtures' @@ -529,3 +530,248 @@ describe('registerWorktreeHandlers', () => { }) }) }) + +describe('worktree SSH branch conflict suffixes', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('suffixes only the SSH worktree path when an exact PR branch checkout path exists', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'abc123' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/fix^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('abc123^{commit}')) { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi + .fn() + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/repo', + head: 'main', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/remote/repo-fix-title-2', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + stat: vi.fn().mockImplementation(async (pathValue: string) => { + if (pathValue === '/remote/repo-fix-title') { + return { size: 0, type: 'directory', mtime: 0 } + } + const error = new Error('missing') as Error & { code: string } + error.code = 'ENOENT' + throw error + }), + readFile: vi.fn().mockRejectedValue(new Error('missing')) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/fix', + '/remote/repo-fix-title-2', + { checkoutExistingBranch: true } + ) + expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/remote/repo-fix-title-2' + }) + }) + + it('suffixes SSH worktree creation when the requested branch already exists on a remote', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'branch' && args.includes('feature/something')) { + return { stdout: '', stderr: '' } + } + if (args[0] === 'show-ref') { + const ref = 'refs/remotes/origin/feature/something' + if (args.includes(ref)) { + return { stdout: `abc ${ref}\n`, stderr: '' } + } + throw Object.assign(new Error('missing remote ref'), { code: 1 }) + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { + throw new Error('missing local branch') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-feature-something-2', + head: 'abc123', + branch: 'refs/heads/feature/something-2', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'feature-something', + branchNameOverride: 'feature/something' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/something-2', + '/remote/repo-feature-something-2', + { base: 'origin/main' } + ) + }) + + it('suffixes SSH worktree creation when a slashed remote owns the requested branch', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\nfoo/bar\n', stderr: '' } + } + if (args[0] === 'show-ref') { + const ref = 'refs/remotes/foo/bar/feature/something' + if (args.includes(ref)) { + return { stdout: `abc ${ref}\n`, stderr: '' } + } + throw Object.assign(new Error('missing remote ref'), { code: 1 }) + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something^{commit}')) { + throw new Error('missing local branch') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/feature/something-2^{commit}')) { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-feature-something-2', + head: 'abc123', + branch: 'refs/heads/feature/something-2', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'feature-something', + branchNameOverride: 'feature/something' + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'feature/something-2', + '/remote/repo-feature-something-2', + { base: 'origin/main' } + ) + }) +}) diff --git a/src/main/ipc/worktrees-ssh-setup-launch.test.ts b/src/main/ipc/worktrees-ssh-setup-launch.test.ts deleted file mode 100644 index fa98b5b62a16..000000000000 --- a/src/main/ipc/worktrees-ssh-setup-launch.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getEffectiveHooksFromConfigMock, - parseOrcaYamlMock, - shouldRunSetupForCreateMock, - resolveSetupRunnerShellMock, - getSshGitProviderMock, - getSshFilesystemProviderMock, - getActiveMultiplexerMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' - -vi.mock('electron', async () => - (await import('./worktrees-test-module-mocks')).electronModuleMock() -) -vi.mock('../git/worktree', async () => - (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() -) -vi.mock('../git/runner', async () => - (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() -) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() -) -vi.mock('../git/git-username', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) - .resolveLocalGitUsernameMock -})) -vi.mock('../github/client', async () => - (await import('./worktrees-test-module-mocks')).githubClientModuleMock() -) -vi.mock('../source-control/hosted-review', async () => - (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() -) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) -vi.mock('../providers/ssh-filesystem-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() -) -vi.mock('./worktree-symlinks', async () => - (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() -) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) -vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) -vi.mock('../setup-runner-script-text', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../worktree-runner-script', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../effective-hook-config', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../setup-hook-env-vars', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) -vi.mock('../terminal-history-deletion', async () => - (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() -) -vi.mock('../ports/advertised-url-watcher', async () => - (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() -) -vi.mock('../workspace-cleanup-scan-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() -) -vi.mock('../workspace-space-analysis-snapshot', async () => - (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() -) -vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => - (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() -) -vi.mock('../runtime/worktree-teardown', async () => - (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() -) -vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) - -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) - - it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--git-path') { - return { - stdout: '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh\n', - stderr: '' - } - } - if (args[0] === 'rev-parse') { - throw new Error('missing local branch') - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/old-improve-dashboard', - head: 'old123', - branch: 'refs/heads/archive/improve-dashboard', - isBare: false, - isMainWorktree: false - }, - { - path: '/remote/repo-improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n', - isBinary: false - }), - createDir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - parseOrcaYamlMock.mockReturnValue({ - scripts: { setup: 'pnpm install' }, - setupAgentStartupPolicy: 'wait-for-setup' - }) - getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - shouldRunSetupForCreateMock.mockReturnValue(true) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard', - setupDecision: 'run' - }) - - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo-improve-dashboard/orca.yaml') - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/setup-runner.sh'], - '/remote/repo-improve-dashboard' - ) - expect(fsProvider.createDir).toHaveBeenCalledWith( - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', - '#!/usr/bin/env bash\nset -e\npnpm install\n' - ) - expect(result).toEqual( - expect.objectContaining({ - setup: { - runnerScriptPath: - '/remote/repo/.git/worktrees/repo-improve-dashboard/orca/setup-runner.sh', - envVars: expect.objectContaining({ - ORCA_ROOT_PATH: '/remote/repo', - ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard' - }), - waitForAgentStartup: true - } - }) - ) - }) - - it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => { - const repo = { - id: 'repo-ssh', - path: 'C:\\remote\\repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--git-path') { - return { - stdout: - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n', - stderr: '' - } - } - if (args[0] === 'rev-parse') { - throw new Error('missing local branch') - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: 'C:\\remote\\improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]) - } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n setup: pnpm install\n', - isBinary: false - }), - createDir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined) - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getSettings.mockReturnValue({ - branchPrefix: 'none', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: false, - terminalWindowsShell: 'git-bash', - workspaceDir: 'C:\\workspace' - }) - getSshGitProviderMock.mockReturnValue(provider) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) - shouldRunSetupForCreateMock.mockReturnValue(true) - resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard', - setupDecision: 'run' - }) - - expect(provider.exec).toHaveBeenCalledWith( - ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], - 'C:\\remote\\improve-dashboard' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', - 'pnpm install' - ) - expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled() - expect(result).toEqual( - expect.objectContaining({ - setup: { - runnerScriptPath: - 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', - envVars: expect.objectContaining({ - ORCA_ROOT_PATH: 'C:\\remote\\repo', - ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard' - }) - } - }) - ) - }) - - it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - removeWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-sparse-dashboard', - head: 'abc123', - branch: 'refs/heads/sparse-dashboard', - isBare: false, - isSparse: true, - isMainWorktree: false - } - ]) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getSparsePresets.mockReturnValue([ - { - id: 'preset-1', - repoId: 'repo-ssh', - name: 'App', - directories: ['apps/mobile', 'packages/shared'], - createdAt: 1, - updatedAt: 1 - } - ]) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'sparse-dashboard', - sparseCheckout: { - directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'], - presetId: 'preset-1' - } - }) - - expect(provider.addWorktree).toHaveBeenCalledWith( - '/remote/repo', - 'sparse-dashboard', - '/remote/repo-sparse-dashboard', - { base: 'origin/main', noCheckout: true } - ) - expect(provider.exec).toHaveBeenCalledWith( - ['sparse-checkout', 'init', '--cone'], - '/remote/repo-sparse-dashboard' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], - '/remote/repo-sparse-dashboard' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['checkout', 'sparse-dashboard'], - '/remote/repo-sparse-dashboard' - ) - expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-ssh::/remote/repo-sparse-dashboard', - expect.objectContaining({ - sparseDirectories: ['apps/mobile', 'packages/shared'], - baseRef: 'refs/remotes/origin/main', - sparseBaseRef: 'refs/remotes/origin/main', - sparsePresetId: 'preset-1' - }) - ) - expect(result).toMatchObject({ - worktree: expect.objectContaining({ - isSparse: true, - sparseDirectories: ['apps/mobile', 'packages/shared'], - sparseBaseRef: 'refs/remotes/origin/main', - sparsePresetId: 'preset-1' - }) - }) - }) -}) diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts index 845ebdfc8a2b..133bc8eca9de 100644 --- a/src/main/runtime/antigravity-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -1,19 +1,26 @@ -/** - * Pins Antigravity readiness to captured transcripts instead of hand-written fixtures. - * - * Five detector attempts were tuned against a five-line screen someone typed from memory, and - * three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what - * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name - * themselves as skipped until someone can reach them. - * - * One case is pinned as a KNOWN DEFECT: the shipped detector refuses a ready screen whose retained - * tail ends on the error block. That asserts what it does, not what it should. - */ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { + finalReplayFrame, + readRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { + describeScreenRuledAgentTranscripts, + readsIdleComposer +} from './screen-ruled-agent-transcript-suite' +import { + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isQuietReadyScreenBody +} from './terminal-wait-detection' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -22,194 +29,341 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const FIXTURE_DIR = join(__dirname, '__fixtures__') -// Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to -// hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. -const READY_TIMEOUT_MS = 2_000 -const REFUSAL_TIMEOUT_MS = 600 -/** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */ -const ANTIGRAVITY_COMMAND = 'agy' -// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw -// control byte in source, which is unreadable and survives badly in diffs. -const ESC = String.fromCharCode(27) - -type TranscriptCase = { - /** Fixture basename; `.txt` under `__fixtures__/`. */ - name: string - /** Capture group identifier. */ - capture: string - what: string - /** What a correct detector must answer. Not what the shipped one answers. */ - expectReady: boolean +describe('Antigravity raw captured readiness', () => { /** - * Set where the shipped detector contradicts the transcript. The case then runs inverted, so - * CI pins the defect instead of going permanently red — and flips to failing the moment - * someone fixes it, which is exactly when these expectations need re-reading. + * Pins Antigravity readiness to captured transcripts instead of hand-written fixtures. + * + * Five detector attempts were tuned against a five-line screen someone typed from memory, and + * three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what + * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name + * themselves as skipped until someone can reach them. + * + * One case is pinned as a KNOWN DEFECT: the shipped detector refuses a ready screen whose retained + * tail ends on the error block. That asserts what it does, not what it should. */ - knownDefect?: string -} - -const TRANSCRIPTS: readonly TranscriptCase[] = [ - { - name: 'antigravity-ready-api-key-gemini-model', - capture: 'B', - what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', - expectReady: true - }, - { - name: 'antigravity-ready-account-info-hidden', - capture: 'B', - what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', - expectReady: true - }, - { - name: 'antigravity-dialog-trust-workspace', - capture: 'C', - what: 'workspace trust dialog owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-model-picker', - capture: 'C', - what: 'model picker owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-command-palette', - capture: 'C', - what: 'slash-command palette owning the screen', - expectReady: false - }, - { - name: 'antigravity-busy-mid-turn', - capture: 'E', - what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt', - expectReady: false - }, - { - // Expected ready because the turn is over and the composer is back on screen. The captured - // turn ends in a backend error, which is the only ending this account's key can produce. - name: 'antigravity-busy-turn-ended', - capture: 'E', - what: 'the turn has ended and the composer has returned, process still alive', - expectReady: true, - knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' - }, - { - name: 'antigravity-dialog-dismissed', - capture: 'D', - what: 'the screen immediately after the model picker is dismissed', - expectReady: true - }, - // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and - // reaching the rest would mean signing the operator out or deleting their config. - { - name: 'antigravity-ready-business-non-gemini', - capture: 'A', - what: 'ready screen, Business account, non-Gemini model', - expectReady: true - }, - { - name: 'antigravity-dialog-sign-in', - capture: 'C', - what: 'sign-in dialog owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-theme-picker', - capture: 'C', - what: 'theme picker owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-privacy-notice', - capture: 'C', - what: 'privacy notice owning the screen', - expectReady: false - }, - { - name: 'antigravity-dialog-update-banner', - capture: 'C', - what: 'update banner owning the screen', - expectReady: false + + const FIXTURE_DIR = join(__dirname, '__fixtures__') + // Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to + // hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. + const READY_TIMEOUT_MS = 2_000 + const REFUSAL_TIMEOUT_MS = 600 + /** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */ + const ANTIGRAVITY_COMMAND = 'agy' + // String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw + // control byte in source, which is unreadable and survives badly in diffs. + const ESC = String.fromCharCode(27) + + type TranscriptCase = { + /** Fixture basename; `.txt` under `__fixtures__/`. */ + name: string + /** Capture group identifier. */ + capture: string + what: string + /** What a correct detector must answer. Not what the shipped one answers. */ + expectReady: boolean + /** + * Set where the shipped detector contradicts the transcript. The case then runs inverted, so + * CI pins the defect instead of going permanently red — and flips to failing the moment + * someone fixes it, which is exactly when these expectations need re-reading. + */ + knownDefect?: string } -] - -function fixturePath(name: string): string { - return join(FIXTURE_DIR, `${name}.txt`) -} - -/** - * A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it - * resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever - * looked ready. The orchestrator treats the last two identically — no prompt is delivered — so - * they are both `ready: false` here. This is the shape `worker-start` sees. - */ -async function readinessVerdict( - transcript: string, - timeoutMs: number -): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> { - const { runtime, handle } = await createTranscriptPane({ - // Why the transcript's own title: every attempt guessed at Antigravity's title. A raw - // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. - paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, - foregroundProcess: ANTIGRAVITY_COMMAND, - data: transcript - }) - try { - const result = (await runtime.waitForTerminal(handle, { - condition: 'tui-idle', - timeoutMs - })) as { satisfied?: boolean; blockedReason?: unknown } - return { - ready: result.satisfied === true, - blockedReason: result.blockedReason ?? null, - outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied' + + const TRANSCRIPTS: readonly TranscriptCase[] = [ + { + name: 'antigravity-ready-api-key-gemini-model', + capture: 'B', + what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', + expectReady: true + }, + { + name: 'antigravity-ready-account-info-hidden', + capture: 'B', + what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', + expectReady: true + }, + { + name: 'antigravity-dialog-trust-workspace', + capture: 'C', + what: 'workspace trust dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-model-picker', + capture: 'C', + what: 'model picker owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-command-palette', + capture: 'C', + what: 'slash-command palette owning the screen', + expectReady: false + }, + { + name: 'antigravity-busy-mid-turn', + capture: 'E', + what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt', + expectReady: false + }, + { + // Expected ready because the turn is over and the composer is back on screen. The captured + // turn ends in a backend error, which is the only ending this account's key can produce. + name: 'antigravity-busy-turn-ended', + capture: 'E', + what: 'the turn has ended and the composer has returned, process still alive', + expectReady: true, + knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' + }, + { + name: 'antigravity-dialog-dismissed', + capture: 'D', + what: 'the screen immediately after the model picker is dismissed', + expectReady: true + }, + // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and + // reaching the rest would mean signing the operator out or deleting their config. + { + name: 'antigravity-ready-business-non-gemini', + capture: 'A', + what: 'ready screen, Business account, non-Gemini model', + expectReady: true + }, + { + name: 'antigravity-dialog-sign-in', + capture: 'C', + what: 'sign-in dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-theme-picker', + capture: 'C', + what: 'theme picker owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-privacy-notice', + capture: 'C', + what: 'privacy notice owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-update-banner', + capture: 'C', + what: 'update banner owning the screen', + expectReady: false } - } catch (error) { - return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` } + ] + + function fixturePath(name: string): string { + return join(FIXTURE_DIR, `${name}.txt`) } -} - -describe('Antigravity readiness, decided by captured transcripts', () => { - for (const transcript of TRANSCRIPTS) { - const path = fixturePath(transcript.name) - const captured = existsSync(path) - const label = `capture ${transcript.capture}: ${transcript.what}` - - // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently - // red; fixing the detector flips this case to failing, which is when these expectations - // need re-reading. The correct answer stays in `expectReady` and in the test's name. - const shipped = - transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady - const verdictName = - transcript.knownDefect === undefined - ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` - : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` - - it.skipIf(!captured)( - verdictName, - async () => { - // A refusal only has to hold for one poll; a ready verdict has to survive the settle - // window. Keeping the refusal short keeps eleven transcripts off the suite's clock. - const verdict = await readinessVerdict( - readFileSync(path, 'utf8'), - transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS - ) - // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca - // does not call the pane ready and type a prompt into a dialog that owns the screen. - expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ - ready: shipped - }) - }, - READY_TIMEOUT_MS + 10_000 - ) - it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => { - const text = readFileSync(path, 'utf8') - // Why: a transcript with no escape bytes went through a terminal's renderer and a - // human's clipboard. It cannot answer what the caret or chrome looked like. - expect(text).toContain(ESC) + /** + * A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it + * resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever + * looked ready. The orchestrator treats the last two identically — no prompt is delivered — so + * they are both `ready: false` here. This is the shape `worker-start` sees. + */ + async function readinessVerdict( + transcript: string, + timeoutMs: number + ): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> { + const { runtime, handle } = await createTranscriptPane({ + // Why the transcript's own title: every attempt guessed at Antigravity's title. A raw + // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. + paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, + foregroundProcess: ANTIGRAVITY_COMMAND, + data: transcript }) + try { + const result = (await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs + })) as { satisfied?: boolean; blockedReason?: unknown } + return { + ready: result.satisfied === true, + blockedReason: result.blockedReason ?? null, + outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied' + } + } catch (error) { + return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` } + } } + + describe('Antigravity readiness, decided by captured transcripts', () => { + for (const transcript of TRANSCRIPTS) { + const path = fixturePath(transcript.name) + const captured = existsSync(path) + const label = `capture ${transcript.capture}: ${transcript.what}` + + // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently + // red; fixing the detector flips this case to failing, which is when these expectations + // need re-reading. The correct answer stays in `expectReady` and in the test's name. + const shipped = + transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady + const verdictName = + transcript.knownDefect === undefined + ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` + : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` + + it.skipIf(!captured)( + verdictName, + async () => { + // A refusal only has to hold for one poll; a ready verdict has to survive the settle + // window. Keeping the refusal short keeps eleven transcripts off the suite's clock. + const verdict = await readinessVerdict( + readFileSync(path, 'utf8'), + transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS + ) + // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca + // does not call the pane ready and type a prompt into a dialog that owns the screen. + expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ + ready: shipped + }) + }, + READY_TIMEOUT_MS + 10_000 + ) + + it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => { + const text = readFileSync(path, 'utf8') + // Why: a transcript with no escape bytes went through a terminal's renderer and a + // human's clipboard. It cannot answer what the caret or chrome looked like. + expect(text).toContain(ESC) + }) + } + }) +}) + +describe('Antigravity screen ruled readiness', () => { + // agy 1.2.14 on macOS, AGY_CLI_HIDE_ACCOUNT_INFO=1 (see each .meta.json); STA-8741. + const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) + const READY = [ + at120x40('antigravity-1-2-14-ready', 'settled startup'), + at120x40('antigravity-1-2-14-ready-accept-edits', '--mode accept-edits'), + at120x40('antigravity-1-2-14-ready-plan', '--mode plan'), + { name: 'antigravity-1-2-14-ready-80x24', what: 'settled startup', cols: 80, rows: 24 }, + at120x40('antigravity-1-2-14-picker-dismissed', '/model closed with Esc'), + at120x40('antigravity-1-2-14-turn-ended', 'a turn has ended') + ] + const NOT_READY = [ + at120x40('antigravity-1-2-14-model-picker', '/model picker open'), + at120x40('antigravity-1-2-14-command-palette', 'slash palette open'), + at120x40('antigravity-1-2-14-busy-thinking', 'spinner before the answer'), + at120x40('antigravity-1-2-14-busy-streaming', 'answer streaming'), + at120x40('antigravity-1-2-14-trust-dialog', 'workspace trust dialog'), + at120x40('antigravity-1-2-14-draft', 'unsent text in the composer') + ] + + describe('Antigravity 1.2.14 readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'antigravity', + foregroundProcess: 'agy', + ready: READY, + notReady: NOT_READY, + // Why these: the line-folded text rule reads only these ready screens. + readyWithoutScreen: [ + 'antigravity-1-2-14-ready', + 'antigravity-1-2-14-ready-80x24', + 'antigravity-1-2-14-picker-dismissed' + ] + }) + + it.each([ + 'antigravity-1-2-14-ready-accept-edits', + 'antigravity-1-2-14-ready-plan', + 'antigravity-1-2-14-turn-ended' + ])('%s: the line-folded text rule alone misses this ready screen', async (name) => { + const { waitText } = await finalReplayFrame(name, 120, 40) + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + + it('does not let the text rules overrule a screen that refused', async () => { + const { waitText } = await finalReplayFrame('antigravity-1-2-14-picker-dismissed', 120, 40) + const { ruledScreenLines } = await finalReplayFrame( + 'antigravity-1-2-14-model-picker', + 120, + 40 + ) + // Presence precondition: the text alone would say ready. + expect(isKnownReadyPromptPreview(waitText)).toBe(true) + expect(isQuietReadyScreenBody(waitText, 'antigravity', () => ruledScreenLines)).toBe(false) + expect(isKnownReadyPromptBody(waitText, 'antigravity', () => ruledScreenLines, false)).toBe( + false + ) + }) + + // Why a caret rule is not enough: agy keeps the bare composer caret painted through both. + it.each(['antigravity-1-2-14-busy-streaming', 'antigravity-1-2-14-model-picker'])( + '%s: the bare caret is still on screen', + async (name) => { + const { ruledScreenLines } = await finalReplayFrame(name, 120, 40) + expect(ruledScreenLines.some((line) => line.trim() === '>')).toBe(true) + } + ) + + // Why a clocked pane waits for quiet: the submit repaint clears the composer a moment before + // it swaps `? for shortcuts` for `esc to cancel`. + it('reads a submit repaint as ready for a moment mid-turn', async () => { + const data = readRuntimeFixture('antigravity-1-2-14-turn-ended') + let submitted = false + let answered = false + let readyMidTurn = 0 + for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) { + submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools')) + answered ||= ruledScreenLines.some((line) => line.trim() === 'ok') + if (submitted && !answered && readsIdleComposer('antigravity', ruledScreenLines)) { + readyMidTurn += 1 + } + } + expect(submitted && answered).toBe(true) + expect(readyMidTurn).toBeGreaterThan(0) + }) + + // Why: a shell auto-title names the process; before the screen decided, that name-only idle + // title settled a pane whose picker carries no blocked wording. + it('does not settle an open model picker from a name-only `agy` title', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'agy', + foregroundProcess: 'agy', + launchAgent: 'antigravity', + data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/) + }, 15_000) + + // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. + it('trusts a reflowed grid again once a PTY resize off it repaints the TUI', async () => { + const turnEnded = readRuntimeFixture('antigravity-1-2-14-turn-ended') + const options = { + paneTitle: 'Terminal', + foregroundProcess: 'agy', + launchAgent: 'antigravity' as const, + data: turnEnded, + size: { cols: 100, rows: 30 } + } + const { runtime, handle } = await createTranscriptPane(options) + const resizePty = (cols: number, rows: number) => { + options.size = { cols, rows } + runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) + } + const settles = async () => + ( + await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ + satisfied: false + })) + ).satisfied + options.size = { cols: 120, rows: 40 } + runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) + await runtime.readTerminal(handle, { screen: true }) + expect(await settles()).toBe(false) + resizePty(121, 40) + resizePty(120, 40) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, turnEnded, Date.now()) + await runtime.readTerminal(handle, { screen: true }) + expect(await settles()).toBe(true) + }, 45_000) + }) }) diff --git a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts deleted file mode 100644 index 1735c102151c..000000000000 --- a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createTranscriptPane, - TRANSCRIPT_PANE_PTY_ID, - waitForTranscriptIdle -} from './agent-transcript-pane-test-harness' -import { - finalReplayFrame, - readRuntimeFixture, - replayTranscript -} from './agent-transcript-replay-test-harness' -import { - describeScreenRuledAgentTranscripts, - readsIdleComposer -} from './screen-ruled-agent-transcript-suite' -import { - isKnownReadyPromptBody, - isKnownReadyPromptPreview, - isQuietReadyScreenBody -} from './terminal-wait-detection' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// agy 1.2.14 on macOS, AGY_CLI_HIDE_ACCOUNT_INFO=1 (see each .meta.json); STA-8741. -const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) -const READY = [ - at120x40('antigravity-1-2-14-ready', 'settled startup'), - at120x40('antigravity-1-2-14-ready-accept-edits', '--mode accept-edits'), - at120x40('antigravity-1-2-14-ready-plan', '--mode plan'), - { name: 'antigravity-1-2-14-ready-80x24', what: 'settled startup', cols: 80, rows: 24 }, - at120x40('antigravity-1-2-14-picker-dismissed', '/model closed with Esc'), - at120x40('antigravity-1-2-14-turn-ended', 'a turn has ended') -] -const NOT_READY = [ - at120x40('antigravity-1-2-14-model-picker', '/model picker open'), - at120x40('antigravity-1-2-14-command-palette', 'slash palette open'), - at120x40('antigravity-1-2-14-busy-thinking', 'spinner before the answer'), - at120x40('antigravity-1-2-14-busy-streaming', 'answer streaming'), - at120x40('antigravity-1-2-14-trust-dialog', 'workspace trust dialog'), - at120x40('antigravity-1-2-14-draft', 'unsent text in the composer') -] - -describe('Antigravity 1.2.14 readiness from captured bytes', () => { - describeScreenRuledAgentTranscripts({ - agent: 'antigravity', - foregroundProcess: 'agy', - ready: READY, - notReady: NOT_READY, - // Why these: the line-folded text rule reads only these ready screens. - readyWithoutScreen: [ - 'antigravity-1-2-14-ready', - 'antigravity-1-2-14-ready-80x24', - 'antigravity-1-2-14-picker-dismissed' - ] - }) - - it.each([ - 'antigravity-1-2-14-ready-accept-edits', - 'antigravity-1-2-14-ready-plan', - 'antigravity-1-2-14-turn-ended' - ])('%s: the line-folded text rule alone misses this ready screen', async (name) => { - const { waitText } = await finalReplayFrame(name, 120, 40) - expect(isKnownReadyPromptPreview(waitText)).toBe(false) - }) - - it('does not let the text rules overrule a screen that refused', async () => { - const { waitText } = await finalReplayFrame('antigravity-1-2-14-picker-dismissed', 120, 40) - const { ruledScreenLines } = await finalReplayFrame('antigravity-1-2-14-model-picker', 120, 40) - // Presence precondition: the text alone would say ready. - expect(isKnownReadyPromptPreview(waitText)).toBe(true) - expect(isQuietReadyScreenBody(waitText, 'antigravity', () => ruledScreenLines)).toBe(false) - expect(isKnownReadyPromptBody(waitText, 'antigravity', () => ruledScreenLines, false)).toBe( - false - ) - }) - - // Why a caret rule is not enough: agy keeps the bare composer caret painted through both. - it.each(['antigravity-1-2-14-busy-streaming', 'antigravity-1-2-14-model-picker'])( - '%s: the bare caret is still on screen', - async (name) => { - const { ruledScreenLines } = await finalReplayFrame(name, 120, 40) - expect(ruledScreenLines.some((line) => line.trim() === '>')).toBe(true) - } - ) - - // Why a clocked pane waits for quiet: the submit repaint clears the composer a moment before - // it swaps `? for shortcuts` for `esc to cancel`. - it('reads a submit repaint as ready for a moment mid-turn', async () => { - const data = readRuntimeFixture('antigravity-1-2-14-turn-ended') - let submitted = false - let answered = false - let readyMidTurn = 0 - for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) { - submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools')) - answered ||= ruledScreenLines.some((line) => line.trim() === 'ok') - if (submitted && !answered && readsIdleComposer('antigravity', ruledScreenLines)) { - readyMidTurn += 1 - } - } - expect(submitted && answered).toBe(true) - expect(readyMidTurn).toBeGreaterThan(0) - }) - - // Why: a shell auto-title names the process; before the screen decided, that name-only idle - // title settled a pane whose picker carries no blocked wording. - it('does not settle an open model picker from a name-only `agy` title', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'agy', - foregroundProcess: 'agy', - launchAgent: 'antigravity', - data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/) - }, 15_000) - - // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. - it('trusts a reflowed grid again once a PTY resize off it repaints the TUI', async () => { - const turnEnded = readRuntimeFixture('antigravity-1-2-14-turn-ended') - const options = { - paneTitle: 'Terminal', - foregroundProcess: 'agy', - launchAgent: 'antigravity' as const, - data: turnEnded, - size: { cols: 100, rows: 30 } - } - const { runtime, handle } = await createTranscriptPane(options) - const resizePty = (cols: number, rows: number) => { - options.size = { cols, rows } - runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) - } - const settles = async () => - (await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ satisfied: false }))) - .satisfied - options.size = { cols: 120, rows: 40 } - runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) - await runtime.readTerminal(handle, { screen: true }) - expect(await settles()).toBe(false) - resizePty(121, 40) - resizePty(120, 40) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, turnEnded, Date.now()) - await runtime.readTerminal(handle, { screen: true }) - expect(await settles()).toBe(true) - }, 45_000) -}) diff --git a/src/main/runtime/claude-task-wakeup-indexed-read.test.ts b/src/main/runtime/claude-task-wakeup-indexed-read.test.ts deleted file mode 100644 index f966c0dce514..000000000000 --- a/src/main/runtime/claude-task-wakeup-indexed-read.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import { - createTranscriptPane, - TRANSCRIPT_PANE_PTY_ID, - waitForTranscriptIdle -} from './agent-transcript-pane-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' -const ready = readFileSync( - join(import.meta.dirname, '__fixtures__/claude-ready-task-wakeup.txt'), - 'utf8' -) - -function owedRow(paneKey: string): AgentStatusIpcPayload { - const now = Date.now() - return { - paneKey, - state: 'working', - mainAgent: { state: 'done', stateStartedAt: now }, - agentType: 'claude', - prompt: '', - connectionId: null, - launchToken: 'transcript-launch', - claudeTaskWakeupPending: 'notification', - providerSession: { key: 'session_id', id: 'session-a' }, - observation: { - origin: 'hook', - authorityId: 'host-a', - incarnation: 1, - revision: 1, - observedAt: now - }, - receivedAt: now, - stateStartedAt: now - } -} - -afterEach(() => vi.useRealTimers()) - -describe('Claude readiness indexed status reads', () => { - it.each([true, false])( - 'reads only its pane with 512 unrelated agents, including an empty index: pending=%s', - async (pending) => { - const unrelated = Array.from({ length: 512 }, (_, index) => owedRow(`other:${index}`)) - const row = owedRow(PANE) - const full = vi.fn(() => [row, ...unrelated]) - const indexed = vi.fn((paneKey: string) => (pending && paneKey === PANE ? [row] : [])) - const pane = await createTranscriptPane( - { - paneTitle: 'Terminal', - foregroundProcess: 'claude', - data: ready, - launchAgent: 'claude' - }, - { getAgentStatusSnapshot: full, getAgentStatusSnapshotForPane: indexed } - ) - row.receivedAt = Date.now() - full.mockClear() - indexed.mockClear() - try { - const waiting = waitForTranscriptIdle(pane, 2_000) - await (pending - ? expect(waiting).rejects.toThrow(/timeout/) - : expect(waiting).resolves.toMatchObject({ satisfied: true })) - expect(indexed).toHaveBeenCalled() - expect(new Set(indexed.mock.calls.map(([paneKey]) => paneKey))).toEqual(new Set([PANE])) - expect(full).not.toHaveBeenCalled() - } finally { - pane.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - } - ) -}) diff --git a/src/main/runtime/cline-screen-readiness-transcripts.test.ts b/src/main/runtime/cline-screen-readiness-transcripts.test.ts index c2ff78bbd96c..de631d4638bc 100644 --- a/src/main/runtime/cline-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/cline-screen-readiness-transcripts.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + waitForTranscriptIdle, + TRANSCRIPT_PANE_PTY_ID +} from './agent-transcript-pane-test-harness' import { finalReadProjection, finalReplayFrame, @@ -11,6 +15,18 @@ import { readsIdleComposer } from './screen-ruled-agent-transcript-suite' import { RuntimeMachineName } from './runtime-machine-name' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { waitForWorkerStartComposer } from './launched-agent-composer-readiness' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + getSyntheticAgentTerminalTitle, + shouldDriveSyntheticAgentTitleFromHook +} from '../../shared/synthetic-agent-title' +import { hasExplicitIdleTitle } from './tui-idle-evidence' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -19,84 +35,428 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -let machineNameStartSpy: { mockRestore(): void } | undefined +describe('Cline captured screen readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined + + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) + }) + + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined + }) + + // cline 3.0.66 on macOS with an isolated config; 3.0.65 on Windows recorded for PR #23269 (see + // each .meta.json); STA-8741. + const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) + const READY = [ + at120x40('cline-3-0-66-ready', 'promo dismissed, startup composer'), + at120x40('cline-3-0-66-ready-plan', 'Plan mode composer'), + { name: 'cline-3-0-66-ready-80x24', what: 'startup composer', cols: 80, rows: 24 }, + at120x40('cline-3-0-66-turn-ended', 'a turn has ended'), + at120x40('cline-3-0-65-win32-startup', 'Windows startup composer') + ] + const NOT_READY = [ + at120x40('cline-3-0-66-promo', 'Cline Desktop promo over the composer'), + at120x40('cline-3-0-66-permission', 'tool approval prompt'), + at120x40('cline-3-0-66-slash-menu', 'slash menu open'), + at120x40('cline-3-0-66-draft', 'unsent text in the composer') + ] + const STREAMING = 'cline-3-0-66-busy-streaming' + + describe('Cline readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'cline', + foregroundProcess: 'cline', + ready: READY, + notReady: NOT_READY, + // Why all: an idle Cline is quiet, so the quiet-process lane settles it. + readyWithoutScreen: READY.map(({ name }) => name) + }) + + // Presence precondition for the restored-pane suite: the read projection blanks this placeholder. + it('reads the ended turn through a projection that blanks its placeholder', async () => { + const { lines, draft } = await finalReadProjection('cline-3-0-66-turn-ended', 120, 40) + expect(lines).toContain('❯') + expect(draft).toBe('Ask anything...') + }) + + // Why only quiescence can refuse it: the streaming reply has scrolled its spinner away. + it('paints the same empty composer while a reply streams', async () => { + const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40) + expect(readsIdleComposer('cline', ruledScreenLines)).toBe(true) + }) + + it('refuses every frame whose spinner row is still on screen', async () => { + let spinnerFrames = 0 + for await (const { ruledScreenLines } of replayTranscript( + readRuntimeFixture(STREAMING), + 120, + 40 + )) { + if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) { + spinnerFrames += 1 + expect(readsIdleComposer('cline', ruledScreenLines)).toBe(false) + } + } + // Presence precondition: the thinking spinner was painted above the composer. + expect(spinnerFrames).toBeGreaterThan(0) + }) -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) + it('does not settle a streaming pane before it goes quiet', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'cline', + launchAgent: 'cline', + data: readRuntimeFixture(STREAMING), + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + // Why 2.5s: inside the 3s quiescence window, past the 2s poll. + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) + }, 15_000) + }) }) -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined +describe('Grok captured worker startup', () => { + /** + * A Grok worker start replayed through the runtime at its recorded read times. Grok draws its + * composer glyph at 0.6 s and then shimmers its logo until 9.9 s, and its only title is its bare + * name, so a wait that holds that title to quiet output answers ten seconds after the composer. + */ + + const COMPOSER_FRAME_MS = GROK_STARTUP_PTY_TRACE.find((chunk) => chunk.data?.includes('❯'))?.t + const LAST_FRAME_MS = GROK_STARTUP_PTY_TRACE.at(-1)?.t ?? 0 + + async function replayGrokWorkerStart(): Promise { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'grok', + launchAgent: 'grok', + size: { cols: 120, rows: 30 }, + data: '' + }) + vi.useFakeTimers() + const startedAt = Date.now() + let settledAt: number | null = null + void waitForWorkerStartComposer(runtime, handle, 'grok', 60_000).then( + (wait) => { + settledAt = wait.satisfied ? Date.now() - startedAt : null + }, + () => {} + ) + for (const chunk of GROK_STARTUP_PTY_TRACE) { + await vi.advanceTimersByTimeAsync(Math.max(0, startedAt + chunk.t - Date.now())) + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + chunk.data ?? 'x'.repeat(chunk.bytes ?? 0), + Date.now() + ) + } + await vi.advanceTimersByTimeAsync(5_000) + return settledAt + } + + describe('a Grok worker start', () => { + afterEach(() => vi.useRealTimers()) + + it('is ready on its composer glyph, not once its logo stops animating', async () => { + expect(COMPOSER_FRAME_MS).toBeLessThan(1_000) + const settledAt = await replayGrokWorkerStart() + expect(settledAt).not.toBeNull() + expect(settledAt).toBeGreaterThanOrEqual(COMPOSER_FRAME_MS ?? 0) + // Within a second of the glyph: main's own wait answered on the title at once (2.2-2.8 s live). + expect(settledAt).toBeLessThan((COMPOSER_FRAME_MS ?? 0) + 1_000) + expect(settledAt).toBeLessThan(LAST_FRAME_MS) + }) + }) }) -// cline 3.0.66 on macOS with an isolated config; 3.0.65 on Windows recorded for PR #23269 (see -// each .meta.json); STA-8741. -const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) -const READY = [ - at120x40('cline-3-0-66-ready', 'promo dismissed, startup composer'), - at120x40('cline-3-0-66-ready-plan', 'Plan mode composer'), - { name: 'cline-3-0-66-ready-80x24', what: 'startup composer', cols: 80, rows: 24 }, - at120x40('cline-3-0-66-turn-ended', 'a turn has ended'), - at120x40('cline-3-0-65-win32-startup', 'Windows startup composer') -] -const NOT_READY = [ - at120x40('cline-3-0-66-promo', 'Cline Desktop promo over the composer'), - at120x40('cline-3-0-66-permission', 'tool approval prompt'), - at120x40('cline-3-0-66-slash-menu', 'slash menu open'), - at120x40('cline-3-0-66-draft', 'unsent text in the composer') -] -const STREAMING = 'cline-3-0-66-busy-streaming' - -describe('Cline readiness from captured bytes', () => { - describeScreenRuledAgentTranscripts({ - agent: 'cline', - foregroundProcess: 'cline', - ready: READY, - notReady: NOT_READY, - // Why all: an idle Cline is quiet, so the quiet-process lane settles it. - readyWithoutScreen: READY.map(({ name }) => name) +describe('Muse captured workspace readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined + + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) }) - // Presence precondition for the restored-pane suite: the read projection blanks this placeholder. - it('reads the ended turn through a projection that blanks its placeholder', async () => { - const { lines, draft } = await finalReadProjection('cline-3-0-66-turn-ended', 120, 40) - expect(lines).toContain('❯') - expect(draft).toBe('Ask anything...') + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined }) - // Why only quiescence can refuse it: the streaming reply has scrolled its spinner away. - it('paints the same empty composer while a reply streams', async () => { - const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40) - expect(readsIdleComposer('cline', ruledScreenLines)).toBe(true) + describe('Muse readiness from captured terminal bytes', () => { + it('recognizes a ready folder workspace without a skills summary', async () => { + const data = readFileSync( + join(__dirname, '__fixtures__', 'muse-empty-folder-ready.txt'), + 'utf8' + ) + expect(data).toContain(String.fromCharCode(27)) + expect(data).not.toContain('Skills:') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'muse-first-class-workspace', + foregroundProcess: 'muse-bin-1.3.0-R3401.1', + launchAgent: 'muse', + data + }) + await runtime.readTerminal(handle, { screen: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 10_000)).resolves.toMatchObject({ + satisfied: true + }) + }, 15_000) }) +}) - it('refuses every frame whose spinner row is still on screen', async () => { - let spinnerFrames = 0 - for await (const { ruledScreenLines } of replayTranscript( - readRuntimeFixture(STREAMING), - 120, - 40 - )) { - if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) { - spinnerFrames += 1 - expect(readsIdleComposer('cline', ruledScreenLines)).toBe(false) +describe('Qoder captured startup readiness', () => { + let machineNameStartSpy: { mockRestore(): void } | undefined + + beforeEach(() => { + machineNameStartSpy = vi + .spyOn(RuntimeMachineName.prototype, 'start') + .mockImplementation(() => {}) + }) + + afterEach(() => { + machineNameStartSpy?.mockRestore() + machineNameStartSpy = undefined + }) + + describe('captured Qoder 1.1.64 startup', () => { + it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( + 'preserves Qoder identity in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(title).toContain(' | Ready') + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qodercli-1.1.64', + launchAgent: 'qoder', + data, + size: { cols: 100, rows: 32 } + }) + const shown = await runtime.showTerminal(handle) + expect(shown.agentIdentity).toBe('qoder') + if (fixture === 'qoder-trust-dialog') { + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 600).catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + if (fixture === 'qoder-ready') { + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 1500) + expect(readiness.satisfied).toBe(true) + } } - } - // Presence precondition: the thinking spinner was painted above the composer. - expect(spinnerFrames).toBeGreaterThan(0) + ) }) - it('does not settle a streaming pane before it goes quiet', async () => { - const { runtime, handle } = await createTranscriptPane({ + describe('captured Qoder China 1.1.65 startup', () => { + it.each(['qoder-cn-startup', 'qoder-cn-signin'])( + 'checks the composer in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI CN') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qoderclicn', + launchAgent: 'qoder-cn', + data, + size: { cols: 120, rows: 40 } + }) + expect((await runtime.showTerminal(handle)).agentIdentity).toBe('qoder-cn') + await runtime.readTerminal(handle, { screen: true }) + const readiness = await waitForTranscriptIdle({ runtime, handle }, 800).catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + ) + }) +}) + +describe('ZCode captured composer readiness', () => { + function readTranscript(): string { + return readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8') + } + + describe('ZCode readiness from captured terminal bytes', () => { + it('accepts a fresh composer after the renderer adopts the terminal handle', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'worker-zcode', + foregroundProcess: 'zcode', + launchAgent: 'zcode', + data: '\x1b[?1049h╭' + }) + await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + }) + + it('never emits an OSC title, so no title lane can settle its wait', () => { + const data = readTranscript() + expect(data).toContain(String.fromCharCode(27)) + // Why: this absence is the whole reason ZCode needs a body-evidence readiness lane. + expect(data).not.toMatch(new RegExp(`${String.fromCharCode(27)}\\][0-2];`)) + }) + + it('keeps repainting long after the composer mounts, so a quiet window never settles', () => { + const data = readTranscript() + const composerIndex = data.indexOf('╭') + expect(composerIndex).toBeGreaterThan(-1) + // Why: ~175KB of banner animation after the composer is up. A quiet-render window + // measured in hundreds of ms cannot fire anywhere in that span. + expect(data.length - composerIndex).toBeGreaterThan(100_000) + }) + + it('leaves no durable readiness evidence in the wait-text tail', async () => { + const data = readTranscript() + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'zcode-first-class-workspace', + foregroundProcess: 'zcode', + launchAgent: 'zcode', + data + }) + // Why this asserts a NEGATIVE: Orca's wait text is a line-folded tail, and ZCode paints + // its composer once and then repaints only the banner — so the composer scrolls out and + // no screen rule can settle the wait. This is the evidence for driving ZCode readiness + // from its synthetic hook title instead (see synthetic-agent-title.ts). + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).rejects.toThrow(/timeout/) + }, 15_000) + + it('settles a tui-idle wait from the synthetic hook title Orca owns for ZCode', () => { + expect(getSyntheticAgentTerminalTitle('zcode', 'done')).toBe('ZCode ready') + expect(getSyntheticAgentTerminalTitle('zcode', 'waiting')).toBe('ZCode - action required') + expect(shouldDriveSyntheticAgentTitleFromHook('zcode', 'working')).toBe(true) + expect( + hasExplicitIdleTitle({ lastAgentStatus: 'idle', lastOutputAt: Date.now() }, 'ZCode ready') + ).toBe(true) + }) + + it('fires the draft-paste signal at the composer mount, not at the hard timeout', () => { + const data = readTranscript() + const scanner = createDraftPasteReadyScanner('zcode-composer-prompt') + const composerIndex = data.indexOf('╭') + // Why: feeding the stream in PTY-sized chunks proves the marker survives chunk splits. + let readyAt: number | null = null + for (let offset = 0; offset < data.length; offset += 4096) { + const chunk = data.slice(offset, offset + 4096) + if (scanner.observe(chunk).ready) { + readyAt = offset + chunk.length + break + } + } + expect(readyAt).not.toBeNull() + // Ready lands on the chunk that carries the composer corner, not thousands of frames later. + expect(readyAt!).toBeGreaterThanOrEqual(composerIndex) + expect(readyAt!).toBeLessThan(composerIndex + 8192) + }) + }) +}) + +describe('Shell titles during agent startup', () => { + /** + * A shell that titles the command it is about to run (zsh preexec auto-title) writes the agent's + * bare name before the agent's TUI has mounted. For an agent whose only rest signal is that name, + * the title alone used to settle `tui-idle`, so a launch pasted its prompt into a TUI still + * booting, or into the shell. A launch readiness wait holds that title to a quiet stream. + */ + + const ESC = String.fromCharCode(27) + const BEL = String.fromCharCode(7) + const BOOT_FRAME = 'Loading…\r\n' + + /** The shell echoes the launch line, then its preexec hook titles the pane with the command. */ + async function launchedPaneTitledByShell(agent: 'gemini' | 'copilot') { + return createTranscriptPane({ paneTitle: 'Terminal', - foregroundProcess: 'cline', - launchAgent: 'cline', - data: readRuntimeFixture(STREAMING), - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - // Why 2.5s: inside the 3s quiescence window, past the 2s poll. - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) - }, 15_000) + foregroundProcess: agent, + launchAgent: agent, + data: `$ ${agent}\r\n${ESC}]2;${agent}${BEL}` + }) + } + + /** The agent is still painting its boot screen: output keeps arriving each second. */ + async function keepBooting( + runtime: Awaited>['runtime'], + seconds: number + ): Promise { + for (let second = 0; second < seconds; second += 1) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, BOOT_FRAME, Date.now()) + await vi.advanceTimersByTimeAsync(1000) + } + } + + describe('a launch waiting on an agent whose shell titled the pane with its name', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // Gemini's bare name was once normalized into its rest glyph; Copilot's name is its only signal. + it.each(['gemini', 'copilot'] as const)( + 'does not treat the shell’s auto-title as readiness while %s is still booting', + async (agent) => { + const { runtime, handle } = await launchedPaneTitledByShell(agent) + const settled = vi.fn() + runtime + .waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 60_000, + launchReadiness: true + }) + .then(settled, settled) + + await keepBooting(runtime, 10) + + expect(settled).not.toHaveBeenCalled() + } + ) + + it('settles once the agent’s stream goes quiet under that title', async () => { + const { runtime, handle } = await launchedPaneTitledByShell('gemini') + const settled = vi.fn() + runtime + .waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 60_000, + launchReadiness: true + }) + .then(settled, settled) + + await keepBooting(runtime, 4) + await vi.advanceTimersByTimeAsync(8_000) + + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) + }) + + it('keeps settling a later, non-launch wait on the name alone, as agents that never quiet need', async () => { + const { runtime, handle } = await launchedPaneTitledByShell('gemini') + const settled = vi.fn() + runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + .then(settled, settled) + + await keepBooting(runtime, 10) + + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) + }) + }) }) diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts index 7ebc7a5d2b86..491e974e2294 100644 --- a/src/main/runtime/codex-header-readiness-transcripts.test.ts +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -15,6 +15,8 @@ import { isKnownReadyPromptSettled, isQuietReadyScreenBody } from './terminal-wait-detection' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' +import { clearWorkingIndicators } from '../../shared/agent-title-status' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -23,300 +25,587 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// codex-cli 0.157.1 recordings at 120x40 (see each .meta.json); STA-8628. -const PLAIN = 'codex-0157-plain-ready' -const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' -const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' -const NO_DAEMON = 'codex-0157-no-daemon-effort-override' -// Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. -const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' -const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] - -async function finalFrame( - name: string, - cols: number, - rows: number -): Promise { - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { - last = frame +describe('Codex captured header readiness', () => { + // codex-cli 0.157.1 recordings at 120x40 (see each .meta.json); STA-8628. + const PLAIN = 'codex-0157-plain-ready' + const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' + const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' + const NO_DAEMON = 'codex-0157-no-daemon-effort-override' + // Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. + const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' + const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] + + async function finalFrame( + name: string, + cols: number, + rows: number + ): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { + last = frame + } + if (!last) { + throw new Error(`empty fixture ${name}`) + } + return last } - if (!last) { - throw new Error(`empty fixture ${name}`) + + function screenShowsLoadingHeader(screenLines: string[]): boolean { + const screen = screenLines.join('\n').toLowerCase() + return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) } - return last -} - -function screenShowsLoadingHeader(screenLines: string[]): boolean { - const screen = screenLines.join('\n').toLowerCase() - return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) -} - -// Codex's default status row opens with ` ·`; only the live chat paints it. -const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ - -function screenShowsProvisionalStartup(screenLines: string[]): boolean { - return ( - screenShowsLoadingHeader(screenLines) && - !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) - ) -} - -describe('Codex 0.157 header readiness from captured bytes', () => { - it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( - '%s: the line-folded wait text never shows a ready header', - async (name) => { - const { waitText } = await finalFrame(name, 120, 40) - // Why: the cell-diff repaint folds to `dirctory:` — the STA-8628 timeout. - expect(waitText.toLowerCase()).toContain('dirctory:') - expect(isKnownReadyPromptPreview(waitText)).toBe(false) - } - ) - - it.each(ALL_FIXTURES)( - '%s: the screen never adds readiness while loading, and is ready at the final screen', - async (name) => { - let sawLoadingHeader = false - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - if (screenShowsLoadingHeader(frame.screenLines)) { - sawLoadingHeader = true - expect(isQuietReadyScreenBody('', 'codex', () => frame.screenLines)).toBe(false) - } - last = frame + + // Codex's default status row opens with ` ·`; only the live chat paints it. + const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ + + function screenShowsProvisionalStartup(screenLines: string[]): boolean { + return ( + screenShowsLoadingHeader(screenLines) && + !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) + ) + } + + describe('Codex 0.157 header readiness from captured bytes', () => { + it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( + '%s: the line-folded wait text never shows a ready header', + async (name) => { + const { waitText } = await finalFrame(name, 120, 40) + // Why: the cell-diff repaint folds to `dirctory:` — the STA-8628 timeout. + expect(waitText.toLowerCase()).toContain('dirctory:') + expect(isKnownReadyPromptPreview(waitText)).toBe(false) } - // Presence precondition: a loading frame was actually exercised. - expect(sawLoadingHeader).toBe(true) - expect(last).not.toBeNull() - expect(isQuietReadyScreenBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) - } - ) - - // Why: 0.157 discards input typed during its daemon start, behind the provisional header. - it.each(ALL_FIXTURES)( - '%s: never ready while the screen shows the provisional `model: loading` startup screen', - async (name) => { - let sawTextOnlyReadiness = false - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - if (screenShowsProvisionalStartup(frame.screenLines)) { - sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - false - ) - expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).toBe( - false - ) + ) + + it.each(ALL_FIXTURES)( + '%s: the screen never adds readiness while loading, and is ready at the final screen', + async (name) => { + let sawLoadingHeader = false + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsLoadingHeader(frame.screenLines)) { + sawLoadingHeader = true + expect(isQuietReadyScreenBody('', 'codex', () => frame.screenLines)).toBe(false) + } + last = frame } + // Presence precondition: a loading frame was actually exercised. + expect(sawLoadingHeader).toBe(true) + expect(last).not.toBeNull() + expect(isQuietReadyScreenBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) } - // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. - if (name === PLAIN || name === FRESH_HOME) { - expect(sawTextOnlyReadiness).toBe(true) - } - } - ) - - // Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475). - describe.each([ - [120, 40], - [80, 24], - [30, 50], - [108, 30], - [60, 5] - ])('at %ix%i the screen never takes a settled header away from the text rules', (cols, rows) => { - it.each(ALL_FIXTURES)('%s', async (name) => { - let settledFrames = 0 - for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { - if (isKnownReadyPromptSettled(frame.waitText)) { - settledFrames += 1 - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - true - ) + ) + + // Why: 0.157 discards input typed during its daemon start, behind the provisional header. + it.each(ALL_FIXTURES)( + '%s: never ready while the screen shows the provisional `model: loading` startup screen', + async (name) => { + let sawTextOnlyReadiness = false + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsProvisionalStartup(frame.screenLines)) { + sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + expect( + isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true) + ).toBe(false) + } + } + // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. + if (name === PLAIN || name === FRESH_HOME) { + expect(sawTextOnlyReadiness).toBe(true) } } - // Presence precondition: the text-copy fixtures reach a settled header. - if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { - expect(settledFrames).toBeGreaterThan(0) + ) + + // Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475). + describe.each([ + [120, 40], + [80, 24], + [30, 50], + [108, 30], + [60, 5] + ])( + 'at %ix%i the screen never takes a settled header away from the text rules', + (cols, rows) => { + it.each(ALL_FIXTURES)('%s', async (name) => { + let settledFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { + if (isKnownReadyPromptSettled(frame.waitText)) { + settledFrames += 1 + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + true + ) + } + } + // Presence precondition: the text-copy fixtures reach a settled header. + if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { + expect(settledFrames).toBeGreaterThan(0) + } + }) } + ) + + it('keeps the text rules when there is no live screen', async () => { + const { waitText } = await finalFrame(PLAIN, 120, 40) + expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe( + isKnownReadyPromptPreview(waitText) + ) + expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(true) }) - }) - it('keeps the text rules when there is no live screen', async () => { - const { waitText } = await finalFrame(PLAIN, 120, 40) - expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe( - isKnownReadyPromptPreview(waitText) - ) - expect(isQuietReadyScreenBody(waitText, 'codex', () => null)).toBe(true) - }) + it('does not read a mid-turn composer as ready', () => { + const screenLines = [ + '› Summarize the repository layout', + '• Working (12s • esc to interrupt)', + '› Ask Codex to do anything', + ' GPT-6-Sol high · ~/repo/app' + ] + expect(isQuietReadyScreenBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) + }) - it('does not read a mid-turn composer as ready', () => { - const screenLines = [ - '› Summarize the repository layout', - '• Working (12s • esc to interrupt)', - '› Ask Codex to do anything', - ' GPT-6-Sol high · ~/repo/app' - ] - expect(isQuietReadyScreenBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) - }) + it('does not settle when a blocking dialog is painted below the header', () => { + const screenLines = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │', + 'Do you trust the contents of this directory?', + 'Press enter to continue' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(false) + }) - it('does not settle when a blocking dialog is painted below the header', () => { - const screenLines = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │', - 'Do you trust the contents of this directory?', - 'Press enter to continue' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(false) - }) + it('reads only the header box, not chat below it that mentions Codex', () => { + const screenLines = [ + '╭──────────────────────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────────────────────╯', + '› Why does OpenAI Codex print model: loading at startup?' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + }) - it('reads only the header box, not chat below it that mentions Codex', () => { - const screenLines = [ - '╭──────────────────────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────────────────────╯', - '› Why does OpenAI Codex print model: loading at startup?' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - }) + it('never reads a non-codex screen, even one showing the Codex header', () => { + const screenLines = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │' + ] + const readScreenLines = vi.fn(() => screenLines) + expect(isQuietReadyScreenBody('', 'claude', readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) - it('never reads a non-codex screen, even one showing the Codex header', () => { - const screenLines = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │' - ] - const readScreenLines = vi.fn(() => screenLines) - expect(isQuietReadyScreenBody('', 'claude', readScreenLines)).toBe(false) - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) + describe('at the 80x24 default grid the header garbles and today’s answer stands', () => { + it.each(ALL_FIXTURES)('%s', async (name) => { + const { screenLines, waitText } = await finalFrame(name, 80, 24) + expect(isQuietReadyScreenBody(waitText, 'codex', () => screenLines)).toBe( + isKnownReadyPromptPreview(waitText) + ) + }) + }) - describe('at the 80x24 default grid the header garbles and today’s answer stands', () => { - it.each(ALL_FIXTURES)('%s', async (name) => { - const { screenLines, waitText } = await finalFrame(name, 80, 24) - expect(isQuietReadyScreenBody(waitText, 'codex', () => screenLines)).toBe( - isKnownReadyPromptPreview(waitText) + describe('through the runtime', () => { + async function codexPane(name: string, size?: { cols: number; rows: number }) { + const created = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size + }) + await created.runtime.readTerminal(created.handle, { screen: true }) + return created + } + + it.each(ALL_FIXTURES)( + '%s: a tui-idle wait settles from the live screen', + async (name) => { + const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, + 15_000 ) + + it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { + const data = readRuntimeFixture(FRESH_HOME) + const install = data.indexOf('Installing daemon') + // Presence precondition: the cut keeps the provisional header and stops before the live chat. + expect(install).toBeGreaterThan(0) + const provisional = data.slice(0, data.indexOf('\n', install) + 1) + expect(provisional).toMatch(/model:.*loading/) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: provisional, + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + // Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds. + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) + + // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the + // runtime asks for a visible-screen read instead of its own text copy. + it('does not settle from a visible-screen read of the provisional screen', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '', + size: { cols: 120, rows: 40 } + }) + await runtime.readTerminal(handle, { screen: true }) + const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.0) │', + '│ model: loading /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' ? for shortcuts' + ], + truncated: false, + nextCursor: null, + source: 'screen' + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) + // Presence precondition: the visible-screen probe actually ran. + expect(readVisibleScreen).toHaveBeenCalled() + }, 15_000) + + // Why: restored bytes set no lastOutputAt, so the quiet lane has no clock to wait out. + it('settles a restored Codex pane from its header, as main did', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '' + }) + await runtime.readTerminal(handle, { screen: true }) + runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, { + text: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: gpt-6-sol high /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' gpt-6-sol high · ~/repo/app' + ].join('\r\n') + }) + // Why no screen: the visible-screen probe must not be what settles the wait. + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [], + truncated: false, + nextCursor: null, + source: 'screen-unavailable' + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it('keeps timing out on the garbled 80x24 default grid, as before', async () => { + const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) }) }) +}) - describe('through the runtime', () => { - async function codexPane(name: string, size?: { cols: number; rows: number }) { - const created = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: readRuntimeFixture(name), - size - }) - await created.runtime.readTerminal(created.handle, { screen: true }) - return created +describe('Codex launched composer readiness', () => { + // A source-control button's multi-line prompt reaches Codex only through this wait, so a Codex + // release that the launch wait cannot read ready reports "prompt wasn't sent". + async function launchedCodexPane(data: string) { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data, + size: { cols: 120, rows: 40 } + }) + } + + async function waitForCodexComposer( + pane: Awaited>, + timeoutMs: number, + streamedData?: string + ) { + // Pane creation uses real timers; only the readiness budget advances virtually. + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + try { + const ready = waitForLaunchedAgentComposer(pane.runtime, pane.handle, 'codex', timeoutMs) + void ready.catch(() => {}) + if (streamedData !== undefined) { + pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, streamedData, Date.now()) + } + await vi.advanceTimersByTimeAsync(timeoutMs + 1) + return await ready + } finally { + vi.useRealTimers() } + } - it.each(ALL_FIXTURES)( - '%s: a tui-idle wait settles from the live screen', - async (name) => { - const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) - // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true + // Why cut: on an Astra model 0.158 sparkles braille stars in the empty composer, redrawing every + // 150 ms, then repaints it clean after 15 s. The capture ends on the first star frame, so replayed + // whole it holds a starred composer still, which the live stream never does. + function greetingBeforeStarfield(): string { + const data = readRuntimeFixture('codex-0158-fresh-home-greeting') + const firstStar = data.search(/38;2;\d+;\d+;\d+;48;2;30;30;30m[⠀-⣿]/) + return data.slice(0, data.lastIndexOf('\x1b[?2026h', firstStar)) + } + + describe('launch readiness for a freshly launched Codex', () => { + it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => { + const data = greetingBeforeStarfield() + // Presence precondition: the cut keeps the live status row and drops every star. + expect(data).toContain('GPT-6-Astra default') + expect(data).not.toMatch(/48;2;30;30;30m[⠀-⣿]/) + const pane = await launchedCodexPane(data) + await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => { + const pane = await launchedCodexPane(readRuntimeFixture('codex-0157-plain-ready')) + await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it.each([ + ['codex-0158-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], + ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], + ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] + ])( + '%s: stops at the dialog as %s instead of pasting into it', + async (fixture, reason) => { + const pane = await launchedCodexPane(readRuntimeFixture(fixture)) + await expect(waitForCodexComposer(pane, 2_500)).resolves.toMatchObject({ + satisfied: false, + blockedReason: reason + }) + }, + 15_000 + ) + + // Why streamed: live, the dialog's `›` selector arrives after bracketed paste and fires the + // composer signal, so only the screen check keeps it from pasting; it hands the dialog to + // `tui-idle` to report, well inside the desktop paste's budget. + it.each([ + ['codex-0157-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], + ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], + ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] + ])( + '%s streamed in after the launch: reports the dialog as %s, never as the composer', + async (fixture, reason) => { + const data = readRuntimeFixture(fixture) + // Presence precondition: the dialog draws Codex's composer glyph after bracketed paste. + expect(data.slice(data.indexOf('\x1b[?2004h'))).toContain('›') + const pane = await launchedCodexPane('') + await expect(waitForCodexComposer(pane, 2_500, data)).resolves.toMatchObject({ + satisfied: false, + blockedReason: reason }) }, 15_000 ) - it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { - const data = readRuntimeFixture(FRESH_HOME) - const install = data.indexOf('Installing daemon') + // Why: 0.157 discards input typed behind its provisional `model: loading` screen. + it('codex-0157-fresh-home-daemon-install: does not read the provisional screen as ready', async () => { + const full = readRuntimeFixture('codex-0157-fresh-home-daemon-install') + const install = full.indexOf('Installing daemon') // Presence precondition: the cut keeps the provisional header and stops before the live chat. expect(install).toBeGreaterThan(0) - const provisional = data.slice(0, data.indexOf('\n', install) + 1) + const provisional = full.slice(0, full.indexOf('\n', install) + 1) expect(provisional).toMatch(/model:.*loading/) - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: provisional, - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - // Why 6s: past the 3s quiescence, so the quiet lane's provisional veto is what holds. - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + const pane = await launchedCodexPane(provisional) + await expect(waitForCodexComposer(pane, 5_000)).rejects.toThrow(/timeout/) }, 15_000) + }) +}) - // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the - // runtime asks for a visible-screen read instead of its own text copy. - it('does not settle from a visible-screen read of the provisional screen', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: '', - size: { cols: 120, rows: 40 } - }) - await runtime.readTerminal(handle, { screen: true }) - const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle, - status: 'running', - tail: [ - '╭──────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.0) │', - '│ model: loading /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────╯', - '› Ask Codex to do anything', - ' ? for shortcuts' - ], - truncated: false, - nextCursor: null, - source: 'screen' - }) - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).rejects.toThrow(/timeout/) - // Presence precondition: the visible-screen probe actually ran. - expect(readVisibleScreen).toHaveBeenCalled() - }, 15_000) +describe('Display title clearing and live agent presence', () => { + // Synthetic protocol cases: they test which title readiness, display and presence each read, + // not what a particular CLI paints. The 3 s timer is the stale-working-title clear. + describe('the stale-working title clear is display-only', () => { + it.each([ + ['claude', '. claude', '* claude'], + ['codex', '⠋ Codex working', 'Codex ready'], + ['gemini', '✦ Gemini CLI', '◇ Gemini CLI'], + ['opencode', '⠋ OC | task', 'OC | task'], + ['pi', 'π : project', 'π - project'] + ] as const)( + '%s does not become ready when its display title clears', + async (agent, busy, idle) => { + vi.useFakeTimers() + try { + const titleChanges: string[] = [] + const { runtime, handle } = await createTranscriptPane( + { + paneTitle: busy, + foregroundProcess: agent, + data: '', + launchAgent: agent + }, + { + onTerminalSideEffects: (batch) => { + for (const fact of batch.facts) { + if (fact.kind === 'title') { + titleChanges.push(fact.normalizedTitle) + } + } + } + } + ) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${busy}\x07`, Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + expect(titleChanges.at(-1)).toBe(clearWorkingIndicators(busy)) + // A renderer graph publish can carry the display-only cleared title back to main. + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: clearWorkingIndicators(busy), + activeLeafId: '11111111-1111-4111-8111-111111111111', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneRuntimeId: 1, + ptyId: TRANSCRIPT_PANE_PTY_ID, + paneTitle: clearWorkingIndicators(busy) + } + ] + }) + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) + const assertion = expect(waiting).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(200)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${idle}\x07`, Date.now()) + const ready = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + const readyAssertion = expect(ready).resolves.toMatchObject({ satisfied: true }) + await Promise.all([readyAssertion, vi.advanceTimersByTimeAsync(2_000)]) + } finally { + vi.useRealTimers() + } + } + ) + + // A name-shaped title (`⠋ Codex working`) clears to `Codex`, which still names an agent, as on main. + it.each([['⠋ repo']])( + 'an agent that exited behind %s does not pass for a running agent', + async (title) => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'zsh', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${title}\x07`, Date.now()) + // The agent exits and the shell prints its prompt: output, but no title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + } + ) + + it('a live agent behind a cleared title is still found by its process', async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("the agent's next genuine title proves presence again", async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠙ repo\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + } finally { + vi.useRealTimers() + } + }) - // Why: restored bytes set no lastOutputAt, so the quiet lane has no clock to wait out. - it('settles a restored Codex pane from its header, as main did', async () => { + it('a live Cursor spinner title proves presence when no foreground read can answer', async () => { const { runtime, handle } = await createTranscriptPane({ paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', + foregroundProcess: null, data: '' }) - await runtime.readTerminal(handle, { screen: true }) - runtime.seedTerminalRestoreTail(TRANSCRIPT_PANE_PTY_ID, { - text: [ - '╭──────────────────────────────────────────╮', - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: gpt-6-sol high /model to change │', - '│ directory: ~/repo/app │', - '╰──────────────────────────────────────────╯', - '› Ask Codex to do anything', - ' gpt-6-sol high · ~/repo/app' - ].join('\r\n') + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' }) - // Why no screen: the visible-screen probe must not be what settles the wait. - vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle, - status: 'running', - tail: [], - truncated: false, - nextCursor: null, - source: 'screen-unavailable' - }) - await expect(waitForTranscriptIdle({ runtime, handle }, 2_500)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) - - it('keeps timing out on the garbled 80x24 default grid, as before', async () => { - const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) - }, 15_000) + }) }) }) diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index db6077a723e6..032f89908a54 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -21,6 +21,7 @@ import { isTuiIdleReadyVerdict, type TuiIdleEvaluationInput } from './tui-idle-evidence' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -29,272 +30,336 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const QUIESCENCE_MS = 3000 - -// codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn, -// and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's. -const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn'] -const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES] -const DIALOG_FIXTURES = [ - 'codex-0-157-1-update-dialog', - 'codex-0-158-0-approval', - 'codex-0-158-0-trustprompt' -] -const STA_8628_FIXTURES = [ - 'codex-0157-plain-ready', - 'codex-0157-effort-override-embedded-warning', - 'codex-0157-config-override-embedded-warning', - 'codex-0157-no-daemon-effort-override' -] - -const BUSY_STATUS_RE = /to interrupt\)/ -const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m -const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m -const DIALOG_RE = - /update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/ - -function screenOf(frame: TranscriptReplayFrame): string { - return frame.screenLines.join('\n').toLowerCase() -} - -async function collectFrames( - data: string | readonly string[], - cols = 120, - rows = 40 -): Promise { - const frames: TranscriptReplayFrame[] = [] - for await (const frame of replayTranscript(data, cols, rows)) { - frames.push(frame) +describe('Codex quiet ready screen', () => { + const QUIESCENCE_MS = 3000 + + // codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn, + // and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's. + const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn'] + const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES] + const DIALOG_FIXTURES = [ + 'codex-0-157-1-update-dialog', + 'codex-0-158-0-approval', + 'codex-0-158-0-trustprompt' + ] + const STA_8628_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override' + ] + + const BUSY_STATUS_RE = /to interrupt\)/ + const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m + const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m + const DIALOG_RE = + /update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/ + + function screenOf(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n').toLowerCase() } - return frames -} - -describe('Codex composer ready screen, frame by frame', () => { - it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])( - '%s: never ready while loading, mid-turn, or under a drawn dialog', - async (name) => { - const seen = { loading: 0, busy: 0, dialog: 0 } - for (const frame of await collectFrames(readRuntimeFixture(name))) { - const screen = screenOf(frame) - // Why only once the composer is gone: a half-drawn dialog keeps it for a few frames, - // which quiescence absorbs; a drawn dialog replaces it. - const kind = HEADER_LOADING_RE.test(screen) - ? 'loading' - : BUSY_STATUS_RE.test(screen) - ? 'busy' - : DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen) - ? 'dialog' - : null - if (kind) { - seen[kind] += 1 - expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({ - kind, - ready: false - }) + + async function collectFrames( + data: string | readonly string[], + cols = 120, + rows = 40 + ): Promise { + const frames: TranscriptReplayFrame[] = [] + for await (const frame of replayTranscript(data, cols, rows)) { + frames.push(frame) + } + return frames + } + + describe('Codex composer ready screen, frame by frame', () => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])( + '%s: never ready while loading, mid-turn, or under a drawn dialog', + async (name) => { + const seen = { loading: 0, busy: 0, dialog: 0 } + for (const frame of await collectFrames(readRuntimeFixture(name))) { + const screen = screenOf(frame) + // Why only once the composer is gone: a half-drawn dialog keeps it for a few frames, + // which quiescence absorbs; a drawn dialog replaces it. + const kind = HEADER_LOADING_RE.test(screen) + ? 'loading' + : BUSY_STATUS_RE.test(screen) + ? 'busy' + : DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen) + ? 'dialog' + : null + if (kind) { + seen[kind] += 1 + expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({ + kind, + ready: false + }) + } } + // Presence preconditions: each fixture exercises the states it was recorded for. + expect(seen.loading).toBeGreaterThan(0) + if (DIALOG_FIXTURES.includes(name)) { + expect(seen.dialog).toBeGreaterThan(0) + } else { + expect(seen.busy).toBeGreaterThan(0) + } + }, + 30_000 + ) + + it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])( + '%s: ready on the final idle screen', + async (name) => { + const frames = await collectFrames(readRuntimeFixture(name)) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true) } - // Presence preconditions: each fixture exercises the states it was recorded for. - expect(seen.loading).toBeGreaterThan(0) - if (DIALOG_FIXTURES.includes(name)) { - expect(seen.dialog).toBeGreaterThan(0) - } else { - expect(seen.busy).toBeGreaterThan(0) - } - }, - 30_000 - ) + ) - it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])( - '%s: ready on the final idle screen', - async (name) => { + it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => { const frames = await collectFrames(readRuntimeFixture(name)) - expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true) - } - ) - - it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => { - const frames = await collectFrames(readRuntimeFixture(name)) - expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false) - }) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false) + }) - it('is not ready without a live screen', () => { - expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false) - }) + it('is not ready without a live screen', () => { + expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false) + }) - it('reads the placeholder only as the composer line, not quoted mid-line', () => { - const quoted = ['$ grep placeholder notes.txt', 'the box says › Ask Codex to do anything', '$'] - expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false) - }) + it('reads the placeholder only as the composer line, not quoted mid-line', () => { + const quoted = [ + '$ grep placeholder notes.txt', + 'the box says › Ask Codex to do anything', + '$' + ] + expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false) + }) - it('reads the busy row only above the composer, not a status row quoted in the answer', () => { - const screenLines = [ - '>_ OpenAI Codex (v0.158.0)', - ' ~/repo', - '', - '› what does the status row look like mid-turn?', - '', - '• It reads like this:', - '', - ' • Working (0s • esc to interrupt)', - '', - ' The timer counts up until the turn ends, and the row', - ' disappears once the answer is complete.', - '', - '› Ask Codex to do anything', - '', - ' gpt-5.6-sol medium · ~/repo', - ' ? for shortcuts' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - const composer = screenLines.indexOf('› Ask Codex to do anything') - const busy = [ - ...screenLines.slice(0, composer), - '• Working (3s • esc to interrupt)', - ' └ Tip: start a fresh idea with /new', - '', - ...screenLines.slice(composer) - ] - expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false) - }) + it('reads the busy row only above the composer, not a status row quoted in the answer', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› what does the status row look like mid-turn?', + '', + '• It reads like this:', + '', + ' • Working (0s • esc to interrupt)', + '', + ' The timer counts up until the turn ends, and the row', + ' disappears once the answer is complete.', + '', + '› Ask Codex to do anything', + '', + ' gpt-5.6-sol medium · ~/repo', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + const composer = screenLines.indexOf('› Ask Codex to do anything') + const busy = [ + ...screenLines.slice(0, composer), + '• Working (3s • esc to interrupt)', + ' └ Tip: start a fresh idea with /new', + '', + ...screenLines.slice(composer) + ] + expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false) + }) - it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => { - // Live 0.158 screen after a worker answered with a status row verbatim (STA-8834). - const screenLines = [ - ' === TASK ===', - ' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.', - '• • Working (0s • esc to interrupt)', - '• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…', - ' └ Sent msg_ff7be3f25d7e', - ' + Show details', - '• • Working (0s • esc to interrupt)', - ' 11:15 PM', - '› Ask Codex to do anything', - ' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome', - ' ? for shortcuts' - ] - expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) - }) + it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => { + // Live 0.158 screen after a worker answered with a status row verbatim (STA-8834). + const screenLines = [ + ' === TASK ===', + ' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.', + '• • Working (0s • esc to interrupt)', + '• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…', + ' └ Sent msg_ff7be3f25d7e', + ' + Show details', + '• • Working (0s • esc to interrupt)', + ' 11:15 PM', + '› Ask Codex to do anything', + ' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + }) - it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => { - const screenLines = [ - '>_ OpenAI Codex (v0.158.0)', - ' ~/repo', - '', - '› tidy the README', - '', - '• Done. Would you like to proceed with the changelog too?', - '', - '› Ask Codex to do anything', - '', - ' ? for shortcuts' - ] - const waitText = screenLines.join('\n') - const verdict = evaluateTuiIdle({ - record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(waitText, 'codex', () => screenLines), - agent: 'codex', - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS + it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› tidy the README', + '', + '• Done. Would you like to proceed with the changelog too?', + '', + '› Ask Codex to do anything', + '', + ' ? for shortcuts' + ] + const waitText = screenLines.join('\n') + const verdict = evaluateTuiIdle({ + record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + agent: 'codex', + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) + expect(verdict.kind).toBe('ready-strong') }) - expect(verdict.kind).toBe('ready-strong') }) -}) -type Timing = { promptSentAtMs: number; chunks: [number, number][] } - -function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { - const data = readRuntimeFixture(name) - const timing: Timing = JSON.parse( - readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') - ) - const chunks: string[] = [] - let offset = 0 - for (const [, length] of timing.chunks) { - chunks.push(data.slice(offset, offset + length)) - offset += length + type Timing = { promptSentAtMs: number; chunks: [number, number][] } + + function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { + const data = readRuntimeFixture(name) + const timing: Timing = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') + ) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + expect(offset).toBe(data.length) + return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } } - expect(offset).toBe(data.length) - return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } -} -describe('the quiet lane over recorded chunk timing (default animations)', () => { - afterEach(() => { - vi.useRealTimers() + describe('the quiet lane over recorded chunk timing (default animations)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(TIMED_FIXTURES)( + '%s: never settles mid-turn, and settles once the finished turn is quiet', + async (name) => { + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + // Why after the replay: the emulator's write flush runs on real timers. + vi.useFakeTimers() + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + expect(lastBusy).toBeGreaterThan(firstTurnChunk) + // The latest moment each frame stays on screen: just before the next chunk lands. + const settlesAt = (index: number, now: number): boolean => { + vi.setSystemTime(now) + return hasQuietReadyScreen( + { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + 'codex', + () => + isQuietReadyScreenBody( + frames[index]!.waitText, + 'codex', + () => frames[index]!.screenLines + ), + QUIESCENCE_MS + ) + } + let readyBodyMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isCodexComposerReadyScreen(screenOf(frames[index]!))) { + readyBodyMidTurn += 1 + } + expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({ + index, + settles: false + }) + } + // Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing. + expect(readyBodyMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + // Why +1: the fake clock keeps whole milliseconds; the recorded times do not. + expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true) + }, + 120_000 + ) }) - it.each(TIMED_FIXTURES)( - '%s: never settles mid-turn, and settles once the finished turn is quiet', - async (name) => { - const { chunks, times, promptAt } = readTimedFixture(name) - const frames = await collectFrames(chunks) - // Why after the replay: the emulator's write flush runs on real timers. - vi.useFakeTimers() - const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) - const firstTurnChunk = times.findIndex((at) => at >= promptAt) - expect(lastBusy).toBeGreaterThan(firstTurnChunk) - // The latest moment each frame stays on screen: just before the next chunk lands. - const settlesAt = (index: number, now: number): boolean => { - vi.setSystemTime(now) - return hasQuietReadyScreen( - { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, - 'codex', - () => - isQuietReadyScreenBody( - frames[index]!.waitText, - 'codex', - () => frames[index]!.screenLines - ), - QUIESCENCE_MS - ) - } - let readyBodyMidTurn = 0 - for (let index = firstTurnChunk; index <= lastBusy; index += 1) { - if (isCodexComposerReadyScreen(screenOf(frames[index]!))) { - readyBodyMidTurn += 1 + describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(['codex-0-155-1-timed-turn', 'codex-0-157-1-timed-sleep-turn'])( + '%s: never settles tui-idle mid-turn, and settles once the finished turn is quiet', + async (name) => { + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + vi.useFakeTimers() + const verdictAt = (index: number, now: number) => { + vi.setSystemTime(now) + const { waitText, screenLines } = frames[index]! + return evaluateTuiIdle({ + record: { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + agent: 'codex', + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) } - expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({ - index, - settles: false - }) - } - // Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing. - expect(readyBodyMidTurn).toBeGreaterThan(0) - const last = frames.length - 1 - // Why +1: the fake clock keeps whole milliseconds; the recorded times do not. - expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true) - }, - 120_000 - ) -}) + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + let headerMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isKnownReadyPromptPreview(frames[index]!.waitText)) { + headerMidTurn += 1 + } + const settles = isTuiIdleReadyVerdict(verdictAt(index, times[index + 1]! - 1)) + expect({ index, settles }).toEqual({ index, settles: false }) + } + // Presence precondition: the ready header is in the tail mid-turn, so the fix is load-bearing. + expect(headerMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + expect(verdictAt(last, times[last]!).kind).toBe('pending') + expect(verdictAt(last, times[last]! + QUIESCENCE_MS + 1).kind).toBe('ready-strong') + }, + 120_000 + ) -describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { - afterEach(() => { - vi.useRealTimers() - }) + const header = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │' + ] - it.each(['codex-0-155-1-timed-turn', 'codex-0-157-1-timed-sleep-turn'])( - '%s: never settles tui-idle mid-turn, and settles once the finished turn is quiet', - async (name) => { - const { chunks, times, promptAt } = readTimedFixture(name) - const frames = await collectFrames(chunks) - vi.useFakeTimers() - const verdictAt = (index: number, now: number) => { - vi.setSystemTime(now) - const { waitText, screenLines } = frames[index]! - return evaluateTuiIdle({ - record: { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + it('reads the header as tier-1 evidence only for a pane with no output clock', () => { + const waitText = header.join('\n') + expect(isKnownReadyPromptBody(waitText, 'codex', () => header, true)).toBe(false) + expect(isKnownReadyPromptBody(waitText, 'codex', () => header, false)).toBe(true) + expect(isKnownReadyPromptBody(waitText, null, () => header, true)).toBe(true) + }) + + // Why: a restored or reattached pane has no lastOutputAt, so the quiet lane can never fire. + const NOW = 60_000 + it.each([ + [null, 'ready-strong'], + [0, 'ready-strong'], + [NOW - 1_000, 'pending'] + ] as const)( + 'a codex pane whose lastOutputAt is %s reads its header as %s', + (lastOutputAt, kind) => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const waitText = header.join('\n') + const record = { lastAgentStatus: null, lastOutputAt, lastOscTitle: null } + const verdict = evaluateTuiIdle({ + record, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => screenLines, true), - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), agent: 'codex', readAgentRuleVerdict: () => null, readScreenInputVeto: () => null, @@ -302,172 +367,305 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) + expect(verdict.kind).toBe(kind) } - const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) - const firstTurnChunk = times.findIndex((at) => at >= promptAt) - let headerMidTurn = 0 - for (let index = firstTurnChunk; index <= lastBusy; index += 1) { - if (isKnownReadyPromptPreview(frames[index]!.waitText)) { - headerMidTurn += 1 - } - const settles = isTuiIdleReadyVerdict(verdictAt(index, times[index + 1]! - 1)) - expect({ index, settles }).toEqual({ index, settles: false }) - } - // Presence precondition: the ready header is in the tail mid-turn, so the fix is load-bearing. - expect(headerMidTurn).toBeGreaterThan(0) - const last = frames.length - 1 - expect(verdictAt(last, times[last]!).kind).toBe('pending') - expect(verdictAt(last, times[last]! + QUIESCENCE_MS + 1).kind).toBe('ready-strong') - }, - 120_000 - ) + ) + }) - const header = [ - '│ >_ OpenAI Codex (v0.157.1) │', - '│ model: GPT-6-Sol high /model to change │', - '│ directory: ~/repo/app │' - ] + describe('reading the live screen never removes quiet-lane readiness', () => { + const records = [ + { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, + { lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' } + ] + describe.each([ + [120, 40], + [80, 24] + ])('at %ix%i', (cols, rows) => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])( + '%s', + async (name) => { + for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) { + for (const agent of ['codex', null] as const) { + const base = { + readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), + agent, + readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + } satisfies Omit + for (const record of records) { + const before = evaluateTuiIdle({ + ...base, + record, + // Why a withheld screen: isolates what the live screen adds to the quiet lane. + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => null) + }) + const after = evaluateTuiIdle({ + ...base, + record, + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines) + }) + if (isTuiIdleReadyVerdict(before)) { + expect(isTuiIdleReadyVerdict(after)).toBe(true) + } + } + } + } + }, + 60_000 + ) + }) + }) + + describe('agent gate', () => { + const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts'] + + it("never reads another agent's screen, even one showing Codex's placeholder", () => { + const readScreenLines = vi.fn(() => placeholderScreen) + for (const agent of ['claude', 'muse', 'gemini'] as const) { + expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false) + } + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) - it('reads the header as tier-1 evidence only for a pane with no output clock', () => { - const waitText = header.join('\n') - expect(isKnownReadyPromptBody(waitText, 'codex', () => header, true)).toBe(false) - expect(isKnownReadyPromptBody(waitText, 'codex', () => header, false)).toBe(true) - expect(isKnownReadyPromptBody(waitText, null, () => header, true)).toBe(true) + it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => { + const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ '] + const readScreenLines = vi.fn(() => catted) + expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) }) - // Why: a restored or reattached pane has no lastOutputAt, so the quiet lane can never fire. - const NOW = 60_000 - it.each([ - [null, 'ready-strong'], - [0, 'ready-strong'], - [NOW - 1_000, 'pending'] - ] as const)( - 'a codex pane whose lastOutputAt is %s reads its header as %s', - (lastOutputAt, kind) => { - vi.useFakeTimers() - vi.setSystemTime(NOW) - const waitText = header.join('\n') - const record = { lastAgentStatus: null, lastOutputAt, lastOscTitle: null } - const verdict = evaluateTuiIdle({ - record, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), - readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), - agent: 'codex', - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS + describe('through the runtime', () => { + // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. + const CODEX_0158 = 'codex-0-158-0-timed-turn' + async function pane( + name: string, + launchAgent: TuiAgent, + size?: { cols: number; rows: number } + ) { + const created = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: launchAgent, + launchAgent, + data: readRuntimeFixture(name), + size }) - expect(verdict.kind).toBe(kind) + await created.runtime.readTerminal(created.handle, { screen: true }) + return created } - ) + + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. + it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 15_000) + + it('keeps a Claude pane showing the same screen pending', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'claude', { + cols: 120, + rows: 40 + }) + await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) + }, 15_000) + }) }) -describe('reading the live screen never removes quiet-lane readiness', () => { - const records = [ - { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, - { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, - { lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' } +describe('Codex startup dialogs', () => { + type StartupDialog = { + name: string + reason: RuntimeTerminalWaitBlockedReason + heading: string + keyRow: string + } + + // codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). + // Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. + const DIALOGS: StartupDialog[] = [ + { + // Recorded separately on 0.157.1 (STA-8834). + name: 'codex-0-157-1-update-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0158-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0158-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0157-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer available', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer offered', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-announcement-dialog', + reason: 'codex-model-migration-prompt', + heading: 'Try new model', + keyRow: 'enter/esc confirm · ctrl+c quit' + } ] - describe.each([ - [120, 40], - [80, 24] - ])('at %ix%i', (cols, rows) => { - it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])( - '%s', - async (name) => { - for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) { - for (const agent of ['codex', null] as const) { - const base = { - readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), - agent, - readAgentRuleVerdict: () => null, - readScreenInputVeto: () => null, - titleObservedAtEpochMs: null, - firstPartyStatus: null, - quiescenceMs: QUIESCENCE_MS - } satisfies Omit - for (const record of records) { - const before = evaluateTuiIdle({ - ...base, - record, - // Why a withheld screen: isolates what the live screen adds to the quiet lane. - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(frame.waitText, agent, () => null) - }) - const after = evaluateTuiIdle({ - ...base, - record, - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines) - }) - if (isTuiIdleReadyVerdict(before)) { - expect(isTuiIdleReadyVerdict(after)).toBe(true) - } - } + const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) + const LIVE_CHAT_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override', + 'codex-0157-fresh-home-daemon-install', + 'codex-0158-fresh-home-greeting' + ] + + function screenText(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n') + } + + async function lastFrame(data: string): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(data, 120, 40)) { + last = frame + } + return last + } + + // Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup + // dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. + function answered(name: string): string { + const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') + return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) + } + + describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { + it.each(DIALOGS)( + '$name: reports $reason from its key row on, and never reads ready while it is up', + async ({ name, reason, heading, keyRow }) => { + let headingFrames = 0 + let keyRowFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + const screen = screenText(frame) + if (screen.includes(heading)) { + headingFrames += 1 + expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + expect( + isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true) + ).toBe(false) + } + if (keyRowFrames > 0 || screen.includes(keyRow)) { + keyRowFrames += 1 + expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) } } - }, - 60_000 + // Presence precondition: the dialog and its key row were painted, not just parsed. + expect(headingFrames).toBeGreaterThan(0) + expect(keyRowFrames).toBeGreaterThan(0) + } ) - }) -}) - -describe('agent gate', () => { - const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts'] - it("never reads another agent's screen, even one showing Codex's placeholder", () => { - const readScreenLines = vi.fn(() => placeholderScreen) - for (const agent of ['claude', 'muse', 'gemini'] as const) { - expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false) - } - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) + it.each(DIALOGS_0158)( + '$name: stops reporting once Codex repaints its chat after it', + async ({ name, heading }) => { + const last = await lastFrame(answered(name)) + // Presence precondition: the answered dialog is still in the text copy. + expect(last?.waitText).toContain(heading.replace(' ·', '')) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() + } + ) - it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => { - const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ '] - const readScreenLines = vi.fn(() => catted) - expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false) - expect(readScreenLines).not.toHaveBeenCalled() - expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) - }) -}) + it.each(DIALOGS)( + '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', + async ({ name, reason }) => { + // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. + const dialog = readRuntimeFixture(name) + const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) + expect( + isQuietReadyScreenBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) + ).toBe(false) + } + ) -describe('through the runtime', () => { - // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. - const CODEX_0158 = 'codex-0-158-0-timed-turn' - async function pane(name: string, launchAgent: TuiAgent, size?: { cols: number; rows: number }) { - const created = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: launchAgent, - launchAgent, - data: readRuntimeFixture(name), - size + it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { + const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. + const chat = [ + '› is there an update available, and do my hooks need review?', + '✨ Update available! 0.158.0 -> 0.159.0', + 'Run npm install -g @openai/codex to update.', + ' gpt-6-astra default · ~/repo', + ' ← for agents · ? for shortcuts' + ].join('\n') + expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() }) - await created.runtime.readTerminal(created.handle, { screen: true }) - return created - } - // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. - it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { - const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true + it('does not name a mid-session Codex popup a hooks review', () => { + // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. + const popup = [ + ' Use this reset?', + ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', + '› 2. No, go back Choose a different reset', + ' enter confirm · esc back' + ].join('\n') + expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') }) - }, 15_000) - it('keeps a Claude pane showing the same screen pending', async () => { - const { runtime, handle } = await pane(CODEX_0158, 'claude', { - cols: 120, - rows: 40 + describe('through the runtime', () => { + it.each(DIALOGS)( + '$name: stops a tui-idle wait as $reason instead of typing into it', + async ({ name, reason }) => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) + }, + 15_000 + ) }) - await expect(waitForTranscriptIdle({ runtime, handle }, 6_000)).rejects.toThrow(/timeout/) - }, 15_000) + }) }) diff --git a/src/main/runtime/codex-startup-dialog-transcripts.test.ts b/src/main/runtime/codex-startup-dialog-transcripts.test.ts deleted file mode 100644 index ac636eb8a052..000000000000 --- a/src/main/runtime/codex-startup-dialog-transcripts.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' -import { - readRuntimeFixture, - replayTranscript, - type TranscriptReplayFrame -} from './agent-transcript-replay-test-harness' -import { - detectTerminalWaitBlockedReason, - isKnownReadyPromptBody, - isQuietReadyScreenBody -} from './terminal-wait-detection' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -type StartupDialog = { - name: string - reason: RuntimeTerminalWaitBlockedReason - heading: string - keyRow: string -} - -// codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). -// Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. -const DIALOGS: StartupDialog[] = [ - { - // Recorded separately on 0.157.1 (STA-8834). - name: 'codex-0-157-1-update-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0157-update-available-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0158-update-available-dialog', - reason: 'agent-update-prompt', - heading: 'Update available ·', - keyRow: 'enter continue · esc skip' - }, - { - name: 'codex-0157-hooks-review-dialog', - reason: 'agent-hooks-review-prompt', - heading: 'Hooks need review', - keyRow: 'enter confirm · esc skip' - }, - { - name: 'codex-0158-hooks-review-dialog', - reason: 'agent-hooks-review-prompt', - heading: 'Hooks need review', - keyRow: 'enter confirm · esc skip' - }, - { - name: 'codex-0157-model-retired-dialog', - reason: 'codex-model-migration-prompt', - heading: 'is no longer available', - keyRow: 'enter/esc continue · ctrl+c quit' - }, - { - name: 'codex-0158-model-retired-dialog', - reason: 'codex-model-migration-prompt', - heading: 'is no longer offered', - keyRow: 'enter/esc continue · ctrl+c quit' - }, - { - name: 'codex-0158-model-announcement-dialog', - reason: 'codex-model-migration-prompt', - heading: 'Try new model', - keyRow: 'enter/esc confirm · ctrl+c quit' - } -] -const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) -const LIVE_CHAT_FIXTURES = [ - 'codex-0157-plain-ready', - 'codex-0157-effort-override-embedded-warning', - 'codex-0157-config-override-embedded-warning', - 'codex-0157-no-daemon-effort-override', - 'codex-0157-fresh-home-daemon-install', - 'codex-0158-fresh-home-greeting' -] - -function screenText(frame: TranscriptReplayFrame): string { - return frame.screenLines.join('\n') -} - -async function lastFrame(data: string): Promise { - let last: TranscriptReplayFrame | null = null - for await (const frame of replayTranscript(data, 120, 40)) { - last = frame - } - return last -} - -// Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup -// dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. -function answered(name: string): string { - const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') - return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) -} - -describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { - it.each(DIALOGS)( - '$name: reports $reason from its key row on, and never reads ready while it is up', - async ({ name, reason, heading, keyRow }) => { - let headingFrames = 0 - let keyRowFrames = 0 - for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { - const screen = screenText(frame) - if (screen.includes(heading)) { - headingFrames += 1 - expect(isQuietReadyScreenBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( - false - ) - expect(isKnownReadyPromptBody(frame.waitText, null, () => frame.screenLines, true)).toBe( - false - ) - } - if (keyRowFrames > 0 || screen.includes(keyRow)) { - keyRowFrames += 1 - expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) - } - } - // Presence precondition: the dialog and its key row were painted, not just parsed. - expect(headingFrames).toBeGreaterThan(0) - expect(keyRowFrames).toBeGreaterThan(0) - } - ) - - it.each(DIALOGS_0158)( - '$name: stops reporting once Codex repaints its chat after it', - async ({ name, heading }) => { - const last = await lastFrame(answered(name)) - // Presence precondition: the answered dialog is still in the text copy. - expect(last?.waitText).toContain(heading.replace(' ·', '')) - expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() - } - ) - - it.each(DIALOGS)( - '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', - async ({ name, reason }) => { - // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. - const dialog = readRuntimeFixture(name) - const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) - expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) - expect( - isQuietReadyScreenBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) - ).toBe(false) - } - ) - - it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { - const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' - expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() - // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. - const chat = [ - '› is there an update available, and do my hooks need review?', - '✨ Update available! 0.158.0 -> 0.159.0', - 'Run npm install -g @openai/codex to update.', - ' gpt-6-astra default · ~/repo', - ' ← for agents · ? for shortcuts' - ].join('\n') - expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() - }) - - it('does not name a mid-session Codex popup a hooks review', () => { - // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. - const popup = [ - ' Use this reset?', - ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', - '› 2. No, go back Choose a different reset', - ' enter confirm · esc back' - ].join('\n') - expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') - }) - - describe('through the runtime', () => { - it.each(DIALOGS)( - '$name: stops a tui-idle wait as $reason instead of typing into it', - async ({ name, reason }) => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data: readRuntimeFixture(name), - size: { cols: 120, rows: 40 } - }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) - ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) - }, - 15_000 - ) - }) -}) diff --git a/src/main/runtime/freebuff-status-transcript.test.ts b/src/main/runtime/freebuff-status-transcript.test.ts deleted file mode 100644 index 04aa1e0a2abd..000000000000 --- a/src/main/runtime/freebuff-status-transcript.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const transcript = readFileSync( - join(import.meta.dirname, '__fixtures__/freebuff-lifecycle.txt'), - 'utf8' -) - -describe('Freebuff execution-host status', () => { - it('publishes real running, question, and settled screens into the canonical store', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - const states: string[] = [] - const questions: string[] = [] - try { - // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. - for (const frame of transcript.split(/(?<=\x1b\[\?2026l)/)) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, frame, Date.now()) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - const row = wiring.statusStore.getStatusSnapshot()[0] - if (row && states.at(-1) !== row.state) { - states.push(row.state) - } - if (row?.interactivePrompt) { - questions.push(row.interactivePrompt) - } - } - expect(states).toContain('working') - expect(states).toContain('waiting') - expect(states.at(-1)).toBe('done') - expect(questions.join('\n')).toMatch(/blue|green/i) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - it.each([ - { name: 'login', cols: 100, rows: 32, input: 'Sign in to Freebuff' }, - { name: 'trust', cols: 120, rows: 40, input: 'Trust repository agent files? [y/N]' } - ])('publishes captured $name startup as blocked', async ({ name, cols, rows, input }) => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols, rows } - }, - wiring.deps - ) - try { - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), - Date.now() - ) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ - state: 'blocked', - toolInput: input, - agentType: 'freebuff' - }) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - - it('clears startup blocks and starts a fresh session after an earlier completed turn', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - launchAgent: 'freebuff', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - try { - for (const name of ['trust', 'ready', 'lifecycle', 'ready']) { - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), - Date.now() - ) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()[0]?.state).toBe( - name === 'trust' ? 'blocked' : 'done' - ) - if (name === 'ready') { - expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ - sessionBoundary: true, - prompt: '' - }) - } - if (name === 'lifecycle') { - expect(wiring.statusStore.getStatusSnapshot()[0]?.sessionBoundary).not.toBe(true) - } - } - expect(wiring.statusStore.getStatusSnapshot()[0]?.toolInput).toBeUndefined() - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) - - it('does not infer remote status from the client screen', async () => { - const wiring = makeAgentStatusStoreWiring() - const { runtime } = await createTranscriptPane( - { - data: '', - paneTitle: 'Freebuff', - foregroundProcess: 'freebuff', - connectionId: 'ssh-test', - size: { cols: 120, rows: 40 } - }, - wiring.deps - ) - try { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, transcript, Date.now()) - await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) - expect(wiring.statusStore.getStatusSnapshot()).toEqual([]) - } finally { - runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - }) -}) diff --git a/src/main/runtime/hermes-readiness-transcript.test.ts b/src/main/runtime/hermes-readiness-transcript.test.ts deleted file mode 100644 index 5605f69f91ef..000000000000 --- a/src/main/runtime/hermes-readiness-transcript.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' -import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8') - -/** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the - * same normalizer the hook relay uses — so a regression in the event mapping fails here too, - * not only in the provider's own unit test. */ -const status = (eventName: string, payload: Record = {}): string => { - const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload) - expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull() - return `\x1b]9999;${JSON.stringify(parsed)}\x07` -} - -const readyPane = async () => - createTranscriptPane({ - paneTitle: 'Hermes Agent', - foregroundProcess: 'hermes', - launchAgent: 'hermes', - size: { cols: 120, rows: 31 }, - data: captured - }) - -describe('Hermes TUI readiness from a captured PTY', () => { - it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => { - const { runtime, handle } = await readyPane() - // What Orca's own managed plugin sends for `on_session_start`. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - - const read = vi.spyOn(runtime, 'readTerminal') - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) - ).resolves.toMatchObject({ satisfied: true }) - // The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it. - expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([]) - }, 5_000) - - it('settles through a leaf handle whose retained tail never showed the ready screen', async () => { - const { runtime } = await readyPane() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression. - const internals = runtime as unknown as { - leaves: Map - issueHandle: (leaf: unknown) => string - } - const leaf = internals.leaves.values().next().value - expect(leaf).toBeDefined() - expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i) - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(internals.issueHandle(leaf), { - condition: 'tui-idle', - timeoutMs: 2_000 - }) - ).resolves.toMatchObject({ satisfied: true }) - }, 5_000) - - it('does not settle while a turn the user started is still running', async () => { - const { runtime, handle } = await readyPane() - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - // `pre_llm_call` — the first event that means a turn actually began. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('pre_llm_call', { user_message: 'hi' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) - - it('does not settle on a turn-end done row, only on a session boundary', async () => { - const { runtime, handle } = await readyPane() - // `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('post_llm_call', { session_id: 's1' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) - - it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => { - const { runtime, handle } = await readyPane() - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - // `pre_approval_request` supersedes the boundary row the session opened with. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('pre_approval_request', { command: 'rm -rf build' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) -}) diff --git a/src/main/runtime/launched-agent-composer-readiness-codex.test.ts b/src/main/runtime/launched-agent-composer-readiness-codex.test.ts deleted file mode 100644 index 1821d4c3a286..000000000000 --- a/src/main/runtime/launched-agent-composer-readiness-codex.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { readRuntimeFixture } from './agent-transcript-replay-test-harness' -import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// A source-control button's multi-line prompt reaches Codex only through this wait, so a Codex -// release that the launch wait cannot read ready reports "prompt wasn't sent". -async function launchedCodexPane(data: string) { - return createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex', - data, - size: { cols: 120, rows: 40 } - }) -} - -async function waitForCodexComposer( - pane: Awaited>, - timeoutMs: number, - streamedData?: string -) { - // Pane creation uses real timers; only the readiness budget advances virtually. - vi.useFakeTimers({ - toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] - }) - try { - const ready = waitForLaunchedAgentComposer(pane.runtime, pane.handle, 'codex', timeoutMs) - void ready.catch(() => {}) - if (streamedData !== undefined) { - pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, streamedData, Date.now()) - } - await vi.advanceTimersByTimeAsync(timeoutMs + 1) - return await ready - } finally { - vi.useRealTimers() - } -} - -// Why cut: on an Astra model 0.158 sparkles braille stars in the empty composer, redrawing every -// 150 ms, then repaints it clean after 15 s. The capture ends on the first star frame, so replayed -// whole it holds a starred composer still, which the live stream never does. -function greetingBeforeStarfield(): string { - const data = readRuntimeFixture('codex-0158-fresh-home-greeting') - const firstStar = data.search(/38;2;\d+;\d+;\d+;48;2;30;30;30m[⠀-⣿]/) - return data.slice(0, data.lastIndexOf('\x1b[?2026h', firstStar)) -} - -describe('launch readiness for a freshly launched Codex', () => { - it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => { - const data = greetingBeforeStarfield() - // Presence precondition: the cut keeps the live status row and drops every star. - expect(data).toContain('GPT-6-Astra default') - expect(data).not.toMatch(/48;2;30;30;30m[⠀-⣿]/) - const pane = await launchedCodexPane(data) - await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) - - it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => { - const pane = await launchedCodexPane(readRuntimeFixture('codex-0157-plain-ready')) - await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 15_000) - - it.each([ - ['codex-0158-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], - ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], - ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] - ])( - '%s: stops at the dialog as %s instead of pasting into it', - async (fixture, reason) => { - const pane = await launchedCodexPane(readRuntimeFixture(fixture)) - await expect(waitForCodexComposer(pane, 2_500)).resolves.toMatchObject({ - satisfied: false, - blockedReason: reason - }) - }, - 15_000 - ) - - // Why streamed: live, the dialog's `›` selector arrives after bracketed paste and fires the - // composer signal, so only the screen check keeps it from pasting; it hands the dialog to - // `tui-idle` to report, well inside the desktop paste's budget. - it.each([ - ['codex-0157-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-update-available-dialog', 'agent-update-prompt'], - ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], - ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], - ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] - ])( - '%s streamed in after the launch: reports the dialog as %s, never as the composer', - async (fixture, reason) => { - const data = readRuntimeFixture(fixture) - // Presence precondition: the dialog draws Codex's composer glyph after bracketed paste. - expect(data.slice(data.indexOf('\x1b[?2004h'))).toContain('›') - const pane = await launchedCodexPane('') - await expect(waitForCodexComposer(pane, 2_500, data)).resolves.toMatchObject({ - satisfied: false, - blockedReason: reason - }) - }, - 15_000 - ) - - // Why: 0.157 discards input typed behind its provisional `model: loading` screen. - it('codex-0157-fresh-home-daemon-install: does not read the provisional screen as ready', async () => { - const full = readRuntimeFixture('codex-0157-fresh-home-daemon-install') - const install = full.indexOf('Installing daemon') - // Presence precondition: the cut keeps the provisional header and stops before the live chat. - expect(install).toBeGreaterThan(0) - const provisional = full.slice(0, full.indexOf('\n', install) + 1) - expect(provisional).toMatch(/model:.*loading/) - const pane = await launchedCodexPane(provisional) - await expect(waitForCodexComposer(pane, 5_000)).rejects.toThrow(/timeout/) - }, 15_000) -}) diff --git a/src/main/runtime/launched-agent-worker-start-grok.test.ts b/src/main/runtime/launched-agent-worker-start-grok.test.ts deleted file mode 100644 index cde8cb8d2cd0..000000000000 --- a/src/main/runtime/launched-agent-worker-start-grok.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * A Grok worker start replayed through the runtime at its recorded read times. Grok draws its - * composer glyph at 0.6 s and then shimmers its logo until 9.9 s, and its only title is its bare - * name, so a wait that holds that title to quiet output answers ten seconds after the composer. - */ - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { waitForWorkerStartComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const COMPOSER_FRAME_MS = GROK_STARTUP_PTY_TRACE.find((chunk) => chunk.data?.includes('❯'))?.t -const LAST_FRAME_MS = GROK_STARTUP_PTY_TRACE.at(-1)?.t ?? 0 - -async function replayGrokWorkerStart(): Promise { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'grok', - launchAgent: 'grok', - size: { cols: 120, rows: 30 }, - data: '' - }) - vi.useFakeTimers() - const startedAt = Date.now() - let settledAt: number | null = null - void waitForWorkerStartComposer(runtime, handle, 'grok', 60_000).then( - (wait) => { - settledAt = wait.satisfied ? Date.now() - startedAt : null - }, - () => {} - ) - for (const chunk of GROK_STARTUP_PTY_TRACE) { - await vi.advanceTimersByTimeAsync(Math.max(0, startedAt + chunk.t - Date.now())) - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - chunk.data ?? 'x'.repeat(chunk.bytes ?? 0), - Date.now() - ) - } - await vi.advanceTimersByTimeAsync(5_000) - return settledAt -} - -describe('a Grok worker start', () => { - afterEach(() => vi.useRealTimers()) - - it('is ready on its composer glyph, not once its logo stops animating', async () => { - expect(COMPOSER_FRAME_MS).toBeLessThan(1_000) - const settledAt = await replayGrokWorkerStart() - expect(settledAt).not.toBeNull() - expect(settledAt).toBeGreaterThanOrEqual(COMPOSER_FRAME_MS ?? 0) - // Within a second of the glyph: main's own wait answered on the title at once (2.2-2.8 s live). - expect(settledAt).toBeLessThan((COMPOSER_FRAME_MS ?? 0) + 1_000) - expect(settledAt).toBeLessThan(LAST_FRAME_MS) - }) -}) diff --git a/src/main/runtime/muse-readiness-transcript.test.ts b/src/main/runtime/muse-readiness-transcript.test.ts deleted file mode 100644 index 705eda71df57..000000000000 --- a/src/main/runtime/muse-readiness-transcript.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { RuntimeMachineName } from './runtime-machine-name' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -let machineNameStartSpy: { mockRestore(): void } | undefined - -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) -}) - -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined -}) - -describe('Muse readiness from captured terminal bytes', () => { - it('recognizes a ready folder workspace without a skills summary', async () => { - const data = readFileSync( - join(__dirname, '__fixtures__', 'muse-empty-folder-ready.txt'), - 'utf8' - ) - expect(data).toContain(String.fromCharCode(27)) - expect(data).not.toContain('Skills:') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'muse-first-class-workspace', - foregroundProcess: 'muse-bin-1.3.0-R3401.1', - launchAgent: 'muse', - data - }) - await runtime.readTerminal(handle, { screen: true }) - await expect(waitForTranscriptIdle({ runtime, handle }, 10_000)).resolves.toMatchObject({ - satisfied: true - }) - }, 15_000) -}) diff --git a/src/main/runtime/omp-readiness-transcripts.test.ts b/src/main/runtime/omp-readiness-transcripts.test.ts index ecfd65e7cc30..6cab4aa1fdec 100644 --- a/src/main/runtime/omp-readiness-transcripts.test.ts +++ b/src/main/runtime/omp-readiness-transcripts.test.ts @@ -4,6 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' import { extractLastOscTitle } from '../../shared/osc-title-extraction' import type { TuiAgent } from '../../shared/tui-agent' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' +import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -12,220 +17,519 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -function transcript(name: string): string { - return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') -} - -// Synthetic repaint of the composer's bottom row (row 21 of the 120x40 capture): no capture -// exists of a post-turn, resumed or non-default-glyph composer, whose effort hint is gone or -// differs. -const HINTLESS_COMPOSER_ROW = '\x1b[21;1H\x1b[2K╰─' -const ASCII_HINT_COMPOSER_ROW = `\x1b[21;1H\x1b[2K╰─${' '.repeat(82)}Shift+Tab to change thinking effort` - -async function waitsReady( - options: { - name: string - title: string - launchAgent?: TuiAgent - repaint?: string - size?: { cols: number; rows: number } | null - busyFirst?: boolean - keepalive?: boolean - }, - timeoutMs = 5_000 -): Promise { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: options.launchAgent ? 'omp' : 'bun', - data: transcript(options.name), - ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), - ...(options.size === null ? {} : { size: options.size ?? { cols: 120, rows: 40 } }) - }) - if (options.busyFirst) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) - } - if (options.repaint) { - // Why the wait: the grid ingests writes asynchronously, and the repaint precedes the title. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.repaint, Date.now()) - await vi.advanceTimersByTimeAsync(50) - } - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${options.title}\x07`, Date.now()) - // OMP 18.4.5 re-asserts bracketed paste every second once a terminal answers its DECRQM probe, - // as xterm and main's query authority do; the capture tool answered nothing. - const keepalive = options.keepalive - ? setInterval(() => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[?2004h', Date.now()), 1_000) - : null - const settled = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }).then( - (result) => result.satisfied === true, - () => false - ) - await vi.advanceTimersByTimeAsync(timeoutMs) - if (keepalive) { - clearInterval(keepalive) +describe('OMP captured composer and title readiness', () => { + function transcript(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } - return settled -} - -describe('OMP 18.4.5 captured readiness', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) - - it.each([ - ['omp-18-setup', 120, 40, false], - ['omp-18-composer', 120, 40, true], - ['omp-18-composer-narrow', 60, 24, true] - ] as const)('%s at %sx%s has readiness %s', async (name, cols, rows, ready) => { - const data = transcript(name) - expect(data).toContain('\x1b[') - expect(data).toContain('\r') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: extractLastOscTitle(data) ?? 'OMP', - foregroundProcess: 'omp', - data, - launchAgent: 'omp', - size: { cols, rows } - }) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = ready - ? expect(result).resolves.toMatchObject({ satisfied: true }) - : expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - }) - it('does not accept the composer while its native title still says working', async () => { + // Synthetic repaint of the composer's bottom row (row 21 of the 120x40 capture): no capture + // exists of a post-turn, resumed or non-default-glyph composer, whose effort hint is gone or + // differs. + const HINTLESS_COMPOSER_ROW = '\x1b[21;1H\x1b[2K╰─' + const ASCII_HINT_COMPOSER_ROW = `\x1b[21;1H\x1b[2K╰─${' '.repeat(82)}Shift+Tab to change thinking effort` + + async function waitsReady( + options: { + name: string + title: string + launchAgent?: TuiAgent + repaint?: string + size?: { cols: number; rows: number } | null + busyFirst?: boolean + keepalive?: boolean + }, + timeoutMs = 5_000 + ): Promise { const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'OMP', - foregroundProcess: 'omp', - data: transcript('omp-18-composer'), - launchAgent: 'omp', - size: { cols: 120, rows: 40 } - }) - // Synthetic busy transition isolates the timer while preserving the captured composer grid. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', Date.now()) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) - const idle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const idleAssertion = expect(idle).resolves.toMatchObject({ satisfied: true }) - await Promise.all([idleAssertion, vi.advanceTimersByTimeAsync(5_000)]) - }) + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ? 'omp' : 'bun', + data: transcript(options.name), + ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), + ...(options.size === null ? {} : { size: options.size ?? { cols: 120, rows: 40 } }) + }) + if (options.busyFirst) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + } + if (options.repaint) { + // Why the wait: the grid ingests writes asynchronously, and the repaint precedes the title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.repaint, Date.now()) + await vi.advanceTimersByTimeAsync(50) + } + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${options.title}\x07`, Date.now()) + // OMP 18.4.5 re-asserts bracketed paste every second once a terminal answers its DECRQM probe, + // as xterm and main's query authority do; the capture tool answered nothing. + const keepalive = options.keepalive + ? setInterval( + () => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[?2004h', Date.now()), + 1_000 + ) + : null + const settled = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }).then( + (result) => result.satisfied === true, + () => false + ) + await vi.advanceTimersByTimeAsync(timeoutMs) + if (keepalive) { + clearInterval(keepalive) + } + return settled + } - it.each([ - ['hintless', HINTLESS_COMPOSER_ROW], - ['ascii-glyph', ASCII_HINT_COMPOSER_ROW] - ])('accepts a %s composer: the effort hint retires and its glyphs vary', async (_, repaint) => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π > capture-cwd', + describe('OMP 18.4.5 captured readiness', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it.each([ + ['omp-18-setup', 120, 40, false], + ['omp-18-composer', 120, 40, true], + ['omp-18-composer-narrow', 60, 24, true] + ] as const)('%s at %sx%s has readiness %s', async (name, cols, rows, ready) => { + const data = transcript(name) + expect(data).toContain('\x1b[') + expect(data).toContain('\r') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: extractLastOscTitle(data) ?? 'OMP', + foregroundProcess: 'omp', + data, launchAgent: 'omp', - repaint + size: { cols, rows } }) - ).toBe(true) - }) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = ready + ? expect(result).resolves.toMatchObject({ satisfied: true }) + : expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) - it("accepts the titlebar extension's post-turn title on a hintless composer", async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', + it('does not accept the composer while its native title still says working', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'OMP', + foregroundProcess: 'omp', + data: transcript('omp-18-composer'), launchAgent: 'omp', - repaint: HINTLESS_COMPOSER_ROW, - busyFirst: true + size: { cols: 120, rows: 40 } }) - ).toBe(true) - }) - - it('accepts an OMP 17-style `π:` title through the quiet name-only lane', async () => { - expect( - await waitsReady({ name: 'omp-18-composer', title: 'π: capture-cwd', launchAgent: 'omp' }) - ).toBe(true) - }) - - it("refuses the wizard's splash, which has no step heading yet", async () => { - const setup = transcript('omp-18-setup') - const splash = setup.slice(0, setup.indexOf('Setup step')) - expect(splash).toContain('\x1b[?1049h') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'omp', - data: splash, - launchAgent: 'omp', - size: { cols: 120, rows: 40 } - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) - const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - const assertion = expect(result).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) - }) + // Synthetic busy transition isolates the timer while preserving the captured composer grid. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const idle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const idleAssertion = expect(idle).resolves.toMatchObject({ satisfied: true }) + await Promise.all([idleAssertion, vi.advanceTimersByTimeAsync(5_000)]) + }) - it.each([ - ['omp-18-composer', true], - ['omp-18-setup', false] - ] as const)('%s under the bracketed-paste keepalive has readiness %s', async (name, ready) => { - expect( - await waitsReady( - { name, title: 'π > capture-cwd', launchAgent: 'omp', keepalive: true }, - 10_000 - ) - ).toBe(ready) - }) + it.each([ + ['hintless', HINTLESS_COMPOSER_ROW], + ['ascii-glyph', ASCII_HINT_COMPOSER_ROW] + ])('accepts a %s composer: the effort hint retires and its glyphs vary', async (_, repaint) => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + repaint + }) + ).toBe(true) + }) - it('accepts a post-turn answer that mentions a setup step on the normal screen', async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', - launchAgent: 'omp', - repaint: '\x1b[12;1H\x1b[2K Setup step 2 of 4: install the dependencies', - busyFirst: true - }) - ).toBe(true) - }) + it("accepts the titlebar extension's post-turn title on a hintless composer", async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: HINTLESS_COMPOSER_ROW, + busyFirst: true + }) + ).toBe(true) + }) - it.each(['π - capture-cwd', 'π: capture-cwd'])( - 'refuses %s while the setup wizard is on screen', - async (title) => { - expect(await waitsReady({ name: 'omp-18-setup', title, launchAgent: 'omp' })).toBe(false) - } - ) + it('accepts an OMP 17-style `π:` title through the quiet name-only lane', async () => { + expect( + await waitsReady({ name: 'omp-18-composer', title: 'π: capture-cwd', launchAgent: 'omp' }) + ).toBe(true) + }) - it('refuses `π >` with no trustworthy screen to rule the setup wizard out', async () => { - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π > capture-cwd', - launchAgent: 'omp', - size: null - }) - ).toBe(false) - // A post-turn title cannot come from setup, so it needs no screen. - expect( - await waitsReady({ - name: 'omp-18-composer', - title: 'π - capture-cwd', + it("refuses the wizard's splash, which has no step heading yet", async () => { + const setup = transcript('omp-18-setup') + const splash = setup.slice(0, setup.indexOf('Setup step')) + expect(splash).toContain('\x1b[?1049h') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'omp', + data: splash, launchAgent: 'omp', - size: null + size: { cols: 120, rows: 40 } }) - ).toBe(true) - }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) - it.each([['pi' as const], [undefined]])( - 'does not make the setup wizard ready at once for agent identity %s', - async (launchAgent) => { - // Identities other than omp keep main's quiet name-only lane, which cannot settle this soon. + it.each([ + ['omp-18-composer', true], + ['omp-18-setup', false] + ] as const)('%s under the bracketed-paste keepalive has readiness %s', async (name, ready) => { expect( await waitsReady( - { - name: 'omp-18-setup', - title: 'π > capture-cwd', - ...(launchAgent ? { launchAgent } : {}) - }, - 1_000 + { name, title: 'π > capture-cwd', launchAgent: 'omp', keepalive: true }, + 10_000 ) + ).toBe(ready) + }) + + it('accepts a post-turn answer that mentions a setup step on the normal screen', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: '\x1b[12;1H\x1b[2K Setup step 2 of 4: install the dependencies', + busyFirst: true + }) + ).toBe(true) + }) + + it.each(['π - capture-cwd', 'π: capture-cwd'])( + 'refuses %s while the setup wizard is on screen', + async (title) => { + expect(await waitsReady({ name: 'omp-18-setup', title, launchAgent: 'omp' })).toBe(false) + } + ) + + it('refuses `π >` with no trustworthy screen to rule the setup wizard out', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + size: null + }) ).toBe(false) + // A post-turn title cannot come from setup, so it needs no screen. + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + size: null + }) + ).toBe(true) + }) + + it.each([['pi' as const], [undefined]])( + 'does not make the setup wizard ready at once for agent identity %s', + async (launchAgent) => { + // Identities other than omp keep main's quiet name-only lane, which cannot settle this soon. + expect( + await waitsReady( + { + name: 'omp-18-setup', + title: 'π > capture-cwd', + ...(launchAgent ? { launchAgent } : {}) + }, + 1_000 + ) + ).toBe(false) + } + ) + }) +}) + +describe('OpenCode captured worker composer', () => { + /** + * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first + * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), + * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. + */ + + // Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title + // and the launcher's cursor toggle are synthetic. + const ZSH_LAUNCH = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + const OPENCODE_PLACEHOLDER = 'Ask anything' + const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + + const RUNS: [string, TuiAgent][] = [ + ['opencode-1-18-32-timed-boot-slow', 'opencode'], + ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], + ['opencode-1-18-32-timed-first-launch', 'opencode'], + ['opencode-cmd-2-0-21-timed-warm-server', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'], + ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] + ] + const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) + const AGENT_ROW = /\u00b7 \S/ + const BOX_BOTTOM_LEFT = '\u2579' + + /** The read that ends the synchronized update drawing OpenCode's input box. */ + function boxRead(chunks: string[]): number { + const data = chunks.join('') + const boxEnd = + data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + + SYNCHRONIZED_UPDATE_END.length + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) + } + + /** The first read whose screen shows `· ` on the box's last line, above its corner. */ + async function agentRowRead(name: string, chunks: string[]): Promise { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') + ) + let read = 0 + for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) { + const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) + if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) { + return read + } + read += 1 } + return -1 + } + + async function replay(name: string, agent: TuiAgent) { + const { chunks, times } = readTimedRuntimeFixture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'opencode', + launchAgent: agent, + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.useFakeTimers() + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) + const settledAt: { + composer: number | null + composerMs: number | null + tuiIdle: number | null + } = { composer: null, composerMs: null, tuiIdle: null } + let read = -1 + const startedAt = Date.now() + const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) + void composer.then(() => { + settledAt.composer ??= read + settledAt.composerMs ??= Date.now() - startedAt + }) + const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + void tuiIdle.then( + () => (settledAt.tuiIdle ??= read), + () => {} + ) + let now = 0 + await vi.advanceTimersByTimeAsync(0) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) + now = times[index] + read = index + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + await vi.advanceTimersByTimeAsync(0) + } + return { + settledAt, + composer, + boxRead: boxRead(chunks), + agentRowRead: await agentRowRead(name, chunks) + } + } + + describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(RUNS)( + '%s: the worker-start lane settles once the box and the agent row are both painted', + async (name, agent) => { + const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + // OpenCode 1 paints the row inside the box's frame, so the box read is the later one there. + expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead)) + } + ) + + it.each(OPENCODE_1_RUNS)( + '%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.composer).toBe(boxRead) + } + ) + + it('never settles on the box while a slow agent list leaves the row unpainted', async () => { + const { settledAt, boxRead } = await replay( + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode2' + ) + expect(settledAt.composer).toBeGreaterThan(boxRead) + }) + + it('takes the box after the grace in a pane too narrow for the agent row', async () => { + const name = 'opencode-2-0-21-timed-narrow-pane' + const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) + const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)] + const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2') + expect(agentRowRead).toBe(-1) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + // Fake timers drop the recorded times' fraction of a millisecond. + expect( + Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS)) + ).toBeLessThan(1) + // The recorder pasted at the grace and pressed Enter after it, and OpenCode took it. + expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!) + }) + + it.each(RUNS)( + '%s: the bare-name tui-idle wait main used would have settled before the box', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.tuiIdle).not.toBeNull() + expect(settledAt.tuiIdle!).toBeLessThan(boxRead) + } + ) + }) +}) + +describe('Freebuff captured canonical status delivery', () => { + const transcript = readFileSync( + join(import.meta.dirname, '__fixtures__/freebuff-lifecycle.txt'), + 'utf8' ) + + describe('Freebuff execution-host status', () => { + it('publishes real running, question, and settled screens into the canonical store', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + const states: string[] = [] + const questions: string[] = [] + try { + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + for (const frame of transcript.split(/(?<=\x1b\[\?2026l)/)) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, frame, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + const row = wiring.statusStore.getStatusSnapshot()[0] + if (row && states.at(-1) !== row.state) { + states.push(row.state) + } + if (row?.interactivePrompt) { + questions.push(row.interactivePrompt) + } + } + expect(states).toContain('working') + expect(states).toContain('waiting') + expect(states.at(-1)).toBe('done') + expect(questions.join('\n')).toMatch(/blue|green/i) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + it.each([ + { name: 'login', cols: 100, rows: 32, input: 'Sign in to Freebuff' }, + { name: 'trust', cols: 120, rows: 40, input: 'Trust repository agent files? [y/N]' } + ])('publishes captured $name startup as blocked', async ({ name, cols, rows, input }) => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols, rows } + }, + wiring.deps + ) + try { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + state: 'blocked', + toolInput: input, + agentType: 'freebuff' + }) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('clears startup blocks and starts a fresh session after an earlier completed turn', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + for (const name of ['trust', 'ready', 'lifecycle', 'ready']) { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]?.state).toBe( + name === 'trust' ? 'blocked' : 'done' + ) + if (name === 'ready') { + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + sessionBoundary: true, + prompt: '' + }) + } + if (name === 'lifecycle') { + expect(wiring.statusStore.getStatusSnapshot()[0]?.sessionBoundary).not.toBe(true) + } + } + expect(wiring.statusStore.getStatusSnapshot()[0]?.toolInput).toBeUndefined() + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('does not infer remote status from the client screen', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + connectionId: 'ssh-test', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, transcript, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()).toEqual([]) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + }) }) diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts deleted file mode 100644 index e49254defddb..000000000000 --- a/src/main/runtime/opencode-worker-composer-transcript.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first - * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), - * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. - */ - -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' -import type { TuiAgent } from '../../shared/tui-agent' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' -import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title -// and the launcher's cursor toggle are synthetic. -const ZSH_LAUNCH = - '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' -const OPENCODE_PLACEHOLDER = 'Ask anything' -const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' - -const RUNS: [string, TuiAgent][] = [ - ['opencode-1-18-32-timed-boot-slow', 'opencode'], - ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], - ['opencode-1-18-32-timed-first-launch', 'opencode'], - ['opencode-cmd-2-0-21-timed-warm-server', 'opencode'], - ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], - ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], - ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], - ['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'], - ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] -] -const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) -const AGENT_ROW = /\u00b7 \S/ -const BOX_BOTTOM_LEFT = '\u2579' - -/** The read that ends the synchronized update drawing OpenCode's input box. */ -function boxRead(chunks: string[]): number { - const data = chunks.join('') - const boxEnd = - data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + - SYNCHRONIZED_UPDATE_END.length - let end = 0 - return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) -} - -/** The first read whose screen shows `· ` on the box's last line, above its corner. */ -async function agentRowRead(name: string, chunks: string[]): Promise { - const meta: { cols: number; rows: number } = JSON.parse( - readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') - ) - let read = 0 - for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) { - const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) - if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) { - return read - } - read += 1 - } - return -1 -} - -async function replay(name: string, agent: TuiAgent) { - const { chunks, times } = readTimedRuntimeFixture(name) - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'opencode', - launchAgent: agent, - size: { cols: 120, rows: 40 }, - data: '' - }) - vi.useFakeTimers() - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) - const settledAt: { composer: number | null; composerMs: number | null; tuiIdle: number | null } = - { composer: null, composerMs: null, tuiIdle: null } - let read = -1 - const startedAt = Date.now() - const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) - void composer.then(() => { - settledAt.composer ??= read - settledAt.composerMs ??= Date.now() - startedAt - }) - const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) - void tuiIdle.then( - () => (settledAt.tuiIdle ??= read), - () => {} - ) - let now = 0 - await vi.advanceTimersByTimeAsync(0) - for (const [index, chunk] of chunks.entries()) { - await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) - now = times[index] - read = index - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) - await vi.advanceTimersByTimeAsync(0) - } - return { - settledAt, - composer, - boxRead: boxRead(chunks), - agentRowRead: await agentRowRead(name, chunks) - } -} - -describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it.each(RUNS)( - '%s: the worker-start lane settles once the box and the agent row are both painted', - async (name, agent) => { - const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent) - await expect(composer).resolves.toMatchObject({ satisfied: true }) - // OpenCode 1 paints the row inside the box's frame, so the box read is the later one there. - expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead)) - } - ) - - it.each(OPENCODE_1_RUNS)( - '%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before', - async (name, agent) => { - const { settledAt, boxRead } = await replay(name, agent) - expect(settledAt.composer).toBe(boxRead) - } - ) - - it('never settles on the box while a slow agent list leaves the row unpainted', async () => { - const { settledAt, boxRead } = await replay( - 'opencode-2-0-21-timed-natural-load-enter-dropped', - 'opencode2' - ) - expect(settledAt.composer).toBeGreaterThan(boxRead) - }) - - it('takes the box after the grace in a pane too narrow for the agent row', async () => { - const name = 'opencode-2-0-21-timed-narrow-pane' - const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) - const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') - const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)] - const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2') - expect(agentRowRead).toBe(-1) - await expect(composer).resolves.toMatchObject({ satisfied: true }) - // Fake timers drop the recorded times' fraction of a millisecond. - expect( - Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS)) - ).toBeLessThan(1) - // The recorder pasted at the grace and pressed Enter after it, and OpenCode took it. - expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!) - }) - - it.each(RUNS)( - '%s: the bare-name tui-idle wait main used would have settled before the box', - async (name, agent) => { - const { settledAt, boxRead } = await replay(name, agent) - expect(settledAt.tuiIdle).not.toBeNull() - expect(settledAt.tuiIdle!).toBeLessThan(boxRead) - } - ) -}) diff --git a/src/main/runtime/qoder-transcripts.test.ts b/src/main/runtime/qoder-transcripts.test.ts deleted file mode 100644 index 9b3452bd8231..000000000000 --- a/src/main/runtime/qoder-transcripts.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { extractLastOscTitle } from '../../shared/osc-title-extraction' -import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection' -import { RuntimeMachineName } from './runtime-machine-name' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -let machineNameStartSpy: { mockRestore(): void } | undefined - -beforeEach(() => { - machineNameStartSpy = vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) -}) - -afterEach(() => { - machineNameStartSpy?.mockRestore() - machineNameStartSpy = undefined -}) - -describe('captured Qoder 1.1.64 startup', () => { - it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( - 'preserves Qoder identity in %s', - async (fixture) => { - const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') - // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. - const title = extractLastOscTitle( - data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') - ) - expect(title).toContain(' | Ready') - expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: title ?? '', - foregroundProcess: 'qodercli-1.1.64', - launchAgent: 'qoder', - data, - size: { cols: 100, rows: 32 } - }) - const shown = await runtime.showTerminal(handle) - expect(shown.agentIdentity).toBe('qoder') - if (fixture === 'qoder-trust-dialog') { - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 600).catch(() => null) - expect(readiness?.satisfied ?? false).toBe(false) - } - if (fixture === 'qoder-ready') { - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 1500) - expect(readiness.satisfied).toBe(true) - } - } - ) -}) - -describe('captured Qoder China 1.1.65 startup', () => { - it.each(['qoder-cn-startup', 'qoder-cn-signin'])('checks the composer in %s', async (fixture) => { - const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') - const title = extractLastOscTitle( - data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') - ) - expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI CN') - const { runtime, handle } = await createTranscriptPane({ - paneTitle: title ?? '', - foregroundProcess: 'qoderclicn', - launchAgent: 'qoder-cn', - data, - size: { cols: 120, rows: 40 } - }) - expect((await runtime.showTerminal(handle)).agentIdentity).toBe('qoder-cn') - await runtime.readTerminal(handle, { screen: true }) - const readiness = await waitForTranscriptIdle({ runtime, handle }, 800).catch(() => null) - expect(readiness?.satisfied ?? false).toBe(false) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts deleted file mode 100644 index 75f69c06ee5d..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('Run delivery history', () => { - const h = createOrchestrationRpcHarness() - afterEach(() => h.cleanup()) - - it('does not label filtered history as an acknowledgeable delivery', async () => { - const { db, ctx, activeRunId } = h.setup() - const params = { terminal: 'term_coord', run: activeRunId, all: true } - db.insertMessage({ - from: 'worker', - to: `run:${activeRunId}`, - runId: activeRunId, - subject: 'waiting' - }) - expect(await h.call('orchestration.check', params, ctx)).toMatchObject({ - count: 1 - }) - expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(false) - const delivery = db.getOrCreateRunDelivery({ - runId: activeRunId!, - consumerGeneration: db.getRun(activeRunId!)!.consumer_generation - })! - db.insertMessage({ - from: 'worker', - to: `run:${activeRunId}`, - runId: activeRunId, - subject: 'later completion', - type: 'worker_done' - }) - const history = await h.call( - 'orchestration.check', - { - ...params, - format: true, - types: 'worker_done' - }, - ctx - ) - expect(history).toMatchObject({ count: 1, messages: [{ subject: 'later completion' }] }) - expect(history).not.toHaveProperty('deliveryId') - expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(true) - expect(db.getMessageById(delivery.messages[0].id)?.read).toBe(0) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts deleted file mode 100644 index 9287cc0c1dc4..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const PANE_OLD = 'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc' -const PANE_NEW = 'tab_new:dddddddd-dddd-4ddd-8ddd-dddddddddddd' - -type CheckResult = { messages: { subject: string }[]; count: number } - -/** - * worker-abandon + worker-start --retry-of moves the Task to another terminal, but the old worker - * keeps polling. Its check used to fall through to the direct mailbox and answer `count: 0`, which - * the worker contract reads as "checkpoint, not a failure" — so it kept editing the new owner's files. - */ -describe('orchestration.check from a terminal whose Attempt was superseded', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let ctx: RpcContext - - afterEach(() => { - h.cleanup() - }) - - function check(handle: string, paneKey: string, params: Record = {}) { - return h.call( - 'orchestration.check', - { terminal: handle, terminalPaneKey: paneKey, ...params }, - ctx - ) as Promise - } - - function startWorker(taskId: string, handle: string, paneKey: string, retryOf?: string): string { - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId, - retryOf, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle, - paneKey, - processIncarnation: `runtime:${handle}:1`, - worktreeId: 'repo::local', - setupState: 'not_applicable', - effects: [] - }) - return started.dispatch.id - } - - function retriedOntoAnotherTerminal(): string { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that moves terminals' }) - const abandoned = startWorker(task.id, 'term_old', PANE_OLD) - db.abandonWorkerDispatch(abandoned, 'epoch_test') - startWorker(task.id, 'term_new', PANE_NEW, abandoned) - return abandoned - } - - it('tells the old worker it lost the Dispatch instead of answering "no mail"', async () => { - retriedOntoAnotherTerminal() - - await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ - code: 'consumer_fenced', - message: expect.stringContaining('no longer owns its Dispatch') - }) - }) - - // The direct mailbox is the old terminal's own, so inspection stays open; only the consuming - // read that a worker treats as a checkpoint is refused. - it('still lets the old worker inspect its direct mailbox with --peek and --all', async () => { - retriedOntoAnotherTerminal() - db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'stand down' }) - - const peeked = await check('term_old', PANE_OLD, { peek: true }) - const history = await check('term_old', PANE_OLD, { all: true }) - - expect(peeked.count).toBe(1) - expect(history.count).toBe(1) - expect(db.getUnreadMessages('term_old')).toHaveLength(1) - }) - - it('fences a terminal whose Attempt failed with no successor', async () => { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that failed outright' }) - const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) - db.failDispatch(dispatch.id, 'worker terminal closed') - - await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ code: 'consumer_fenced' }) - }) - - // A superseded worker whose pane is gone cannot run-use either; the stop signal outranks the - // rebind advice, and a caller with no settled Attempt still gets the rebind advice. - it('fences a paneless caller whose Attempt was superseded, and only that caller', async () => { - retriedOntoAnotherTerminal() - - await expect( - h.call('orchestration.check', { terminal: 'term_old' }, ctx) - ).rejects.toMatchObject({ code: 'consumer_fenced' }) - await expect( - h.call('orchestration.check', { terminal: 'term_never_dispatched' }, ctx) - ).rejects.toMatchObject({ code: 'stable_pane_required' }) - }) - - it('keeps serving direct mail to a terminal whose Attempt completed normally', async () => { - ;({ db, ctx } = h.setup()) - const task = db.createTask({ spec: 'work that finished' }) - const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) - db.completeDispatch(dispatch.id) - db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'one more thing' }) - - const result = await check('term_old', PANE_OLD) - - expect(result.messages.map((message) => message.subject)).toEqual(['one more thing']) - expect(db.getUnreadMessages('term_old')).toEqual([]) - }) - - it('serves the new owner its Dispatch mailbox as usual', async () => { - const abandoned = retriedOntoAnotherTerminal() - const current = db.getDispatchContext(db.getDispatchContextById(abandoned)!.task_id)! - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${current.id}`, - subject: 'carry on', - runId: current.run_id - }) - - const result = await check('term_new', PANE_NEW) - - expect(result.messages.map((message) => message.subject)).toEqual(['carry on']) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts index 54c64feed624..d161cd9172eb 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts @@ -1,7 +1,9 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../../../core' import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration-compatibility-evidence' +import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' import { createRootDispatch, reattachDispatchConsumer @@ -231,3 +233,383 @@ describe('orchestration.check on a re-attached Dispatch', () => { expect(result.messages.map((message) => message.subject)).toEqual(['do the work']) }) }) + +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const OTHER_WORKER_PANE = 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const NON_PARTY_PANE = 'tab_teammate:dddddddd-dddd-4ddd-8ddd-dddddddddddd' +const WORKER_PROCESS = 'runtime_test:term_worker:1' + +describe('worker report authority without a Dispatch capability', () => { + const harness = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let panes: Record + + afterEach(() => harness.cleanup()) + + function setup(): void { + ;({ db, runtime } = harness.setup()) + ctx = { runtime } + panes = { + term_coord: harness.coordinatorPaneKey, + term_worker: WORKER_PANE, + term_other: OTHER_WORKER_PANE, + term_teammate: NON_PARTY_PANE + } + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) + vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( + (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null + ) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') + } + + function startWorker(name: string, paneKey: string): { taskId: string; dispatchId: string } { + const task = db.createTask({ spec: `${name} work` }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: `term_${name}`, + paneKey, + processIncarnation: `runtime_test:term_${name}:1`, + worktreeId: `repo::${name}`, + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.dispatch.id) + return { taskId: task.id, dispatchId: started.dispatch.id } + } + + function workerDone( + worker: { taskId: string; dispatchId: string }, + evidence?: OrchestrationCompatibilityEvidence, + outcome = 'succeeded' + ) { + return harness.call( + 'orchestration.send', + { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ ...worker, outcome }) + }, + { ...ctx, orchestrationCompatibilityEvidence: evidence } + ) + } + + function ask(evidence?: OrchestrationCompatibilityEvidence) { + return harness.call( + 'orchestration.ask', + { from: 'term_worker', question: 'Proceed?', timeoutMs: 1 }, + { ...ctx, orchestrationCompatibilityEvidence: evidence } + ) + } + + describe('caller fence', () => { + it.each([ + ['the Run coordinator', harness.coordinatorPaneKey], + ['another Dispatch worker', OTHER_WORKER_PANE] + ])('refuses a report sent from %s terminal and records nothing', async (_party, paneKey) => { + setup() + startWorker('other', OTHER_WORKER_PANE) + const worker = startWorker('worker', WORKER_PANE) + + await expect(workerDone(worker, { paneKey })).rejects.toMatchObject({ + code: 'consumer_fenced', + data: { effectsApplied: false } + }) + await expect(ask({ paneKey })).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect( + db.db + .prepare("SELECT COUNT(*) AS count FROM messages WHERE from_handle = 'term_worker'") + .get() + ).toEqual({ count: 0 }) + expect(db.getTask(worker.taskId)?.status).toBe('dispatched') + }) + + it.each<[string, OrchestrationCompatibilityEvidence | undefined]>([ + ['no identity env (old CLI, scrubbed env)', undefined], + ['its own pane', { paneKey: WORKER_PANE, terminalHandle: 'term_stale_after_remint' }], + ["another Orca's pane", { paneKey: 'tab_elsewhere:eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }], + ['a stale handle', { terminalHandle: 'term_gone' }], + ['a live pane that is no orchestration party', { paneKey: NON_PARTY_PANE }] + ])('accepts a report whose env names %s', async (_case, evidence) => { + setup() + const worker = startWorker('worker', WORKER_PANE) + + expect(await workerDone(worker, evidence)).toMatchObject({ + lifecycle: { action: 'completed' } + }) + expect(db.getTask(worker.taskId)?.status).toBe('completed') + }) + + it('passes the fence for a stale --from handle and leaves refusal to the process check', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + panes = { ...panes, term_worker_reminted: WORKER_PANE } + delete panes.term_worker + + expect(await workerDone(worker, { paneKey: WORKER_PANE })).toMatchObject({ + lifecycle: { action: 'rejected', code: 'worker_identity_changed' } + }) + }) + }) + + describe('worker states', () => { + it('refuses reports and questions while a stop is in flight', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + + expect(await workerDone(worker)).toMatchObject({ + lifecycle: { action: 'rejected', code: 'dispatch_inactive' } + }) + await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(db.getWorkerDispatch(worker.dispatchId)?.state).toBe('stopping') + }) + + it.each([ + ['succeeded', 'completed', 'succeeded'], + ['failed', 'failed', 'failed'] + ])( + 'settles a %s report after a stop whose outcome is unknown', + async (outcome, taskStatus, workerState) => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') + + expect(await workerDone(worker, undefined, outcome)).toMatchObject({ + lifecycle: { action: taskStatus } + }) + expect(db.getTask(worker.taskId)?.status).toBe(taskStatus) + expect(db.getWorkerDispatch(worker.dispatchId)).toMatchObject({ + state: workerState, + ...(outcome === 'succeeded' ? { last_error: null } : {}) + }) + } + ) + + it('refuses a stale process in the worker pane after an unknown stop', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') + + expect(await workerDone(worker)).toMatchObject({ + lifecycle: { code: 'worker_identity_changed' } + }) + await expect(ask()).rejects.toMatchObject({ code: 'worker_identity_changed' }) + }) + }) + + describe('remote worker states', () => { + function startRemoteWorker(): string { + const dispatchId = 'ctx_remote_worker' + db.createRemoteDispatchAttachment({ + runId: 'run-home', + dispatchId, + taskId: 'task_remote_worker', + homePeerFingerprint: 'run-home-device', + protocolVersion: 1, + runtimeEpoch: 'epoch_worker_host', + mutationReceipt: { + callerFingerprint: 'run-home-device', + requestId: 'remote_attach', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_attach_payload' + } + }) + db.prepareRemoteAttachmentAuthority({ + dispatchId, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS, + worktreeId: 'repo::remote', + terminalHandle: 'term_worker', + setupState: 'completed', + effects: [] + }) + return dispatchId + } + + it.each([ + [ + 'stop_unknown', + (dispatchId: string) => { + db.markRemoteAttachmentReady(dispatchId) + db.beginRemoteAttachmentStop(dispatchId) + db.markRemoteAttachmentStopUnknown(dispatchId, 'tab not owned by Orca') + } + ], + [ + 'start_unknown', + (dispatchId: string) => + db.failRemoteAttachment(dispatchId, 'agent_readiness', 'connection lost', true) + ] + ])('settles a remote report from %s', async (state, reachState) => { + setup() + const dispatchId = startRemoteWorker() + reachState(dispatchId) + expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe(state) + + expect(await workerDone({ taskId: 'task_remote_worker', dispatchId })).toMatchObject({ + lifecycle: { action: 'completed' } + }) + expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe('succeeded') + }) + + it('refuses a remote report while the stop is in flight', async () => { + setup() + const dispatchId = startRemoteWorker() + db.markRemoteAttachmentReady(dispatchId) + db.beginRemoteAttachmentStop(dispatchId) + + await expect(workerDone({ taskId: 'task_remote_worker', dispatchId })).rejects.toMatchObject({ + code: 'dispatch_inactive' + }) + await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(db.listPendingFederationRelay(dispatchId, 'to_home')).toEqual([]) + }) + }) +}) + +const PANE = 'tab_lead:22222222-2222-4222-9222-222222222222' +const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' +const SESSION = testOrcaSessionId('4bd46b4a-035b-41dd-a122-a9c29122ff11') + +describe.each([false, true])('Dispatch recipient identity (settled=%s)', (settled) => { + const h = createOrchestrationRpcHarness() + let state: ReturnType + let dispatch: ReturnType + + beforeEach(() => { + state = h.setup() + const task = state.db.createTask({ spec: 'lead' }) + dispatch = createRootDispatch(state.db, task.id, 'term_lead', PANE) + if (settled) { + state.db.completeDispatch(dispatch.id) + } + vi.spyOn(state.runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + handle === 'term_lead' ? PANE : h.coordinatorPaneKey + ) + }) + afterEach(() => h.cleanup()) + + function send() { + return h.call( + 'orchestration.send', + { from: 'term_coord', to: `dispatch:${dispatch.id}`, subject: 'follow up' }, + state.ctx + ) + } + function sessionRun() { + state.db.db + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run(SESSION, dispatch.id) + return state.db.createRun({ + objective: 'session lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION + }) + } + async function expectRun(runId: string) { + if (settled) { + await expect(send()).rejects.toMatchObject({ + code: 'dispatch_inactive', + message: expect.stringContaining(`Send to run:${runId} instead`) + }) + expect(state.db.getInbox()).toEqual([]) + } else { + expect(await send()).toMatchObject({ message: { to_handle: `run:${runId}`, run_id: runId } }) + } + } + async function expectNoRedirect(unrelatedRun: string) { + if (settled) { + await expect(send()).rejects.toMatchObject({ + code: 'dispatch_inactive', + message: expect.not.stringContaining(unrelatedRun) + }) + expect(state.db.getInbox()).toEqual([]) + } else { + expect(await send()).toMatchObject({ + message: { to_handle: `dispatch:${dispatch.id}`, run_id: dispatch.run_id } + }) + } + } + + it('uses a durable session binding without a live pane', async () => { + const run = sessionRun() + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) + await expectRun(run.id) + }) + + it('uses the recorded session instead of an unrelated Run now occupying the saved pane', async () => { + const run = sessionRun() + state.db.createRun({ + objective: 'new occupant', + coordinatorHandle: 'term_other', + coordinatorPaneKey: PANE + }) + await expectRun(run.id) + }) + + it('ignores an old session column left behind by an older binary rebind', async () => { + const run = sessionRun() + state.db.db + .prepare( + 'UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, consumer_generation = consumer_generation + 1 WHERE id = ?' + ) + .run('term_other', OTHER, run.id) + await expectNoRedirect(run.id) + }) + + it('does not follow a closed handle to another occupant of its old pane', async () => { + const run = state.db.createRun({ + objective: 'new occupant', + coordinatorHandle: 'term_other', + coordinatorPaneKey: PANE + }) + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) + await expectNoRedirect(run.id) + }) + + it.each(['replacement:pty:2', null])( + 'does not redirect with a replaced or unverifiable process: %s', + async (process) => { + const run = state.db.createRun({ + objective: 'pane run', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: PANE + }) + state.db.db + .prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?') + .run('original:pty:1', dispatch.id) + vi.mocked(state.runtime.getTerminalProcessIncarnation).mockReturnValue(process) + await expectNoRedirect(run.id) + expect(state.db.getDispatchContextById(dispatch.id)?.status).toBe( + settled ? 'completed' : dispatch.status + ) + } + ) + + it('accepts a reminted tab half with the same pane leaf and process', async () => { + const run = state.db.createRun({ + objective: 'same pane', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: PANE + }) + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue( + PANE.replace('tab_lead', 'tab_restored') + ) + await expectRun(run.id) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts deleted file mode 100644 index 4ab4b6555a09..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const PANE = 'tab_lead:22222222-2222-4222-9222-222222222222' -const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' -const SESSION = testOrcaSessionId('4bd46b4a-035b-41dd-a122-a9c29122ff11') - -describe.each([false, true])('Dispatch recipient identity (settled=%s)', (settled) => { - const h = createOrchestrationRpcHarness() - let state: ReturnType - let dispatch: ReturnType - - beforeEach(() => { - state = h.setup() - const task = state.db.createTask({ spec: 'lead' }) - dispatch = createRootDispatch(state.db, task.id, 'term_lead', PANE) - if (settled) { - state.db.completeDispatch(dispatch.id) - } - vi.spyOn(state.runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => - handle === 'term_lead' ? PANE : h.coordinatorPaneKey - ) - }) - afterEach(() => h.cleanup()) - - function send() { - return h.call( - 'orchestration.send', - { from: 'term_coord', to: `dispatch:${dispatch.id}`, subject: 'follow up' }, - state.ctx - ) - } - function sessionRun() { - state.db.db - .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') - .run(SESSION, dispatch.id) - return state.db.createRun({ - objective: 'session lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: null, - coordinatorOrcaSessionId: SESSION - }) - } - async function expectRun(runId: string) { - if (settled) { - await expect(send()).rejects.toMatchObject({ - code: 'dispatch_inactive', - message: expect.stringContaining(`Send to run:${runId} instead`) - }) - expect(state.db.getInbox()).toEqual([]) - } else { - expect(await send()).toMatchObject({ message: { to_handle: `run:${runId}`, run_id: runId } }) - } - } - async function expectNoRedirect(unrelatedRun: string) { - if (settled) { - await expect(send()).rejects.toMatchObject({ - code: 'dispatch_inactive', - message: expect.not.stringContaining(unrelatedRun) - }) - expect(state.db.getInbox()).toEqual([]) - } else { - expect(await send()).toMatchObject({ - message: { to_handle: `dispatch:${dispatch.id}`, run_id: dispatch.run_id } - }) - } - } - - it('uses a durable session binding without a live pane', async () => { - const run = sessionRun() - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) - await expectRun(run.id) - }) - - it('uses the recorded session instead of an unrelated Run now occupying the saved pane', async () => { - const run = sessionRun() - state.db.createRun({ - objective: 'new occupant', - coordinatorHandle: 'term_other', - coordinatorPaneKey: PANE - }) - await expectRun(run.id) - }) - - it('ignores an old session column left behind by an older binary rebind', async () => { - const run = sessionRun() - state.db.db - .prepare( - 'UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, consumer_generation = consumer_generation + 1 WHERE id = ?' - ) - .run('term_other', OTHER, run.id) - await expectNoRedirect(run.id) - }) - - it('does not follow a closed handle to another occupant of its old pane', async () => { - const run = state.db.createRun({ - objective: 'new occupant', - coordinatorHandle: 'term_other', - coordinatorPaneKey: PANE - }) - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) - await expectNoRedirect(run.id) - }) - - it.each(['replacement:pty:2', null])( - 'does not redirect with a replaced or unverifiable process: %s', - async (process) => { - const run = state.db.createRun({ - objective: 'pane run', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: PANE - }) - state.db.db - .prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?') - .run('original:pty:1', dispatch.id) - vi.mocked(state.runtime.getTerminalProcessIncarnation).mockReturnValue(process) - await expectNoRedirect(run.id) - expect(state.db.getDispatchContextById(dispatch.id)?.status).toBe( - settled ? 'completed' : dispatch.status - ) - } - ) - - it('accepts a reminted tab half with the same pane leaf and process', async () => { - const run = state.db.createRun({ - objective: 'same pane', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: PANE - }) - vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue( - PANE.replace('tab_lead', 'tab_restored') - ) - await expectRun(run.id) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts index 3f759cc3d15a..c7c4edf23629 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts @@ -1,10 +1,370 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../../../core' +import type { OrchestrationDb, RunRow } from '../../../../orchestration/db' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { DispatchContextRow } from '../../../../orchestration/types' import { createRootDispatch, reattachDispatchConsumer } from '../../../../orchestration/db/root-dispatch-test-fixture' import { createOrchestrationRpcHarness } from '../rpc-test-harness' +describe('Run delivery history', () => { + const h = createOrchestrationRpcHarness() + afterEach(() => h.cleanup()) + + it('does not label filtered history as an acknowledgeable delivery', async () => { + const { db, ctx, activeRunId } = h.setup() + const params = { terminal: 'term_coord', run: activeRunId, all: true } + db.insertMessage({ + from: 'worker', + to: `run:${activeRunId}`, + runId: activeRunId, + subject: 'waiting' + }) + expect(await h.call('orchestration.check', params, ctx)).toMatchObject({ + count: 1 + }) + expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(false) + const delivery = db.getOrCreateRunDelivery({ + runId: activeRunId!, + consumerGeneration: db.getRun(activeRunId!)!.consumer_generation + })! + db.insertMessage({ + from: 'worker', + to: `run:${activeRunId}`, + runId: activeRunId, + subject: 'later completion', + type: 'worker_done' + }) + const history = await h.call( + 'orchestration.check', + { + ...params, + format: true, + types: 'worker_done' + }, + ctx + ) + expect(history).toMatchObject({ count: 1, messages: [{ subject: 'later completion' }] }) + expect(history).not.toHaveProperty('deliveryId') + expect(db.hasOutstandingRunDelivery(activeRunId!)).toBe(true) + expect(db.getMessageById(delivery.messages[0].id)?.read).toBe(0) + }) +}) + +const PANE_OLD = 'tab_old:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const PANE_NEW = 'tab_new:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +type CheckResult = { messages: { subject: string }[]; count: number } + +/** + * worker-abandon + worker-start --retry-of moves the Task to another terminal, but the old worker + * keeps polling. Its check used to fall through to the direct mailbox and answer `count: 0`, which + * the worker contract reads as "checkpoint, not a failure" — so it kept editing the new owner's files. + */ +describe('orchestration.check from a terminal whose Attempt was superseded', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let ctx: RpcContext + + afterEach(() => { + h.cleanup() + }) + + function check(handle: string, paneKey: string, params: Record = {}) { + return h.call( + 'orchestration.check', + { terminal: handle, terminalPaneKey: paneKey, ...params }, + ctx + ) as Promise + } + + function startWorker(taskId: string, handle: string, paneKey: string, retryOf?: string): string { + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + retryOf, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey, + processIncarnation: `runtime:${handle}:1`, + worktreeId: 'repo::local', + setupState: 'not_applicable', + effects: [] + }) + return started.dispatch.id + } + + function retriedOntoAnotherTerminal(): string { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that moves terminals' }) + const abandoned = startWorker(task.id, 'term_old', PANE_OLD) + db.abandonWorkerDispatch(abandoned, 'epoch_test') + startWorker(task.id, 'term_new', PANE_NEW, abandoned) + return abandoned + } + + it('tells the old worker it lost the Dispatch instead of answering "no mail"', async () => { + retriedOntoAnotherTerminal() + + await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ + code: 'consumer_fenced', + message: expect.stringContaining('no longer owns its Dispatch') + }) + }) + + // The direct mailbox is the old terminal's own, so inspection stays open; only the consuming + // read that a worker treats as a checkpoint is refused. + it('still lets the old worker inspect its direct mailbox with --peek and --all', async () => { + retriedOntoAnotherTerminal() + db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'stand down' }) + + const peeked = await check('term_old', PANE_OLD, { peek: true }) + const history = await check('term_old', PANE_OLD, { all: true }) + + expect(peeked.count).toBe(1) + expect(history.count).toBe(1) + expect(db.getUnreadMessages('term_old')).toHaveLength(1) + }) + + it('fences a terminal whose Attempt failed with no successor', async () => { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that failed outright' }) + const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) + db.failDispatch(dispatch.id, 'worker terminal closed') + + await expect(check('term_old', PANE_OLD)).rejects.toMatchObject({ code: 'consumer_fenced' }) + }) + + // A superseded worker whose pane is gone cannot run-use either; the stop signal outranks the + // rebind advice, and a caller with no settled Attempt still gets the rebind advice. + it('fences a paneless caller whose Attempt was superseded, and only that caller', async () => { + retriedOntoAnotherTerminal() + + await expect( + h.call('orchestration.check', { terminal: 'term_old' }, ctx) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + await expect( + h.call('orchestration.check', { terminal: 'term_never_dispatched' }, ctx) + ).rejects.toMatchObject({ code: 'stable_pane_required' }) + }) + + it('keeps serving direct mail to a terminal whose Attempt completed normally', async () => { + ;({ db, ctx } = h.setup()) + const task = db.createTask({ spec: 'work that finished' }) + const dispatch = createRootDispatch(db, task.id, 'term_old', PANE_OLD) + db.completeDispatch(dispatch.id) + db.insertMessage({ from: 'term_coord', to: 'term_old', subject: 'one more thing' }) + + const result = await check('term_old', PANE_OLD) + + expect(result.messages.map((message) => message.subject)).toEqual(['one more thing']) + expect(db.getUnreadMessages('term_old')).toEqual([]) + }) + + it('serves the new owner its Dispatch mailbox as usual', async () => { + const abandoned = retriedOntoAnotherTerminal() + const current = db.getDispatchContext(db.getDispatchContextById(abandoned)!.task_id)! + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${current.id}`, + subject: 'carry on', + runId: current.run_id + }) + + const result = await check('term_new', PANE_NEW) + + expect(result.messages.map((message) => message.subject)).toEqual(['carry on']) + }) +}) + +// A lead is dispatched by a root coordinator and then coordinates its own Run from the same pane. +// That pane's `check` reads its own Run mailbox, so mail meant for it must land there. +describe('mail for a lead whose pane coordinates its own Run', () => { + const h = createOrchestrationRpcHarness() + const coordPane = 'tab_coord:11111111-1111-4111-8111-111111111111' + const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let rootRun: RunRow + let dispatch: DispatchContextRow + + afterEach(() => { + h.cleanup() + }) + + function setup(): void { + ;({ db, runtime, ctx } = h.setup(false)) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? coordPane : handle === 'term_lead' ? leadPane : null + ) + rootRun = db.createRun({ + objective: 'root', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: coordPane + }) + const task = db.createTask({ spec: 'lead the sub-project', runId: rootRun.id }) + dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) + } + + function bindLeadRun(): RunRow { + return db.createRun({ + objective: 'lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: leadPane + }) + } + + async function call(name: string, params: Record) { + return h.call(name, params, ctx) + } + + async function leadInbox(params: Record = {}): Promise { + return call('orchestration.check', { terminal: 'term_lead', ...params }) + } + + function deliveryIdOf(result: unknown): string { + if ( + typeof result === 'object' && + result !== null && + 'deliveryId' in result && + typeof result.deliveryId === 'string' + ) { + return result.deliveryId + } + throw new Error('check returned no delivery') + } + + it('routes dispatch: mail to the Run the assignee pane now coordinates', async () => { + setup() + const leadRun = bindLeadRun() + + const result = await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Follow-up for the lead' + }) + + expect(result).toMatchObject({ + message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id }, + warnings: [{ code: 'recipient_run_bound_redirect' }] + }) + expect(await leadInbox()).toMatchObject({ messages: [{ subject: 'Follow-up for the lead' }] }) + }) + + it('still reads dispatch mail that arrived before the pane bound its own Run', async () => { + setup() + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Sent before the lead bound a Run', + runId: rootRun.id + }) + const leadRun = bindLeadRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${leadRun.id}`, + subject: 'Sub-worker report', + runId: leadRun.id + }) + + const first = await leadInbox() + expect(first).toMatchObject({ messages: [{ subject: 'Sent before the lead bound a Run' }] }) + + const second = await leadInbox({ ack: deliveryIdOf(first) }) + expect(second).toMatchObject({ messages: [{ subject: 'Sub-worker report' }] }) + }) + + it('keeps the --types wake condition when older Dispatch mail does not match it', async () => { + setup() + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Older status note', + type: 'status', + runId: rootRun.id + }) + const leadRun = bindLeadRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${leadRun.id}`, + subject: 'Sub-worker finished', + type: 'worker_done', + runId: leadRun.id + }) + + const woke = await leadInbox({ wait: true, types: 'worker_done', timeoutMs: 500 }) + + expect(woke).toMatchObject({ + runId: leadRun.id, + messages: [{ subject: 'Sub-worker finished' }] + }) + }) + + it('delivers a reply to a Run-bound sender and wakes its waiting check', async () => { + setup() + const leadRun = bindLeadRun() + const report = db.insertMessage({ + from: 'term_lead', + to: `run:${rootRun.id}`, + subject: 'Lead report', + runId: rootRun.id + }) + + const waiting = leadInbox({ wait: true, timeoutMs: 2_000 }) + const reply = await call('orchestration.reply', { + id: report.id, + from: 'term_coord', + body: 'Decision' + }) + + expect(reply).toMatchObject({ + message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id } + }) + expect(await waiting).toMatchObject({ + timedOut: false, + messages: [{ subject: 'Re: Lead report' }] + }) + }) + + it('keeps dispatch: mail on the Dispatch mailbox while the assignee has no Run', async () => { + setup() + + const result = await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Plain worker follow-up' + }) + + expect(result).toMatchObject({ message: { to_handle: `dispatch:${dispatch.id}` } }) + expect(result).not.toHaveProperty('warnings') + }) + + it('keeps a reply on the raw handle when the sender has no Run or live pane', async () => { + setup() + const note = db.insertMessage({ + from: 'term_offline', + to: `run:${rootRun.id}`, + subject: 'Offline note', + runId: rootRun.id + }) + + const reply = await call('orchestration.reply', { + id: note.id, + from: 'term_coord', + body: 'Ack' + }) + + expect(reply).toMatchObject({ message: { to_handle: 'term_offline', run_id: rootRun.id } }) + }) +}) + const LEAD = 'tab_lead:22222222-2222-4222-9222-222222222222' const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts deleted file mode 100644 index bb6c7041cee6..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb, RunRow } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { DispatchContextRow } from '../../../../orchestration/types' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -// A lead is dispatched by a root coordinator and then coordinates its own Run from the same pane. -// That pane's `check` reads its own Run mailbox, so mail meant for it must land there. -describe('mail for a lead whose pane coordinates its own Run', () => { - const h = createOrchestrationRpcHarness() - const coordPane = 'tab_coord:11111111-1111-4111-8111-111111111111' - const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let rootRun: RunRow - let dispatch: DispatchContextRow - - afterEach(() => { - h.cleanup() - }) - - function setup(): void { - ;({ db, runtime, ctx } = h.setup(false)) - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_coord' ? coordPane : handle === 'term_lead' ? leadPane : null - ) - rootRun = db.createRun({ - objective: 'root', - coordinatorHandle: 'term_coord', - coordinatorPaneKey: coordPane - }) - const task = db.createTask({ spec: 'lead the sub-project', runId: rootRun.id }) - dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) - } - - function bindLeadRun(): RunRow { - return db.createRun({ - objective: 'lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: leadPane - }) - } - - async function call(name: string, params: Record) { - return h.call(name, params, ctx) - } - - async function leadInbox(params: Record = {}): Promise { - return call('orchestration.check', { terminal: 'term_lead', ...params }) - } - - function deliveryIdOf(result: unknown): string { - if ( - typeof result === 'object' && - result !== null && - 'deliveryId' in result && - typeof result.deliveryId === 'string' - ) { - return result.deliveryId - } - throw new Error('check returned no delivery') - } - - it('routes dispatch: mail to the Run the assignee pane now coordinates', async () => { - setup() - const leadRun = bindLeadRun() - - const result = await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Follow-up for the lead' - }) - - expect(result).toMatchObject({ - message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id }, - warnings: [{ code: 'recipient_run_bound_redirect' }] - }) - expect(await leadInbox()).toMatchObject({ messages: [{ subject: 'Follow-up for the lead' }] }) - }) - - it('still reads dispatch mail that arrived before the pane bound its own Run', async () => { - setup() - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Sent before the lead bound a Run', - runId: rootRun.id - }) - const leadRun = bindLeadRun() - db.insertMessage({ - from: 'term_worker', - to: `run:${leadRun.id}`, - subject: 'Sub-worker report', - runId: leadRun.id - }) - - const first = await leadInbox() - expect(first).toMatchObject({ messages: [{ subject: 'Sent before the lead bound a Run' }] }) - - const second = await leadInbox({ ack: deliveryIdOf(first) }) - expect(second).toMatchObject({ messages: [{ subject: 'Sub-worker report' }] }) - }) - - it('keeps the --types wake condition when older Dispatch mail does not match it', async () => { - setup() - db.insertMessage({ - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Older status note', - type: 'status', - runId: rootRun.id - }) - const leadRun = bindLeadRun() - db.insertMessage({ - from: 'term_worker', - to: `run:${leadRun.id}`, - subject: 'Sub-worker finished', - type: 'worker_done', - runId: leadRun.id - }) - - const woke = await leadInbox({ wait: true, types: 'worker_done', timeoutMs: 500 }) - - expect(woke).toMatchObject({ - runId: leadRun.id, - messages: [{ subject: 'Sub-worker finished' }] - }) - }) - - it('delivers a reply to a Run-bound sender and wakes its waiting check', async () => { - setup() - const leadRun = bindLeadRun() - const report = db.insertMessage({ - from: 'term_lead', - to: `run:${rootRun.id}`, - subject: 'Lead report', - runId: rootRun.id - }) - - const waiting = leadInbox({ wait: true, timeoutMs: 2_000 }) - const reply = await call('orchestration.reply', { - id: report.id, - from: 'term_coord', - body: 'Decision' - }) - - expect(reply).toMatchObject({ - message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id } - }) - expect(await waiting).toMatchObject({ - timedOut: false, - messages: [{ subject: 'Re: Lead report' }] - }) - }) - - it('keeps dispatch: mail on the Dispatch mailbox while the assignee has no Run', async () => { - setup() - - const result = await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Plain worker follow-up' - }) - - expect(result).toMatchObject({ message: { to_handle: `dispatch:${dispatch.id}` } }) - expect(result).not.toHaveProperty('warnings') - }) - - it('keeps a reply on the raw handle when the sender has no Run or live pane', async () => { - setup() - const note = db.insertMessage({ - from: 'term_offline', - to: `run:${rootRun.id}`, - subject: 'Offline note', - runId: rootRun.id - }) - - const reply = await call('orchestration.reply', { - id: note.id, - from: 'term_coord', - body: 'Ack' - }) - - expect(reply).toMatchObject({ message: { to_handle: 'term_offline', run_id: rootRun.id } }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts index 9f57957a10a2..c3f6d2aec2fc 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../../../core' import type { OrchestrationDb } from '../../../../orchestration/db' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' import { openDecisionGateFromMessage } from '../../../../orchestration/coordinator-decision-gates' import { applyEscalationToDispatch } from '../../../../orchestration/coordinator-escalation-triage' import { createOrchestrationRpcHarness } from '../rpc-test-harness' @@ -143,3 +144,251 @@ describe('orchestration.send Dispatch authority', () => { } ) }) + +// The same delivery plumbing `check` already strips; a send/reply receipt is the same mailbox row. +const INTERNAL_COLUMNS = [ + 'read', + 'sequence', + 'sender_pane_key', + 'pointer_enter_pending', + 'pointer_pty_id', + 'pointer_process_incarnation' +] + +function terminalSummary(handle: string): RuntimeTerminalSummary { + return { + handle, + ptyId: `pty_${handle}`, + worktreeId: 'wt_default', + worktreePath: '/tmp/wt', + branch: 'main', + tabId: 'tab_1', + leafId: handle, + title: null, + connected: true, + writable: true, + lastOutputAt: null, + preview: '' + } +} + +describe('orchestration send and reply receipts', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string | undefined + + afterEach(() => h.cleanup()) + + function setup(): void { + ;({ db, runtime, ctx, activeRunId } = h.setup()) + } + + it('keeps delivery plumbing out of a point-to-point send receipt', async () => { + setup() + + const result = (await h.call( + 'orchestration.send', + { from: 'term_coord', to: `run:${activeRunId}`, subject: 'plumbing' }, + ctx + )) as { message: Record } + + expect(result.message).toMatchObject({ subject: 'plumbing' }) + for (const column of INTERNAL_COLUMNS) { + expect(result.message).not.toHaveProperty(column) + } + }) + + it('keeps delivery plumbing out of a group send receipt', async () => { + setup() + const terminals = [terminalSummary('term_coord'), terminalSummary('term_worker')] + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals, + totalCount: terminals.length, + truncated: false + }) + createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker') + + const result = (await h.call( + 'orchestration.send', + { from: 'term_coord', to: '@all', subject: 'group plumbing' }, + ctx + )) as { messages: Record[] } + + expect(result.messages).toHaveLength(1) + for (const message of result.messages) { + for (const column of INTERNAL_COLUMNS) { + expect(message).not.toHaveProperty(column) + } + } + }) + + it('keeps delivery plumbing out of a reply receipt', async () => { + setup() + const original = db.insertMessage({ + from: 'term_worker', + to: `run:${activeRunId}`, + subject: 'Need an answer' + }) + + const result = (await h.call( + 'orchestration.reply', + { id: original.id, body: 'One durable answer', from: 'term_coord' }, + ctx + )) as { message: Record } + + expect(result.message).toMatchObject({ subject: 'Re: Need an answer' }) + for (const column of INTERNAL_COLUMNS) { + expect(result.message).not.toHaveProperty(column) + } + }) +}) + +describe('orchestration.send between terminals in no Run', () => { + const h = createOrchestrationRpcHarness() + afterEach(() => h.cleanup()) + + it('delivers terminal-to-terminal mail when neither terminal is in a Run', async () => { + // Two plain panes and `send --to `: the first command the guide teaches. #19542 + // refused this with a bare "Run is required"; it files under the unbound Run instead. + const { db, runtime, ctx } = h.setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_a' ? 'tab_a:leaf_a' : handle === 'term_b' ? 'tab_b:leaf_b' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + const result = (await h.call( + 'orchestration.send', + { from: 'term_a', to: 'term_b', subject: 'hello from no Run' }, + ctx + )) as { message: { id: string; run_id: string; to_handle: string } } + + expect(result.message).toMatchObject({ run_id: 'run_unbound', to_handle: 'term_b' }) + expect(db.getRun('run_unbound')).toMatchObject({ legacy: 0 }) + expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([result.message.id]) + const checked = (await h.call('orchestration.check', { terminal: 'term_b' }, ctx)) as { + messages: { id: string }[] + } + expect(checked.messages.map((row) => row.id)).toEqual([result.message.id]) + }) +}) + +describe('orchestration.send to a settled Dispatch mailbox', () => { + const h = createOrchestrationRpcHarness() + let db: OrchestrationDb + let ctx: RpcContext + + afterEach(() => { + h.cleanup() + }) + + function setup(): void { + ;({ db, ctx } = h.setup()) + } + + async function call(name: string, params: Record) { + return h.call(name, params, ctx) + } + + it.each([ + ['completed', (settledDb: OrchestrationDb, id: string) => settledDb.completeDispatch(id)], + [ + 'failed', + (settledDb: OrchestrationDb, id: string) => + settledDb.failDispatch(id, 'worker terminal closed') + ] + ])('rejects mail to a %s Dispatch instead of reporting success', async (_status, settle) => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + settle(db, dispatch.id) + + await expect( + call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + ).rejects.toMatchObject({ code: 'dispatch_inactive' }) + }) + + it('names the Run mailbox that is still reachable', async () => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + db.completeDispatch(dispatch.id) + + await expect( + call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + ).rejects.toThrow(new RegExp(`run:${dispatch.run_id}`)) + }) + + it('names the Run a settled assignee now coordinates, not the sender Run', async () => { + setup() + const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' + vi.spyOn(ctx.runtime, 'getLiveTerminalPaneKey').mockReturnValue(leadPane) + const task = db.createTask({ spec: 'lead that settled and kept coordinating' }) + const dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) + db.completeDispatch(dispatch.id) + const leadRun = db.createRun({ + objective: 'lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: leadPane + }) + + const rejection = call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + + await expect(rejection).rejects.toMatchObject({ code: 'dispatch_inactive' }) + await expect(rejection).rejects.toThrow(new RegExp(`run:${leadRun.id}`)) + await expect(rejection).rejects.not.toThrow(new RegExp(`run:${dispatch.run_id}`)) + expect(db.getInbox()).toEqual([]) + await expect( + call('orchestration.check', { terminal: 'term_coord', run: leadRun.id }) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + // Run addresses are already visible to callers; a hint grants no consuming authority. + expect(await call('orchestration.runShow', { id: leadRun.id })).toMatchObject({ + run: { id: leadRun.id } + }) + }) + + it('does not write an undeliverable message row', async () => { + setup() + const task = db.createTask({ spec: 'worker that already reported' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + db.completeDispatch(dispatch.id) + + await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }).catch(() => undefined) + + const stranded = db.db + .prepare('SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?') + .get(`dispatch:${dispatch.id}`) as { count: number } + expect(stranded.count).toBe(0) + }) + + it('still delivers to an active Dispatch mailbox', async () => { + setup() + const task = db.createTask({ spec: 'worker still running' }) + const dispatch = createRootDispatch(db, task.id, 'term_worker') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Pause after this step' + })) as { message: { to_handle: string } } + + expect(result.message.to_handle).toBe(`dispatch:${dispatch.id}`) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts deleted file mode 100644 index 232a5992a872..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' - -// The same delivery plumbing `check` already strips; a send/reply receipt is the same mailbox row. -const INTERNAL_COLUMNS = [ - 'read', - 'sequence', - 'sender_pane_key', - 'pointer_enter_pending', - 'pointer_pty_id', - 'pointer_process_incarnation' -] - -function terminalSummary(handle: string): RuntimeTerminalSummary { - return { - handle, - ptyId: `pty_${handle}`, - worktreeId: 'wt_default', - worktreePath: '/tmp/wt', - branch: 'main', - tabId: 'tab_1', - leafId: handle, - title: null, - connected: true, - writable: true, - lastOutputAt: null, - preview: '' - } -} - -describe('orchestration send and reply receipts', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let activeRunId: string | undefined - - afterEach(() => h.cleanup()) - - function setup(): void { - ;({ db, runtime, ctx, activeRunId } = h.setup()) - } - - it('keeps delivery plumbing out of a point-to-point send receipt', async () => { - setup() - - const result = (await h.call( - 'orchestration.send', - { from: 'term_coord', to: `run:${activeRunId}`, subject: 'plumbing' }, - ctx - )) as { message: Record } - - expect(result.message).toMatchObject({ subject: 'plumbing' }) - for (const column of INTERNAL_COLUMNS) { - expect(result.message).not.toHaveProperty(column) - } - }) - - it('keeps delivery plumbing out of a group send receipt', async () => { - setup() - const terminals = [terminalSummary('term_coord'), terminalSummary('term_worker')] - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals, - totalCount: terminals.length, - truncated: false - }) - createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker') - - const result = (await h.call( - 'orchestration.send', - { from: 'term_coord', to: '@all', subject: 'group plumbing' }, - ctx - )) as { messages: Record[] } - - expect(result.messages).toHaveLength(1) - for (const message of result.messages) { - for (const column of INTERNAL_COLUMNS) { - expect(message).not.toHaveProperty(column) - } - } - }) - - it('keeps delivery plumbing out of a reply receipt', async () => { - setup() - const original = db.insertMessage({ - from: 'term_worker', - to: `run:${activeRunId}`, - subject: 'Need an answer' - }) - - const result = (await h.call( - 'orchestration.reply', - { id: original.id, body: 'One durable answer', from: 'term_coord' }, - ctx - )) as { message: Record } - - expect(result.message).toMatchObject({ subject: 'Re: Need an answer' }) - for (const column of INTERNAL_COLUMNS) { - expect(result.message).not.toHaveProperty(column) - } - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts deleted file mode 100644 index 84cb250a19e6..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('orchestration.send between terminals in no Run', () => { - const h = createOrchestrationRpcHarness() - afterEach(() => h.cleanup()) - - it('delivers terminal-to-terminal mail when neither terminal is in a Run', async () => { - // Two plain panes and `send --to `: the first command the guide teaches. #19542 - // refused this with a bare "Run is required"; it files under the unbound Run instead. - const { db, runtime, ctx } = h.setup(false) - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_a' ? 'tab_a:leaf_a' : handle === 'term_b' ? 'tab_b:leaf_b' : null - ) - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) - - const result = (await h.call( - 'orchestration.send', - { from: 'term_a', to: 'term_b', subject: 'hello from no Run' }, - ctx - )) as { message: { id: string; run_id: string; to_handle: string } } - - expect(result.message).toMatchObject({ run_id: 'run_unbound', to_handle: 'term_b' }) - expect(db.getRun('run_unbound')).toMatchObject({ legacy: 0 }) - expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([result.message.id]) - const checked = (await h.call('orchestration.check', { terminal: 'term_b' }, ctx)) as { - messages: { id: string }[] - } - expect(checked.messages.map((row) => row.id)).toEqual([result.message.id]) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts deleted file mode 100644 index ddffed468900..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -describe('orchestration.send to a settled Dispatch mailbox', () => { - const h = createOrchestrationRpcHarness() - let db: OrchestrationDb - let ctx: RpcContext - - afterEach(() => { - h.cleanup() - }) - - function setup(): void { - ;({ db, ctx } = h.setup()) - } - - async function call(name: string, params: Record) { - return h.call(name, params, ctx) - } - - it.each([ - ['completed', (settledDb: OrchestrationDb, id: string) => settledDb.completeDispatch(id)], - [ - 'failed', - (settledDb: OrchestrationDb, id: string) => - settledDb.failDispatch(id, 'worker terminal closed') - ] - ])('rejects mail to a %s Dispatch instead of reporting success', async (_status, settle) => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - settle(db, dispatch.id) - - await expect( - call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - ).rejects.toMatchObject({ code: 'dispatch_inactive' }) - }) - - it('names the Run mailbox that is still reachable', async () => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - db.completeDispatch(dispatch.id) - - await expect( - call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - ).rejects.toThrow(new RegExp(`run:${dispatch.run_id}`)) - }) - - it('names the Run a settled assignee now coordinates, not the sender Run', async () => { - setup() - const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' - vi.spyOn(ctx.runtime, 'getLiveTerminalPaneKey').mockReturnValue(leadPane) - const task = db.createTask({ spec: 'lead that settled and kept coordinating' }) - const dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) - db.completeDispatch(dispatch.id) - const leadRun = db.createRun({ - objective: 'lead', - coordinatorHandle: 'term_lead', - coordinatorPaneKey: leadPane - }) - - const rejection = call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }) - - await expect(rejection).rejects.toMatchObject({ code: 'dispatch_inactive' }) - await expect(rejection).rejects.toThrow(new RegExp(`run:${leadRun.id}`)) - await expect(rejection).rejects.not.toThrow(new RegExp(`run:${dispatch.run_id}`)) - expect(db.getInbox()).toEqual([]) - await expect( - call('orchestration.check', { terminal: 'term_coord', run: leadRun.id }) - ).rejects.toMatchObject({ code: 'consumer_fenced' }) - // Run addresses are already visible to callers; a hint grants no consuming authority. - expect(await call('orchestration.runShow', { id: leadRun.id })).toMatchObject({ - run: { id: leadRun.id } - }) - }) - - it('does not write an undeliverable message row', async () => { - setup() - const task = db.createTask({ spec: 'worker that already reported' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - db.completeDispatch(dispatch.id) - - await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'One more thing' - }).catch(() => undefined) - - const stranded = db.db - .prepare('SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?') - .get(`dispatch:${dispatch.id}`) as { count: number } - expect(stranded.count).toBe(0) - }) - - it('still delivers to an active Dispatch mailbox', async () => { - setup() - const task = db.createTask({ spec: 'worker still running' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker') - - const result = (await call('orchestration.send', { - from: 'term_coord', - to: `dispatch:${dispatch.id}`, - subject: 'Pause after this step' - })) as { message: { to_handle: string } } - - expect(result.message.to_handle).toBe(`dispatch:${dispatch.id}`) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts deleted file mode 100644 index 6c311baf84d1..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../../../core' -import type { OrchestrationDb } from '../../../../orchestration/db' -import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration-compatibility-evidence' -import { createOrchestrationRpcHarness } from '../rpc-test-harness' - -const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const OTHER_WORKER_PANE = 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' -const NON_PARTY_PANE = 'tab_teammate:dddddddd-dddd-4ddd-8ddd-dddddddddddd' -const WORKER_PROCESS = 'runtime_test:term_worker:1' - -describe('worker report authority without a Dispatch capability', () => { - const harness = createOrchestrationRpcHarness() - let db: OrchestrationDb - let runtime: OrcaRuntimeService - let ctx: RpcContext - let panes: Record - - afterEach(() => harness.cleanup()) - - function setup(): void { - ;({ db, runtime } = harness.setup()) - ctx = { runtime } - panes = { - term_coord: harness.coordinatorPaneKey, - term_worker: WORKER_PANE, - term_other: OTHER_WORKER_PANE, - term_teammate: NON_PARTY_PANE - } - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) - vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( - (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null - ) - vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') - } - - function startWorker(name: string, paneKey: string): { taskId: string; dispatchId: string } { - const task = db.createTask({ spec: `${name} work` }) - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: `term_${name}`, - paneKey, - processIncarnation: `runtime_test:term_${name}:1`, - worktreeId: `repo::${name}`, - effects: [], - setupState: 'not_applicable', - terminalOwnership: 'created' - }) - db.markWorkerDispatchReady(started.dispatch.id) - return { taskId: task.id, dispatchId: started.dispatch.id } - } - - function workerDone( - worker: { taskId: string; dispatchId: string }, - evidence?: OrchestrationCompatibilityEvidence, - outcome = 'succeeded' - ) { - return harness.call( - 'orchestration.send', - { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ ...worker, outcome }) - }, - { ...ctx, orchestrationCompatibilityEvidence: evidence } - ) - } - - function ask(evidence?: OrchestrationCompatibilityEvidence) { - return harness.call( - 'orchestration.ask', - { from: 'term_worker', question: 'Proceed?', timeoutMs: 1 }, - { ...ctx, orchestrationCompatibilityEvidence: evidence } - ) - } - - describe('caller fence', () => { - it.each([ - ['the Run coordinator', harness.coordinatorPaneKey], - ['another Dispatch worker', OTHER_WORKER_PANE] - ])('refuses a report sent from %s terminal and records nothing', async (_party, paneKey) => { - setup() - startWorker('other', OTHER_WORKER_PANE) - const worker = startWorker('worker', WORKER_PANE) - - await expect(workerDone(worker, { paneKey })).rejects.toMatchObject({ - code: 'consumer_fenced', - data: { effectsApplied: false } - }) - await expect(ask({ paneKey })).rejects.toMatchObject({ code: 'consumer_fenced' }) - expect( - db.db - .prepare("SELECT COUNT(*) AS count FROM messages WHERE from_handle = 'term_worker'") - .get() - ).toEqual({ count: 0 }) - expect(db.getTask(worker.taskId)?.status).toBe('dispatched') - }) - - it.each<[string, OrchestrationCompatibilityEvidence | undefined]>([ - ['no identity env (old CLI, scrubbed env)', undefined], - ['its own pane', { paneKey: WORKER_PANE, terminalHandle: 'term_stale_after_remint' }], - ["another Orca's pane", { paneKey: 'tab_elsewhere:eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }], - ['a stale handle', { terminalHandle: 'term_gone' }], - ['a live pane that is no orchestration party', { paneKey: NON_PARTY_PANE }] - ])('accepts a report whose env names %s', async (_case, evidence) => { - setup() - const worker = startWorker('worker', WORKER_PANE) - - expect(await workerDone(worker, evidence)).toMatchObject({ - lifecycle: { action: 'completed' } - }) - expect(db.getTask(worker.taskId)?.status).toBe('completed') - }) - - it('passes the fence for a stale --from handle and leaves refusal to the process check', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - panes = { ...panes, term_worker_reminted: WORKER_PANE } - delete panes.term_worker - - expect(await workerDone(worker, { paneKey: WORKER_PANE })).toMatchObject({ - lifecycle: { action: 'rejected', code: 'worker_identity_changed' } - }) - }) - }) - - describe('worker states', () => { - it('refuses reports and questions while a stop is in flight', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - - expect(await workerDone(worker)).toMatchObject({ - lifecycle: { action: 'rejected', code: 'dispatch_inactive' } - }) - await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) - expect(db.getWorkerDispatch(worker.dispatchId)?.state).toBe('stopping') - }) - - it.each([ - ['succeeded', 'completed', 'succeeded'], - ['failed', 'failed', 'failed'] - ])( - 'settles a %s report after a stop whose outcome is unknown', - async (outcome, taskStatus, workerState) => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') - - expect(await workerDone(worker, undefined, outcome)).toMatchObject({ - lifecycle: { action: taskStatus } - }) - expect(db.getTask(worker.taskId)?.status).toBe(taskStatus) - expect(db.getWorkerDispatch(worker.dispatchId)).toMatchObject({ - state: workerState, - ...(outcome === 'succeeded' ? { last_error: null } : {}) - }) - } - ) - - it('refuses a stale process in the worker pane after an unknown stop', async () => { - setup() - const worker = startWorker('worker', WORKER_PANE) - db.beginWorkerStop(worker.dispatchId, 'epoch_home') - db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') - vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') - - expect(await workerDone(worker)).toMatchObject({ - lifecycle: { code: 'worker_identity_changed' } - }) - await expect(ask()).rejects.toMatchObject({ code: 'worker_identity_changed' }) - }) - }) - - describe('remote worker states', () => { - function startRemoteWorker(): string { - const dispatchId = 'ctx_remote_worker' - db.createRemoteDispatchAttachment({ - runId: 'run-home', - dispatchId, - taskId: 'task_remote_worker', - homePeerFingerprint: 'run-home-device', - protocolVersion: 1, - runtimeEpoch: 'epoch_worker_host', - mutationReceipt: { - callerFingerprint: 'run-home-device', - requestId: 'remote_attach', - method: 'orchestration.federationAttachStart', - payloadHash: 'remote_attach_payload' - } - }) - db.prepareRemoteAttachmentAuthority({ - dispatchId, - paneKey: WORKER_PANE, - processIncarnation: WORKER_PROCESS, - worktreeId: 'repo::remote', - terminalHandle: 'term_worker', - setupState: 'completed', - effects: [] - }) - return dispatchId - } - - it.each([ - [ - 'stop_unknown', - (dispatchId: string) => { - db.markRemoteAttachmentReady(dispatchId) - db.beginRemoteAttachmentStop(dispatchId) - db.markRemoteAttachmentStopUnknown(dispatchId, 'tab not owned by Orca') - } - ], - [ - 'start_unknown', - (dispatchId: string) => - db.failRemoteAttachment(dispatchId, 'agent_readiness', 'connection lost', true) - ] - ])('settles a remote report from %s', async (state, reachState) => { - setup() - const dispatchId = startRemoteWorker() - reachState(dispatchId) - expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe(state) - - expect(await workerDone({ taskId: 'task_remote_worker', dispatchId })).toMatchObject({ - lifecycle: { action: 'completed' } - }) - expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe('succeeded') - }) - - it('refuses a remote report while the stop is in flight', async () => { - setup() - const dispatchId = startRemoteWorker() - db.markRemoteAttachmentReady(dispatchId) - db.beginRemoteAttachmentStop(dispatchId) - - await expect(workerDone({ taskId: 'task_remote_worker', dispatchId })).rejects.toMatchObject({ - code: 'dispatch_inactive' - }) - await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) - expect(db.listPendingFederationRelay(dispatchId, 'to_home')).toEqual([]) - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts deleted file mode 100644 index f93df6e4d7a7..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' -import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -const READY_WAIT = { - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null -} satisfies RuntimeTerminalWait - -describe('Antigravity orchestration worker lifecycle', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { - h.setup() - const readiness = h.deferred() - vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) - - const pending = h.startWorker({ agent: 'antigravity' }) - await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) - - expect(h.runtime.createTerminal).toHaveBeenCalledWith( - 'id:repo::worktree', - expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) - ) - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ - ownership_state: 'owned', - terminal_handle: 'term_worker' - }) - - readiness.resolve(READY_WAIT) - await expect(pending).resolves.toEqual( - expect.objectContaining({ dispatchId: expect.any(String) }) - ) - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) - }) - - it('stops only the owned AGY terminal', async () => { - h.setup() - const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) - - await expect( - h.call('orchestration.workerStop', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') - }) - - it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker('succeeded', { - agent: 'antigravity' - }) - vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'owned', - release_state: 'releasing' - }) - - await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ - attempted: 1, - released: 1 - }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - release_state: 'released' - }) - expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) - expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') - }) - - it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker('succeeded', { - agent: 'antigravity' - }) - vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'release_unknown' }) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'owned', - release_state: 'unknown' - }) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'released' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts deleted file mode 100644 index bbca9ec29ec7..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition -// used to reject it and its abandoned Task had no documented route back. -describe('worker-start --retry-of a context-only Dispatch', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - async function dispatchContextOnly( - spec: string - ): Promise<{ taskId: string; dispatchId: string }> { - const task = harness.db.createTask({ spec, runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { id: string } } - expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined() - return { taskId: task.id, dispatchId: result.dispatch.id } - } - - it('restarts the Task after the attempt is abandoned', async () => { - const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt') - - await expect( - harness.call('orchestration.workerAbandon', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) - expect(harness.db.getTask(taskId)?.status).toBe('blocked') - - const retried = (await harness.call('orchestration.workerStart', { - task: taskId, - from: 'term_coord', - terminal: 'term_worker', - retryOf: dispatchId - })) as { dispatchId: string; state: string } - - expect(retried.state).toBe('ready') - expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe( - dispatchId - ) - expect(harness.db.getTask(taskId)?.status).toBe('dispatched') - }) - - it('still refuses to retry an attempt that has not settled', async () => { - const { taskId, dispatchId } = await dispatchContextOnly('live attempt') - - await expect( - harness.call('orchestration.workerStart', { - task: taskId, - from: 'term_coord', - terminal: 'term_worker', - retryOf: dispatchId - }) - ).rejects.toMatchObject({ code: 'task_not_startable' }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts deleted file mode 100644 index 75ad57f3a120..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -type ListedWorker = { - dispatchId: string - workerState: string - dispatchStatus: string - projection: { - outcome: string - liveness: { verdict: string; reason?: string } - nextAction: { kind: string; argv: string[] } - attention: { categories: string[]; requiresAction: boolean } - } -} - -describe('pre-v3 dispatch rows in worker-list', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - /** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no - * worker_dispatches row, which is what every dispatch made before supervised workers looks like. */ - function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string { - const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId }) - const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`) - if (status === 'completed') { - h.db.completeDispatch(dispatch.id) - } - if (status === 'failed') { - h.db.failDispatch(dispatch.id, 'legacy failure') - } - return dispatch.id - } - - async function listWorkers(): Promise> { - const listed = (await h.call('orchestration.workerList', { - paginate: true, - run: h.activeRunId - })) as { workers: ListedWorker[] } - return new Map(listed.workers.map((worker) => [worker.dispatchId, worker])) - } - - it('projects a settled legacy dispatch as settled with nothing to act on', async () => { - h.setup() - const completed = createLegacyDispatch('completed') - - const worker = (await listWorkers()).get(completed)! - - expect(worker.workerState).toBe('unsupervised') - expect(worker.dispatchStatus).toBe('completed') - // `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded` - // report or a task completion, so the durable record is the whole settlement. - expect(worker.projection.outcome).toBe('succeeded') - // Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch - // that never had a worker row has no process whose absence could require action. - expect(worker.projection.liveness).toEqual({ - verdict: 'unverifiable', - reason: 'unsupervised_settled' - }) - expect(worker.projection.attention.categories).not.toContain('unverifiable') - expect(worker.projection.attention.requiresAction).toBe(false) - expect(worker.projection.nextAction.kind).toBe('none') - }) - - it.each(['completed', 'failed'] as const)( - 'closes a pending question when a legacy dispatch settles as %s', - async (status) => { - h.setup() - const task = h.db.createTask({ spec: `legacy ${status} with question`, runId: h.activeRunId }) - const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`) - const asked = h.db.createQuestion({ - runId: h.activeRunId, - dispatchId: dispatch.id, - askerHandle: `term_legacy_q_${status}`, - question: 'Which branch?' - }) - // Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch. - if (status === 'completed') { - h.db.updateTaskStatus(task.id, 'completed', 'done') - } else { - h.db.failDispatch(dispatch.id, 'legacy failure') - } - - const worker = (await listWorkers()).get(dispatch.id)! - - expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed') - expect(worker.dispatchStatus).toBe(status) - expect(worker.projection.attention.categories).not.toContain('input') - // Nothing can answer a question on a settled Dispatch, so `input` must not outlive it. - expect(worker.projection.attention.requiresAction).toBe(status === 'failed') - } - ) - - it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => { - h.setup() - const failed = createLegacyDispatch('failed') - - const worker = (await listWorkers()).get(failed)! - - expect(worker.dispatchStatus).toBe('failed') - expect(worker.projection.outcome).toBe('failed') - expect(worker.projection.attention.categories).toEqual(['failure']) - expect(worker.projection.attention.requiresAction).toBe(true) - }) - - it('leaves an unsettled legacy dispatch genuinely unknown', async () => { - h.setup() - const dispatched = createLegacyDispatch('dispatched') - - const worker = (await listWorkers()).get(dispatched)! - - expect(worker.projection.outcome).toBe('in_progress') - expect(worker.projection.liveness).toEqual({ - verdict: 'unverifiable', - reason: 'missing_status' - }) - expect(worker.projection.attention.categories).toContain('unverifiable') - expect(worker.projection.attention.requiresAction).toBe(true) - expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts deleted file mode 100644 index bd5becb2a06b..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -// A coordinator that records context against its own terminal delegated nothing, so the row it -// leaves behind must not read back as the coordinator's own parent Attempt. -describe('context-only self-dispatch and nesting depth', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - async function selfDispatch(): Promise { - const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_coord' - })) as { dispatch: { id: string } } - return result.dispatch.id - } - - it('leaves the coordinator able to start a worker', async () => { - const selfDispatchId = await selfDispatch() - expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({ - creator_handle: 'term_coord', - creator_pane_key: harness.coordinatorPaneKey - }) - - const started = await harness.startWorker({ terminal: 'term_worker' }) - - expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({ - depth: 1, - creator_dispatch_id: null - }) - }) - - it('still counts a real assignment to another pane as a nesting parent', async () => { - const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId }) - const delegated = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { id: string } } - - expect( - harness.db.resolveCreatorDepth({ - kind: 'terminal', - handle: 'term_worker', - paneKey: harness.workerPaneKey - }) - ).toBe(1) - expect( - harness.db.resolveCreatorDispatchId({ - kind: 'terminal', - handle: 'term_worker', - paneKey: harness.workerPaneKey - }) - ).toBe(delegated.dispatch.id) - }) - - it('reports the self-dispatching coordinator as a root', async () => { - await selfDispatch() - - const creator = { - kind: 'terminal', - handle: 'term_coord', - paneKey: harness.coordinatorPaneKey - } as const - expect(harness.db.resolveCreatorDepth(creator)).toBe(0) - expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull() - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts deleted file mode 100644 index 434b0ee97885..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-brief-agent-in-front.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -const PTY_ID = 'pty_worker' - -// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the -// launched agent found in front, or the shell runs it. -describe('a worker start writes its brief only into the agent it launched', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - function launchedPane(foreground: LaunchedAgentForeground): string[] { - h.setup() - const writes: string[] = [] - vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) - vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true) - vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {}) - vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation( - async (handle, text, options) => { - await options?.beforeWrite?.(PTY_ID) - writes.push(text) - return { handle, accepted: true, bytesWritten: text.length } - } - ) - return writes - } - - it('types nothing when the agent exited and its shell is in front', async () => { - const writes = launchedPane('shell') - - await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow() - expect(writes).toEqual([]) - }) - - it('writes the brief once into the agent found in front', async () => { - const writes = launchedPane('agent') - - await h.startWorker({ agent: 'claude' }) - expect(writes).toHaveLength(1) - }) - - it('leaves a terminal the caller supplied to its own idle wait', async () => { - const writes = launchedPane('shell') - - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled() - expect(writes).toHaveLength(1) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts index d37d0b04a594..06db0da17d84 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' @@ -60,3 +61,282 @@ describe('orchestration worker-list Run scope (runtime)', () => { ) }) }) + +// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition +// used to reject it and its abandoned Task had no documented route back. +describe('worker-start --retry-of a context-only Dispatch', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + async function dispatchContextOnly( + spec: string + ): Promise<{ taskId: string; dispatchId: string }> { + const task = harness.db.createTask({ spec, runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { id: string } } + expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined() + return { taskId: task.id, dispatchId: result.dispatch.id } + } + + it('restarts the Task after the attempt is abandoned', async () => { + const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt') + + await expect( + harness.call('orchestration.workerAbandon', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) + expect(harness.db.getTask(taskId)?.status).toBe('blocked') + + const retried = (await harness.call('orchestration.workerStart', { + task: taskId, + from: 'term_coord', + terminal: 'term_worker', + retryOf: dispatchId + })) as { dispatchId: string; state: string } + + expect(retried.state).toBe('ready') + expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe( + dispatchId + ) + expect(harness.db.getTask(taskId)?.status).toBe('dispatched') + }) + + it('still refuses to retry an attempt that has not settled', async () => { + const { taskId, dispatchId } = await dispatchContextOnly('live attempt') + + await expect( + harness.call('orchestration.workerStart', { + task: taskId, + from: 'term_coord', + terminal: 'term_worker', + retryOf: dispatchId + }) + ).rejects.toMatchObject({ code: 'task_not_startable' }) + }) +}) + +// A coordinator that records context against its own terminal delegated nothing, so the row it +// leaves behind must not read back as the coordinator's own parent Attempt. +describe('context-only self-dispatch and nesting depth', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + async function selfDispatch(): Promise { + const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_coord' + })) as { dispatch: { id: string } } + return result.dispatch.id + } + + it('leaves the coordinator able to start a worker', async () => { + const selfDispatchId = await selfDispatch() + expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({ + creator_handle: 'term_coord', + creator_pane_key: harness.coordinatorPaneKey + }) + + const started = await harness.startWorker({ terminal: 'term_worker' }) + + expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({ + depth: 1, + creator_dispatch_id: null + }) + }) + + it('still counts a real assignment to another pane as a nesting parent', async () => { + const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId }) + const delegated = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { id: string } } + + expect( + harness.db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_worker', + paneKey: harness.workerPaneKey + }) + ).toBe(1) + expect( + harness.db.resolveCreatorDispatchId({ + kind: 'terminal', + handle: 'term_worker', + paneKey: harness.workerPaneKey + }) + ).toBe(delegated.dispatch.id) + }) + + it('reports the self-dispatching coordinator as a root', async () => { + await selfDispatch() + + const creator = { + kind: 'terminal', + handle: 'term_coord', + paneKey: harness.coordinatorPaneKey + } as const + expect(harness.db.resolveCreatorDepth(creator)).toBe(0) + expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull() + }) +}) + +type ReadWithProjection = { projection?: OrchestrationFleetWorker | null } + +describe('orchestration worker-read fleet projection', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => { + h.setup() + const { dispatchId } = await h.startWorker() + + const read = (await h.call('orchestration.workerRead', { + dispatch: dispatchId + })) as ReadWithProjection & { status: { liveness?: string } } + + expect(read.projection?.dispatchId).toBe(dispatchId) + // The agent verdict is not the PTY verdict; worker-read must carry both. + expect(read.status.liveness).toBe('live') + expect(read.projection?.liveness.verdict).toBe('unverifiable') + }) + + it('carries the projection on an archived read after release', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + await h.call('orchestration.workerRelease', { dispatch: dispatchId }) + + const read = (await h.call('orchestration.workerRead', { + dispatch: dispatchId + })) as ReadWithProjection + + expect(read.projection?.dispatchId).toBe(dispatchId) + expect(read.projection?.liveness.verdict).toBe('exited') + }) +}) + +type ListedWorker = { + dispatchId: string + workerState: string + dispatchStatus: string + projection: { + outcome: string + liveness: { verdict: string; reason?: string } + nextAction: { kind: string; argv: string[] } + attention: { categories: string[]; requiresAction: boolean } + } +} + +describe('pre-v3 dispatch rows in worker-list', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + /** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no + * worker_dispatches row, which is what every dispatch made before supervised workers looks like. */ + function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string { + const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId }) + const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`) + if (status === 'completed') { + h.db.completeDispatch(dispatch.id) + } + if (status === 'failed') { + h.db.failDispatch(dispatch.id, 'legacy failure') + } + return dispatch.id + } + + async function listWorkers(): Promise> { + const listed = (await h.call('orchestration.workerList', { + paginate: true, + run: h.activeRunId + })) as { workers: ListedWorker[] } + return new Map(listed.workers.map((worker) => [worker.dispatchId, worker])) + } + + it('projects a settled legacy dispatch as settled with nothing to act on', async () => { + h.setup() + const completed = createLegacyDispatch('completed') + + const worker = (await listWorkers()).get(completed)! + + expect(worker.workerState).toBe('unsupervised') + expect(worker.dispatchStatus).toBe('completed') + // `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded` + // report or a task completion, so the durable record is the whole settlement. + expect(worker.projection.outcome).toBe('succeeded') + // Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch + // that never had a worker row has no process whose absence could require action. + expect(worker.projection.liveness).toEqual({ + verdict: 'unverifiable', + reason: 'unsupervised_settled' + }) + expect(worker.projection.attention.categories).not.toContain('unverifiable') + expect(worker.projection.attention.requiresAction).toBe(false) + expect(worker.projection.nextAction.kind).toBe('none') + }) + + it.each(['completed', 'failed'] as const)( + 'closes a pending question when a legacy dispatch settles as %s', + async (status) => { + h.setup() + const task = h.db.createTask({ spec: `legacy ${status} with question`, runId: h.activeRunId }) + const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`) + const asked = h.db.createQuestion({ + runId: h.activeRunId, + dispatchId: dispatch.id, + askerHandle: `term_legacy_q_${status}`, + question: 'Which branch?' + }) + // Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch. + if (status === 'completed') { + h.db.updateTaskStatus(task.id, 'completed', 'done') + } else { + h.db.failDispatch(dispatch.id, 'legacy failure') + } + + const worker = (await listWorkers()).get(dispatch.id)! + + expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed') + expect(worker.dispatchStatus).toBe(status) + expect(worker.projection.attention.categories).not.toContain('input') + // Nothing can answer a question on a settled Dispatch, so `input` must not outlive it. + expect(worker.projection.attention.requiresAction).toBe(status === 'failed') + } + ) + + it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => { + h.setup() + const failed = createLegacyDispatch('failed') + + const worker = (await listWorkers()).get(failed)! + + expect(worker.dispatchStatus).toBe('failed') + expect(worker.projection.outcome).toBe('failed') + expect(worker.projection.attention.categories).toEqual(['failure']) + expect(worker.projection.attention.requiresAction).toBe(true) + }) + + it('leaves an unsettled legacy dispatch genuinely unknown', async () => { + h.setup() + const dispatched = createLegacyDispatch('dispatched') + + const worker = (await listWorkers()).get(dispatched)! + + expect(worker.projection.outcome).toBe('in_progress') + expect(worker.projection.liveness).toEqual({ + verdict: 'unverifiable', + reason: 'missing_status' + }) + expect(worker.projection.attention.categories).toContain('unverifiable') + expect(worker.projection.attention.requiresAction).toBe(true) + expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts deleted file mode 100644 index 3ae9c0c44d01..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -type ReadWithProjection = { projection?: OrchestrationFleetWorker | null } - -describe('orchestration worker-read fleet projection', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => { - h.setup() - const { dispatchId } = await h.startWorker() - - const read = (await h.call('orchestration.workerRead', { - dispatch: dispatchId - })) as ReadWithProjection & { status: { liveness?: string } } - - expect(read.projection?.dispatchId).toBe(dispatchId) - // The agent verdict is not the PTY verdict; worker-read must carry both. - expect(read.status.liveness).toBe('live') - expect(read.projection?.liveness.verdict).toBe('unverifiable') - }) - - it('carries the projection on an archived read after release', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - await h.call('orchestration.workerRelease', { dispatch: dispatchId }) - - const read = (await h.call('orchestration.workerRead', { - dispatch: dispatchId - })) as ReadWithProjection - - expect(read.projection?.dispatchId).toBe(dispatchId) - expect(read.projection?.liveness.verdict).toBe('exited') - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts index 9a6a399cfb16..7904c8e04723 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' +import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('workerRelease on a retained resource whose process exited', () => { @@ -102,3 +104,101 @@ describe('workerRelease on a retained resource whose process exited', () => { ) }) }) + +const READY_WAIT = { + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null +} satisfies RuntimeTerminalWait + +describe('Antigravity orchestration worker lifecycle', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { + h.setup() + const readiness = h.deferred() + vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) + + const pending = h.startWorker({ agent: 'antigravity' }) + await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) + + expect(h.runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) + ) + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ + ownership_state: 'owned', + terminal_handle: 'term_worker' + }) + + readiness.resolve(READY_WAIT) + await expect(pending).resolves.toEqual( + expect.objectContaining({ dispatchId: expect.any(String) }) + ) + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + }) + + it('stops only the owned AGY terminal', async () => { + h.setup() + const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) + + await expect( + h.call('orchestration.workerStop', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) + + it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'releasing' + }) + + await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ + attempted: 1, + released: 1 + }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + release_state: 'released' + }) + expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) + expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') + }) + + it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_unknown' }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'unknown' + }) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts index b2627bd13d38..f6742cea93ec 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('worker-start --terminal target', () => { @@ -157,3 +159,101 @@ describe('orchestration.dispatch --to the caller', () => { expect(result.dispatch.assignee_pane_key).toBe(harness.workerPaneKey) }) }) + +const PTY_ID = 'pty_worker' + +// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the +// launched agent found in front, or the shell runs it. +describe('a worker start writes its brief only into the agent it launched', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + function launchedPane(foreground: LaunchedAgentForeground): string[] { + h.setup() + const writes: string[] = [] + vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) + vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true) + vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {}) + vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation( + async (handle, text, options) => { + await options?.beforeWrite?.(PTY_ID) + writes.push(text) + return { handle, accepted: true, bytesWritten: text.length } + } + ) + return writes + } + + it('types nothing when the agent exited and its shell is in front', async () => { + const writes = launchedPane('shell') + + await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow() + expect(writes).toEqual([]) + }) + + it('writes the brief once into the agent found in front', async () => { + const writes = launchedPane('agent') + + await h.startWorker({ agent: 'claude' }) + expect(writes).toHaveLength(1) + }) + + it('leaves a terminal the caller supplied to its own idle wait', async () => { + const writes = launchedPane('shell') + + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled() + expect(writes).toHaveLength(1) + }) +}) + +describe('composer-marker first dispatch readiness', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for the new composer before dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) + ) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) + + it('keeps reused terminals on the normal idle wait', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle' }) + ) + }) + + it('never delivers a task after a startup timeout', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) + await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts deleted file mode 100644 index 79df47af820c..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -describe('composer-marker first dispatch readiness', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - it.each(['zcode', 'opencode', 'opencode2'] as const)( - '%s waits for the new composer before dispatch', - async (agent) => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - agent, - 60_000 - ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve({ - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null - }) - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - } - ) - - it('keeps reused terminals on the normal idle wait', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() - expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( - 'term_worker', - expect.objectContaining({ condition: 'tui-idle' }) - ) - }) - - it('never delivers a task after a startup timeout', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) - await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/screen-ruled-readiness-scope.test.ts b/src/main/runtime/screen-ruled-readiness-scope.test.ts deleted file mode 100644 index af3fd48ae449..000000000000 --- a/src/main/runtime/screen-ruled-readiness-scope.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { readRuntimeFixture } from './agent-transcript-replay-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Why: the grid-trust check and raw rows belong to screen-ruled agents; every other agent must -// keep reading the screen exactly as it did before them. -describe('screen-rule trust stays with screen-ruled agents', () => { - it('a Codex pane still settles from its screen when the PTY reports another grid', async () => { - const options = { - paneTitle: 'Terminal', - foregroundProcess: 'codex', - launchAgent: 'codex' as const, - // Only the live screen shows this recording's header (STA-8628): the text reads `dirctory:`. - data: readRuntimeFixture('codex-0157-effort-override-embedded-warning'), - size: { cols: 120, rows: 40 } - } - const { runtime, handle } = await createTranscriptPane(options) - await runtime.readTerminal(handle, { screen: true }) - options.size = { cols: 100, rows: 30 } - await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, 20_000) -}) diff --git a/src/main/runtime/stale-title-display-clear.test.ts b/src/main/runtime/stale-title-display-clear.test.ts deleted file mode 100644 index 3a5c169573c1..000000000000 --- a/src/main/runtime/stale-title-display-clear.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { clearWorkingIndicators } from '../../shared/agent-title-status' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -// Synthetic protocol cases: they test which title readiness, display and presence each read, -// not what a particular CLI paints. The 3 s timer is the stale-working-title clear. -describe('the stale-working title clear is display-only', () => { - it.each([ - ['claude', '. claude', '* claude'], - ['codex', '⠋ Codex working', 'Codex ready'], - ['gemini', '✦ Gemini CLI', '◇ Gemini CLI'], - ['opencode', '⠋ OC | task', 'OC | task'], - ['pi', 'π : project', 'π - project'] - ] as const)( - '%s does not become ready when its display title clears', - async (agent, busy, idle) => { - vi.useFakeTimers() - try { - const titleChanges: string[] = [] - const { runtime, handle } = await createTranscriptPane( - { - paneTitle: busy, - foregroundProcess: agent, - data: '', - launchAgent: agent - }, - { - onTerminalSideEffects: (batch) => { - for (const fact of batch.facts) { - if (fact.kind === 'title') { - titleChanges.push(fact.normalizedTitle) - } - } - } - } - ) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${busy}\x07`, Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - expect(titleChanges.at(-1)).toBe(clearWorkingIndicators(busy)) - // A renderer graph publish can carry the display-only cleared title back to main. - runtime.syncWindowGraph(1, { - tabs: [ - { - tabId: 'tab-1', - worktreeId: 'wt-1', - title: clearWorkingIndicators(busy), - activeLeafId: '11111111-1111-4111-8111-111111111111', - layout: null - } - ], - leaves: [ - { - tabId: 'tab-1', - worktreeId: 'wt-1', - leafId: '11111111-1111-4111-8111-111111111111', - paneRuntimeId: 1, - ptyId: TRANSCRIPT_PANE_PTY_ID, - paneTitle: clearWorkingIndicators(busy) - } - ] - }) - const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) - const assertion = expect(waiting).rejects.toThrow('timeout') - await Promise.all([assertion, vi.advanceTimersByTimeAsync(200)]) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${idle}\x07`, Date.now()) - const ready = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) - const readyAssertion = expect(ready).resolves.toMatchObject({ satisfied: true }) - await Promise.all([readyAssertion, vi.advanceTimersByTimeAsync(2_000)]) - } finally { - vi.useRealTimers() - } - } - ) - - // A name-shaped title (`⠋ Codex working`) clears to `Codex`, which still names an agent, as on main. - it.each([['⠋ repo']])( - 'an agent that exited behind %s does not pass for a running agent', - async (title) => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'zsh', - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${title}\x07`, Date.now()) - // The agent exits and the shell prints its prompt: output, but no title. - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false, - status: null - }) - await expect( - runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) - ).resolves.toBe(false) - } finally { - vi.useRealTimers() - } - } - ) - - it('a live agent behind a cleared title is still found by its process', async () => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'codex', - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: null - }) - await expect( - runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) - ).resolves.toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it("the agent's next genuine title proves presence again", async () => { - vi.useFakeTimers() - try { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: null, - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) - await vi.advanceTimersByTimeAsync(3_000) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠙ repo\x07', Date.now()) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'working' - }) - } finally { - vi.useRealTimers() - } - }) - - it('a live Cursor spinner title proves presence when no foreground read can answer', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: null, - data: '' - }) - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'working' - }) - }) -}) diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 84d7e2042639..47104705d101 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -1,5 +1,3 @@ -// A worker parked on an interactive prompt must be distinguishable from one that is thinking -// or inside a long tool call (STA-4513, STA-3714). import { readFileSync } from 'node:fs' import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { join } from 'node:path' @@ -8,9 +6,11 @@ import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from './orca-runtime-core' import { createTranscriptPane, type TranscriptPaneOptions, - TRANSCRIPT_PANE_PTY_ID as PTY_ID + TRANSCRIPT_PANE_PTY_ID as PTY_ID, + waitForTranscriptIdle } from './agent-transcript-pane-test-harness' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' +import { readRuntimeFixture } from './agent-transcript-replay-test-harness' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -19,207 +19,319 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no -// escape bytes and no carriage returns, so they came through a terminal's renderer and a -// clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on — -// and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer. -// Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes. -function fixture(name: string): string { - return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') -} -const CURSOR_APPROVAL = fixture('cursor-agent-approval-prompt') -const CURSOR_LONG_TOOL_CALL = fixture('cursor-agent-long-tool-call') -const CURSOR_IDLE = fixture('cursor-agent-idle-after-approval') - -// Claude Code 2.1.234's own trust screen, which the runtime already matched by shape. -const CLAUDE_TRUST = [ - 'Accessing workspace:\n', - '/private/tmp/repo\n', - 'Quick safety check: Is this a project you created or one you trust?\n', - '❯ 1. Yes, I trust this folder\n', - ' 2. No, exit\n' -].join('') - -function agentStatusOsc(state: string): string { - return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` -} - -async function createPane( - options: TranscriptPaneOptions -): Promise>> { - // Compose the same central hook-store wiring as desktop and orcad so OSC rows exercise the - // production status path rather than silently disappearing in a bare runtime fixture. - const statusWiring = makeAgentStatusStoreWiring() - return createTranscriptPane(options, statusWiring.deps) -} - -// cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads -// that as `working`; the title is identical whether it is running a command or waiting. -const CURSOR_TITLE = '⠇ Cursor Agent' - -afterEach(() => vi.useRealTimers()) - -describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { - describe('cursor-agent approval menu, the case no hook reports', () => { - it('names the pending approval on the pane a coordinator inspects', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL +describe('Interactive wait visibility and terminal admission', () => { + // A worker parked on an interactive prompt must be distinguishable from one that is thinking + // or inside a long tool call (STA-4513, STA-3714). + + // cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no + // escape bytes and no carriage returns, so they came through a terminal's renderer and a + // clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on — + // and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer. + // Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes. + function fixture(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') + } + const CURSOR_APPROVAL = fixture('cursor-agent-approval-prompt') + const CURSOR_LONG_TOOL_CALL = fixture('cursor-agent-long-tool-call') + const CURSOR_IDLE = fixture('cursor-agent-idle-after-approval') + + // Claude Code 2.1.234's own trust screen, which the runtime already matched by shape. + const CLAUDE_TRUST = [ + 'Accessing workspace:\n', + '/private/tmp/repo\n', + 'Quick safety check: Is this a project you created or one you trust?\n', + '❯ 1. Yes, I trust this folder\n', + ' 2. No, exit\n' + ].join('') + + function agentStatusOsc(state: string): string { + return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` + } + + async function createPane( + options: TranscriptPaneOptions + ): Promise>> { + // Compose the same central hook-store wiring as desktop and orcad so OSC rows exercise the + // production status path rather than silently disappearing in a bare runtime fixture. + const statusWiring = makeAgentStatusStoreWiring() + return createTranscriptPane(options, statusWiring.deps) + } + + // cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads + // that as `working`; the title is identical whether it is running a command or waiting. + const CURSOR_TITLE = '⠇ Cursor Agent' + + afterEach(() => vi.useRealTimers()) + + describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { + describe('cursor-agent approval menu, the case no hook reports', () => { + it('names the pending approval on the pane a coordinator inspects', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt' } + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt', + since: expect.any(Number) + }) }) - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt' } + it('refuses a guarded send and a dispatch preamble while the approval is up', async () => { + // Why this matters beyond reporting: the coordinator's preamble was typed straight into + // the approval dialog instead of being refused (the STA-2631 shape). Both refusal paths + // are asserted here, not just the `permission` verdict they read. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).rejects.toThrow('terminal_guard_permission') + await expect( + runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'prompt-text', - reason: 'agent-approval-prompt', - since: expect.any(Number) - }) - }) - it('refuses a guarded send and a dispatch preamble while the approval is up', async () => { - // Why this matters beyond reporting: the coordinator's preamble was typed straight into - // the approval dialog instead of being refused (the STA-2631 shape). Both refusal paths - // are asserted here, not just the `permission` verdict they read. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL + it('lets a dispatch preamble through once the same lane is working', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_LONG_TOOL_CALL + }) + + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).resolves.toBeUndefined() }) - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: true, - status: 'permission' + it('refuses to call the lane idle while the approval is unanswered', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 400 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-approval-prompt' }) }) - await expect( - assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) - ).rejects.toThrow('terminal_guard_permission') - await expect( - runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) - ).rejects.toThrow('agent_prompt_blocked') - }) - it('lets a dispatch preamble through once the same lane is working', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_LONG_TOOL_CALL + it('reports no wait for the same agent inside a long tool call', async () => { + // The control: identical vendor, identical spinner title, a real `sleep 60` running. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_LONG_TOOL_CALL + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ agentWait: null }) }) - await expect( - assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) - ).resolves.toBeUndefined() - }) + it('reports no wait once the agent is idle again', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_IDLE + }) - it('refuses to call the lane idle while the approval is unanswered', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 400 }) - ).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-approval-prompt' }) - }) + it('treats an answered menu still in scrollback as scrollback', async () => { + // Why: the menu is not erased on every terminal, so a live dialog is one that still owns + // the bottom of the screen rather than one that merely appears somewhere in the tail. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\n${CURSOR_IDLE}` + }) - it('reports no wait for the same agent inside a long tool call', async () => { - // The control: identical vendor, identical spinner title, a real `sleep 60` running. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_LONG_TOOL_CALL + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ agentWait: null }) - }) + it('treats an answered menu followed by any later output as scrollback', async () => { + // Why not keyed on cursor's own follow-up line: output after the menu can be anything, + // and a stale hit here fails tui-idle and refuses prompt injection on a healthy lane. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() + + runtime.onPtyData( + PTY_ID, + '\nCommand completed successfully.\nContinuing automatically.\n', + Date.now() + ) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) - it('reports no wait once the agent is idle again', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_IDLE + it('is not anchored by the agent narrating a choice after the menu', async () => { + // Why: matching each marker independently let prose below the answered menu carry the + // anchor down to the bottom of the screen and revive it. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\nApproved. Next time I will suggest Run Everything instead.\n` + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) + it('is not anchored by the agent narrating a choice before the menu', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `You can pick Run Everything or Run (once) if you prefer.\nStill thinking.\n` + }) - it('treats an answered menu still in scrollback as scrollback', async () => { - // Why: the menu is not erased on every terminal, so a live dialog is one that still owns - // the bottom of the screen rather than one that merely appears somewhere in the tail. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\n${CURSOR_IDLE}` + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) + it('requires the dialog to be the last thing on the screen', async () => { + // Not a tolerance question: one line of slack is exactly enough for the agent's own + // narration to revive an answered menu, and every capture of a live dialog ends on it. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\n Auto · 6.1%\n` + }) - it('treats an answered menu followed by any later output as scrollback', async () => { - // Why not keyed on cursor's own follow-up line: output after the menu can be anything, - // and a stale hit here fails tui-idle and refuses prompt injection on a healthy lane. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() - runtime.onPtyData( - PTY_ID, - '\nCommand completed successfully.\nContinuing automatically.\n', - Date.now() - ) + it('reports the same wait for a pane whose PTY is not local', async () => { + // The verdict is derived from retained tail and title state, so an SSH or WSL pane + // reaches it the same way a local one does. + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL, + connectionId: 'ssh:build-host' + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-approval-prompt' + }) + }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + it('ignores a partial menu that names no decision keys', async () => { + const { runtime, handle } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: 'The agent asked: Run this command? I said yes and it worked.\n' + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) }) - it('is not anchored by the agent narrating a choice after the menu', async () => { - // Why: matching each marker independently let prose below the answered menu carry the - // anchor down to the bottom of the screen and revive it. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\nApproved. Next time I will suggest Run Everything instead.\n` + describe('prompts the runtime already matched but never surfaced', () => { + it('surfaces a startup trust screen on the pane, not only on terminal wait', async () => { + // A pane on its trust screen still wears Orca's tab title; the agent has set none. + const { runtime, handle } = await createPane({ + paneTitle: 'sta4513-claude', + foregroundProcess: 'claude', + data: CLAUDE_TRUST + }) + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' } + }) }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + it('still lets a live working title clear a stale startup prompt', async () => { + // Pins the shared authority getTerminalAgentStatus and the agent-prompt send guard + // already use: for the startup modals, a live non-permission title is the staleness + // proof, because their text survives in scrollback with no self-dismissal marker. + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: CLAUDE_TRUST + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) }) - it('is not anchored by the agent narrating a choice before the menu', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `You can pick Run Everything or Run (once) if you prefer.\nStill thinking.\n` + describe('hook-reported waits (STA-3714)', () => { + it('surfaces an agent-reported blocked state with hook provenance', async () => { + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('waiting') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'hook', + since: expect.any(Number) + }) + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + agentWait: { source: 'hook' } + }) }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) + it('reports no wait for a hook-reported working turn', async () => { + const { runtime, handle } = await createPane({ + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('working') + }) - it('requires the dialog to be the last thing on the screen', async () => { - // Not a tolerance question: one line of slack is exactly enough for the agent's own - // narration to revive an answered menu, and every capture of a live dialog ends on it. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\n Auto · 6.1%\n` + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + it('drops a retained permission row once the agent stops owning the pane', async () => { + // Why not the title alone: a shell that takes the pane back usually sets something like + // `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for + // AGENT_STATUS_STALE_AFTER_MS — half an hour of reporting a dead agent as waiting. + const { runtime, handle } = await createPane({ + paneTitle: 'jinwoo@host: ~/repo', + foregroundProcess: 'zsh', + data: agentStatusOsc('waiting') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) + + it('drops a retained permission row once a shell owns the pane', async () => { + const { runtime, handle } = await createPane({ + paneTitle: 'zsh', + foregroundProcess: 'zsh', + data: agentStatusOsc('blocked') + }) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + }) }) - it('reports the same wait for a pane whose PTY is not local', async () => { - // The verdict is derived from retained tail and title state, so an SSH or WSL pane - // reaches it the same way a local one does. + it('still reports a live dialog restored from terminal history', async () => { + // Why this case exists: a lane parked on a prompt emits no bytes, so an Orca restart is + // exactly when it would go quiet forever. A restored tail carries no waitBlockedAt, and + // the approval menu does not need one — being at the bottom of the restored screen is + // itself the proof that this is where the pane stopped. const { runtime, handle } = await createPane({ paneTitle: CURSOR_TITLE, foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL, - connectionId: 'ssh:build-host' + data: '' }) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CURSOR_APPROVAL, lastTitle: CURSOR_TITLE }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ source: 'prompt-text', @@ -227,232 +339,151 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { }) }) - it('ignores a partial menu that names no decision keys', async () => { + it('does not resurrect a startup modal from a restored tail', async () => { + // The startup prompts keep the timestamp rule: their text lingers in scrollback with no + // marker for whether it was answered, so restored bytes alone must not mint a wait. const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: 'The agent asked: Run this command? I said yes and it worked.\n' + paneTitle: 'sta4513-claude', + foregroundProcess: 'claude', + data: '' }) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CLAUDE_TRUST }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - }) - describe('prompts the runtime already matched but never surfaced', () => { - it('surfaces a startup trust screen on the pane, not only on terminal wait', async () => { - // A pane on its trust screen still wears Orca's tab title; the agent has set none. + it('stops reporting a wait once the pane is no longer running', async () => { + // Why: the menu stays at the bottom of a dead pane's tail forever. A worker whose process + // is gone needs intervention, not an answer, so it must not read as blocked on a human. const { runtime, handle } = await createPane({ - paneTitle: 'sta4513-claude', - foregroundProcess: 'claude', - data: CLAUDE_TRUST + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' } - }) + runtime.onPtyExit(PTY_ID, 0) + + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() }) - it('still lets a live working title clear a stale startup prompt', async () => { - // Pins the shared authority getTerminalAgentStatus and the agent-prompt send guard - // already use: for the startup modals, a live non-permission title is the staleness - // proof, because their text survives in scrollback with no self-dismissal marker. + it('does not accrue a probe per poll while the foreground probe wedges', async () => { + // Why: the timeout abandons the wait, not the request. Without single-flighting, a + // coordinator watching a wedged remote host adds one live probe on every poll. + let probes = 0 const { runtime, handle } = await createPane({ paneTitle: '✻ Claude Code', foregroundProcess: 'claude', - data: CLAUDE_TRUST + data: agentStatusOsc('waiting'), + onForegroundProbe: () => { + probes += 1 + }, + foregroundProbeHangs: true }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - }) - - describe('hook-reported waits (STA-3714)', () => { - it('surfaces an agent-reported blocked state with hook provenance', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const probing = Promise.all([ + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle), + runtime.getTerminalInteractiveWait(handle) + ]) + void probing.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + await probing + + expect(probes).toBe(1) + } finally { + vi.useRealTimers() + } + }, 20_000) + + it('accepts a menu whose keys are rendered as glyphs', async () => { const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('waiting') + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: [ + 'Run this command?\n', + ' → Run (once) (\u21b5)\n', + ' Run Everything (\u21e7\u21b9)\n' + ].join('') }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'hook', - since: expect.any(Number) - }) - await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'hook' } + reason: 'agent-approval-prompt' }) }) - it('reports no wait for a hook-reported working turn', async () => { + it('still rejects prose that merely ends in parentheses', async () => { const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('working') + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: `${CURSOR_APPROVAL}\nNext time I will suggest Run Everything (as before)\n` }) await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) - it('drops a retained permission row once the agent stops owning the pane', async () => { - // Why not the title alone: a shell that takes the pane back usually sets something like - // `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for - // AGENT_STATUS_STALE_AFTER_MS — half an hour of reporting a dead agent as waiting. + it('leaves the wait unevaluated when the foreground probe wedges', async () => { + // Why bounded: this probe reaches a PTY controller that can be a remote host. A wedged + // one must leave the wait unknown, not stall every caller of showTerminal. const { runtime, handle } = await createPane({ - paneTitle: 'jinwoo@host: ~/repo', - foregroundProcess: 'zsh', - data: agentStatusOsc('waiting') + paneTitle: '✻ Claude Code', + foregroundProcess: 'claude', + data: agentStatusOsc('waiting'), + foregroundProbeHangs: true }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('drops a retained permission row once a shell owns the pane', async () => { - const { runtime, handle } = await createPane({ - paneTitle: 'zsh', - foregroundProcess: 'zsh', - data: agentStatusOsc('blocked') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const waiting = runtime.getTerminalInteractiveWait(handle) + void waiting.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + await expect(waiting).resolves.toBeUndefined() + const showing = runtime.showTerminal(handle) + void showing.catch(() => undefined) + await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) + const show = await showing + expect('agentWait' in show).toBe(false) + } finally { + vi.useRealTimers() + } + }, 15_000) + + it('answers undefined rather than "not waiting" for a pane it cannot read', async () => { + const { runtime } = await createPane({ + paneTitle: CURSOR_TITLE, + foregroundProcess: 'cursor-agent', + data: CURSOR_APPROVAL }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - }) - - it('still reports a live dialog restored from terminal history', async () => { - // Why this case exists: a lane parked on a prompt emits no bytes, so an Orca restart is - // exactly when it would go quiet forever. A restored tail carries no waitBlockedAt, and - // the approval menu does not need one — being at the bottom of the restored screen is - // itself the proof that this is where the pane stopped. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: '' - }) - runtime.seedTerminalRestoreTail(PTY_ID, { text: CURSOR_APPROVAL, lastTitle: CURSOR_TITLE }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - source: 'prompt-text', - reason: 'agent-approval-prompt' - }) - }) - - it('does not resurrect a startup modal from a restored tail', async () => { - // The startup prompts keep the timestamp rule: their text lingers in scrollback with no - // marker for whether it was answered, so restored bytes alone must not mint a wait. - const { runtime, handle } = await createPane({ - paneTitle: 'sta4513-claude', - foregroundProcess: 'claude', - data: '' - }) - runtime.seedTerminalRestoreTail(PTY_ID, { text: CLAUDE_TRUST }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() - }) - - it('stops reporting a wait once the pane is no longer running', async () => { - // Why: the menu stays at the bottom of a dead pane's tail forever. A worker whose process - // is gone needs intervention, not an answer, so it must not read as blocked on a human. - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL - }) - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.not.toBeNull() - - runtime.onPtyExit(PTY_ID, 0) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeUndefined() - }) - - it('does not accrue a probe per poll while the foreground probe wedges', async () => { - // Why: the timeout abandons the wait, not the request. Without single-flighting, a - // coordinator watching a wedged remote host adds one live probe on every poll. - let probes = 0 - const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('waiting'), - onForegroundProbe: () => { - probes += 1 - }, - foregroundProbeHangs: true - }) - - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - try { - const probing = Promise.all([ - runtime.getTerminalInteractiveWait(handle), - runtime.getTerminalInteractiveWait(handle), - runtime.getTerminalInteractiveWait(handle) - ]) - void probing.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - await probing - - expect(probes).toBe(1) - } finally { - vi.useRealTimers() - } - }, 20_000) - - it('accepts a menu whose keys are rendered as glyphs', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: [ - 'Run this command?\n', - ' → Run (once) (\u21b5)\n', - ' Run Everything (\u21e7\u21b9)\n' - ].join('') - }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ - reason: 'agent-approval-prompt' - }) - }) - - it('still rejects prose that merely ends in parentheses', async () => { - const { runtime, handle } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: `${CURSOR_APPROVAL}\nNext time I will suggest Run Everything (as before)\n` + await expect( + runtime.getTerminalInteractiveWait('term_does_not_exist') + ).resolves.toBeUndefined() }) - - await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() }) +}) - it('leaves the wait unevaluated when the foreground probe wedges', async () => { - // Why bounded: this probe reaches a PTY controller that can be a remote host. A wedged - // one must leave the wait unknown, not stall every caller of showTerminal. - const { runtime, handle } = await createPane({ - paneTitle: '✻ Claude Code', - foregroundProcess: 'claude', - data: agentStatusOsc('waiting'), - foregroundProbeHangs: true - }) - - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - try { - const waiting = runtime.getTerminalInteractiveWait(handle) - void waiting.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - await expect(waiting).resolves.toBeUndefined() - const showing = runtime.showTerminal(handle) - void showing.catch(() => undefined) - await vi.advanceTimersByTimeAsync(TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS) - const show = await showing - expect('agentWait' in show).toBe(false) - } finally { - vi.useRealTimers() - } - }, 15_000) - - it('answers undefined rather than "not waiting" for a pane it cannot read', async () => { - const { runtime } = await createPane({ - paneTitle: CURSOR_TITLE, - foregroundProcess: 'cursor-agent', - data: CURSOR_APPROVAL - }) - - await expect(runtime.getTerminalInteractiveWait('term_does_not_exist')).resolves.toBeUndefined() +describe('Screen rule readiness scope', () => { + // Why: the grid-trust check and raw rows belong to screen-ruled agents; every other agent must + // keep reading the screen exactly as it did before them. + describe('screen-rule trust stays with screen-ruled agents', () => { + it('a Codex pane still settles from its screen when the PTY reports another grid', async () => { + const options = { + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex' as const, + // Only the live screen shows this recording's header (STA-8628): the text reads `dirctory:`. + data: readRuntimeFixture('codex-0157-effort-override-embedded-warning'), + size: { cols: 120, rows: 40 } + } + const { runtime, handle } = await createTranscriptPane(options) + await runtime.readTerminal(handle, { screen: true }) + options.size = { cols: 100, rows: 30 } + await expect(waitForTranscriptIdle({ runtime, handle }, 8_000)).resolves.toMatchObject({ + condition: 'tui-idle', + satisfied: true + }) + }, 20_000) }) }) diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts index fd9f35e80654..7a61cd340811 100644 --- a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -1,8 +1,16 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi, afterEach } from 'vitest' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { makePaneKey } from '../../shared/stable-pane-id' import type { TuiAgent } from '../../shared/tui-agent' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' import type { OrcaRuntimeService } from './orca-runtime' vi.mock('electron', () => ({ @@ -12,213 +20,403 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// The harness pane's identity (agent-transcript-pane-test-harness.ts). -const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') -const OTHER_PANE_KEY = makePaneKey('tab-9', '99999999-9999-4999-8999-999999999999') -// A Pi turn in progress: no ready sign the other lanes could settle on. -const BUSY_SCREEN = '⠋ Working...\r\n' -// A Codex turn in progress, which Codex's screen rules read as busy. -const CODEX_BUSY_SCREEN = '• Working (4s • esc to interrupt)\r\n' -// OpenCode 1.18's permission dialog, as the line tail keeps it after the prompt is answered. -const OPENCODE_PERMISSION_DIALOG = [ - '△ Permission required', - '# Shell command', - '$ echo hi', - ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', - '' -].join('\r\n') -// OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. -const OPENCODE_QUESTION = [ - ' ┃ Which color do you prefer?', - ' ┃ 1. Red', - ' ┃ 2. Blue', - ' ┃ 3. Type your own answer', - ' ┃ ↑↓ select enter submit esc dismiss', - '' -].join('\r\n') -// Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. -const WAIT_MS = 150 - -function row(overrides: Partial = {}): AgentStatusIpcPayload { - const now = Date.now() - return { - paneKey: PANE_KEY, - connectionId: null, - state: 'done', - prompt: '', - agentType: 'pi', - receivedAt: now, - stateStartedAt: now, - ...overrides +describe('Hermes captured hook readiness', () => { + const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8') + + /** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the + * same normalizer the hook relay uses — so a regression in the event mapping fails here too, + * not only in the provider's own unit test. */ + const status = (eventName: string, payload: Record = {}): string => { + const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload) + expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull() + return `\x1b]9999;${JSON.stringify(parsed)}\x07` } -} - -async function waitOutcome(options: { - rows: (handle: string) => AgentStatusIpcPayload[] - launchAgent?: TuiAgent - data?: string - afterCreate?: (runtime: OrcaRuntimeService, handle: string) => unknown -}): Promise { - let handle = '' - const pane = await createTranscriptPane( - { - paneTitle: 'Terminal', - foregroundProcess: options.launchAgent ?? 'pi', - data: options.data ?? BUSY_SCREEN, - launchAgent: options.launchAgent ?? 'pi' - }, - { getAgentStatusSnapshot: () => options.rows(handle) } - ) - handle = pane.handle - await options.afterCreate?.(pane.runtime, pane.handle) - try { - const result = await pane.runtime.waitForTerminal(pane.handle, { - condition: 'tui-idle', - timeoutMs: WAIT_MS + + const readyPane = async () => + createTranscriptPane({ + paneTitle: 'Hermes Agent', + foregroundProcess: 'hermes', + launchAgent: 'hermes', + size: { cols: 120, rows: 31 }, + data: captured }) - return result.blockedReason ? `blocked:${result.blockedReason}` : 'ready' - } catch (error) { - return error instanceof Error ? error.message : String(error) - } -} -afterEach(() => { - vi.restoreAllMocks() -}) + describe('Hermes TUI readiness from a captured PTY', () => { + it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => { + const { runtime, handle } = await readyPane() + // What Orca's own managed plugin sends for `on_session_start`. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) -describe('tui-idle hook lane through the runtime', () => { - it('settles a mid-turn screen on the hook row alone, with no ready title (headless serve)', async () => { - expect(await waitOutcome({ rows: () => [row()] })).toBe('ready') - }) + const read = vi.spyOn(runtime, 'readTerminal') + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + ).resolves.toMatchObject({ satisfied: true }) + // The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it. + expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([]) + }, 5_000) - it('without a row, the same pane does not settle', async () => { - expect(await waitOutcome({ rows: () => [] })).toBe('timeout') - }) + it('settles through a leaf handle whose retained tail never showed the ready screen', async () => { + const { runtime } = await readyPane() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression. + const internals = runtime as unknown as { + leaves: Map + issueHandle: (leaf: unknown) => string + } + const leaf = internals.leaves.values().next().value + expect(leaf).toBeDefined() + expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i) + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) - it('joins a row on the terminal handle when its pane key is not the pane', async () => { - expect( - await waitOutcome({ - rows: (handle) => [row({ paneKey: OTHER_PANE_KEY, terminalHandle: handle })] - }) - ).toBe('ready') - }) + await expect( + runtime.waitForTerminal(internals.issueHandle(leaf), { + condition: 'tui-idle', + timeoutMs: 2_000 + }) + ).resolves.toMatchObject({ satisfied: true }) + }, 5_000) - it('falls back to the other lanes for a row no pane key or handle joins', async () => { - expect(await waitOutcome({ rows: () => [row({ paneKey: OTHER_PANE_KEY })] })).toBe('timeout') - }) + it('does not settle while a turn the user started is still running', async () => { + const { runtime, handle } = await readyPane() + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + // `pre_llm_call` — the first event that means a turn actually began. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('pre_llm_call', { user_message: 'hi' }), + Date.now() + ) - it('ignores the row the PTY id held before a respawn', async () => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - expect(await waitOutcome({ rows })).toBe('ready') - expect( - await waitOutcome({ - rows, - afterCreate: (runtime) => - runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { - value: 0, - generation: 'reset' - }) - }) - ).toBe('timeout') + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + + it('does not settle on a turn-end done row, only on a session boundary', async () => { + const { runtime, handle } = await readyPane() + // `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('post_llm_call', { session_id: 's1' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) + + it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => { + const { runtime, handle } = await readyPane() + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('on_session_start', { session_id: 's1' }), + Date.now() + ) + // `pre_approval_request` supersedes the boundary row the session opened with. + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + status('pre_approval_request', { command: 'rm -rf build' }), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) + ).rejects.toThrow('timeout') + }, 3_000) }) +}) + +describe('Claude indexed task wakeup readiness', () => { + const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + const ready = readFileSync( + join(import.meta.dirname, '__fixtures__/claude-ready-task-wakeup.txt'), + 'utf8' + ) - // Both write funnels record input (terminal-run-facts-input.test.ts), the user's keys included: - // typing `pi` to restart the agent in the same shell must not read the old process's done. - it.each([ - ['a prompt Orca sent', 'next task\r'], - ['the user restarting the agent in the same shell', 'pi\r'] - ])('reads no done from before %s', async (_label, input) => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { - runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) + function owedRow(paneKey: string): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey, + state: 'working', + mainAgent: { state: 'done', stateStartedAt: now }, + agentType: 'claude', + prompt: '', + connectionId: null, + launchToken: 'transcript-launch', + claudeTaskWakeupPending: 'notification', + providerSession: { key: 'session_id', id: 'session-a' }, + observation: { + origin: 'hook', + authorityId: 'host-a', + incarnation: 1, + revision: 1, + observedAt: now + }, + receivedAt: now, + stateStartedAt: now } - expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') - expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') - }) + } + + afterEach(() => vi.useRealTimers()) - it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - expect( - await waitOutcome({ - rows, - afterCreate: (runtime) => { - runtime.terminalRunFacts.recordInput( - TRANSCRIPT_PANE_PTY_ID, - 'driving', - 'next\r', - before + 1 - ) - runtime.noteTerminalSpawnCommit({ - id: TRANSCRIPT_PANE_PTY_ID, - agentSessionEnsure: { disposition: 'adopted' } - }) + describe('Claude readiness indexed status reads', () => { + it.each([true, false])( + 'reads only its pane with 512 unrelated agents, including an empty index: pending=%s', + async (pending) => { + const unrelated = Array.from({ length: 512 }, (_, index) => owedRow(`other:${index}`)) + const row = owedRow(PANE) + const full = vi.fn(() => [row, ...unrelated]) + const indexed = vi.fn((paneKey: string) => (pending && paneKey === PANE ? [row] : [])) + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: 'claude', + data: ready, + launchAgent: 'claude' + }, + { getAgentStatusSnapshot: full, getAgentStatusSnapshotForPane: indexed } + ) + row.receivedAt = Date.now() + full.mockClear() + indexed.mockClear() + try { + const waiting = waitForTranscriptIdle(pane, 2_000) + await (pending + ? expect(waiting).rejects.toThrow(/timeout/) + : expect(waiting).resolves.toMatchObject({ satisfied: true })) + expect(indexed).toHaveBeenCalled() + expect(new Set(indexed.mock.calls.map(([paneKey]) => paneKey))).toEqual(new Set([PANE])) + expect(full).not.toHaveBeenCalled() + } finally { + pane.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) } - }) - ).toBe('timeout') + } + ) }) +}) - // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. - it('keeps the row while the agent paints its own shell-integration markers', async () => { - expect( - await waitOutcome({ - rows: () => [row({ receivedAt: Date.now() - 1000 })], - data: `\x1b]133;A\x07OK\x1b]133;C\x07${BUSY_SCREEN}` - }) - ).toBe('ready') - }) +describe('Canonical hook status readiness', () => { + // The harness pane's identity (agent-transcript-pane-test-harness.ts). + const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const OTHER_PANE_KEY = makePaneKey('tab-9', '99999999-9999-4999-8999-999999999999') + // A Pi turn in progress: no ready sign the other lanes could settle on. + const BUSY_SCREEN = '⠋ Working...\r\n' + // A Codex turn in progress, which Codex's screen rules read as busy. + const CODEX_BUSY_SCREEN = '• Working (4s • esc to interrupt)\r\n' + // OpenCode 1.18's permission dialog, as the line tail keeps it after the prompt is answered. + const OPENCODE_PERMISSION_DIALOG = [ + '△ Permission required', + '# Shell command', + '$ echo hi', + ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', + '' + ].join('\r\n') + // OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. + const OPENCODE_QUESTION = [ + ' ┃ Which color do you prefer?', + ' ┃ 1. Red', + ' ┃ 2. Blue', + ' ┃ 3. Type your own answer', + ' ┃ ↑↓ select enter submit esc dismiss', + '' + ].join('\r\n') + // Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. + const WAIT_MS = 150 - // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so only its done - // decides; a working row leaves its title and screen rules to decide. - it('settles Codex on its done, and leaves a working row to its screen rules', async () => { - const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } - expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe('ready') - expect( - await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex', state: 'working' })] }) - ).toBe('timeout') - }) + function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'pi', + receivedAt: now, + stateStartedAt: now, + ...overrides + } + } - it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { - const options = { launchAgent: 'opencode' as const, data: OPENCODE_PERMISSION_DIALOG } - expect(await waitOutcome({ ...options, rows: () => [] })).toBe( - 'blocked:agent-interactive-prompt' + async function waitOutcome(options: { + rows: (handle: string) => AgentStatusIpcPayload[] + launchAgent?: TuiAgent + data?: string + afterCreate?: (runtime: OrcaRuntimeService, handle: string) => unknown + }): Promise { + let handle = '' + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ?? 'pi', + data: options.data ?? BUSY_SCREEN, + launchAgent: options.launchAgent ?? 'pi' + }, + { getAgentStatusSnapshot: () => options.rows(handle) } ) - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] - }) - ).toBe('blocked:agent-interactive-prompt') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + handle = pane.handle + await options.afterCreate?.(pane.runtime, pane.handle) + try { + const result = await pane.runtime.waitForTerminal(pane.handle, { + condition: 'tui-idle', + timeoutMs: WAIT_MS }) - ).toBe('ready') + return result.blockedReason ? `blocked:${result.blockedReason}` : 'ready' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + } + + afterEach(() => { + vi.restoreAllMocks() }) - it('blocks on an open question the hook reports, and settles once it is answered', async () => { - const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } - expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] - }) - ).toBe('blocked:agent-interactive-prompt') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] - }) - ).toBe('ready') + describe('tui-idle hook lane through the runtime', () => { + it('settles a mid-turn screen on the hook row alone, with no ready title (headless serve)', async () => { + expect(await waitOutcome({ rows: () => [row()] })).toBe('ready') + }) + + it('without a row, the same pane does not settle', async () => { + expect(await waitOutcome({ rows: () => [] })).toBe('timeout') + }) + + it('joins a row on the terminal handle when its pane key is not the pane', async () => { + expect( + await waitOutcome({ + rows: (handle) => [row({ paneKey: OTHER_PANE_KEY, terminalHandle: handle })] + }) + ).toBe('ready') + }) + + it('falls back to the other lanes for a row no pane key or handle joins', async () => { + expect(await waitOutcome({ rows: () => [row({ paneKey: OTHER_PANE_KEY })] })).toBe('timeout') + }) + + it('ignores the row the PTY id held before a respawn', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect(await waitOutcome({ rows })).toBe('ready') + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => + runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { + value: 0, + generation: 'reset' + }) + }) + ).toBe('timeout') + }) + + // Both write funnels record input (terminal-run-facts-input.test.ts), the user's keys included: + // typing `pi` to restart the agent in the same shell must not read the old process's done. + it.each([ + ['a prompt Orca sent', 'next task\r'], + ['the user restarting the agent in the same shell', 'pi\r'] + ])('reads no done from before %s', async (_label, input) => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { + runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) + } + expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') + expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') + }) + + it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => { + runtime.terminalRunFacts.recordInput( + TRANSCRIPT_PANE_PTY_ID, + 'driving', + 'next\r', + before + 1 + ) + runtime.noteTerminalSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + agentSessionEnsure: { disposition: 'adopted' } + }) + } + }) + ).toBe('timeout') + }) + + // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. + it('keeps the row while the agent paints its own shell-integration markers', async () => { + expect( + await waitOutcome({ + rows: () => [row({ receivedAt: Date.now() - 1000 })], + data: `\x1b]133;A\x07OK\x1b]133;C\x07${BUSY_SCREEN}` + }) + ).toBe('ready') + }) + + // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so only its done + // decides; a working row leaves its title and screen rules to decide. + it('settles Codex on its done, and leaves a working row to its screen rules', async () => { + const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } + expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe( + 'ready' + ) + expect( + await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex', state: 'working' })] }) + ).toBe('timeout') + }) + + it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_PERMISSION_DIALOG } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe( + 'blocked:agent-interactive-prompt' + ) + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) + + it('blocks on an open question the hook reports, and settles once it is answered', async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) }) }) diff --git a/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts b/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts deleted file mode 100644 index ab2677a98f48..000000000000 --- a/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * A shell that titles the command it is about to run (zsh preexec auto-title) writes the agent's - * bare name before the agent's TUI has mounted. For an agent whose only rest signal is that name, - * the title alone used to settle `tui-idle`, so a launch pasted its prompt into a TUI still - * booting, or into the shell. A launch readiness wait holds that title to a quiet stream. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const BOOT_FRAME = 'Loading…\r\n' - -/** The shell echoes the launch line, then its preexec hook titles the pane with the command. */ -async function launchedPaneTitledByShell(agent: 'gemini' | 'copilot') { - return createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: agent, - launchAgent: agent, - data: `$ ${agent}\r\n${ESC}]2;${agent}${BEL}` - }) -} - -/** The agent is still painting its boot screen: output keeps arriving each second. */ -async function keepBooting( - runtime: Awaited>['runtime'], - seconds: number -): Promise { - for (let second = 0; second < seconds; second += 1) { - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, BOOT_FRAME, Date.now()) - await vi.advanceTimersByTimeAsync(1000) - } -} - -describe('a launch waiting on an agent whose shell titled the pane with its name', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - // Gemini's bare name was once normalized into its rest glyph; Copilot's name is its only signal. - it.each(['gemini', 'copilot'] as const)( - 'does not treat the shell’s auto-title as readiness while %s is still booting', - async (agent) => { - const { runtime, handle } = await launchedPaneTitledByShell(agent) - const settled = vi.fn() - runtime - .waitForTerminal(handle, { - condition: 'tui-idle', - timeoutMs: 60_000, - launchReadiness: true - }) - .then(settled, settled) - - await keepBooting(runtime, 10) - - expect(settled).not.toHaveBeenCalled() - } - ) - - it('settles once the agent’s stream goes quiet under that title', async () => { - const { runtime, handle } = await launchedPaneTitledByShell('gemini') - const settled = vi.fn() - runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000, launchReadiness: true }) - .then(settled, settled) - - await keepBooting(runtime, 4) - await vi.advanceTimersByTimeAsync(8_000) - - expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) - }) - - it('keeps settling a later, non-launch wait on the name alone, as agents that never quiet need', async () => { - const { runtime, handle } = await launchedPaneTitledByShell('gemini') - const settled = vi.fn() - runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) - .then(settled, settled) - - await keepBooting(runtime, 10) - - expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) - }) -}) diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts deleted file mode 100644 index 558aa3f306e5..000000000000 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { - getSyntheticAgentTerminalTitle, - shouldDriveSyntheticAgentTitleFromHook -} from '../../shared/synthetic-agent-title' -import { createTranscriptPane } from './agent-transcript-pane-test-harness' -import { hasExplicitIdleTitle } from './tui-idle-evidence' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) - -function readTranscript(): string { - return readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8') -} - -describe('ZCode readiness from captured terminal bytes', () => { - it('accepts a fresh composer after the renderer adopts the terminal handle', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'worker-zcode', - foregroundProcess: 'zcode', - launchAgent: 'zcode', - data: '\x1b[?1049h╭' - }) - await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ - handle, - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null - }) - }) - - it('never emits an OSC title, so no title lane can settle its wait', () => { - const data = readTranscript() - expect(data).toContain(String.fromCharCode(27)) - // Why: this absence is the whole reason ZCode needs a body-evidence readiness lane. - expect(data).not.toMatch(new RegExp(`${String.fromCharCode(27)}\\][0-2];`)) - }) - - it('keeps repainting long after the composer mounts, so a quiet window never settles', () => { - const data = readTranscript() - const composerIndex = data.indexOf('╭') - expect(composerIndex).toBeGreaterThan(-1) - // Why: ~175KB of banner animation after the composer is up. A quiet-render window - // measured in hundreds of ms cannot fire anywhere in that span. - expect(data.length - composerIndex).toBeGreaterThan(100_000) - }) - - it('leaves no durable readiness evidence in the wait-text tail', async () => { - const data = readTranscript() - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'zcode-first-class-workspace', - foregroundProcess: 'zcode', - launchAgent: 'zcode', - data - }) - // Why this asserts a NEGATIVE: Orca's wait text is a line-folded tail, and ZCode paints - // its composer once and then repaints only the banner — so the composer scrolls out and - // no screen rule can settle the wait. This is the evidence for driving ZCode readiness - // from its synthetic hook title instead (see synthetic-agent-title.ts). - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) - ).rejects.toThrow(/timeout/) - }, 15_000) - - it('settles a tui-idle wait from the synthetic hook title Orca owns for ZCode', () => { - expect(getSyntheticAgentTerminalTitle('zcode', 'done')).toBe('ZCode ready') - expect(getSyntheticAgentTerminalTitle('zcode', 'waiting')).toBe('ZCode - action required') - expect(shouldDriveSyntheticAgentTitleFromHook('zcode', 'working')).toBe(true) - expect( - hasExplicitIdleTitle({ lastAgentStatus: 'idle', lastOutputAt: Date.now() }, 'ZCode ready') - ).toBe(true) - }) - - it('fires the draft-paste signal at the composer mount, not at the hard timeout', () => { - const data = readTranscript() - const scanner = createDraftPasteReadyScanner('zcode-composer-prompt') - const composerIndex = data.indexOf('╭') - // Why: feeding the stream in PTY-sized chunks proves the marker survives chunk splits. - let readyAt: number | null = null - for (let offset = 0; offset < data.length; offset += 4096) { - const chunk = data.slice(offset, offset + 4096) - if (scanner.observe(chunk).ready) { - readyAt = offset + chunk.length - break - } - } - expect(readyAt).not.toBeNull() - // Ready lands on the chunk that carries the composer corner, not thousands of frames later. - expect(readyAt!).toBeGreaterThanOrEqual(composerIndex) - expect(readyAt!).toBeLessThan(composerIndex + 8192) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx new file mode 100644 index 000000000000..8b0d39764b22 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx @@ -0,0 +1,277 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() +}) + +{ + // An approval whose subject a newer Orca wrote, with no detail to show. + const NEWER_APPROVAL: AgentJournalRenderItem = JSON.parse( + JSON.stringify({ + itemId: 'approval-item', + revision: 3, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review proposed change', + detail: null, + subject: { kind: 'diff', path: 'a.ts' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) + + it("hands the card a newer Orca's subject in a writable chat, and its cancel goes to the host with the card", () => { + mocks.turnId = 'turn-1' + mocks.promptItems = [NEWER_APPROVAL] + render( + + ) + expect(mocks.approvalCardProps).toMatchObject({ + approval: { subject: { kind: 'diff' } } + }) + expect(mocks.approvalCardProps?.approval).not.toHaveProperty('detail') + mocks.approvalCardProps?.onCancel?.() + expect(mocks.cancel).toHaveBeenCalledWith('turn-1', { + itemId: 'approval-item', + expectedRevision: 3 + }) + }) + + // The host's cancel names its turn, so with none running nothing is sent. + it('sends nothing for the card when no turn is running', () => { + mocks.promptItems = [NEWER_APPROVAL] + render( + + ) + mocks.approvalCardProps?.onCancel?.() + expect(mocks.cancel).not.toHaveBeenCalled() + }) + + it('cancels a Pi extension dialog outside a model turn', () => { + mocks.promptItems = [NEWER_APPROVAL] + render( + + ) + mocks.approvalCardProps?.onCancel?.() + expect(mocks.cancel).toHaveBeenCalledWith(undefined, { + itemId: 'approval-item', + expectedRevision: 3 + }) + }) + + function renderSession() { + render( + + ) + } + + // Nothing here answers it, so a send is the way on: it starts a turn, whose card cancel works. + it('keeps the composer open and writable beside a card this build cannot answer', () => { + mocks.promptItems = [NEWER_APPROVAL] + renderSession() + expect(mocks.composerProps).not.toBeNull() + expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: false }) + }) + + it('gives a card this build can answer the composer slot', () => { + const plan: AgentJournalRenderItem = JSON.parse( + JSON.stringify(NEWER_APPROVAL).replace( + '{"kind":"diff","path":"a.ts"}', + '{"kind":"plan","text":"do it"}' + ) + ) + mocks.promptItems = [plan] + renderSession() + expect(mocks.composerProps).toBeNull() + expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: true }) + }) +} + +{ + const SESSION_ID = 'start-failure-session' + const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } + const START_FAILED_REASON = + 'Claude stopped before it finished starting. Send your message to try again.' + + function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + } + } + + function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { + return { + outbox: { + clientMessageId, + sessionId: SESSION_ID, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + state: 'rejected', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null, + lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } } + }, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason, + rejection, + submittedAt: 1, + resolvedAt: 1 + } + } + } + + // The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them. + function renderPane(messages: ReturnType[]): void { + mocks.submissions = messages.map((message) => message.submission) + localStorage.setItem( + `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, + JSON.stringify(messages.map((message) => message.outbox)) + ) + render( + + ) + } + + async function notice(clientMessageId: string): Promise { + return waitFor(() => { + const row = document.querySelector( + `[data-message-id="${agentJournalSubmissionKey(clientMessageId)}"]` + ) + if (!row) { + throw new Error(`no notice on ${clientMessageId}`) + } + return row + }) + } + + // The start's own row says why, so its rejected messages say only that they were not sent. Sending + // one again is a new message, so none offers a Retry. + it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + + renderPane([ + rejected('first', START_FAILED_REASON, START_FAILED), + rejected('second', START_FAILED_REASON, START_FAILED) + ]) + + for (const id of ['first', 'second']) { + const row = await notice(id) + expect(within(row).getByText('Your message was not sent.')).toBeTruthy() + expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull() + } + expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() + }) + + it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + const providerRejected: AgentSessionFailureFact = { + kind: 'providerRejected', + detail: { text: 'Image type .bmp', audience: 'person' } + } + + renderPane([ + rejected('stated', START_FAILED_REASON, START_FAILED), + rejected( + 'other', + 'The provider did not accept this message: Image type .bmp.', + providerRejected + ) + ]) + + expect(within(await notice('stated')).getByText('Your message was not sent.')).toBeTruthy() + expect( + within(await notice('other')).getByText( + 'The provider did not accept this message: Image type .bmp.' + ) + ).toBeTruthy() + }) + + it("keeps the start failure's own words when its row is not loaded", async () => { + renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) + + expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy() + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx deleted file mode 100644 index 8d56bcd32330..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import type { StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - resetStructuredSessionMocks() -}) - -const STILL_WORKING = "The agent is still working. Run /clear when it's done." -const BACKGROUND = - 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.' - -function renderPane() { - const pane = () => ( - - ) - const view = render(pane()) - // A fresh element: the mocked controller reads the chat's state only when the pane renders. - return { rerender: () => view.rerender(pane()) } -} - -function composerSays( - text: string | null, - refusedWhile?: StructuredAgentSessionCommandRefusalCause -) { - const onError = mocks.composerProps?.structuredTransport?.onError - if (typeof onError !== 'function') { - throw new Error('the composer has no transport') - } - act(() => onError(text, { refusedWhile })) -} - -it('a /clear refused while the agent works is said until the agent stops, then gone', () => { - mocks.turnId = 'turn-1' - const pane = renderPane() - composerSays(STILL_WORKING, 'working') - expect(screen.getAllByText(STILL_WORKING)).toHaveLength(1) - - mocks.turnId = null - pane.rerender() - expect(screen.queryByText(STILL_WORKING)).toBeNull() - // Gone, not hidden: the agent working again later is not what that press was refused for. - mocks.turnId = 'turn-2' - pane.rerender() - expect(screen.queryByText(STILL_WORKING)).toBeNull() -}) - -it('a /clear refused behind background tasks is said until the last one ends, then gone', () => { - mocks.monitoringBackgroundTasks = true - const pane = renderPane() - composerSays(BACKGROUND, 'background') - expect(screen.getAllByText(BACKGROUND)).toHaveLength(1) - - mocks.monitoringBackgroundTasks = false - pane.rerender() - expect(screen.queryByText(BACKGROUND)).toBeNull() -}) - -it('a line naming nothing the chat shows stays until the next send, as before', () => { - mocks.turnId = 'turn-1' - const pane = renderPane() - composerSays('Remove attachments before using a chat-session command.') - mocks.turnId = null - pane.rerender() - expect(screen.getByText('Remove attachments before using a chat-session command.')).toBeTruthy() - composerSays(null) - expect(screen.queryByText('Remove attachments before using a chat-session command.')).toBeNull() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx deleted file mode 100644 index 9f0b181d203c..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -// @vitest-environment happy-dom - -// A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, even after the -// transcript loaded: its words alone, and nothing that could only be refused again. - -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import { TooltipProvider } from '@/components/ui/tooltip' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) -vi.mock('@/lib/structured-agent-session-launch', () => - moduleFactories.structuredAgentSessionLaunch() -) -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() -}) - -const APPROVAL: AgentJournalRenderItem = JSON.parse( - JSON.stringify({ - itemId: 'approval-item', - revision: 3, - sequence: 1, - observedAt: 1, - body: { - kind: 'approval', - title: 'Run the plan?', - detail: null, - subject: { kind: 'plan', text: 'x' }, - options: [{ id: 'allow', label: 'Approve' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - }) -) - -/** A chat with its transcript on screen, a pending approval, a goal, a queued card and a running - * background task, whose read then fails with `reason`. */ -function renderLoadedChatThenFailedRead( - reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' -): void { - mocks.status = 'error' - mocks.readRefusal = { code: 'agent_session_journal_unreadable', details: { reason } } - mocks.turnId = 'turn-1' - mocks.promptItems = [APPROVAL] - mocks.threadGoal = { - goal: { - objective: 'Ship', - status: 'active', - tokenBudget: null, - tokensUsed: 0, - timeUsedSeconds: 1, - createdAt: 1, - updatedAt: 1 - }, - pending: false, - change: vi.fn() - } - mocks.queuedCards = [ - { messageId: 'draft-1', position: 1, text: 'queued text', state: 'waiting', hold: 'turn' } - ] - mocks.monitoringBackgroundTasks = true - mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }] - render( - - - - ) -} - -it.each([ - ['journalCorrupt', 'Unable to load this chat.'], - ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.'] -] as const)('a %s read after the transcript loaded leaves only its words', (reason, words) => { - renderLoadedChatThenFailedRead(reason) - - expect(screen.getAllByText(words)).toHaveLength(1) - expect(screen.queryByTestId('message-list')).toBeNull() - expect(mocks.composerProps).toBeNull() - expect(mocks.approvalCardProps).toBeNull() - expect(document.querySelector('[data-native-chat-thread-goal]')).toBeNull() - expect(document.querySelector('[data-queued-message-id="draft-1"]')).toBeNull() - expect(document.querySelector('[data-native-chat-background-tasks]')).toBeNull() -}) - -it('a failed read that can clear keeps the transcript and every control', () => { - renderLoadedChatThenFailedRead('journalUnavailable') - - expect(screen.getByTestId('message-list')).toBeTruthy() - expect(mocks.approvalCardProps).not.toBeNull() - expect(document.querySelector('[data-queued-message-id="draft-1"]')).not.toBeNull() - expect(document.querySelector('[data-native-chat-background-tasks]')).not.toBeNull() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.first-read.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.first-read.test.tsx deleted file mode 100644 index e2e204fa4cbd..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.first-read.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/lib/structured-agent-session-launch', () => - moduleFactories.structuredAgentSessionLaunch() -) -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -const START_A_CHAT = 'Start a chat with Codex' -const LOADING_TEXT = /Loading conversation|Reading the agent transcript/ - -function loadingCue(): HTMLElement | null { - return document.querySelector('[data-native-chat-loading-cue="true"]') -} - -function sessionView(): React.JSX.Element { - return ( - - ) -} - -describe('NativeChatStructuredSession before its first read settles', () => { - afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() - }) - - it.each(['idle', 'loading'] as const)( - 'shows a reopened chat only a textless loading cue while its read is %s', - (status) => { - mocks.messages = [] - mocks.status = status - render(sessionView()) - - const cue = screen.getByRole('status', { name: 'Loading chat' }) - expect(cue).toBe(loadingCue()) - expect(cue.textContent).toBe('') - expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) - expect(screen.queryByText(START_A_CHAT)).toBeNull() - expect(screen.getByTestId('structured-composer')).toBeTruthy() - } - ) - - it('drops the cue for the empty state once the read settles with nothing in it', () => { - mocks.messages = [] - mocks.status = 'idle' - const { rerender } = render(sessionView()) - expect(screen.queryByText(START_A_CHAT)).toBeNull() - - mocks.status = 'ready' - rerender(sessionView()) - expect(screen.getByText(START_A_CHAT)).toBeTruthy() - expect(loadingCue()).toBeNull() - }) - - it('shows no cue over a transcript, even while a read is still loading', () => { - mocks.status = 'loading' - render(sessionView()) - - expect(screen.getByTestId('message-list')).toBeTruthy() - expect(loadingCue()).toBeNull() - }) - - it('does not hold a cancelled resume on the cue', () => { - mocks.messages = [] - mocks.launchLifecycle = 'cancelled' - mocks.launchResumes = true - mocks.status = 'ready' - render(sessionView()) - - expect(loadingCue()).toBeNull() - }) - - it('keeps a chat this view started on its empty state through publish and the first read', () => { - mocks.messages = [] - mocks.launchLifecycle = 'pending' - // The real controller reports `ready` while the launch has not published. - mocks.status = 'ready' - const { rerender } = render(sessionView()) - expect(screen.getByText(START_A_CHAT)).toBeTruthy() - - mocks.launchLifecycle = null - for (const status of ['idle', 'loading', 'ready'] as const) { - mocks.status = status - rerender(sessionView()) - expect(screen.getByText(START_A_CHAT)).toBeTruthy() - expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) - expect(loadingCue()).toBeNull() - } - }) - - it.each([ - ['failed', 'Chat could not be started.'], - ['visibility-unknown', 'Chat connection could not be confirmed.'] - ] as const)( - 'leaves a %s resume blank beside its Retry line, since nothing is reading it', - (lifecycle, line) => { - mocks.messages = [] - mocks.launchLifecycle = lifecycle - mocks.launchResumes = true - mocks.status = 'ready' - render(sessionView()) - - expect(loadingCue()).toBeNull() - expect(screen.queryByText(START_A_CHAT)).toBeNull() - expect(screen.getByText(line)).toBeTruthy() - expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() - } - ) - - it('does not invite a first message into a resumed chat whose history is not read yet', () => { - mocks.messages = [] - mocks.launchLifecycle = 'pending' - mocks.launchResumes = true - mocks.status = 'ready' - render(sessionView()) - - expect(screen.queryByText(START_A_CHAT)).toBeNull() - expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) - expect(loadingCue()).not.toBeNull() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx new file mode 100644 index 000000000000..88389dca2bc9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() +}) + +{ + const START_A_CHAT = 'Start a chat with Codex' + const LOADING_TEXT = /Loading conversation|Reading the agent transcript/ + + function loadingCue(): HTMLElement | null { + return document.querySelector('[data-native-chat-loading-cue="true"]') + } + + function sessionView(): React.JSX.Element { + return ( + + ) + } + + describe('NativeChatStructuredSession before its first read settles', () => { + it.each(['idle', 'loading'] as const)( + 'shows a reopened chat only a textless loading cue while its read is %s', + (status) => { + mocks.messages = [] + mocks.status = status + render(sessionView()) + + const cue = screen.getByRole('status', { name: 'Loading chat' }) + expect(cue).toBe(loadingCue()) + expect(cue.textContent).toBe('') + expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) + expect(screen.queryByText(START_A_CHAT)).toBeNull() + expect(screen.getByTestId('structured-composer')).toBeTruthy() + } + ) + + it('drops the cue for the empty state once the read settles with nothing in it', () => { + mocks.messages = [] + mocks.status = 'idle' + const { rerender } = render(sessionView()) + expect(screen.queryByText(START_A_CHAT)).toBeNull() + + mocks.status = 'ready' + rerender(sessionView()) + expect(screen.getByText(START_A_CHAT)).toBeTruthy() + expect(loadingCue()).toBeNull() + }) + + it('shows no cue over a transcript, even while a read is still loading', () => { + mocks.status = 'loading' + render(sessionView()) + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(loadingCue()).toBeNull() + }) + + it('does not hold a cancelled resume on the cue', () => { + mocks.messages = [] + mocks.launchLifecycle = 'cancelled' + mocks.launchResumes = true + mocks.status = 'ready' + render(sessionView()) + + expect(loadingCue()).toBeNull() + }) + + it('keeps a chat this view started on its empty state through publish and the first read', () => { + mocks.messages = [] + mocks.launchLifecycle = 'pending' + // The real controller reports `ready` while the launch has not published. + mocks.status = 'ready' + const { rerender } = render(sessionView()) + expect(screen.getByText(START_A_CHAT)).toBeTruthy() + + mocks.launchLifecycle = null + for (const status of ['idle', 'loading', 'ready'] as const) { + mocks.status = status + rerender(sessionView()) + expect(screen.getByText(START_A_CHAT)).toBeTruthy() + expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) + expect(loadingCue()).toBeNull() + } + }) + + it.each([ + ['failed', 'Chat could not be started.'], + ['visibility-unknown', 'Chat connection could not be confirmed.'] + ] as const)( + 'leaves a %s resume blank beside its Retry line, since nothing is reading it', + (lifecycle, line) => { + mocks.messages = [] + mocks.launchLifecycle = lifecycle + mocks.launchResumes = true + mocks.status = 'ready' + render(sessionView()) + + expect(loadingCue()).toBeNull() + expect(screen.queryByText(START_A_CHAT)).toBeNull() + expect(screen.getByText(line)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() + } + ) + + it('does not invite a first message into a resumed chat whose history is not read yet', () => { + mocks.messages = [] + mocks.launchLifecycle = 'pending' + mocks.launchResumes = true + mocks.status = 'ready' + render(sessionView()) + + expect(screen.queryByText(START_A_CHAT)).toBeNull() + expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0) + expect(loadingCue()).not.toBeNull() + }) + }) +} + +{ + // A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, even after the + // transcript loaded: its words alone, and nothing that could only be refused again. + + const APPROVAL: AgentJournalRenderItem = JSON.parse( + JSON.stringify({ + itemId: 'approval-item', + revision: 3, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Run the plan?', + detail: null, + subject: { kind: 'plan', text: 'x' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) + + /** A chat with its transcript on screen, a pending approval, a goal, a queued card and a running + * background task, whose read then fails with `reason`. */ + function renderLoadedChatThenFailedRead( + reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' + ): void { + mocks.status = 'error' + mocks.readRefusal = { code: 'agent_session_journal_unreadable', details: { reason } } + mocks.turnId = 'turn-1' + mocks.promptItems = [APPROVAL] + mocks.threadGoal = { + goal: { + objective: 'Ship', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 1, + createdAt: 1, + updatedAt: 1 + }, + pending: false, + change: vi.fn() + } + mocks.queuedCards = [ + { messageId: 'draft-1', position: 1, text: 'queued text', state: 'waiting', hold: 'turn' } + ] + mocks.monitoringBackgroundTasks = true + mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }] + render( + + + + ) + } + + it.each([ + ['journalCorrupt', 'Unable to load this chat.'], + ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.'] + ] as const)('a %s read after the transcript loaded leaves only its words', (reason, words) => { + renderLoadedChatThenFailedRead(reason) + + expect(screen.getAllByText(words)).toHaveLength(1) + expect(screen.queryByTestId('message-list')).toBeNull() + expect(mocks.composerProps).toBeNull() + expect(mocks.approvalCardProps).toBeNull() + expect(document.querySelector('[data-native-chat-thread-goal]')).toBeNull() + expect(document.querySelector('[data-queued-message-id="draft-1"]')).toBeNull() + expect(document.querySelector('[data-native-chat-background-tasks]')).toBeNull() + }) + + it('a failed read that can clear keeps the transcript and every control', () => { + renderLoadedChatThenFailedRead('journalUnavailable') + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(mocks.approvalCardProps).not.toBeNull() + expect(document.querySelector('[data-queued-message-id="draft-1"]')).not.toBeNull() + expect(document.querySelector('[data-native-chat-background-tasks]')).not.toBeNull() + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx deleted file mode 100644 index 52b9a19e6b81..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() -}) - -// An approval whose subject a newer Orca wrote, with no detail to show. -const NEWER_APPROVAL: AgentJournalRenderItem = JSON.parse( - JSON.stringify({ - itemId: 'approval-item', - revision: 3, - sequence: 1, - observedAt: 1, - body: { - kind: 'approval', - title: 'Review proposed change', - detail: null, - subject: { kind: 'diff', path: 'a.ts' }, - options: [{ id: 'allow', label: 'Approve' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - }) -) - -it("hands the card a newer Orca's subject in a writable chat, and its cancel goes to the host with the card", () => { - mocks.turnId = 'turn-1' - mocks.promptItems = [NEWER_APPROVAL] - render( - - ) - expect(mocks.approvalCardProps).toMatchObject({ - approval: { subject: { kind: 'diff' } } - }) - expect(mocks.approvalCardProps?.approval).not.toHaveProperty('detail') - mocks.approvalCardProps?.onCancel?.() - expect(mocks.cancel).toHaveBeenCalledWith('turn-1', { - itemId: 'approval-item', - expectedRevision: 3 - }) -}) - -// The host's cancel names its turn, so with none running nothing is sent. -it('sends nothing for the card when no turn is running', () => { - mocks.promptItems = [NEWER_APPROVAL] - render( - - ) - mocks.approvalCardProps?.onCancel?.() - expect(mocks.cancel).not.toHaveBeenCalled() -}) - -it('cancels a Pi extension dialog outside a model turn', () => { - mocks.promptItems = [NEWER_APPROVAL] - render( - - ) - mocks.approvalCardProps?.onCancel?.() - expect(mocks.cancel).toHaveBeenCalledWith(undefined, { - itemId: 'approval-item', - expectedRevision: 3 - }) -}) - -function renderSession() { - render( - - ) -} - -// Nothing here answers it, so a send is the way on: it starts a turn, whose card cancel works. -it('keeps the composer open and writable beside a card this build cannot answer', () => { - mocks.promptItems = [NEWER_APPROVAL] - renderSession() - expect(mocks.composerProps).not.toBeNull() - expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: false }) -}) - -it('gives a card this build can answer the composer slot', () => { - const plan: AgentJournalRenderItem = JSON.parse( - JSON.stringify(NEWER_APPROVAL).replace( - '{"kind":"diff","path":"a.ts"}', - '{"kind":"plan","text":"do it"}' - ) - ) - mocks.promptItems = [plan] - renderSession() - expect(mocks.composerProps).toBeNull() - expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: true }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx deleted file mode 100644 index ccfba80707e6..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - resetStructuredSessionMocks() -}) - -function renderPane(): void { - render( - - ) -} - -function journalRefusal( - reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' -) { - return { code: 'agent_session_journal_unreadable', details: { reason } } as const -} - -// The host's message and code never reach the pane; it words the refusal, and says it once. The -// read retries on its own, which the pane does not report. -it('says only that a failed read with no refusal did not load, and adds nothing of the host', () => { - mocks.status = 'error' - mocks.messages = [] - - renderPane() - - expect(screen.getByText('Could not load conversation')).toBeTruthy() - expect(screen.queryByText(/keeps trying/)).toBeNull() - expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() - expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() -}) - -it('says a damaged history cannot load in one line, without claiming Orca keeps trying', () => { - mocks.status = 'error' - mocks.readRefusal = journalRefusal('journalCorrupt') - mocks.messages = [] - - renderPane() - - expect(screen.getAllByText('Unable to load this chat.')).toHaveLength(1) - expect(screen.queryByText('Could not load conversation')).toBeNull() - expect(screen.queryByText(/keeps trying/)).toBeNull() - expect(screen.queryByText(/agent_session_/)).toBeNull() - // Nothing to send into: a send would only be refused and say it again. - expect(mocks.composerProps).toBeNull() -}) - -it("names a history that couldn't open right now once, in its one line", () => { - mocks.status = 'error' - mocks.readRefusal = journalRefusal('journalUnavailable') - mocks.messages = [] - - renderPane() - - expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1) - expect(screen.queryByText('Could not load conversation')).toBeNull() - expect(screen.queryByText(/keeps trying/)).toBeNull() - expect(screen.queryByText(/Try again/)).toBeNull() - // It can clear, so the composer stays and a send waits for the read. - expect(mocks.composerProps).not.toBeNull() -}) - -it("says a code's own words that the history didn't load, and nothing under them", () => { - mocks.status = 'error' - mocks.readRefusal = { - code: 'agent_session_checkpoint_stale', - details: { reason: 'fenceStale' } - } as const - mocks.messages = [] - - renderPane() - - expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) - expect(screen.queryByText(/keeps trying/)).toBeNull() -}) - -// "This isn't available in this chat." would name nothing the reader asked for. -it('says why the history did not load for a chat its host cannot run', () => { - mocks.status = 'error' - mocks.readRefusal = { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } as const - mocks.messages = [] - - renderPane() - - expect( - screen.getAllByText( - "Orca can't run this agent in a chat here. This chat's history couldn't be loaded." - ) - ).toHaveLength(1) - expect(screen.queryByText(/isn't available|newer Orca/)).toBeNull() -}) - -it('says once that a newer Orca saved the chat and only an update opens it, with no composer', () => { - mocks.status = 'error' - mocks.readRefusal = journalRefusal('journalWrittenByNewerOrca') - mocks.messages = [] - - renderPane() - - expect( - screen.getAllByText('This chat was saved by a newer Orca. Update Orca to open it.') - ).toHaveLength(1) - expect(screen.queryByText('Could not load conversation')).toBeNull() - expect(screen.queryByText(/keeps trying/)).toBeNull() - expect(mocks.composerProps).toBeNull() -}) - -// Only a chat that never loaded stores a failure with no host refusal (the read owner drops it after load). -it('says once that the history did not load beside a bubble of a chat that never loaded', () => { - mocks.status = 'error' - - render( - - ) - - expect(screen.getByTestId('message-list')).toBeTruthy() - expect(screen.getByTestId('structured-composer')).toBeTruthy() - expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) - expect(screen.queryByText(/reconnect/i)).toBeNull() -}) - -it('words a failed reconnect beside a transcript it keeps', () => { - mocks.status = 'error' - mocks.readRefusal = journalRefusal('journalUnavailable') - - renderPane() - - expect(screen.getByTestId('message-list')).toBeTruthy() - expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx new file mode 100644 index 000000000000..2f64fad8aa93 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx @@ -0,0 +1,241 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() +}) + +{ + function renderPane(): void { + render( + + ) + } + + function journalRefusal( + reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' + ) { + return { code: 'agent_session_journal_unreadable', details: { reason } } as const + } + + // The host's message and code never reach the pane; it words the refusal, and says it once. The + // read retries on its own, which the pane does not report. + it('says only that a failed read with no refusal did not load, and adds nothing of the host', () => { + mocks.status = 'error' + mocks.messages = [] + + renderPane() + + expect(screen.getByText('Could not load conversation')).toBeTruthy() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() + expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() + }) + + it('says a damaged history cannot load in one line, without claiming Orca keeps trying', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalCorrupt') + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText('Unable to load this chat.')).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(screen.queryByText(/agent_session_/)).toBeNull() + // Nothing to send into: a send would only be refused and say it again. + expect(mocks.composerProps).toBeNull() + }) + + it("names a history that couldn't open right now once, in its one line", () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalUnavailable') + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(screen.queryByText(/Try again/)).toBeNull() + // It can clear, so the composer stays and a send waits for the read. + expect(mocks.composerProps).not.toBeNull() + }) + + it("says a code's own words that the history didn't load, and nothing under them", () => { + mocks.status = 'error' + mocks.readRefusal = { + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + } as const + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) + expect(screen.queryByText(/keeps trying/)).toBeNull() + }) + + // "This isn't available in this chat." would name nothing the reader asked for. + it('says why the history did not load for a chat its host cannot run', () => { + mocks.status = 'error' + mocks.readRefusal = { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } as const + mocks.messages = [] + + renderPane() + + expect( + screen.getAllByText( + "Orca can't run this agent in a chat here. This chat's history couldn't be loaded." + ) + ).toHaveLength(1) + expect(screen.queryByText(/isn't available|newer Orca/)).toBeNull() + }) + + it('says once that a newer Orca saved the chat and only an update opens it, with no composer', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalWrittenByNewerOrca') + mocks.messages = [] + + renderPane() + + expect( + screen.getAllByText('This chat was saved by a newer Orca. Update Orca to open it.') + ).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(mocks.composerProps).toBeNull() + }) + + // Only a chat that never loaded stores a failure with no host refusal (the read owner drops it after load). + it('says once that the history did not load beside a bubble of a chat that never loaded', () => { + mocks.status = 'error' + + render( + + ) + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(screen.getAllByText("This chat's history couldn't be loaded.")).toHaveLength(1) + expect(screen.queryByText(/reconnect/i)).toBeNull() + }) + + it('words a failed reconnect beside a transcript it keeps', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalUnavailable') + + renderPane() + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() + }) +} + +{ + const STILL_WORKING = "The agent is still working. Run /clear when it's done." + const BACKGROUND = + 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.' + + function renderPane() { + const pane = () => ( + + ) + const view = render(pane()) + // A fresh element: the mocked controller reads the chat's state only when the pane renders. + return { rerender: () => view.rerender(pane()) } + } + + function composerSays( + text: string | null, + refusedWhile?: StructuredAgentSessionCommandRefusalCause + ) { + const onError = mocks.composerProps?.structuredTransport?.onError + if (typeof onError !== 'function') { + throw new Error('the composer has no transport') + } + act(() => onError(text, { refusedWhile })) + } + + it('a /clear refused while the agent works is said until the agent stops, then gone', () => { + mocks.turnId = 'turn-1' + const pane = renderPane() + composerSays(STILL_WORKING, 'working') + expect(screen.getAllByText(STILL_WORKING)).toHaveLength(1) + + mocks.turnId = null + pane.rerender() + expect(screen.queryByText(STILL_WORKING)).toBeNull() + // Gone, not hidden: the agent working again later is not what that press was refused for. + mocks.turnId = 'turn-2' + pane.rerender() + expect(screen.queryByText(STILL_WORKING)).toBeNull() + }) + + it('a /clear refused behind background tasks is said until the last one ends, then gone', () => { + mocks.monitoringBackgroundTasks = true + const pane = renderPane() + composerSays(BACKGROUND, 'background') + expect(screen.getAllByText(BACKGROUND)).toHaveLength(1) + + mocks.monitoringBackgroundTasks = false + pane.rerender() + expect(screen.queryByText(BACKGROUND)).toBeNull() + }) + + it('a line naming nothing the chat shows stays until the next send, as before', () => { + mocks.turnId = 'turn-1' + const pane = renderPane() + composerSays('Remove attachments before using a chat-session command.') + mocks.turnId = null + pane.rerender() + expect(screen.getByText('Remove attachments before using a chat-session command.')).toBeTruthy() + composerSays(null) + expect(screen.queryByText('Remove attachments before using a chat-session command.')).toBeNull() + }) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx deleted file mode 100644 index 73334d71ca3b..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen, waitFor, within } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() -}) - -const SESSION_ID = 'start-failure-session' -const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } -const START_FAILED_REASON = - 'Claude stopped before it finished starting. Send your message to try again.' - -function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { - return { - itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')), - revision: 1, - sequence: 1, - observedAt: 1, - body: { - kind: 'status', - tone: 'error', - ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) - } - } -} - -function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { - return { - outbox: { - clientMessageId, - sessionId: SESSION_ID, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - previewUris: [], - state: 'rejected', - queuedAt: 1, - lastAttemptAt: null, - retryAfterUnknownSubmittedAt: null, - lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } } - }, - submission: { - clientMessageId, - fence: 1, - payloadFingerprint: clientMessageId, - dispatchState: 'rejected', - providerItemId: null, - reason, - rejection, - submittedAt: 1, - resolvedAt: 1 - } - } -} - -// The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them. -function renderPane(messages: ReturnType[]): void { - mocks.submissions = messages.map((message) => message.submission) - localStorage.setItem( - `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, - JSON.stringify(messages.map((message) => message.outbox)) - ) - render( - - ) -} - -async function notice(clientMessageId: string): Promise { - return waitFor(() => { - const row = document.querySelector( - `[data-message-id="${agentJournalSubmissionKey(clientMessageId)}"]` - ) - if (!row) { - throw new Error(`no notice on ${clientMessageId}`) - } - return row - }) -} - -// The start's own row says why, so its rejected messages say only that they were not sent. Sending -// one again is a new message, so none offers a Retry. -it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { - mocks.journalItems = [startFailureRow(START_FAILED)] - - renderPane([ - rejected('first', START_FAILED_REASON, START_FAILED), - rejected('second', START_FAILED_REASON, START_FAILED) - ]) - - for (const id of ['first', 'second']) { - const row = await notice(id) - expect(within(row).getByText('Your message was not sent.')).toBeTruthy() - expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull() - } - expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() -}) - -it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => { - mocks.journalItems = [startFailureRow(START_FAILED)] - const providerRejected: AgentSessionFailureFact = { - kind: 'providerRejected', - detail: { text: 'Image type .bmp', audience: 'person' } - } - - renderPane([ - rejected('stated', START_FAILED_REASON, START_FAILED), - rejected( - 'other', - 'The provider did not accept this message: Image type .bmp.', - providerRejected - ) - ]) - - expect(within(await notice('stated')).getByText('Your message was not sent.')).toBeTruthy() - expect( - within(await notice('other')).getByText( - 'The provider did not accept this message: Image type .bmp.' - ) - ).toBeTruthy() -}) - -it("keeps the start failure's own words when its row is not loaded", async () => { - renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) - - expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 0ba9acd7de09..410cb2b8da48 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -360,6 +360,7 @@ export function createStructuredSessionMocks() { const resetStructuredSessionMocks = (): void => { mocks.call.mockReset() + mocks.fileLinkClick.mockClear() mocks.launchLifecycle = null mocks.ownerWorktreeId = 'wt-1' mocks.fileLinkContext = DEFAULT_FILE_LINK_CONTEXT @@ -402,6 +403,10 @@ export function createStructuredSessionMocks() { mocks.loadingOlder = false mocks.olderHistoryGeneration = 0 mocks.loadOlder.mockReset() + mocks.queuedCards = [] + mocks.queuedSteer.mockClear() + mocks.queuedRemove.mockClear() + mocks.queuedEdit.mockClear() Object.assign(mocks, { queuedResumable: false, queueSendsNext: false, unavailable: null }) mocks.queuedResume.mockReset() mocks.revealLatest.mockReset() diff --git a/src/renderer/src/lib/github-source-runtime-context.test.ts b/src/renderer/src/lib/github-source-runtime-context.test.ts index 8759da60e6f2..942b446bc693 100644 --- a/src/renderer/src/lib/github-source-runtime-context.test.ts +++ b/src/renderer/src/lib/github-source-runtime-context.test.ts @@ -1,5 +1,3 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { RuntimeClientTarget } from '../runtime/runtime-client-target' @@ -162,24 +160,3 @@ describe('getGitHubRepoRoutingTarget', () => { ).toEqual({ kind: 'environment', environmentId: 'source-runtime' }) }) }) - -function rendererSourceFiles(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) { - return rendererSourceFiles(path) - } - return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [path] : [] - }) -} - -describe('GitHub repo routing call sites', () => { - it('never spreads a task source over repo-owner settings (#7623)', () => { - const rendererRoot = resolve(__dirname, '..') - // Why: a local source spreads `activeRuntimeEnvironmentId: null` over a runtime owner. - const offenders = rendererSourceFiles(rendererRoot) - .filter((file) => /\.\.\.getTaskSourceRuntimeSettings\(/.test(readFileSync(file, 'utf8'))) - .map((file) => relative(rendererRoot, file).split('\\').join('/')) - expect(offenders).toEqual([]) - }) -}) diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 276810168f43..4d15a2339381 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -1,5 +1,3 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' @@ -209,112 +207,3 @@ describe('editorTabDocumentFolderAccess', () => { expect(editorTabDocumentFolderAccess(useAppStore.getState(), file)).toBeUndefined() }) }) - -// Why a ratchet: a content-driven reader that adopted user-file would bring back the round-1 leak. -// These scans only see the two ways a renderer file can produce user-file today, by name and by -// pattern: building user-file access directly, and opening a tab the tab rule reads as user-named. They -// cannot see a decision laundered through props or a helper, so they are a tripwire for review, not -// a proof; a branded user-named path type is the follow-up that would make it one. -const USER_NAMED_ACCESS_IMPORTERS = [ - 'components/browser-pane/describe-page/browser-artifact-upload.ts', - 'components/native-chat/use-native-chat-external-attachments.ts', - 'components/sidebar/useSidebarProjectDrop.ts', - 'components/terminal-pane/terminal-host-workspace-file.ts', - 'hooks/composer-state/attachment-drop-state.ts', - 'lib/local-file-access.ts', - 'lib/user-opened-local-path.ts' -] - -// Files whose openFile call can store relativePath === filePath (read and saved as user-named by -// the tab rule) or open a floating-workspace tab. The scan matches by value, so a few listed files -// only look like it (their relativePath is genuinely relative); review each new entry by hand. -// Files that build write access beside an opened document; each must derive it from the tab rule. -const DOCUMENT_FOLDER_ACCESS_BUILDERS = [ - 'components/editor/editor-header-file-rename.ts', - 'components/editor/rich-markdown-image-insert.ts', - 'components/tab-bar/EditorFileTab.tsx', - 'lib/execute-open-editor-path-move.ts', - 'lib/local-file-access.ts' -] - -const USER_NAMED_TAB_OPENERS = [ - 'components/browser-pane/navigate/navigate-browser-page-url.ts', - 'components/editor/editor-dropped-file-open.ts', - 'components/editor/markdown-preview-link-actions.ts', - 'components/floating-terminal/use-floating-terminal-create-actions.ts', - 'components/quick-open-file-navigation.ts', - 'components/right-sidebar/ai-vault-session-log-open.ts', - 'components/right-sidebar/source-control/notes/use-note-opening.ts', - 'components/right-sidebar/useFileExplorerHandlers.ts', - 'components/settings/KeybindingsFileActions.tsx', - 'components/tab-bar/tab-create-entry-absolute-file.ts', - 'components/terminal-pane/terminal-file-open-routing.ts', - 'lib/floating-workspace-tab-creation.ts', - 'lib/open-document-in-floating-workspace.ts', - 'store/slices/editor/actions/markdown-link-action.ts' -] - -function collectSourceFiles(dir: string): string[] { - return readdirSync(dir).flatMap((entry) => { - const full = join(dir, entry) - if (statSync(full).isDirectory()) { - return collectSourceFiles(full) - } - return /\.tsx?$/.test(entry) && - !/\.test\.tsx?$/.test(entry) && - !/test-(harness|fixture)/.test(entry) - ? [full] - : [] - }) -} - -function opensUserNamedTabs(source: string): boolean { - if (!/\bopenFile\(/.test(source)) { - return false - } - if (source.includes('FLOATING_TERMINAL_WORKTREE_ID')) { - return true - } - const filePathValues = [...source.matchAll(/\bfilePath:\s*([\w.]+)/g)] - .map((match) => match[1]) - .filter((value) => value !== 'string') - if (/\bfilePath,/.test(source)) { - filePathValues.push('filePath') - } - const relativePathValues = [ - ...source.matchAll(/\brelativePath(?::|\s*=)\s*([\s\S]*?)(?:,\s*\n|\n\s*[})])/g) - ].map((match) => match[1]) - return relativePathValues.some((value) => - filePathValues.some((filePath) => - new RegExp(`(^|[^\\w.])${filePath.replace(/\./g, '\\.')}($|[^\\w.])`).test(value) - ) - ) -} - -function rendererFilesMatching(test: (source: string) => boolean): string[] { - const rendererRoot = resolve(__dirname, '..') - return collectSourceFiles(rendererRoot) - .filter((file) => test(readFileSync(file, 'utf8'))) - .map((file) => relative(rendererRoot, file).split('\\').join('/')) - .sort() -} - -describe('user-named file access ratchet', () => { - it('lists every file that builds user-file access', () => { - expect( - rendererFilesMatching((source) => /\buserNamedFileAccess\b|kind: 'user-file'/.test(source)) - ).toEqual(USER_NAMED_ACCESS_IMPORTERS) - }) - - it('lists every file that builds document-folder write access', () => { - expect( - rendererFilesMatching((source) => - /\b(editorTab)?[dD]ocumentFolderAccess\b|kind: 'document-folder'/.test(source) - ) - ).toEqual(DOCUMENT_FOLDER_ACCESS_BUILDERS) - }) - - it('lists every file that can open a tab read as user-named', () => { - expect(rendererFilesMatching(opensUserNamedTabs)).toEqual(USER_NAMED_TAB_OPENERS) - }) -}) From e7d7c5bbebf545209f767bfc34cfffa7e88b289b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 14:28:22 -0700 Subject: [PATCH 012/110] refactor(worktrees): worktree create, listing, removal and repo hook/ref reads route by their owner (#27298) * refactor(worktrees): worktree create, listing, removal and repo hook/ref reads route by their owner Worktree create, prefetch, detected listings and missing-terminal teardown, preserved-branch cleanup and removal follow the repo or workspace owner. Repo hook and base-ref reads take the repo's host id instead of settings. Composer, settings panes and the setup guide pass the selected repo's host. * test(hooks): build the duplicate-row state without a cast --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 45 +++----------- config/owner-routing-baseline.txt | 15 ----- .../use-automations-page-setup-state.ts | 17 +++++- .../use-smart-workspace-secondary-searches.ts | 10 ++-- .../components/repo/CreateFromPicker.test.tsx | 20 +------ .../src/components/repo/CreateFromPicker.tsx | 29 ++------- .../review/use-review-context.ts | 2 - .../sync/use-base-ref-default.ts | 13 +--- .../source-control/sync/use-base-refs.ts | 3 - ...-create-pull-request-base-ref-discovery.ts | 39 ++++++------ ...llRequestDialogFields.repo-default.test.ts | 9 +++ .../useCreatePullRequestDialogFields.ts | 1 - .../src/components/settings/BaseRefPicker.tsx | 44 ++++++-------- ...AccountSection.inventory-lifetime.test.tsx | 11 ++-- .../RepositoryGitHubAccountSection.tsx | 16 +++-- .../settings/RepositoryHooksSection.test.ts | 6 +- .../settings/RepositoryHooksSection.tsx | 11 +--- .../settings/RepositoryIconPicker.tsx | 9 ++- .../settings/use-repository-issue-command.ts | 23 +++---- .../use-settings-repo-scroll-effects.ts | 12 +--- .../setup-guide-progress-readiness.ts | 8 +-- .../use-setup-guide-progress.test.ts | 14 ++--- .../setup-guide/use-setup-guide-progress.ts | 5 +- .../sidebar/SetupScriptPromptCard.tsx | 4 +- .../composer-submit-orchestration.ts | 2 +- .../composer-state/composer-target-state.ts | 3 +- .../provider-runtime-sync.test.ts | 3 +- .../composer-state/provider-runtime-sync.ts | 27 +++++---- .../quick-creation-execution.ts | 10 ++-- .../composer-state/runtime-target-model.ts | 3 + .../runtime-target-selection.ts | 7 +++ .../src/lib/ensure-hooks-confirmed.test.ts | 19 +++++- .../src/lib/ensure-hooks-confirmed.ts | 55 +++++++++-------- .../launch-work-item-direct-owner-row.test.ts | 12 +++- .../lib/launch-work-item-direct-preflight.ts | 2 +- .../src/lib/launch-work-item-direct.ts | 7 ++- .../src/lib/worktree-creation-flow-startup.ts | 9 ++- .../src/runtime/runtime-hooks-client.test.ts | 54 ++++++++--------- .../src/runtime/runtime-hooks-client.ts | 56 ++++++++--------- .../src/runtime/runtime-repo-client.test.ts | 8 +-- .../src/runtime/runtime-repo-client.ts | 40 +++++++------ .../worktrees-create-base-status.test.ts | 60 ++++++++++++------- .../worktrees-create-parent-pick.test.ts | 18 +++++- .../worktrees-fetch-owner-routing.test.ts | 4 ++ .../worktrees-remote-runtime-create.test.ts | 37 +++++++++--- .../worktrees-remote-runtime-removal.test.ts | 15 ++++- .../worktrees/create/create-worktree.ts | 11 ++-- .../create/prefetch-worktree-create-base.ts | 9 ++- .../detected-worktree-provider-request.ts | 30 ++++++---- .../listing/detected-worktree-refresh.ts | 27 +++------ .../worktrees/listing/fetch-all-worktrees.ts | 26 ++++---- .../listing/fetch-detected-worktrees.ts | 21 +++---- .../worktrees/listing/fetch-worktrees.ts | 13 +--- .../listing/known-ssh-worktree-fetch.ts | 8 +-- .../teardown/force-delete-preserved-branch.ts | 11 ++-- .../missing-worktree-terminal-teardown.ts | 7 +-- .../worktrees/teardown/remove-worktree.ts | 13 +--- 57 files changed, 485 insertions(+), 508 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 3df156508098..712a0324ec09 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -17,7 +17,6 @@ 1 src/renderer/src/components/activity/use-agent-pane-threads.ts 4 src/renderer/src/components/agent/AgentSettingsDialog.tsx 1 src/renderer/src/components/automations/automation-host-client.ts -1 src/renderer/src/components/automations/automation-orca-save.ts 1 src/renderer/src/components/automations/automations-page-fixtures.ts 1 src/renderer/src/components/automations/use-automation-source-availability.ts 1 src/renderer/src/components/automations/use-automation-source-host-availability.ts @@ -95,11 +94,10 @@ 1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts 4 src/renderer/src/components/new-workspace/use-linear-source-connection.ts 1 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts -2 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts 2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts 1 src/renderer/src/components/quick-open-file-list-test-harness.ts -3 src/renderer/src/components/repo/CreateFromPicker.tsx 1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx 2 src/renderer/src/components/right-sidebar/FileExplorer.tsx 1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx @@ -114,23 +112,19 @@ 1 src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts 1 src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx 1 src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx -1 src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts -2 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts +1 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts 2 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts 1 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts 4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts 2 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts 2 src/renderer/src/components/right-sidebar/useFileSearchScope.ts 2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts -3 src/renderer/src/components/settings/BaseRefPicker.tsx +1 src/renderer/src/components/settings/BaseRefPicker.tsx 2 src/renderer/src/components/settings/BrowserPane.tsx 1 src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/IntegrationsPane.tsx 3 src/renderer/src/components/settings/LinearAgentSkillPane.tsx -2 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx -1 src/renderer/src/components/settings/RepositoryHooksSection.tsx -1 src/renderer/src/components/settings/RepositoryIconPicker.tsx 4 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx 2 src/renderer/src/components/settings/Settings.tsx 1 src/renderer/src/components/settings/SettingsSetupGuidePane.tsx @@ -150,7 +144,6 @@ 2 src/renderer/src/components/settings/task-tracker-integration-cards.tsx 2 src/renderer/src/components/settings/token-source-control-integration-cards.tsx 2 src/renderer/src/components/settings/use-integration-provider-status-refresh.ts -3 src/renderer/src/components/settings/use-repository-issue-command.ts 1 src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts 2 src/renderer/src/components/settings/use-settings-navigation-model.ts 3 src/renderer/src/components/settings/use-settings-store-model.ts @@ -158,8 +151,7 @@ 4 src/renderer/src/components/settings/use-task-source-provider-readiness.ts 1 src/renderer/src/components/setup-guide/SetupGuideModal.tsx 1 src/renderer/src/components/setup-guide/SetupGuideTelemetryObserver.tsx -1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts -3 src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +2 src/renderer/src/components/setup-guide/use-setup-guide-progress.ts 1 src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx 3 src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx 1 src/renderer/src/components/sidebar/SavedHostScopeWidenGate.tsx @@ -290,10 +282,7 @@ 1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts 2 src/renderer/src/hooks/composer-state/composer-source-state.ts -1 src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts -2 src/renderer/src/hooks/composer-state/composer-target-state.ts -1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts -1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts +1 src/renderer/src/hooks/composer-state/composer-target-state.ts 1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts 1 src/renderer/src/hooks/editor-external-watch-target-rows.ts 3 src/renderer/src/hooks/editor-external-watch-targets.ts @@ -336,7 +325,6 @@ 1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host 1 src/renderer/src/lib/doc-preview-grants.ts 1 src/renderer/src/lib/empty-workspace-default-agent-chat.ts -3 src/renderer/src/lib/ensure-hooks-confirmed.ts 3 src/renderer/src/lib/execute-open-editor-path-move.ts 1 src/renderer/src/lib/execution-host-display-label.ts 2 src/renderer/src/lib/execution-host-facts.ts @@ -351,7 +339,6 @@ 2 src/renderer/src/lib/launch-agent-web-host-tab.ts 2 src/renderer/src/lib/launch-ai-vault-session.ts 1 src/renderer/src/lib/launch-structured-agent-session.ts -1 src/renderer/src/lib/launch-work-item-direct.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts 1 src/renderer/src/lib/linear-issue-url-lookup.ts 2 src/renderer/src/lib/local-preflight-context.ts @@ -386,10 +373,6 @@ 1 src/renderer/src/lib/worktree-activation-pty-inventory.ts 3 src/renderer/src/lib/worktree-activation.ts 1 src/renderer/src/lib/worktree-creation-agent-seeds.ts -1 src/renderer/src/lib/worktree-creation-flow-execute.ts -1 src/renderer/src/lib/worktree-creation-flow-startup.ts -1 src/renderer/src/lib/worktree-creation-flow.ts -1 src/renderer/src/lib/worktree-creation-hook-preparation.ts 1 src/renderer/src/lib/worktree-initial-terminal-seeding.ts 1 src/renderer/src/lib/worktree-jump-navigation.ts 2 src/renderer/src/lib/worktree-operation-route.ts @@ -399,7 +382,6 @@ 1 src/renderer/src/runtime/close-mirrored-editor-tab.ts 1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts 1 src/renderer/src/runtime/runtime-client-target.ts -3 src/renderer/src/runtime/runtime-hooks-client.ts 17 src/renderer/src/runtime/runtime-jira-client.ts 2 src/renderer/src/runtime/runtime-jira-summary-client.ts 1 src/renderer/src/runtime/runtime-jira-target.ts @@ -407,7 +389,6 @@ 8 src/renderer/src/runtime/runtime-linear-client.ts 6 src/renderer/src/runtime/runtime-linear-issue-mutations.ts 12 src/renderer/src/runtime/runtime-linear-project-client.ts -3 src/renderer/src/runtime/runtime-repo-client.ts 3 src/renderer/src/runtime/structured-agent-session-owner.ts 1 src/renderer/src/runtime/structured-conversation-name.ts 5 src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts @@ -496,17 +477,12 @@ 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts 3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts -12 src/renderer/src/store/slices/worktrees.ts -3 src/renderer/src/store/slices/worktrees/create/create-worktree.ts +7 src/renderer/src/store/slices/worktrees.ts +1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts 1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts -2 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts -2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts -7 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts -4 src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts -4 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts -6 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts +2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts 1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts -2 src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts 2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts 3 src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts 2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -515,10 +491,7 @@ 1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts 1 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts 3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts -1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts -1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts 1 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts -2 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts 1 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts 2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts 1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index a054597471b1..528a9498e9d9 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -4,21 +4,6 @@ # Prune after removing sites: pnpm check:owner-routing-ratchet --prune 1 src/renderer/src/components/linear-api-key-dialog.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx -1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx -1 src/renderer/src/components/settings/RepositoryIconPicker.tsx -1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts -1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts -1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts -1 src/renderer/src/lib/worktree-creation-flow-startup.ts -3 src/renderer/src/runtime/runtime-hooks-client.ts -3 src/renderer/src/runtime/runtime-repo-client.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts -1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts -1 src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts -2 src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts -1 src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts -1 src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts -1 src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts -1 src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index 88e5d54b1b14..ae0939085fe3 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -5,7 +5,11 @@ import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import type { AutomationDraft } from './AutomationEditorDialog' import { getAutomationHostTargetFromKey, type AutomationHostTarget } from './automation-host-client' import { getAutomationCreateRepos } from './automation-create-projects' -import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + getRepoExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { getVisibleAutomationSetupDecision } from './automation-setup-decision' import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner' import type { AutomationsPageListState } from './use-automations-page-list-state' @@ -116,8 +120,15 @@ export function useAutomationsPageSetupState({ if (existingPromise) { return (await existingPromise).hooks } - const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) - const promise = checkRuntimeHooks(settingsForRepo, repoId, hostId) + // Why: a repo id on two hosts has no single owner to inspect; never let focus pick one. + const ownerRows = hostId ? [] : repos.filter((repo) => repo.id === repoId) + const ownerHostId = + hostId ?? (ownerRows.length === 1 ? getRepoExecutionHostId(ownerRows[0]) : null) + const promise = ( + ownerHostId + ? checkRuntimeHooks(ownerHostId, repoId) + : Promise.reject(new Error('The project host is unknown.')) + ) .then((result) => ({ hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null), ok: result.status !== 'error' diff --git a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts index 7c18681bb1d2..1d9a48af476a 100644 --- a/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts +++ b/src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { useEffect, useMemo, useRef } from 'react' import { searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client' import { lookupLinearIssueUrl } from '@/lib/linear-issue-url-lookup' @@ -37,7 +38,6 @@ export function useSmartWorkspaceSecondarySearches({ mode, selectedRepo, debouncedQuery, - selectedRepoOwnerSettings, setBranches, setBranchResultsSource, setBranchesLoading, @@ -98,8 +98,10 @@ export function useSmartWorkspaceSecondarySearches({ ] ) + // Why: branches are searched on the selected repo's own host. + const selectedRepoHostId = selectedRepo ? getRepoExecutionHostId(selectedRepo) : null useEffect(() => { - if (!branchSearchRequest) { + if (!branchSearchRequest || !selectedRepoHostId) { setBranches([]) setBranchResultsSource(null) setBranchesLoading(false) @@ -109,7 +111,7 @@ export function useSmartWorkspaceSecondarySearches({ // Why: visibility retains prior rows while typing ahead of the debounced query. setBranchesLoading(true) void searchRuntimeRepoBaseRefDetails( - selectedRepoOwnerSettings, + selectedRepoHostId, branchSearchRequest.repoId, branchSearchRequest.query, branchSearchRequest.limit @@ -139,7 +141,7 @@ export function useSmartWorkspaceSecondarySearches({ } }, [ branchSearchRequest, - selectedRepoOwnerSettings, + selectedRepoHostId, setBranches, setBranchResultsSource, setBranchesLoading diff --git a/src/renderer/src/components/repo/CreateFromPicker.test.tsx b/src/renderer/src/components/repo/CreateFromPicker.test.tsx index 9216645fbc6d..c54a1e657799 100644 --- a/src/renderer/src/components/repo/CreateFromPicker.test.tsx +++ b/src/renderer/src/components/repo/CreateFromPicker.test.tsx @@ -110,11 +110,7 @@ describe('CreateFromPicker host routing', () => { await renderPicker(repo) - expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'owner-runtime' }, - repo.id, - 'runtime:owner-runtime' - ) + expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith('runtime:owner-runtime', repo.id) }) // Why: the composer's Branch tab already lists on an empty query; a minimum-length gate left @@ -131,13 +127,7 @@ describe('CreateFromPicker host routing', () => { await new Promise((resolve) => setTimeout(resolve, 250)) }) - expect(searchRuntimeRepoBaseRefs).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: null }, - repo.id, - '', - 30, - 'local' - ) + expect(searchRuntimeRepoBaseRefs).toHaveBeenCalledWith('local', repo.id, '', 30) }) it('keeps an explicit local repo on the local client even when a runtime is focused', async () => { @@ -146,11 +136,7 @@ describe('CreateFromPicker host routing', () => { await renderPicker(repo) - expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: null }, - repo.id, - 'local' - ) + expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledWith('local', repo.id) expect(searchRuntimeRepoBaseRefs).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/repo/CreateFromPicker.tsx b/src/renderer/src/components/repo/CreateFromPicker.tsx index 80d63909a741..5b58fc6a7e72 100644 --- a/src/renderer/src/components/repo/CreateFromPicker.tsx +++ b/src/renderer/src/components/repo/CreateFromPicker.tsx @@ -19,8 +19,6 @@ import { cn } from '@/lib/utils' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { getRepoExecutionHostId } from '../../../../shared/execution-host' -import { useAppStore } from '@/store' -import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefs @@ -56,11 +54,6 @@ export function CreateFromPicker({ onValueChange: (baseBranch: string) => void onSetDefault?: (baseBranch: string) => void | Promise }): React.JSX.Element { - // Per-repo evidence, not the ambient active-runtime setting; the base-ref helpers - // just take a settings-shaped object, so it is synthesized at each call below. - const repoRuntimeEnvironmentId = useAppStore((state) => - getRuntimeEnvironmentIdForRepo(state, repoId) - ) const repo = repoMap.get(repoId) const repoHostId = repo ? getRepoExecutionHostId(repo) : undefined const [open, setOpen] = React.useState(false) @@ -161,16 +154,12 @@ export function CreateFromPicker({ ) React.useEffect(() => { - if (!repoId) { + if (!repoId || !repoHostId) { return } let stale = false setDefaultBaseRef(null) - void getRuntimeRepoBaseRefDefault( - { activeRuntimeEnvironmentId: repoRuntimeEnvironmentId }, - repoId, - repoHostId - ) + void getRuntimeRepoBaseRefDefault(repoHostId, repoId) .then((result) => { if (!stale) { setDefaultBaseRef(result.defaultBaseRef) @@ -184,7 +173,7 @@ export function CreateFromPicker({ return () => { stale = true } - }, [repoHostId, repoRuntimeEnvironmentId, repoId]) + }, [repoHostId, repoId]) React.useEffect(() => { if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) { @@ -195,7 +184,7 @@ export function CreateFromPicker({ const trimmedQuery = query.trim() // Why: an empty query lists the repo's branches, as the composer's Branch tab already does; // a minimum length left this picker showing only the default and worktree branches. - if (!open || !repoId) { + if (!open || !repoId || !repoHostId) { setSearchResults([]) setIsSearching(false) return @@ -204,13 +193,7 @@ export function CreateFromPicker({ let stale = false setIsSearching(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs( - { activeRuntimeEnvironmentId: repoRuntimeEnvironmentId }, - repoId, - trimmedQuery, - 30, - repoHostId - ) + void searchRuntimeRepoBaseRefs(repoHostId, repoId, trimmedQuery, 30) .then((results) => { if (!stale) { setSearchResults(results) @@ -232,7 +215,7 @@ export function CreateFromPicker({ stale = true window.clearTimeout(timer) } - }, [repoHostId, repoRuntimeEnvironmentId, open, query, repoId]) + }, [repoHostId, open, query, repoId]) const compactTriggerContent = ( <> diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts index 581045013f42..61857afa30f0 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts @@ -20,7 +20,6 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, activeRepoTarget, activeWorktree, activeWorktreeId, @@ -58,7 +57,6 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, activeRepoWorktreeBaseRef: activeRepo?.worktreeBaseRef, activeWorktreeBaseRef: activeWorktree?.baseRef, hostedReview, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts index 2da00d5bcc0a..c1f48124ff12 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts @@ -6,32 +6,26 @@ export function useSourceControlBaseRefDefault({ activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, isBranchVisible, isFolder }: { activeRepoConnectionId: string | null activeRepoExecutionHostId: ExecutionHostId | null activeRepoId: string | null - activeRepoRuntimeEnvironmentId: string | null | undefined isBranchVisible: boolean isFolder: boolean }): string | null { // Why: start null (not 'origin/main') so branch compare doesn't fire with a fabricated ref before the IPC resolves. const [defaultBaseRef, setDefaultBaseRef] = useState(null) useEffect(() => { - if (!isBranchVisible || !activeRepoId || isFolder) { + if (!isBranchVisible || !activeRepoId || !activeRepoExecutionHostId || isFolder) { return } // Why: reset to null so that effectiveBaseRef becomes falsy until the IPC resolves, so branch compare can't fire with a stale defaultBaseRef from a different repo (transient "invalid-base" on switch). setDefaultBaseRef(null) let stale = false - void getRuntimeRepoBaseRefDefault( - { activeRuntimeEnvironmentId: activeRepoRuntimeEnvironmentId }, - activeRepoId, - // Why: the direct-repo path resolves the record by OWNER host, not the focused runtime. - activeRepoExecutionHostId ?? undefined - ) + // Why: resolved on the repo's OWNER host, not the focused runtime. + void getRuntimeRepoBaseRefDefault(activeRepoExecutionHostId, activeRepoId) .then((result) => { if (!stale) { // IPC returns a { defaultBaseRef, remoteCount } envelope; only defaultBaseRef is needed here (remoteCount powers BaseRefPicker's multi-remote hint). @@ -53,7 +47,6 @@ export function useSourceControlBaseRefDefault({ activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, isBranchVisible, isFolder ]) diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-refs.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-refs.ts index 04614dd5b594..d747aba2708d 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-refs.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-refs.ts @@ -15,7 +15,6 @@ export function useSourceControlBaseRefs({ activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, activeRepoWorktreeBaseRef, activeWorktreeBaseRef, hostedReview, @@ -27,7 +26,6 @@ export function useSourceControlBaseRefs({ activeRepoConnectionId: SourceControlWorktreeContext['activeRepoConnectionId'] activeRepoExecutionHostId: SourceControlWorktreeContext['activeRepoExecutionHostId'] activeRepoId: string | null - activeRepoRuntimeEnvironmentId: string | null activeRepoWorktreeBaseRef: string | undefined activeWorktreeBaseRef: string | undefined hostedReview: HostedReviewInfo | null @@ -40,7 +38,6 @@ export function useSourceControlBaseRefs({ activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, - activeRepoRuntimeEnvironmentId, isBranchVisible, isFolder }) diff --git a/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts b/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts index bf2c30658b3c..95fea02981e8 100644 --- a/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts +++ b/src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts @@ -1,5 +1,7 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' -import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { useAppStore } from '@/store' +import { findRepoForHost } from '@/store/slices/repo-host-identity' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefDetails @@ -12,8 +14,6 @@ import { type CreatePullRequestBaseRefDiscoveryOptions = { open: boolean repoId: string - /** The repo owner's transport; `null` skips discovery. */ - target: RuntimeClientTarget | null base: string baseQuery: string setBase: Dispatch> @@ -22,17 +22,9 @@ type CreatePullRequestBaseRefDiscoveryOptions = { setBaseSearchError: Dispatch> } -// Why: the repo client still takes the settings shape; build it from the owner, never from focus. -function repoClientRoute(target: RuntimeClientTarget) { - return { - activeRuntimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null - } -} - export function useCreatePullRequestBaseRefDiscovery({ open, repoId, - target, base, baseQuery, setBase, @@ -44,6 +36,11 @@ export function useCreatePullRequestBaseRefDiscovery({ // previous repo's default branch would silently suppress the stacked-PR lookup. const [repoDefault, setRepoDefault] = useState<{ repoId: string; baseRef: string } | null>(null) const repoDefaultBaseRef = repoDefault?.repoId === repoId ? repoDefault.baseRef : null + // Why: refs live on the repo's own host, not the focused one. + const repoHostId = useAppStore((s) => { + const repo = findRepoForHost(s.repos, repoId, { settings: s.settings }) + return repo ? getRepoExecutionHostId(repo) : null + }) // Why: resolved separately from eligibility's defaultBaseRef, which reports the // worktree's own base. Consumers that need "is this the repo's default branch?" @@ -51,11 +48,11 @@ export function useCreatePullRequestBaseRefDiscovery({ useEffect(() => { // Why: the repo default doesn't move while a repo stays open, so skip the probe // once it is known — on a remote runtime it is an RPC round-trip per composer open. - if (!open || repoDefaultBaseRef || !target) { + if (!open || repoDefaultBaseRef || !repoHostId) { return } let stale = false - void getRuntimeRepoBaseRefDefault(repoClientRoute(target), repoId) + void getRuntimeRepoBaseRefDefault(repoHostId, repoId) .then((result) => { if (!stale && result.defaultBaseRef) { setRepoDefault({ repoId, baseRef: stripBaseRef(result.defaultBaseRef) }) @@ -65,7 +62,7 @@ export function useCreatePullRequestBaseRefDiscovery({ return () => { stale = true } - }, [open, repoDefaultBaseRef, repoId, target]) + }, [open, repoDefaultBaseRef, repoHostId, repoId]) useEffect(() => { if (!open || base || !repoDefaultBaseRef) { @@ -75,7 +72,7 @@ export function useCreatePullRequestBaseRefDiscovery({ }, [base, open, repoDefaultBaseRef, setBase]) useEffect(() => { - if (!open || baseQuery.trim().length < 2 || !target) { + if (!open || !repoHostId || baseQuery.trim().length < 2) { setBaseResults([]) setBaseSearchPending(false) setBaseSearchError(null) @@ -84,7 +81,7 @@ export function useCreatePullRequestBaseRefDiscovery({ let stale = false setBaseSearchPending(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefDetails(repoClientRoute(target), repoId, baseQuery.trim(), 20) + void searchRuntimeRepoBaseRefDetails(repoHostId, repoId, baseQuery.trim(), 20) .then((results) => { if (!stale) { setBaseResults(normalizeCreateReviewBaseSearchResults(results)) @@ -107,7 +104,15 @@ export function useCreatePullRequestBaseRefDiscovery({ stale = true window.clearTimeout(timer) } - }, [baseQuery, open, repoId, target, setBaseResults, setBaseSearchError, setBaseSearchPending]) + }, [ + baseQuery, + open, + repoHostId, + repoId, + setBaseResults, + setBaseSearchError, + setBaseSearchPending + ]) return repoDefaultBaseRef } diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts index ffc092f2abd2..b1e068f6ad99 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts @@ -13,6 +13,7 @@ vi.mock('@/runtime/runtime-repo-client', () => ({ })) const { useCreatePullRequestDialogFields } = await import('./useCreatePullRequestDialogFields') +const { useAppStore } = await import('@/store') type DialogFields = ReturnType @@ -87,6 +88,14 @@ function renderFields(initialRepoId: string): { beforeEach(() => { getRuntimeRepoBaseRefDefault.mockReset() + // Why: refs are read on the repo row's own host. + const repo = { path: '/repo', displayName: 'Repo', badgeColor: '#000', addedAt: 0 } + useAppStore.setState({ + repos: [ + { ...repo, id: 'repo-1' }, + { ...repo, id: 'repo-2' } + ] + }) }) describe('useCreatePullRequestDialogFields repo default base ref', () => { diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index dbe37c1823a4..84e236d7095e 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -198,7 +198,6 @@ export function useCreatePullRequestDialogFields({ const repoDefaultBaseRef = useCreatePullRequestBaseRefDiscovery({ open, repoId, - target, base, baseQuery, setBase, diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index 8bb96a508cc6..49037b623bdd 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -3,14 +3,14 @@ import { useEffect, useRef, useState } from 'react' import { Button } from '../ui/button' import { Input } from '../ui/input' import { useAppStore } from '@/store' -import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' +import { findRepoForHost } from '@/store/slices/repo-host-identity' import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefs } from '@/runtime/runtime-repo-client' import { isRuntimeRepoRefSearchQueryWithinLimit } from '@/runtime/runtime-repo-search-bounds' import { translate } from '@/i18n/i18n' -import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' type BaseRefPickerProps = { repoId: string @@ -27,15 +27,14 @@ export function BaseRefPicker({ onSelect, onUsePrimary }: BaseRefPickerProps): React.JSX.Element { - const focusedRuntimeEnvironmentId = useAppStore((state) => - getRuntimeEnvironmentIdForRepo(state, repoId) - ) - const selectedHost = parseExecutionHostId(hostId) - const activeRuntimeEnvironmentId = hostId - ? selectedHost?.kind === 'runtime' - ? selectedHost.environmentId - : null - : focusedRuntimeEnvironmentId + // Why: refs live on the repo's own host; with no host given, the repo row names it. + const ownerHostId = useAppStore((state) => { + if (hostId) { + return hostId + } + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) + return repo ? getRepoExecutionHostId(repo) : null + }) // Why: null until the IPC resolves (or when the repo has no default base ref // available). We avoid seeding with 'origin/main' because that would display // a fabricated default in repos that don't actually have origin/main. @@ -73,11 +72,10 @@ export function BaseRefPicker({ const loadDefaultBaseRef = async (): Promise => { try { - const result = await getRuntimeRepoBaseRefDefault( - { activeRuntimeEnvironmentId }, - repoId, - hostId - ) + if (!ownerHostId) { + return + } + const result = await getRuntimeRepoBaseRefDefault(ownerHostId, repoId) if (!stale) { setDefaultBaseRef(result.defaultBaseRef) setRemoteCount(result.remoteCount) @@ -103,7 +101,7 @@ export function BaseRefPicker({ return () => { stale = true } - }, [activeRuntimeEnvironmentId, hostId, repoId]) + }, [ownerHostId, repoId]) useEffect(() => { if (!isRuntimeRepoRefSearchQueryWithinLimit(baseRefQuery)) { @@ -112,7 +110,7 @@ export function BaseRefPicker({ return } const trimmedQuery = baseRefQuery.trim() - if (trimmedQuery.length < 2) { + if (trimmedQuery.length < 2 || !ownerHostId) { setBaseRefResults([]) setIsSearchingBaseRefs(false) return @@ -122,13 +120,7 @@ export function BaseRefPicker({ setIsSearchingBaseRefs(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs( - { activeRuntimeEnvironmentId }, - repoId, - trimmedQuery, - 20, - hostId - ) + void searchRuntimeRepoBaseRefs(ownerHostId, repoId, trimmedQuery, 20) .then((results) => { if (!stale) { setBaseRefResults(results) @@ -151,7 +143,7 @@ export function BaseRefPicker({ stale = true window.clearTimeout(timer) } - }, [activeRuntimeEnvironmentId, baseRefQuery, hostId, repoId]) + }, [baseRefQuery, ownerHostId, repoId]) const effectiveBaseRef = currentBaseRef ?? defaultBaseRef diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx index 979a79e3baef..94cbd5b53528 100644 --- a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx @@ -189,22 +189,23 @@ describe('repository account inventory request ownership', () => { expect(listAccountsMock).toHaveBeenCalledTimes(2) }) - it('reloads for a changed legacy runtime target and keeps explicit refresh forced', async () => { + it('keeps an unstamped repo on this computer when the default host changes', async () => { const repo: Repo = { ...BASE_REPO, executionHostId: undefined } storeState.settings = { activeRuntimeEnvironmentId: 'server-1' } await render(repo) storeState.settings = { activeRuntimeEnvironmentId: 'server-2' } await render(repo) + expect(listAccountsMock).toHaveBeenCalledTimes(1) expect(listAccountsMock).toHaveBeenNthCalledWith( - 2, - { kind: 'environment', environmentId: 'server-2' }, + 1, + { kind: 'local' }, { id: repo.id, path: repo.path }, { refreshCapability: false } ) await act(async () => getRefreshButton().click()) expect(listAccountsMock).toHaveBeenNthCalledWith( - 3, - { kind: 'environment', environmentId: 'server-2' }, + 2, + { kind: 'local' }, { id: repo.id, path: repo.path }, { refreshCapability: true } ) diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx index f5da489a363a..5d10750e6575 100644 --- a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx @@ -7,8 +7,8 @@ import { Button } from '../ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { useAppStore } from '../../store' -import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { searchKeywords } from './settings-search-keywords' import { @@ -35,7 +35,6 @@ export function RepositoryGitHubAccountSection({ updateRepo, forceVisible }: RepositoryGitHubAccountSectionProps): React.JSX.Element | null { - const settings = useAppStore((state) => state.settings) const [inventory, setInventory] = useState(null) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) @@ -44,13 +43,12 @@ export function RepositoryGitHubAccountSection({ const loadGenerationRef = useRef(0) const selectLabelId = useId() - // Why: getRepoOwnerRoutedSettings returns a fresh object each render, and the target - // depends on nothing else — key the memo on the id so it stays stable. - const activeRuntimeEnvironmentId = - getRepoOwnerRoutedSettings(settings, repo)?.activeRuntimeEnvironmentId ?? null + // Why: the binding lives on the repo's own host; key the memo on its id so it stays stable. + const ownerHost = parseExecutionHostId(getRepoExecutionHostId(repo)) + const ownerEnvironmentId = ownerHost?.kind === 'runtime' ? ownerHost.environmentId : null const runtimeTarget = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), - [activeRuntimeEnvironmentId] + () => runtimeTargetForOwnerEnvironment(ownerEnvironmentId), + [ownerEnvironmentId] ) const loadInventory = useCallback( diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts index 4c8d329d3b5a..83902cdebf11 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts +++ b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts @@ -168,10 +168,6 @@ describe('RepositoryHooksSection execution ownership', () => { await Promise.resolve() }) - expect(readRuntimeIssueCommand).toHaveBeenCalledWith( - { activeRuntimeEnvironmentId: 'hub' }, - repo.id, - 'runtime:hub' - ) + expect(readRuntimeIssueCommand).toHaveBeenCalledWith('runtime:hub', repo.id) }) }) diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index 21df23bea0f6..4a1b13fcb6a9 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import type { HookCommandSourcePolicy, @@ -8,7 +8,7 @@ import type { SetupRunPolicy } from '../../../../shared/orca-yaml-hook-types' import type { Repo } from '../../../../shared/repo-types' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { resolveHookCommandSourcePolicy } from '../../../../shared/hook-command-source-policy' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' @@ -61,12 +61,6 @@ export function RepositoryHooksSection({ const settingsSearchQuery = useAppStore((state) => state.settingsSearchQuery) const selectedHostId = getRepoExecutionHostId(repo) const repoHostIdentity = `${selectedHostId}\0${repo.id}` - const hookRuntimeSettings = useMemo(() => { - const parsedHost = parseExecutionHostId(selectedHostId) - return { - activeRuntimeEnvironmentId: parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null - } - }, [selectedHostId]) const yamlState = yamlHooks ? 'loaded' : hasHooksFile @@ -82,7 +76,6 @@ export function RepositoryHooksSection({ updateHookSettingsPolicyDraft } = useRepositoryHookSettingsDraft({ repo, repoHostIdentity, onUpdateHookSettings }) const issueCommand = useRepositoryIssueCommand({ - hookRuntimeSettings, repoId: repo.id, repoHostIdentity, selectedHostId diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 033bf24391ed..f37929e73c09 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -8,7 +8,7 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { Button } from '../ui/button' import { Label } from '../ui/label' import { RepoIconGlyph, getRepoLucideIconOptions } from '../repo/repo-icon' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { useMountedRef } from '@/hooks/useMountedRef' import { RepositoryIconColorSection } from './RepositoryIconColorSection' @@ -33,16 +33,15 @@ export function RepositoryIconPicker({ // Why: resolve this repo's upstream/avatar on the host that owns it, not the // focused runtime. const selectedHost = parseExecutionHostId(getRepoExecutionHostId(repo)) - const activeRuntimeEnvironmentId = - selectedHost?.kind === 'runtime' ? selectedHost.environmentId : null + const ownerEnvironmentId = selectedHost?.kind === 'runtime' ? selectedHost.environmentId : null const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : null const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : '' const selectedBadgeColor = normalizeRepoBadgeColor(repo.badgeColor) ?? DEFAULT_REPO_BADGE_COLOR const initialTab = repo.repoIcon?.type === 'emoji' ? 'emoji' : repo.repoIcon?.type === 'lucide' ? 'icon' : 'avatar' const runtimeTarget = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), - [activeRuntimeEnvironmentId] + () => runtimeTargetForOwnerEnvironment(ownerEnvironmentId), + [ownerEnvironmentId] ) const currentIconLabel = useMemo(() => { diff --git a/src/renderer/src/components/settings/use-repository-issue-command.ts b/src/renderer/src/components/settings/use-repository-issue-command.ts index 8462a2b32cc7..cfa0af4618f4 100644 --- a/src/renderer/src/components/settings/use-repository-issue-command.ts +++ b/src/renderer/src/components/settings/use-repository-issue-command.ts @@ -3,15 +3,11 @@ import { toast } from 'sonner' import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' import type { ExecutionHostId } from '../../../../shared/execution-host' -type HookRuntimeSettings = { activeRuntimeEnvironmentId: string | null } - export function useRepositoryIssueCommand({ - hookRuntimeSettings, repoId, repoHostIdentity, selectedHostId }: { - hookRuntimeSettings: HookRuntimeSettings repoId: string repoHostIdentity: string selectedHostId: ExecutionHostId @@ -37,7 +33,7 @@ export function useRepositoryIssueCommand({ updateIssueCommandDraft('') setHasSharedIssueCommand(false) setIssueCommandSaveError(null) - void readRuntimeIssueCommand(hookRuntimeSettings, repoId, selectedHostId) + void readRuntimeIssueCommand(selectedHostId, repoId) .then((result) => { if (cancelled) { return @@ -60,23 +56,18 @@ export function useRepositoryIssueCommand({ cancelled = true const draft = issueCommandDraftRef.current.trim() if (draft !== lastCommittedIssueCommandRef.current) { - void writeRuntimeIssueCommand(hookRuntimeSettings, repoId, draft, selectedHostId).catch( - (error) => { - console.error( - '[RepositoryHooksSection] Failed to save issue command on unmount:', - error - ) - } - ) + void writeRuntimeIssueCommand(selectedHostId, repoId, draft).catch((error) => { + console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', error) + }) } } - }, [hookRuntimeSettings, repoHostIdentity, repoId, selectedHostId, updateIssueCommandDraft]) + }, [repoHostIdentity, repoId, selectedHostId, updateIssueCommandDraft]) const commitIssueCommand = useCallback(async (): Promise => { const trimmed = issueCommandDraft.trim() updateIssueCommandDraft(trimmed) try { - await writeRuntimeIssueCommand(hookRuntimeSettings, repoId, trimmed, selectedHostId) + await writeRuntimeIssueCommand(selectedHostId, repoId, trimmed) lastCommittedIssueCommandRef.current = trimmed setIssueCommandSaveError(null) } catch (error) { @@ -86,7 +77,7 @@ export function useRepositoryIssueCommand({ setIssueCommandSaveError(message) toast.error(message) } - }, [hookRuntimeSettings, issueCommandDraft, repoId, selectedHostId, updateIssueCommandDraft]) + }, [issueCommandDraft, repoId, selectedHostId, updateIssueCommandDraft]) return { issueCommandDraft, diff --git a/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts b/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts index 93172d0e00a0..490be7d5c865 100644 --- a/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts +++ b/src/renderer/src/components/settings/use-settings-repo-scroll-effects.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import { isFolderRepo } from '../../../../shared/repo-kind' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { getRepoHostIdentity } from '../../store/slices/repo-host-identity' import type { SettingsStoreModel } from './use-settings-store-model' @@ -80,15 +80,7 @@ export function useSettingsRepoScrollEffects( } try { const hostId = getRepoExecutionHostId(repo) - const parsedHost = parseExecutionHostId(hostId) - const result = await checkRuntimeHooks( - { - activeRuntimeEnvironmentId: - parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null - }, - repo.id, - hostId - ) + const result = await checkRuntimeHooks(hostId, repo.id) if (stale || requestSeq !== repoHooksRequestSeqRef.current) { return } diff --git a/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts b/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts index 629e9ff0a23e..37a4edfc1bdf 100644 --- a/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts +++ b/src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts @@ -1,4 +1,4 @@ -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { ComputerUsePermissionStatusResult } from '../../../../shared/computer-use-permissions-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' @@ -31,16 +31,16 @@ export const INITIAL_SETUP_SCRIPT_PROBE_STATE: SetupScriptProbeState = { export function getSetupScriptProbeSignature( settings: Pick | null | undefined, - orderedGitRepos: readonly Pick[] + orderedGitRepos: readonly Pick[] ): string | null { if (!settings) { return null } - const target = getActiveRuntimeTarget(settings) + // Why: each repo's hooks are probed on its own host, so the host is part of the signature. return JSON.stringify({ - runtime: target.kind === 'environment' ? target.environmentId : 'local', repos: orderedGitRepos.map((repo) => ({ id: repo.id, + host: getRepoExecutionHostId(repo), commandSourcePolicy: repo.hookSettings?.commandSourcePolicy ?? null, setup: repo.hookSettings?.scripts?.setup ?? null })) diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts index 7baab33fafa4..306b98a04461 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts @@ -215,18 +215,16 @@ describe('getSetupGuideProgressReady', () => { }) describe('setup script probe readiness', () => { - it('derives the probe signature from runtime and ordered git repo inputs', () => { + it('derives the probe signature from each repo host and order', () => { const localSignature = getSetupScriptProbeSignature({ activeRuntimeEnvironmentId: null }, [ { id: 'repo-a', hookSettings: undefined }, { id: 'repo-b', hookSettings: undefined } ]) - const remoteSignature = getSetupScriptProbeSignature( - { activeRuntimeEnvironmentId: 'runtime-1' }, - [ - { id: 'repo-a', hookSettings: undefined }, - { id: 'repo-b', hookSettings: undefined } - ] - ) + // Why: hooks are probed on each repo's own host, so a repo moving host changes the signature. + const remoteSignature = getSetupScriptProbeSignature({ activeRuntimeEnvironmentId: null }, [ + { id: 'repo-a', hookSettings: undefined, executionHostId: 'runtime:runtime-1' }, + { id: 'repo-b', hookSettings: undefined } + ]) const reorderedSignature = getSetupScriptProbeSignature({ activeRuntimeEnvironmentId: null }, [ { id: 'repo-b', hookSettings: undefined }, { id: 'repo-a', hookSettings: undefined } diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts index 89842c2a856a..a301dd021bb8 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useAppStore } from '@/store' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { hasFeatureInteraction } from '../../../../shared/feature-interactions' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' @@ -170,7 +171,9 @@ export function useSetupGuideProgress( async function refreshSetupScriptState(): Promise { for (const repo of orderedGitRepos) { - const hooksResult = await checkRuntimeHooks(settings, repo.id).catch(() => null) + const hooksResult = await checkRuntimeHooks(getRepoExecutionHostId(repo), repo.id).catch( + () => null + ) if (stale) { return } diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index a5ab9fa694b2..da9ad57d18e2 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -75,8 +75,8 @@ function SetupScriptPromptCard(): React.JSX.Element | null { const hostId = getRepoExecutionHostId(repo) const inspection = await inspectSetupScriptPromptState({ repo, - checkHooks: () => checkRuntimeHooks(settings, repo.id, hostId), - inspectImports: () => inspectRuntimeSetupScriptImports(settings, repo.id, hostId) + checkHooks: () => checkRuntimeHooks(hostId, repo.id), + inspectImports: () => inspectRuntimeSetupScriptImports(hostId, repo.id) }) if (!cancelled) { const nextState = { diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index 52ab98e8e907..2bb1003e7448 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -112,7 +112,7 @@ export function useComposerSubmitOrchestration( selectedRepoExecutionHostId: target.runtimeTargetSelection.selectedRepoExecutionHostId, selectedRepoIsGit: target.runtimeTargetSelection.selectedRepoIsGit, selectedRepoIsRemote: target.runtimeTargetSelection.selectedRepoIsRemote, - selectedRepoSettings: target.runtimeTargetSelection.selectedRepoSettings, + selectedRepoTarget: target.runtimeTargetSelection.selectedRepoTarget, selectedRepoStartupShell: target.runtimeTargetSelection.selectedRepoStartupShell, selectedWorkspaceTarget: target.runtimeTargetSelection.selectedWorkspaceTarget, settings: target.composerTargetStore.settings, diff --git a/src/renderer/src/hooks/composer-state/composer-target-state.ts b/src/renderer/src/hooks/composer-state/composer-target-state.ts index bbc3eb2fe474..8c8fdf3fcdf2 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-state.ts @@ -115,8 +115,7 @@ export function useComposerTargetState( selectedRepoHookContextKey: runtimeTargetSelection.selectedRepoHookContextKey, selectedRepoIsGit: runtimeTargetSelection.selectedRepoIsGit, selectedRepoPath: asyncComposerState.selectedRepoPath, - selectedRepoSettings: runtimeTargetSelection.selectedRepoSettings, - selectedRepoSettingsRef: asyncComposerState.selectedRepoSettingsRef, + selectedRepoTarget: runtimeTargetSelection.selectedRepoTarget, setCheckedHooksContextKey: asyncComposerState.setCheckedHooksContextKey, setSelectedRepoSlug: asyncComposerState.setSelectedRepoSlug, setSetupAgentStartupPolicy: asyncComposerState.setSetupAgentStartupPolicy, diff --git a/src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts b/src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts index ede6eed77960..71f1d6d4bbd5 100644 --- a/src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts +++ b/src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts @@ -50,8 +50,7 @@ describe('useComposerProviderRuntimeSync', () => { selectedRepoExecutionHostId: 'local', selectedRepoHookContextKey: 'local:repo', selectedRepoIsGit: true, - selectedRepoSettings: null, - selectedRepoSettingsRef: { current: null }, + selectedRepoTarget: { kind: 'local' }, setCheckedHooksContextKey: vi.fn(), setSelectedRepoSlug, diff --git a/src/renderer/src/hooks/composer-state/provider-runtime-sync.ts b/src/renderer/src/hooks/composer-state/provider-runtime-sync.ts index 969de7f479b2..136f2cc0b434 100644 --- a/src/renderer/src/hooks/composer-state/provider-runtime-sync.ts +++ b/src/renderer/src/hooks/composer-state/provider-runtime-sync.ts @@ -9,8 +9,7 @@ export type ComposerProviderRuntimeSyncInput = Pick< | 'selectedRepoHookContextKey' | 'selectedRepoIsGit' | 'selectedRepoPath' - | 'selectedRepoSettings' - | 'selectedRepoSettingsRef' + | 'selectedRepoTarget' | 'setCheckedHooksContextKey' | 'setSelectedRepoSlug' | 'setSetupAgentStartupPolicy' @@ -28,7 +27,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { type HookCheckResult, checkRuntimeHooks } from '@/runtime/runtime-hooks-client' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' import { getRepoSetupAgentStartupPolicy, @@ -44,8 +43,7 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn selectedRepoHookContextKey, selectedRepoIsGit, selectedRepoPath, - selectedRepoSettings, - selectedRepoSettingsRef, + selectedRepoTarget, setCheckedHooksContextKey, setSelectedRepoSlug, setSetupAgentStartupPolicy, @@ -197,10 +195,11 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn } // Why: drop the cache entry on failure so a transient IPC error doesn't pin every later // check for this repo/host to the same rejection. - const promise: Promise = checkRuntimeHooks( - selectedRepoSettingsRef.current, - targetRepoId, - selectedRepoExecutionHostId ?? undefined + // Why: the repo row names its host; an unknown one is inspected nowhere. + const promise: Promise = ( + selectedRepoExecutionHostId + ? checkRuntimeHooks(selectedRepoExecutionHostId, targetRepoId) + : Promise.reject(new Error('The project host is unknown.')) ).catch((error: unknown) => { if (hookCheckRef.current?.promise === promise) { hookCheckRef.current = null @@ -210,7 +209,7 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn hookCheckRef.current = { key, promise } return promise }, - [selectedRepoExecutionHostId, selectedRepoSettingsRef] + [selectedRepoExecutionHostId] ) const commitHookCheckIfCurrent = useCallback( @@ -231,7 +230,11 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn return } let cancelled = false - const target = getActiveRuntimeTarget(selectedRepoSettings) + const target = selectedRepoTarget + if (!target) { + setSelectedRepoSlug(null) + return + } const slugRequest = target.kind === 'environment' ? callRuntimeRpc( @@ -264,7 +267,7 @@ export function useComposerProviderRuntimeSync(input: ComposerProviderRuntimeSyn selectedRepo, selectedRepoIsGit, selectedRepoPath, - selectedRepoSettings, + selectedRepoTarget, setSelectedRepoSlug ]) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index ea6b7631a600..e6b279f98e6f 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -23,7 +23,7 @@ type QuickCreationExecutionInput = Pick< | 'selectedRepoExecutionHostId' | 'selectedRepoIsGit' | 'selectedRepoIsRemote' - | 'selectedRepoSettings' + | 'selectedRepoTarget' | 'selectedRepoStartupShell' | 'selectedWorkspaceTarget' | 'settings' @@ -37,7 +37,6 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { translate } from '@/i18n/i18n' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' @@ -69,7 +68,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, - selectedRepoSettings, + selectedRepoTarget, selectedRepoStartupShell, selectedWorkspaceTarget, settings, @@ -194,8 +193,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { repoId, ephemeralVmRecipe, indeterminateProgress: - Boolean(activeEphemeralVmRecipeId) || - getActiveRuntimeTarget(selectedRepoSettings).kind !== 'local', + Boolean(activeEphemeralVmRecipeId) || selectedRepoTarget?.kind === 'environment', taskSourceContext, linkedWorkItem: submitLinkedWorkItem, workspaceRunContext, @@ -280,7 +278,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, - selectedRepoSettings, + selectedRepoTarget, selectedRepoStartupShell, selectedWorkspaceTarget, settings, diff --git a/src/renderer/src/hooks/composer-state/runtime-target-model.ts b/src/renderer/src/hooks/composer-state/runtime-target-model.ts index b3ef985701d0..df4da929d57c 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-model.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-model.ts @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RefObject } from 'react' import type { ExecutionHostId, ParsedExecutionHost } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' @@ -45,6 +46,8 @@ export type ComposerRuntimeTargetModel = { projectHostSetupOptions: ProjectHostSetupOption[] projectOptions: NewWorkspaceProjectOption[] selectedRepoSettings: Pick | null + /** Transport to the selected repo's owner; `null` with no repo selected. */ + selectedRepoTarget: RuntimeClientTarget | null selectedRecipeRepoId: string | null selectedRecipeRepoConnectionId: string | null ephemeralVmsEnabled: boolean diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts index 242fed020e86..ba6224009e0e 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -1,3 +1,4 @@ +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' import type { ComposerRuntimeTargetSelectionInput } from './composer-target-input-contracts' import { useEffect, useMemo, useRef } from 'react' @@ -221,6 +222,11 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe ) }, [selectedRepo, settings]) + const selectedRepoTarget = useMemo( + () => (selectedRepo ? runtimeTargetForOwnerHostId(getRepoExecutionHostId(selectedRepo)) : null), + [selectedRepo] + ) + // Why: key on repo id, not the repo object — updateRepo replaces it by reference and would re-run this effect, wiping the user's chosen recipe. const selectedRecipeRepoId = selectedRepo?.id ?? null @@ -292,6 +298,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe projectHostSetupOptions, projectOptions, selectedRepoSettings, + selectedRepoTarget, selectedRecipeRepoId, selectedRecipeRepoConnectionId, ephemeralVmsEnabled, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 808378e0502a..67be19596bcf 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -225,7 +225,7 @@ describe('ensureHooksConfirmed', () => { const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive') expect(decision).toBe('run') - expect(hooksCheckMock).toHaveBeenCalledWith({ repoId: 'repo-1' }) + expect(hooksCheckMock).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'ssh:ssh-1' }) expect(pending).toHaveLength(0) }) @@ -251,6 +251,23 @@ describe('ensureHooksConfirmed', () => { expect(runtimeEnvironmentCallMock).not.toHaveBeenCalled() }) + it('refuses to pick a host when the repo id is on two hosts and none is given', async () => { + const { state, pending } = createTestState() + Object.assign(state, { + repos: [ + { id: 'repo-1', displayName: 'Runtime', executionHostId: 'runtime:env-1' }, + { id: 'repo-1', displayName: 'Local', executionHostId: 'local' } + ] + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'setup') + + expect(decision).toBe('skip') + expect(hooksCheckMock).not.toHaveBeenCalled() + expect(runtimeEnvironmentCallMock).not.toHaveBeenCalled() + expect(pending).toHaveLength(0) + }) + it('checks runtime-owned repo hooks through the repo owner runtime', async () => { const { state, pending } = createTestState({ settings: { activeRuntimeEnvironmentId: 'focused-env' }, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index cb5ce3d1c63a..2b74f73ea3b2 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -7,11 +7,10 @@ import { readRuntimeIssueCommand, type IssueCommandReadResult } from '@/runtime/runtime-hooks-client' -import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner' import { MODAL_DISMISSED_KEY } from '@/store/slices/modal-slot-dismissal' import { getRepoExecutionHostId, - parseExecutionHostId, + toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' @@ -74,25 +73,31 @@ function findHookRepo(state: AppState, repoId: string, hostId?: ExecutionHostId) : state.repos.find((repo) => repo.id === repoId) } -function settingsForHookRepoOwner( +/** + * Host that inspects the repo's hooks: a runtime owner the caller captured (a server-owned SSH + * repo runs there), else the given host, else the repo row's own. Never the focused host. + */ +function hookRepoOwnerHostId( state: AppState, repoId: string, hostId?: ExecutionHostId, runtimeOwnerEnvironmentId?: string | null -): AppState['settings'] { - const parsedHost = hostId ? parseExecutionHostId(hostId) : null - const runtimeEnvironmentId = - runtimeOwnerEnvironmentId?.trim() || - (hostId - ? parsedHost?.kind === 'runtime' - ? parsedHost.environmentId - : null - : getRuntimeEnvironmentIdForRepo(state, repoId)) - // Why: hook inspection must follow the repo owner. SSH/local repos execute - // through desktop IPC, while runtime repos may differ from the focused host. - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) +): ExecutionHostId { + const ownerEnvironmentId = runtimeOwnerEnvironmentId?.trim() + if (ownerEnvironmentId) { + return toRuntimeExecutionHostId(ownerEnvironmentId) + } + const rows = hostId ? [] : state.repos.filter((entry) => entry.id === repoId) + // Why: with the id on two hosts, trusting one row's script could run the other's unseen. + if (rows.length > 1) { + throw new Error('The project exists on more than one host.') + } + const repo = rows[0] ?? null + const ownerHostId = hostId ?? (repo ? getRepoExecutionHostId(repo) : null) + if (!ownerHostId) { + throw new Error('The project host is unknown.') + } + return ownerHostId } function canUseRepoWideTrust(state: AppState, repoId: string): boolean { @@ -215,11 +220,7 @@ export async function readAndConfirmRuntimeIssueCommand( ): Promise { let result: IssueCommandReadResult try { - result = await readRuntimeIssueCommand( - settingsForHookRepoOwner(state, repoId, hostId), - repoId, - hostId - ) + result = await readRuntimeIssueCommand(hookRepoOwnerHostId(state, repoId, hostId), repoId) } catch { result = { status: 'error', @@ -258,9 +259,8 @@ export async function ensureHooksConfirmed( // Why: hostId disambiguates duplicate repo ids on the local IPC path, // matching the checkRuntimeHooks call below. const result = await readRuntimeIssueCommand( - settingsForHookRepoOwner(state, repoId, hostId, runtimeOwnerEnvironmentId), - repoId, - hostId + hookRepoOwnerHostId(state, repoId, hostId, runtimeOwnerEnvironmentId), + repoId ) if (result.source === 'local') { return 'run' @@ -285,9 +285,8 @@ export async function ensureHooksConfirmed( return 'run' } const result = await checkRuntimeHooks( - settingsForHookRepoOwner(state, repoId, hostId, runtimeOwnerEnvironmentId), - repoId, - hostId + hookRepoOwnerHostId(state, repoId, hostId, runtimeOwnerEnvironmentId), + repoId ) if (result.status === 'error') { return 'skip' diff --git a/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts b/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts index 9310ca91768b..8df6a70ba010 100644 --- a/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => { resolvePrBase: vi.fn(), checkRuntimeHooks: vi.fn(), callRuntimeRpc: vi.fn(), + ensureHooksConfirmed: vi.fn(), store } }) @@ -14,7 +15,7 @@ const mocks = vi.hoisted(() => { vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) vi.mock('@/lib/ensure-hooks-confirmed', () => ({ - ensureHooksConfirmed: vi.fn().mockResolvedValue('run') + ensureHooksConfirmed: mocks.ensureHooksConfirmed })) vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/runtime/runtime-hooks-client', () => ({ checkRuntimeHooks: mocks.checkRuntimeHooks })) @@ -27,6 +28,7 @@ import { launchWorkItemDirect } from './launch-work-item-direct' describe('launchWorkItemDirect owner row', () => { beforeEach(() => { vi.clearAllMocks() + mocks.ensureHooksConfirmed.mockResolvedValue('run') vi.stubGlobal('window', { api: { worktrees: { resolvePrBase: mocks.resolvePrBase } } }) mocks.checkRuntimeHooks.mockResolvedValue({ hasHooks: false, @@ -67,7 +69,13 @@ describe('launchWorkItemDirect owner row', () => { item: { type: 'pr', number: 7, title: 'Fix', url: 'https://github.com/o/r/pull/7' } }) - expect(mocks.checkRuntimeHooks).toHaveBeenCalledWith(null, 'repo-1', 'runtime:env-b') + expect(mocks.checkRuntimeHooks).toHaveBeenCalledWith('runtime:env-b', 'repo-1') + expect(mocks.ensureHooksConfirmed).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + 'setup', + 'runtime:env-b' + ) expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'env-b' }, 'worktree.resolvePrBase', diff --git a/src/renderer/src/lib/launch-work-item-direct-preflight.ts b/src/renderer/src/lib/launch-work-item-direct-preflight.ts index 2c7478d6d2fa..7bc3fde1176b 100644 --- a/src/renderer/src/lib/launch-work-item-direct-preflight.ts +++ b/src/renderer/src/lib/launch-work-item-direct-preflight.ts @@ -38,7 +38,7 @@ export async function resolveDirectSetupDecision( let yamlHooks: OrcaHooks | null = null try { // Why: the same owner host as the PR start point and createWorktree, never focus. - const result = await checkRuntimeHooks(null, repoId, ownerHostId) + const result = await checkRuntimeHooks(ownerHostId, repoId) yamlHooks = (result.hooks as OrcaHooks | null) ?? null } catch { yamlHooks = null diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 7c7197f95e74..cfefddfe2ea5 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -118,7 +118,12 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return false } - const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup') + const trustDecision = await ensureHooksConfirmed( + useAppStore.getState(), + repoId, + 'setup', + repoOwnerHostId + ) const finalSetupDecision: SetupDecision = trustDecision === 'skip' ? 'skip' : setupResolution.decision diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index 539c566a800e..c44f513a331c 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerHostId } from '@/runtime/runtime-client-target' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import type { WorktreeCreationPhase, @@ -39,7 +40,11 @@ export function getWorktreeCreationIndeterminate(request: WorktreeCreationReques if (request.worktreeCreateProgressMode) { return request.worktreeCreateProgressMode === 'indeterminate' } - return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local' + // Why: progress follows the host the workspace is created on, not the focused one. + const target = request.executionHostId + ? runtimeTargetForOwnerHostId(request.executionHostId) + : runtimeTargetForRepoOwner(useAppStore.getState(), request.repoId) + return target?.kind !== 'local' } export function getInitialWorktreeCreationPhase( diff --git a/src/renderer/src/runtime/runtime-hooks-client.test.ts b/src/renderer/src/runtime/runtime-hooks-client.test.ts index 61a94647ce46..31e3c1c6bd6b 100644 --- a/src/renderer/src/runtime/runtime-hooks-client.test.ts +++ b/src/renderer/src/runtime/runtime-hooks-client.test.ts @@ -54,19 +54,26 @@ describe('runtime hooks client', () => { source: 'none' }) - await checkRuntimeHooks({ activeRuntimeEnvironmentId: null }, 'repo-1') - await inspectRuntimeSetupScriptImports({ activeRuntimeEnvironmentId: null }, 'repo-1') - await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'repo-1') - await writeRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'repo-1', 'Fix it') + await checkRuntimeHooks('local', 'repo-1') + await inspectRuntimeSetupScriptImports('local', 'repo-1') + await readRuntimeIssueCommand('local', 'repo-1') + await writeRuntimeIssueCommand('local', 'repo-1', 'Fix it') - expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'repo-1' }) - expect(hooksInspectSetupScriptImports).toHaveBeenCalledWith({ repoId: 'repo-1' }) - expect(hooksReadIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1' }) - expect(hooksWriteIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1', content: 'Fix it' }) + expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'local' }) + expect(hooksInspectSetupScriptImports).toHaveBeenCalledWith({ + repoId: 'repo-1', + hostId: 'local' + }) + expect(hooksReadIssueCommand).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'local' }) + expect(hooksWriteIssueCommand).toHaveBeenCalledWith({ + repoId: 'repo-1', + content: 'Fix it', + hostId: 'local' + }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - it('routes hook operations through the active runtime environment', async () => { + it('routes hook operations through the repo owner runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', ok: true, @@ -74,10 +81,10 @@ describe('runtime hooks client', () => { _meta: { runtimeId: 'runtime-1' } }) - await checkRuntimeHooks({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') - await inspectRuntimeSetupScriptImports({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') - await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1') - await writeRuntimeIssueCommand({ activeRuntimeEnvironmentId: 'env-1' }, 'repo-1', 'Fix it') + await checkRuntimeHooks('runtime:env-1', 'repo-1') + await inspectRuntimeSetupScriptImports('runtime:env-1', 'repo-1') + await readRuntimeIssueCommand('runtime:env-1', 'repo-1') + await writeRuntimeIssueCommand('runtime:env-1', 'repo-1', 'Fix it') expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { selector: 'env-1', @@ -120,23 +127,10 @@ describe('runtime hooks client', () => { source: 'none' }) - await checkRuntimeHooks( - { activeRuntimeEnvironmentId: 'focused-elsewhere' }, - 'same-repo', - 'ssh:server' - ) - await inspectRuntimeSetupScriptImports( - { activeRuntimeEnvironmentId: 'focused-elsewhere' }, - 'same-repo', - 'ssh:server' - ) - await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'same-repo', 'ssh:server') - await writeRuntimeIssueCommand( - { activeRuntimeEnvironmentId: null }, - 'same-repo', - 'Fix it', - 'ssh:server' - ) + await checkRuntimeHooks('ssh:server', 'same-repo') + await inspectRuntimeSetupScriptImports('ssh:server', 'same-repo') + await readRuntimeIssueCommand('ssh:server', 'same-repo') + await writeRuntimeIssueCommand('ssh:server', 'same-repo', 'Fix it') expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'ssh:server' }) expect(hooksInspectSetupScriptImports).toHaveBeenCalledWith({ diff --git a/src/renderer/src/runtime/runtime-hooks-client.ts b/src/renderer/src/runtime/runtime-hooks-client.ts index ee70da86b3ca..31d235c7f161 100644 --- a/src/renderer/src/runtime/runtime-hooks-client.ts +++ b/src/renderer/src/runtime/runtime-hooks-client.ts @@ -1,18 +1,16 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { OrcaHooks } from '../../../shared/orca-yaml-hook-types' -import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' import type { SetupScriptImportCandidate } from '../../../shared/setup-script-imports' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' +import { runtimeTargetForOwnerHostId, type RuntimeClientTarget } from './runtime-client-target' -function getHookInspectionTarget( - settings: Pick | null | undefined, - hostId?: ExecutionHostId -): ReturnType { - const parsedHost = parseExecutionHostId(hostId) - if (parsedHost?.kind === 'runtime') { - return { kind: 'environment', environmentId: parsedHost.environmentId } +/** The repo's own host: a server's runtime, or this app (with `hostId`) for local and SSH repos. */ +function getHookInspectionTarget(hostId: ExecutionHostId): RuntimeClientTarget { + const target = runtimeTargetForOwnerHostId(hostId) + if (!target) { + throw new Error('The project host is unresolved.') } - return parsedHost ? { kind: 'local' } : getActiveRuntimeTarget(settings) + return target } export type HookCheckResult = { @@ -32,13 +30,12 @@ export type IssueCommandReadResult = { } export async function checkRuntimeHooks( - settings: Pick | null | undefined, - repoId: string, - hostId?: ExecutionHostId + hostId: ExecutionHostId, + repoId: string ): Promise { - const target = getHookInspectionTarget(settings, hostId) + const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - return window.api.hooks.check({ repoId, ...(hostId ? { hostId } : {}) }) + return window.api.hooks.check({ repoId, hostId }) } return callRuntimeRpc( target, @@ -49,13 +46,12 @@ export async function checkRuntimeHooks( } export async function inspectRuntimeSetupScriptImports( - settings: Pick | null | undefined, - repoId: string, - hostId?: ExecutionHostId + hostId: ExecutionHostId, + repoId: string ): Promise { - const target = getHookInspectionTarget(settings, hostId) + const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - return window.api.hooks.inspectSetupScriptImports({ repoId, ...(hostId ? { hostId } : {}) }) + return window.api.hooks.inspectSetupScriptImports({ repoId, hostId }) } return callRuntimeRpc( target, @@ -66,13 +62,12 @@ export async function inspectRuntimeSetupScriptImports( } export async function readRuntimeIssueCommand( - settings: Pick | null | undefined, - repoId: string, - hostId?: ExecutionHostId + hostId: ExecutionHostId, + repoId: string ): Promise { - const target = getActiveRuntimeTarget(settings) + const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - return window.api.hooks.readIssueCommand({ repoId, ...(hostId ? { hostId } : {}) }) + return window.api.hooks.readIssueCommand({ repoId, hostId }) } return callRuntimeRpc( target, @@ -83,14 +78,13 @@ export async function readRuntimeIssueCommand( } export async function writeRuntimeIssueCommand( - settings: Pick | null | undefined, + hostId: ExecutionHostId, repoId: string, - content: string, - hostId?: ExecutionHostId + content: string ): Promise { - const target = getActiveRuntimeTarget(settings) + const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - await window.api.hooks.writeIssueCommand({ repoId, content, ...(hostId ? { hostId } : {}) }) + await window.api.hooks.writeIssueCommand({ repoId, content, hostId }) return } await callRuntimeRpc( diff --git a/src/renderer/src/runtime/runtime-repo-client.test.ts b/src/renderer/src/runtime/runtime-repo-client.test.ts index cbd233f0e95b..73247437ff35 100644 --- a/src/renderer/src/runtime/runtime-repo-client.test.ts +++ b/src/renderer/src/runtime/runtime-repo-client.test.ts @@ -38,7 +38,7 @@ afterEach(() => { describe('runtime repo client search bounds', () => { it('rejects oversized local base-ref searches before IPC', async () => { await expect( - searchRuntimeRepoBaseRefs(null, 'repo-1', 'x'.repeat(3 * 1024), 20) + searchRuntimeRepoBaseRefs('local', 'repo-1', 'x'.repeat(3 * 1024), 20) ).resolves.toEqual([]) expect(searchBaseRefs).not.toHaveBeenCalled() @@ -48,7 +48,7 @@ describe('runtime repo client search bounds', () => { it('rejects oversized runtime base-ref detail searches before RPC', async () => { await expect( searchRuntimeRepoBaseRefDetails( - { activeRuntimeEnvironmentId: 'env-1' }, + 'runtime:env-1', 'repo-1', 'secret-token-value'.repeat(256), 20 @@ -63,8 +63,8 @@ describe('runtime repo client search bounds', () => { getBaseRefDefault.mockResolvedValue({ defaultBaseRef: 'origin/main', remoteCount: 1 }) searchBaseRefs.mockResolvedValue(['origin/main']) - await getRuntimeRepoBaseRefDefault(null, 'same-repo', 'ssh:server') - await searchRuntimeRepoBaseRefs(null, 'same-repo', 'main', 20, 'ssh:server') + await getRuntimeRepoBaseRefDefault('ssh:server', 'same-repo') + await searchRuntimeRepoBaseRefs('ssh:server', 'same-repo', 'main', 20) expect(getBaseRefDefault).toHaveBeenCalledWith({ repoId: 'same-repo', diff --git a/src/renderer/src/runtime/runtime-repo-client.ts b/src/renderer/src/runtime/runtime-repo-client.ts index 67f9a24d0cc7..56214db1c92c 100644 --- a/src/renderer/src/runtime/runtime-repo-client.ts +++ b/src/renderer/src/runtime/runtime-repo-client.ts @@ -1,23 +1,31 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { BaseRefSearchResult } from '../../../shared/repo-types' import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' +import { runtimeTargetForOwnerHostId, type RuntimeClientTarget } from './runtime-client-target' import { isRuntimeRepoRefSearchQueryWithinLimit } from './runtime-repo-search-bounds' import type { ExecutionHostId } from '../../../shared/execution-host' +/** The repo's own host: a server's runtime, or this app (with `hostId`) for local and SSH repos. */ +function repoHostTarget(hostId: ExecutionHostId): RuntimeClientTarget { + const target = runtimeTargetForOwnerHostId(hostId) + if (!target) { + throw new Error('The project host is unresolved.') + } + return target +} + export type RuntimeRepoBaseRefDefault = { defaultBaseRef: string | null remoteCount: number } export async function getRuntimeRepoBaseRefDefault( - settings: Pick | null | undefined, - repoId: string, - hostId?: ExecutionHostId + hostId: ExecutionHostId, + repoId: string ): Promise { - const target = getActiveRuntimeTarget(settings) + const target = repoHostTarget(hostId) if (target.kind !== 'environment') { - return window.api.repos.getBaseRefDefault({ repoId, ...(hostId ? { hostId } : {}) }) + return window.api.repos.getBaseRefDefault({ repoId, hostId }) } return callRuntimeRpc( target, @@ -28,18 +36,17 @@ export async function getRuntimeRepoBaseRefDefault( } export async function searchRuntimeRepoBaseRefs( - settings: Pick | null | undefined, + hostId: ExecutionHostId, repoId: string, query: string, - limit: number, - hostId?: ExecutionHostId + limit: number ): Promise { if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) { return [] } - const target = getActiveRuntimeTarget(settings) + const target = repoHostTarget(hostId) if (target.kind !== 'environment') { - return window.api.repos.searchBaseRefs({ repoId, query, limit, ...(hostId ? { hostId } : {}) }) + return window.api.repos.searchBaseRefs({ repoId, query, limit, hostId }) } const result = await callRuntimeRpc<{ refs: string[]; truncated: boolean }>( target, @@ -51,22 +58,21 @@ export async function searchRuntimeRepoBaseRefs( } export async function searchRuntimeRepoBaseRefDetails( - settings: Pick | null | undefined, + hostId: ExecutionHostId, repoId: string, query: string, - limit: number, - hostId?: ExecutionHostId + limit: number ): Promise { if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) { return [] } - const target = getActiveRuntimeTarget(settings) + const target = repoHostTarget(hostId) if (target.kind !== 'environment') { return window.api.repos.searchBaseRefDetails({ repoId, query, limit, - ...(hostId ? { hostId } : {}) + hostId }) } const result = await callRuntimeRpc<{ diff --git a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts index fcee4b6a3c20..59a8bbc05e9a 100644 --- a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts +++ b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts @@ -1,10 +1,11 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import type { LocalBaseRefRefreshResult } from '../../../../shared/worktree/base-ref-drift-types' import { toast } from 'sonner' import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' -import { makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures' +import { TEST_REPO, makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, mockApi, @@ -39,7 +40,7 @@ describe('createWorktree base status merge', () => { }) it('prefetches create base through desktop IPC on the local runtime target', async () => { - const store = createTestStore() + const store = createRepoTestStore() await store.getState().prefetchWorktreeCreateBase('repo1', 'origin/main') @@ -51,10 +52,11 @@ describe('createWorktree base status merge', () => { }) it('prefetches create base through runtime RPC for remote runtime targets', async () => { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'remote-runtime' } } as Partial) + seedRepoHost(store, 'runtime:remote-runtime') runtimeEnvironmentCall.mockResolvedValue({ id: 'req', ok: true, result: null }) await store.getState().prefetchWorktreeCreateBase('repo1') @@ -71,7 +73,7 @@ describe('createWorktree base status merge', () => { it('marks the create payload as a generated name only when the caller says so', async () => { // Why: the host retires generated names permanently, and the creature pool contains ordinary // words ("orca", "runner", "molly"). A name the user typed must stay reusable. - const store = createTestStore() + const store = createRepoTestStore() mockApi.worktrees.create.mockResolvedValue({ worktree: makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) }) @@ -114,7 +116,7 @@ describe('createWorktree base status merge', () => { }) it('passes linked work item and creation agent metadata through the create IPC payload', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -174,7 +176,7 @@ describe('createWorktree base status merge', () => { }) it('adopts an explicit provisioned root without calling ordinary worktree create', async () => { - const store = createTestStore() + const store = createRepoTestStore() const adopted = makeWorktree({ id: 'repo1::/workspace/repo', repoId: 'repo1', @@ -237,7 +239,7 @@ describe('createWorktree base status merge', () => { }) it('stamps the owning runtime host onto worktrees created on a remote runtime', async () => { - const store = createTestStore() + const store = createRepoTestStore() const created = makeWorktree({ id: 'repo-remote::/remote/feature', repoId: 'repo-remote', @@ -277,7 +279,7 @@ describe('createWorktree base status merge', () => { }) it('passes the active folder workspace as parent for in-app worktree creates', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -310,7 +312,7 @@ describe('createWorktree base status merge', () => { }) it('merges create result metadata into a worktree inserted by the watcher race', async () => { - const store = createTestStore() + const store = createRepoTestStore() const watcherWorktree = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -335,7 +337,7 @@ describe('createWorktree base status merge', () => { }) it('requests a warning dialog when creation falls back to a local base', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -369,7 +371,7 @@ describe('createWorktree base status merge', () => { status: LocalBaseRefRefreshResult['status'] expectedReason: string }[])('warns when local base ref refresh returns $status', async ({ status, expectedReason }) => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -410,7 +412,7 @@ describe('createWorktree base status merge', () => { }) it('falls back to the generic dirty detail when ownerWorktreePath is missing', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -435,7 +437,7 @@ describe('createWorktree base status merge', () => { }) it('names the toast from branch when displayName is blank', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -464,7 +466,8 @@ describe('createWorktree base status merge', () => { // Creates that joined one refresh report the same fact, so they share one toast per repo+branch. it('reuses one toast id for every create of the same repo and base branch', async () => { - const store = createTestStore() + const store = createRepoTestStore() + store.setState({ repos: [TEST_REPO, { ...TEST_REPO, id: 'repo2' }] }) const refresh = { status: 'skipped_error', baseRef: 'origin/main', @@ -490,7 +493,7 @@ describe('createWorktree base status merge', () => { }) it('does not warn when the local base ref refresh succeeds', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -511,7 +514,7 @@ describe('createWorktree base status merge', () => { }) it('does not warn when local base ref refresh is omitted from the create result', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -525,7 +528,7 @@ describe('createWorktree base status merge', () => { }) it('suggests turning on local main freshness when the create result reports a stale local base', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', @@ -554,7 +557,7 @@ describe('createWorktree base status merge', () => { }) it('persists the dismissal flag when the suggestion toast is closed or swiped', async () => { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ settings: { refreshLocalBaseRefOnWorktreeCreate: false } as AppState['settings'], updateSettings: vi.fn().mockResolvedValue(undefined) @@ -580,7 +583,7 @@ describe('createWorktree base status merge', () => { }) it('does not record a dismissal on close when the feature is already enabled', async () => { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ settings: { refreshLocalBaseRefOnWorktreeCreate: true } as AppState['settings'], updateSettings: vi.fn().mockResolvedValue(undefined) @@ -606,7 +609,7 @@ describe('createWorktree base status merge', () => { }) it('stamps manualOrder on create while Manual sort is active', async () => { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ sortBy: 'manual' } as Partial) const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(123_456) const wt = makeWorktree({ @@ -634,7 +637,7 @@ describe('createWorktree base status merge', () => { }) it('passes branchNameOverride through the local create IPC payload', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/feature-something', repoId: 'repo1', @@ -672,7 +675,7 @@ describe('createWorktree base status merge', () => { }) it('retries a suffixed branchNameOverride when local IPC reports a branch conflict', async () => { - const store = createTestStore() + const store = createRepoTestStore() const error = new Error( 'Branch "feature/something" already exists. Pick a different worktree name.' ) @@ -721,7 +724,7 @@ describe('createWorktree base status merge', () => { }) it('does not overwrite a newer reconcile status with the initial checking status', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) mockApi.worktrees.create.mockImplementation(async () => { store.getState().updateWorktreeBaseStatus({ @@ -753,3 +756,14 @@ describe('createWorktree base status merge', () => { }) }) }) + +// Creation routes by the project row's own host, so every test seeds that row. +function seedRepoHost(store: ReturnType, hostId: ExecutionHostId): void { + store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] }) +} + +function createRepoTestStore(): ReturnType { + const store = createTestStore() + seedRepoHost(store, 'local') + return store +} diff --git a/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts b/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts index 0e16557a1cfc..913bda4e46a7 100644 --- a/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts +++ b/src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts @@ -1,10 +1,11 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import type { Worktree } from '../../../../shared/worktree/types' import { toast } from 'sonner' import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { worktreeWorkspaceKey, folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { makeLineage, makeWorktree } from './worktrees-slice-test-fixtures' +import { TEST_REPO, makeLineage, makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, mockApi, @@ -36,7 +37,7 @@ describe('createWorktree composer parent pick', () => { }) function createParentPickStore(parent?: Worktree) { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ worktreesByRepo: { repo1: parent ? [parent] : [] } } as Partial) return store } @@ -211,7 +212,7 @@ describe('createWorktree parent pick on a remote runtime', () => { }) function createRemoteStore() { - const store = createTestStore() + const store = createRepoTestStore() store.setState({ repos: [ { @@ -386,3 +387,14 @@ describe('createWorktree parent pick on a remote runtime', () => { expect(toast.warning).not.toHaveBeenCalled() }) }) + +// Creation routes by the project row's own host, so every test seeds that row. +function seedRepoHost(store: ReturnType, hostId: ExecutionHostId): void { + store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] }) +} + +function createRepoTestStore(): ReturnType { + const store = createTestStore() + seedRepoHost(store, 'local') + return store +} diff --git a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts index b92879e32bcc..e403414e7728 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts @@ -633,6 +633,10 @@ describe('fetchWorktrees', () => { { id: 'repo2', path: '/r2', displayName: 'R2', badgeColor: '#000', addedAt: 0 } ] } as Partial) + // Ingest stamps server rows; the listing follows the row's host. + store.setState({ + repos: store.getState().repos.map((repo) => ({ ...repo, executionHostId: 'runtime:env-1' })) + }) // Why: a mobile-scope device token is denied non-allowlisted runtime RPCs. runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts index 09b3374cf1e0..3ac39370dd67 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts @@ -1,10 +1,11 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { createCompatibleRuntimeStatusResponse, type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' -import { makeWorktree } from './worktrees-slice-test-fixtures' +import { TEST_REPO, makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, mockApi, @@ -39,7 +40,7 @@ describe('worktree remote runtime mutations', () => { }) it('creates worktrees through the active remote runtime environment', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/feature', repoId: 'repo1', @@ -55,6 +56,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const result = await store .getState() @@ -100,7 +102,7 @@ describe('worktree remote runtime mutations', () => { }) it('forwards generated-name provenance through paired-runtime create', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/nautilus', repoId: 'repo1' }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-create', @@ -112,6 +114,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const createWorktree = store.getState().createWorktree const args: Parameters = ['repo1', 'nautilus'] args[25] = { nameWasGenerated: true } @@ -127,7 +130,7 @@ describe('worktree remote runtime mutations', () => { }) it('persists Jira item and source context through paired-runtime create', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/jira-link', repoId: 'repo1', @@ -143,6 +146,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const linkedWorkItem = { provider: 'jira' as const, type: 'issue' as const, @@ -187,11 +191,12 @@ describe('worktree remote runtime mutations', () => { runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => args.method === 'status.get' ? oldRuntimeStatus : runtimeEnvironmentCall(args) ) - const store = createTestStore() + const store = createRepoTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const createWorktree = store.getState().createWorktree const args: Parameters = ['repo1', 'jira-link'] args[25] = { @@ -210,7 +215,7 @@ describe('worktree remote runtime mutations', () => { }) it('passes startup commands through remote runtime worktree creation', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/agent-startup', repoId: 'repo1', @@ -226,6 +231,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') await store .getState() @@ -281,7 +287,7 @@ describe('worktree remote runtime mutations', () => { }) it('passes task startup drafts only to the owning remote runtime', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/task-draft', repoId: 'repo1', @@ -297,6 +303,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') const createWorktree = store.getState().createWorktree const args: Parameters = ['repo1', 'task-draft', undefined, 'inherit'] args[10] = 'codex' @@ -317,7 +324,7 @@ describe('worktree remote runtime mutations', () => { }) it('passes startup commands through local worktree creation IPC', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/local-agent-startup', repoId: 'repo1', @@ -384,7 +391,7 @@ describe('worktree remote runtime mutations', () => { }) it('retries a suffixed branchNameOverride when runtime create reports a branch conflict', async () => { - const store = createTestStore() + const store = createRepoTestStore() const wt = makeWorktree({ id: 'repo1::/path/feature-something-2', repoId: 'repo1', @@ -402,6 +409,7 @@ describe('worktree remote runtime mutations', () => { settings: { activeRuntimeEnvironmentId: 'env-1' } as never, worktreesByRepo: { repo1: [] } } as Partial) + seedRepoHost(store, 'runtime:env-1') await store .getState() @@ -442,3 +450,14 @@ describe('worktree remote runtime mutations', () => { ) }) }) + +// Creation routes by the project row's own host, so every test seeds that row. +function seedRepoHost(store: ReturnType, hostId: ExecutionHostId): void { + store.setState({ repos: [{ ...TEST_REPO, executionHostId: hostId }] }) +} + +function createRepoTestStore(): ReturnType { + const store = createTestStore() + seedRepoHost(store, 'local') + return store +} diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts index 0bbe857ef6a6..e55168f82bc0 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts @@ -188,6 +188,13 @@ describe('worktree remote runtime mutations', () => { runtimeOwnerEnvironmentId: 'owner-hub' }) runtimeEnvironmentCall + // Why: the archive-hook check runs on the HUB too; this app cannot reach its SSH target. + .mockResolvedValueOnce({ + id: 'rpc-hooks-check', + ok: true, + result: { hasHooks: false, hooks: null, mayNeedUpdate: false }, + _meta: { runtimeId: 'runtime-owner-hub' } + }) .mockResolvedValueOnce({ id: 'rpc-rm-nested', ok: true, @@ -219,6 +226,12 @@ describe('worktree remote runtime mutations', () => { } }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'owner-hub', + method: 'repo.hooksCheck', + params: { repo: 'repo-ssh' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { selector: 'owner-hub', method: 'worktree.rm', params: { @@ -239,7 +252,7 @@ describe('worktree remote runtime mutations', () => { .forceDeletePreservedBranch(wt.id, 'feature/nested', 'saved-head') expect(forceResult).toEqual({ ok: true, deleted: true }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { selector: 'owner-hub', method: 'worktree.forceDeleteBranch', params: { diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index 465c81a6d18d..653f5ea99679 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -14,13 +14,13 @@ import { import { assertRuntimeEnvironmentCapability, callRuntimeRpc, - getActiveRuntimeTarget + type RuntimeClientTarget } from '../../../../runtime/runtime-rpc-client' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast' -import { settingsForRepoOwner } from '../listing/worktree-owner-settings' import { applyCreatedWorktree } from './created-worktree-state-merge' import { isRuntimeLineageParentMissingError } from '../listing/runtime-worktree-rpc-errors' import { @@ -35,7 +35,7 @@ import { type WorktreeCreateParentPick } from './worktree-create-parent-pick' -type RuntimeTarget = ReturnType +type RuntimeTarget = RuntimeClientTarget type CreateAttemptOutcome = { result: CreateWorktreeResult @@ -180,7 +180,10 @@ export function createCreateWorktree( // Direct SSH still uses desktop IPC; paired runtimes use their captured environment. const target = options?.executionHostId ? (runtimeTargetForExecutionHostId(options.executionHostId) ?? { kind: 'local' as const }) - : getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + : runtimeTargetForRepoOwner(get(), repoId) + if (!target) { + throw new Error('The project host is unresolved. Choose a host and retry.') + } const creationHostId = adoptFromEndpoint(target, { kind: 'createdWorktree', requestedHostId: options?.executionHostId, diff --git a/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts b/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts index 721a7e6d6beb..f3e05377bce9 100644 --- a/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts +++ b/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts @@ -1,7 +1,7 @@ import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { settingsForRepoOwner } from '../listing/worktree-owner-settings' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { runtimeTargetForRepoOwner } from '@/lib/repo-runtime-owner' export function createPrefetchWorktreeCreateBase( _set: WorktreeSliceSet, @@ -9,7 +9,10 @@ export function createPrefetchWorktreeCreateBase( ): WorktreeSlice['prefetchWorktreeCreateBase'] { return async (repoId, baseBranch) => { try { - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + const target = runtimeTargetForRepoOwner(get(), repoId) + if (!target) { + return + } if (target.kind === 'local') { await window.api.worktrees.prefetchCreateBase({ repoId, diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts index 4f13b4a6105c..b8a18357029b 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts @@ -1,8 +1,14 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerHostId, + type RuntimeClientTarget +} from '../../../../runtime/runtime-client-target' import { getEnvironmentSshStateGeneration } from '../../runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '../../runtime-status' -import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' +import { + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../../../shared/execution-host' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' import type { RuntimeWorktreeListResult } from '../../../../../../shared/runtime-types' import type { @@ -18,15 +24,20 @@ import { isRuntimeMethodNotFoundError } from './runtime-worktree-rpc-errors' import { toLegacyDetectedWorktreeResult } from './worktree-host-ownership' import { isWorktreeScanFailureKind } from '../../../../../../shared/worktree-scan-failure' +/** The listing host is the one the caller asked about, never the focused one. */ +export function detectedWorktreeTarget(executionHostId: ExecutionHostId): RuntimeClientTarget { + const target = runtimeTargetForOwnerHostId(executionHostId) + if (!target) { + throw new Error('The workspace host is unresolved.') + } + return target +} + export async function listDetectedWorktreesForRepo( - settings: AppState['settings'], + target: Extract, repoId: string, options: BackgroundRuntimeRefreshOptions = {} ): Promise { - const target = getActiveRuntimeTarget(settings) - if (target.kind === 'local') { - throw new Error('Local detected-worktree reads require a provider lease') - } try { return await callRuntimeRpc( target, @@ -55,11 +66,10 @@ export async function listDetectedWorktreesForRepo( } export function detectedWorktreeRefreshKey( - settings: AppState['settings'], repoId: string, options: DetectedWorktreeRefreshOptions ): string { - const target = getActiveRuntimeTarget(settings) + const target = detectedWorktreeTarget(options.executionHostId) const targetKey = target.kind === 'local' ? 'local' : `runtime:${target.environmentId}` const parts = [ repoId, diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts index 7024140e7269..471a0370fa09 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts @@ -1,4 +1,3 @@ -import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' import { getEnvironmentSshStateGeneration } from '../../runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '../../runtime-status' import { @@ -10,7 +9,6 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' import type { HostQualifiedDetectedWorktreeResult, @@ -25,6 +23,7 @@ import { teardownMissingWorktreeTerminalsBestEffort } from '../teardown/missing- import { directSshAuthorityIsComplete } from './direct-ssh-authority' import { detectedWorktreeRefreshKey, + detectedWorktreeTarget, isDetectedWorktreeListResult, listDetectedWorktreesForRepo, startDetectedWorktreeProviderRequest @@ -54,7 +53,6 @@ export const detectedWorktreeRefreshLeaseRegistry = createDetectedWorktreeRefres }) export function acquireDetectedWorktreeRefreshLeaseForRepo( - settings: AppState['settings'], repoId: string, options: DetectedWorktreeRefreshOptions ): DetectedWorktreeRefreshLease { @@ -62,7 +60,7 @@ export function acquireDetectedWorktreeRefreshLeaseForRepo( if (!parsedHost || parsedHost.kind === 'runtime') { throw new Error('Provider leases require a local or direct SSH execution host') } - const publicKey = detectedWorktreeRefreshKey(settings, repoId, options) + const publicKey = detectedWorktreeRefreshKey(repoId, options) if (parsedHost.kind === 'local') { return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { repoId, @@ -143,19 +141,18 @@ export function normalizeNotAdmittedProviderResult( } async function listDetectedWorktreesForRuntimeRepoOnce( - settings: AppState['settings'], repoId: string, options: DetectedWorktreeRefreshOptions, environmentId: string ): Promise { // Why recomputed per attempt: the key embeds both generations, so a retry after a // reconnect must not join the superseded connection's in-flight scan. - const key = detectedWorktreeRefreshKey(settings, repoId, options) + const key = detectedWorktreeRefreshKey(repoId, options) const connectionGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) let refresh = runtimeDetectedWorktreeRefreshesInFlight.get(key) if (!refresh) { - refresh = listDetectedWorktreesForRepo(settings, repoId, { + refresh = listDetectedWorktreesForRepo({ kind: 'environment', environmentId }, repoId, { reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure }) runtimeDetectedWorktreeRefreshesInFlight.set(key, refresh) @@ -175,7 +172,7 @@ async function listDetectedWorktreesForRuntimeRepoOnce( // not stop terminals either. if (!options.isStaleCatalogPublication?.(result)) { await teardownMissingWorktreeTerminalsBestEffort( - settings, + { kind: 'environment', environmentId }, repoId, options.connectionId, options.knownWorktreeIds, @@ -200,20 +197,14 @@ async function listDetectedWorktreesForRuntimeRepoOnce( } export async function listDetectedWorktreesForRepoCoalesced( - settings: AppState['settings'], repoId: string, options: DetectedWorktreeRefreshOptions ): Promise { - const target = getActiveRuntimeTarget(settings) + const target = detectedWorktreeTarget(options.executionHostId) if (target.kind === 'environment') { for (let attempt = 0; ; attempt += 1) { try { - return await listDetectedWorktreesForRuntimeRepoOnce( - settings, - repoId, - options, - target.environmentId - ) + return await listDetectedWorktreesForRuntimeRepoOnce(repoId, options, target.environmentId) } catch (err) { // Why re-read instead of surfacing: the fence proves this answer predates the // current connection, not that the repo has no worktrees. Callers drop the repo @@ -226,7 +217,7 @@ export async function listDetectedWorktreesForRepoCoalesced( } } - const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, repoId, options) + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(repoId, options) let providerResult: HostQualifiedDetectedWorktreeResult try { providerResult = await lease.result @@ -258,7 +249,7 @@ export async function listDetectedWorktreesForRepoCoalesced( } if (!options.isStaleCatalogPublication?.(providerResult.result)) { await teardownMissingWorktreeTerminalsBestEffort( - settings, + target, repoId, options.connectionId, options.knownWorktreeIds, diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts index 92aa9b42e3a6..021d0642db70 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts @@ -19,7 +19,6 @@ import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' import { mapReposForWorktreeRefresh } from './worktree-refresh-pool' -import { settingsForKnownRepoOwner } from './worktree-owner-settings' export function createFetchAllWorktrees( set: WorktreeSliceSet, @@ -44,7 +43,6 @@ export function createFetchAllWorktrees( const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] const hostId = getRepoExecutionHostId(r) const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) - const settings = settingsForKnownRepoOwner(requestStartedState.settings, r) const parsedHost = parseExecutionHostId(hostId) const directSshAuthority = parsedHost?.kind === 'ssh' @@ -54,7 +52,7 @@ export function createFetchAllWorktrees( await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return } - const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { + const refresh = await listDetectedWorktreesForRepoCoalesced(r.id, { executionHostId: hostId, reuseRecentCompatibilityFailure: true, directSshAuthority, @@ -113,19 +111,15 @@ export function createFetchAllWorktrees( await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return { repoId: r.id, ok: false as const } } - const refresh = await listDetectedWorktreesForRepoCoalesced( - settingsForKnownRepoOwner(requestStartedState.settings, r), - r.id, - { - executionHostId: hostId, - reuseRecentCompatibilityFailure: true, - directSshAuthority, - connectionId: r.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId), - isStaleCatalogPublication: (result) => - isStaleWorktreeCatalogPublication(get(), r.id, hostId, result.catalogVersion) - } - ) + const refresh = await listDetectedWorktreesForRepoCoalesced(r.id, { + executionHostId: hostId, + reuseRecentCompatibilityFailure: true, + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId), + isStaleCatalogPublication: (result) => + isStaleWorktreeCatalogPublication(get(), r.id, hostId, result.catalogVersion) + }) if (refresh.status !== 'admitted') { return { repoId: r.id, ok: false as const } } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts index 3091c69faa81..9d574868807c 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts @@ -19,7 +19,6 @@ import { } from './worktree-host-ownership' import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' -import { settingsForRepoOwner } from './worktree-owner-settings' export function createFetchDetectedWorktrees( set: WorktreeSliceSet, @@ -46,18 +45,14 @@ export function createFetchDetectedWorktrees( await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) return null } - const refresh = await listDetectedWorktreesForRepoCoalesced( - settingsForRepoOwner(ownerState, repoId, hostId), - repoId, - { - executionHostId: hostId, - directSshAuthority, - connectionId: repoOwner?.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId), - isStaleCatalogPublication: (result) => - isStaleWorktreeCatalogPublication(get(), repoId, hostId, result.catalogVersion) - } - ) + const refresh = await listDetectedWorktreesForRepoCoalesced(repoId, { + executionHostId: hostId, + directSshAuthority, + connectionId: repoOwner?.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId), + isStaleCatalogPublication: (result) => + isStaleWorktreeCatalogPublication(get(), repoId, hostId, result.catalogVersion) + }) if (refresh.status !== 'admitted') { return null } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts index 3c2c91877ec6..d9a5f50cdc09 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts @@ -25,7 +25,6 @@ import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' import { refreshRemoteWorktreeLineageBestEffort } from '../metadata/worktree-lineage-refresh' -import { settingsForRepoOwner } from './worktree-owner-settings' export function createFetchWorktrees( set: WorktreeSliceSet, @@ -71,16 +70,6 @@ export function createFetchWorktrees( hostId, settings: ownerState.settings }) - const ownerSettings = settingsForRepoOwner( - ownerState, - repoId, - hostId, - ownerWasMissingAtStart && (useLocalOwner || options?.executionHostId !== undefined) - ) - const settings = - useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId - ? { ...ownerSettings, activeRuntimeEnvironmentId: null } - : ownerSettings const parsedHost = parseExecutionHostId(hostId) const directSshAuthority = parsedHost?.kind === 'ssh' @@ -94,7 +83,7 @@ export function createFetchWorktrees( } return false } - const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { + const refresh = await listDetectedWorktreesForRepoCoalesced(repoId, { executionHostId: hostId, requireAuthoritative: options?.requireAuthoritative, directSshAuthority, 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 851197fbbf4f..68b478e9f264 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 @@ -19,7 +19,6 @@ import { worktreeHostMatchOptions, worktreeMatchesHost } from './worktree-host-ownership' -import { settingsForRepoOwner } from './worktree-owner-settings' import { getCurrentDirectSshAuthority } from './direct-ssh-authority' import { acquireDetectedWorktreeRefreshLeaseForRepo, @@ -210,17 +209,12 @@ export function acquireDirectSshDetectedWorktreeRefresh( request.repoId, request.executionHostId ) - const settings = settingsForRepoOwner( - requestStartedState, - request.repoId, - request.executionHostId - ) const options: DetectedWorktreeRefreshOptions = { executionHostId: request.executionHostId, directSshAuthority: request.authority, requireAuthoritative: request.requireAuthoritative } - const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, request.repoId, options) + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(request.repoId, options) let mergedResult: HostQualifiedDetectedWorktreeResult | undefined return { diff --git a/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts b/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts index 18b66df9f46e..0c113d9eaa08 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts @@ -3,13 +3,14 @@ import { parseExecutionHostId } from '../../../../../../shared/execution-host' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup' import type { ForceDeleteWorktreeBranchResult } from '../../../../../../shared/worktree/create-types' import type { PreservedBranchCleanup } from '../../../../../../shared/preserved-branch-cleanup' import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' +import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from '../listing/worktree-slice-constants' export function createForceDeletePreservedBranch( _set: WorktreeSliceSet, @@ -59,8 +60,10 @@ export function createForceDeletePreservedBranch( const cleanupHostId = options?.hostId ?? retainedTarget?.cleanup.hostId // Why: the removed row no longer records its nested HUB owner, so retain the deletion-time route. const target = - retainedTarget?.target ?? - getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) + retainedTarget?.target ?? runtimeTargetForWorkspaceOwner(get(), { workspaceId: worktreeId }) + if (!target) { + throw new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) + } const parsedCleanupHost = parseExecutionHostId(cleanupHostId) const effectiveHostId = target.kind === 'environment' && diff --git a/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts index fef22fcdec90..30004fc06fe2 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts @@ -1,5 +1,4 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import type { AppState } from '../../../types' +import { callRuntimeRpc, type RuntimeClientTarget } from '../../../../runtime/runtime-rpc-client' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' import { isRuntimeMethodNotFoundError } from '../listing/runtime-worktree-rpc-errors' @@ -9,7 +8,8 @@ import { isRuntimeMethodNotFoundError } from '../listing/runtime-worktree-rpc-er const missingWorktreeTeardownsInFlight = new Map>() export async function teardownMissingWorktreeTerminalsBestEffort( - settings: AppState['settings'], + /** The host whose listing found the worktrees missing. */ + target: RuntimeClientTarget, repoId: string, connectionId: string | null | undefined, // Why: refreshes that never purge omit this; an absent snapshot means @@ -25,7 +25,6 @@ export async function teardownMissingWorktreeTerminalsBestEffort( if (missingIds.length === 0) { return } - const target = getActiveRuntimeTarget(settings) const normalizedConnectionId = connectionId ?? null const key = [ target.kind === 'local' ? 'local' : `runtime:${target.environmentId}`, diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 1ad559bfe420..5c7f0dc5f21a 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -4,7 +4,7 @@ import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/cre import { getRepoIdFromWorktreeId } from '../../worktree-helpers' import { parseExecutionHostId } from '../../../../../../shared/execution-host' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' -import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../../../runtime/runtime-client-target' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { readIpcErrorDetail } from '@/lib/ipc-error' @@ -12,8 +12,7 @@ import { isArchiveHookRemovalError } from '../../../../../../shared/worktree/arc import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { resolveWorktreeOperationRouteResult, - resolveWorktreeOperationRouteResultForHost, - settingsForWorktreeOperationRoute + resolveWorktreeOperationRouteResultForHost } from '@/lib/worktree-operation-route' import { beginHostQualifiedRemoval, @@ -115,13 +114,7 @@ export function createRemoveWorktree( removalGenerationGuard?.assertCurrent() } // Why: forget-local clears Orca's records via local IPC regardless of host — the remote is gone or unreachable. - const target = getActiveRuntimeTarget( - removalRoute - ? settingsForWorktreeOperationRoute(get().settings, removalRoute) - : get().settings - ? { ...get().settings, activeRuntimeEnvironmentId: null } - : { activeRuntimeEnvironmentId: null } - ) + const target = runtimeTargetForOwnerEnvironment(removalRoute?.runtimeEnvironmentId ?? null) const unprovableRemoteRouting = policy.forgetLocalOnly ? null : refuseUnprovableRemoteHostRouting(get, worktreeId, target.kind) From ddcbb780131ceade3d9de5d6d1c0656069e9b68d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 14:43:42 -0700 Subject: [PATCH 013/110] chore(lint): bypassing owner routing fails lint (#27313) * chore(lint): delete the focus-routing helpers; bypassing owner routing fails lint The last six renderer sites (Settings visibility defaults, preflight readiness, Linear key dialog copy) mirror the default scope host and now say so through defaultScopeHost. getActiveRuntimeTarget and settingsForRuntimeOwner are deleted with their re-exports, the unused settings-fabrication helpers go with them, and the owner-routing baseline is empty, so any new use fails lint. The provider dispatch rationale is rewritten in neutral, shorter terms. * test(preflight): keep the existing cast's rationale on the edited line --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 3 - config/owner-routing-baseline.txt | 9 +-- .../scripts/check-owner-routing-ratchet.mjs | 4 +- .../execution-host-provider-dispatch.ts | 38 +++------- .../connect-dialog-outside-dismiss.test.tsx | 4 +- .../editor/ExternalFileChangeBanner.test.tsx | 4 +- .../ExternalFileChangeCompareDialog.test.tsx | 4 +- .../editor-restored-tab-conflict-scan.test.ts | 4 +- .../rich-markdown-async-image-paste.test.ts | 4 +- .../rich-markdown-async-image-table.test.ts | 4 +- ...h-markdown-image-insert-code-block.test.ts | 4 +- .../editor/rich-markdown-image-insert.test.ts | 6 +- .../editor/rich-markdown-paste-image.test.ts | 10 +-- .../editor/useLocalImagePick.test.tsx | 4 +- .../src/components/linear-api-key-dialog.tsx | 5 +- ...turedAgentSessionPaneOverlayLayer.test.tsx | 11 +-- .../file-explorer-runtime-owner.ts | 12 ---- .../src/components/settings/GeneralPane.tsx | 5 +- ...AccountSection.inventory-lifetime.test.tsx | 4 -- .../RepositoryGitHubAccountSection.test.tsx | 1 - ...yIconPicker.github-avatar-refresh.test.tsx | 3 +- .../WorktreeCard.affiliate-list-mode.test.tsx | 4 +- .../WorktreeCard.compact-hover.test.tsx | 4 +- ....compact-ports-hover-independence.test.tsx | 4 +- .../WorktreeList.card-memo-stability.test.tsx | 1 - ....lineage-agent-expansion-coupling.test.tsx | 1 - ...ktreeList.lineage-child-real-card.test.tsx | 1 - ...treeList.status-lane-lineage-drop.test.tsx | 1 - .../sidebar/useAddRepoCloneFlow.test.ts | 1 - .../useCreateRepo.default-checkout.test.ts | 1 - .../PortsStatusSegment.host-routing.test.tsx | 2 - .../status-bar/claude-status-sign-in.test.tsx | 2 +- .../status-bar/codex-status-sign-in.test.tsx | 4 +- .../ports-status-popover-rows.test.tsx | 1 - ...abCloseCommands.structured-session.test.ts | 3 +- .../useTabGroupWorkspaceModel.focus.test.ts | 8 --- .../terminal-workspace-keydown.test.ts | 1 - .../src/hooks/useGitHubSlugMetadata.test.tsx | 6 +- .../src/hooks/useIssueMetadata.test.tsx | 6 +- ...ve-agent-note-send-explicit-target.test.ts | 4 -- ...ve-agent-note-send-focused-session.test.ts | 4 -- ...e-agent-note-send-target-detection.test.ts | 4 -- .../lib/automation-session-observer.test.ts | 3 +- ...ex-session-restart-account-recheck.test.ts | 4 +- .../codex-session-restart-shell-flap.test.ts | 4 +- .../src/lib/codex-session-restart.test.ts | 4 +- .../src/lib/launch-work-item-direct.test.ts | 1 - .../src/lib/open-agent-message-sender.test.ts | 7 +- .../lib/workspace-port-scan-publish.test.ts | 1 - ...tigravity-generation-compatibility.test.ts | 1 - .../src/runtime/runtime-client-target.ts | 19 ----- .../src/runtime/runtime-rpc-client.test.ts | 14 ---- .../src/runtime/runtime-rpc-client.ts | 6 +- src/renderer/src/store/repos/owner-routing.ts | 34 +-------- .../src/store/repos/repo-catalog-merge.ts | 4 +- .../src/store/slices/editor-open-diff.test.ts | 2 +- .../slices/hosted-review-cache-race.test.ts | 8 +-- .../store/slices/hosted-review-cache.test.ts | 8 +-- .../hosted-review-request-lifetime.test.ts | 6 +- .../src/store/slices/hosted-review.test.ts | 8 +-- .../src/store/slices/preflight.test.ts | 9 +-- src/renderer/src/store/slices/preflight.ts | 6 +- .../repos-host-identity-routing.test.ts | 2 +- .../worktree-visibility-owner-settings.ts | 10 ++- .../worktree-visibility-settings-write.ts | 10 +-- .../listing/worktree-owner-settings.ts | 69 +------------------ .../teardown/dispatch-worktree-removal.ts | 6 +- .../host-qualified-worktree-removal.ts | 4 +- .../preserved-branch-cleanup-target.ts | 4 +- 69 files changed, 84 insertions(+), 376 deletions(-) delete mode 100644 src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 712a0324ec09..777e5cca62a5 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -381,7 +381,6 @@ 1 src/renderer/src/lib/worktree-sort-order-host-split.ts 1 src/renderer/src/runtime/close-mirrored-editor-tab.ts 1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts -1 src/renderer/src/runtime/runtime-client-target.ts 17 src/renderer/src/runtime/runtime-jira-client.ts 2 src/renderer/src/runtime/runtime-jira-summary-client.ts 1 src/renderer/src/runtime/runtime-jira-target.ts @@ -415,7 +414,6 @@ 3 src/renderer/src/store/project-groups/project-group-mutations.ts 1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts 1 src/renderer/src/store/repos/converted-ssh-browser-pages.ts -2 src/renderer/src/store/repos/owner-routing.ts 2 src/renderer/src/store/repos/repo-add-actions.ts 1 src/renderer/src/store/repos/repo-update.ts 1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -484,7 +482,6 @@ 2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts 1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts 2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts -3 src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts 2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts 1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts 3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 528a9498e9d9..df69f491126c 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -1,9 +1,4 @@ # Renderer call sites that route by the Active Server focus setting: # getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file. -# This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead. -# Prune after removing sites: pnpm check:owner-routing-ratchet --prune -1 src/renderer/src/components/linear-api-key-dialog.tsx -1 src/renderer/src/components/settings/GeneralPane.tsx -1 src/renderer/src/store/slices/preflight.ts -1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts -3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts +# The helpers are deleted and this list is empty: any new use fails lint. Route by the +# resource's owner; only a creation flow with no source row may use defaultCreationHost. diff --git a/config/scripts/check-owner-routing-ratchet.mjs b/config/scripts/check-owner-routing-ratchet.mjs index b557cbdd5187..454b1931225a 100644 --- a/config/scripts/check-owner-routing-ratchet.mjs +++ b/config/scripts/check-owner-routing-ratchet.mjs @@ -340,8 +340,8 @@ export const RATCHETS = [ header: [ '# Renderer call sites that route by the Active Server focus setting:', '# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file.', - '# This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead.', - '# Prune after removing sites: pnpm check:owner-routing-ratchet --prune' + '# The helpers are deleted and this list is empty: any new use fails lint. Route by the', + "# resource's owner; only a creation flow with no source row may use defaultCreationHost." ], advice: "Route by the resource's owner (resolveOwner + callHostRoute) instead of the Active Server setting." diff --git a/src/main/providers/execution-host-provider-dispatch.ts b/src/main/providers/execution-host-provider-dispatch.ts index da7ba4b54d8a..7895b2e5e8ff 100644 --- a/src/main/providers/execution-host-provider-dispatch.ts +++ b/src/main/providers/execution-host-provider-dispatch.ts @@ -1,33 +1,17 @@ /** - * Host-keyed provider dispatch: one entry per execution host kind, with `local` among them. + * Host-keyed provider dispatch: one route per execution host kind, `local` included. * - * The incumbent spelling across main is `const c = repo.connectionId; c ? sshProvider(c) : local()`, - * where `null` means *both* "resolved: this is local" and "could not resolve". Every path that - * cannot determine the host therefore answers "local" and runs remote work on the client — the - * #11163 defect class, which has produced a reproduced cross-host leak (an `ssh:` worktree - * resolving to another target) and near-misses where a transcript that exists only on a remote host - * would have been read locally. The shape also cannot express a `runtime:` host at all. + * The input is an `ExecutionHostId`, never null, and an id that names no host throws instead of + * degrading to `local`, so work whose host is unknown never runs on this client (#11163). The + * resolution layer (`getRepoExecutionHostId`, `getWorktreeExecutionHostId`, + * `resolveWorktreeExecutionHost`) reports `unresolved` as its own verdict. * - * This module removes that spelling. Its input is an `ExecutionHostId`, which is never null, and an - * id that names no host throws instead of degrading. `getRepoExecutionHostId` / - * `getWorktreeExecutionHostId` / `resolveWorktreeExecutionHost` are the resolution layer that feeds - * it; the last one already answers `unresolved` as a distinct verdict rather than "local". - * - * Why a route union rather than a uniform `getGitProviderForHost(): IGitProvider`: - * - * - `local` git and filesystem work takes per-worktree execution options (`wslDistro`, - * `sharedLinkPaths`, admission tier). A registered stateless provider would silently drop WSL - * routing, so the local git and filesystem routes carry a factory that is built per call. - * - `runtime:` is never executed in this process; it is forwarded to that server, which - * treats it as its own `local`. A runtime repo's `connectionId` names the *server's* SSH target, - * so handing it to this client's SSH table would dial a same-named target on the wrong host. - * - * So each host kind is its own variant and callers switch exhaustively; `runtime` cannot collapse - * into `local` by omission. - * - * Inside the `ssh` variant, `provider: null` means "remote and currently unreachable", never - * "local" — loss of contact is never evidence of locality (the `live` / `unverifiable` / `exited` - * rule in AGENTS.md). + * Each kind is its own variant so callers switch exhaustively: + * - `local` git and filesystem routes carry a per-call factory, because they need per-worktree + * options (WSL distro, shared links, admission tier) a shared provider would drop. + * - `runtime:` is forwarded to that server. Its repo's `connectionId` names the server's SSH + * target, so it must never reach this client's SSH table. + * - `ssh` with `provider: null` means "remote and unreachable", never "local". */ import { diff --git a/src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx b/src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx index 5a7802d35b14..684499accce5 100644 --- a/src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx +++ b/src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx @@ -28,9 +28,7 @@ vi.mock('@/store', () => ({ } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) let root: Root | null = null diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx index a4c6598b3075..dde8fe822a45 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx @@ -15,9 +15,7 @@ vi.mock('@/store', () => ({ vi.mock('@/runtime/runtime-file-client', () => ({ readRuntimeFileContent: readRuntimeFileContentMock })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: () => null -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('@/lib/connection-context', () => ({ // Why: the floating workspace is always client-local; other owners are still loading. getConnectionIdForFile: (worktreeId: string) => diff --git a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx index 799cf3d72854..a379cfd1a8eb 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx @@ -13,9 +13,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/runtime/runtime-file-client', () => ({ readRuntimeFileContent: mocks.readRuntimeFileContent })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: () => null -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: mocks.getConnectionIdForFile })) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 1e6bbe22779d..1cda0011ebd4 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -15,9 +15,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/runtime/runtime-file-client', () => ({ readRuntimeFileContent: mocks.readRuntimeFileContent })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: () => null -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: mocks.getConnectionIdForFile })) diff --git a/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts b/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts index 74c4e599b98b..bc36c25b931b 100644 --- a/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts @@ -25,9 +25,7 @@ vi.mock('@/store', () => ({ })) } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: vi.fn((settings) => settings) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } })) type ImportResult = Awaited> diff --git a/src/renderer/src/components/editor/rich-markdown-async-image-table.test.ts b/src/renderer/src/components/editor/rich-markdown-async-image-table.test.ts index 9c973af78d14..036d1567913d 100644 --- a/src/renderer/src/components/editor/rich-markdown-async-image-table.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-async-image-table.test.ts @@ -19,9 +19,7 @@ vi.mock('@/store', () => ({ })) } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: vi.fn((settings) => settings) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } })) type ImportResult = Awaited> diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts index e343914f2aca..73c8f934ed24 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts @@ -30,9 +30,7 @@ vi.mock('@/store', () => ({ } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: vi.fn((settings) => settings) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts index 71495d4ceaa3..d3083448bcc4 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts @@ -24,11 +24,7 @@ vi.mock('@/store', () => ({ } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: vi.fn((settings, runtimeEnvironmentId) => - runtimeEnvironmentId ? { activeRuntimeEnvironmentId: runtimeEnvironmentId } : settings - ) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } diff --git a/src/renderer/src/components/editor/rich-markdown-paste-image.test.ts b/src/renderer/src/components/editor/rich-markdown-paste-image.test.ts index 870eb821fe90..28365250b6cb 100644 --- a/src/renderer/src/components/editor/rich-markdown-paste-image.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-paste-image.test.ts @@ -22,15 +22,7 @@ vi.mock('@/store', () => ({ } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: vi.fn((settings, runtimeEnvironmentId) => - runtimeEnvironmentId === null - ? { activeRuntimeEnvironmentId: null } - : runtimeEnvironmentId - ? { activeRuntimeEnvironmentId: runtimeEnvironmentId } - : settings - ) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } diff --git a/src/renderer/src/components/editor/useLocalImagePick.test.tsx b/src/renderer/src/components/editor/useLocalImagePick.test.tsx index 0ea9dcab5d8e..6aaf0e207954 100644 --- a/src/renderer/src/components/editor/useLocalImagePick.test.tsx +++ b/src/renderer/src/components/editor/useLocalImagePick.test.tsx @@ -21,9 +21,7 @@ vi.mock('@/store', () => ({ })) } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: vi.fn((settings) => settings) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } })) type ImportResult = Awaited> diff --git a/src/renderer/src/components/linear-api-key-dialog.tsx b/src/renderer/src/components/linear-api-key-dialog.tsx index 9e463ead652f..3c59cfb94fbf 100644 --- a/src/renderer/src/components/linear-api-key-dialog.tsx +++ b/src/renderer/src/components/linear-api-key-dialog.tsx @@ -5,7 +5,7 @@ import { buildLinearPersonalApiKeySettingsUrl, buildLinearWorkspaceApiSettingsUrl } from '../../../shared/linear/links' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { defaultScopeHost } from '@/lib/default-creation-host' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' import { Button } from '@/components/ui/button' @@ -57,7 +57,8 @@ export function LinearApiKeyDialog({ const apiKeyErrorId = useId() const [dialogState, setDialogState] = useState(createLinearApiKeyDialogState) - const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + // Why: connectLinear stores the key on the default scope host, so the copy names that host. + const runtimeTarget = useMemo(() => defaultScopeHost(settings), [settings]) const personalKeyUrl = buildLinearPersonalApiKeySettingsUrl(workspace?.organizationUrlKey) const workspaceApiUrl = buildLinearWorkspaceApiSettingsUrl(workspace?.organizationUrlKey) const submitLabel = connectLabel ?? (workspace ? 'Update access' : 'Connect') diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx index 45eb5b4c0aa3..19b4bb24a3ae 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -39,16 +39,7 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ state.runtimeEnvironmentId ? `runtime:${state.runtimeEnvironmentId}` : 'local' })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: ({ - activeRuntimeEnvironmentId - }: { - activeRuntimeEnvironmentId: string | null - }) => - activeRuntimeEnvironmentId - ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } - : { kind: 'local' } -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('./NativeChatView', async () => { const { useEffect } = await import('react') diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts deleted file mode 100644 index a98a38d4edca..000000000000 --- a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' -import { useAppStore } from '@/store' - -export function getRightSidebarWorktreeRuntimeSettings( - worktreeId: string | null | undefined -): Pick { - const store = useAppStore.getState() - // Why: right-sidebar file/git actions operate on the selected workspace. - // Route by that workspace owner so global focused-host changes cannot retarget them. - return getSettingsForWorktreeRuntimeOwner(store, worktreeId) -} diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index e9b9fc4bc30b..7bb18fa1ff12 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -23,7 +23,7 @@ import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { DefaultWindowsProjectRuntimeSetting } from './DefaultWindowsProjectRuntimeSetting' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { defaultScopeHost } from '@/lib/default-creation-host' export { createAutoSaveDelayDraftState, @@ -107,7 +107,8 @@ export function GeneralPane({ const defaultsSupportedRuntimeEnvironmentId = useAppStore( (s) => s.worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId ) - const activeRuntimeTarget = getActiveRuntimeTarget(settings) + // Why: the store mirrors the default scope host's visibility defaults until Settings picks a host. + const activeRuntimeTarget = defaultScopeHost(settings) const defaultsSupported = activeRuntimeTarget.kind === 'local' || activeRuntimeTarget.environmentId === defaultsSupportedRuntimeEnvironmentId diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx index 94cbd5b53528..907e7203d275 100644 --- a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx @@ -26,10 +26,6 @@ vi.mock('../../store', () => { }) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: (settings: Pick | null) => - settings?.activeRuntimeEnvironmentId - ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } - : { kind: 'local' }, callRuntimeRpc: vi.fn() })) diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx index cd9154797d13..bcbb90d6faad 100644 --- a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx @@ -38,7 +38,6 @@ vi.mock('@/lib/repo-runtime-owner', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: vi.fn() })) diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx index 2d2da75d4838..d3b0fb9bc87f 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx @@ -7,8 +7,7 @@ import type { Repo } from '../../../../shared/repo-types' import { RepositoryIconPicker } from './RepositoryIconPicker' vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn(), - getActiveRuntimeTarget: () => ({ kind: 'local' }) + callRuntimeRpc: vi.fn() })) vi.mock('./RepositoryIconColorSection', () => ({ diff --git a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx index 1cdaa5a039be..bc581ee22135 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx @@ -69,9 +69,7 @@ vi.mock('@/lib/sidebar-worktree-activation', () => ({ activateWorktreeFromSidebar: testDoubles.activateWorktreeFromSidebar })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'idle' diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 09517679688c..603db9f484b5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -95,9 +95,7 @@ vi.mock('@/lib/sidebar-worktree-activation', () => ({ activateWorktreeFromSidebar: vi.fn() })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'active' diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index dd324b3e1534..6c0d9f61504c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -109,9 +109,7 @@ vi.mock('@/lib/sidebar-worktree-activation', () => ({ activateWorktreeFromSidebar: vi.fn() })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'active' diff --git a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx index 8447ad6fbcfb..9905a6ee83ef 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx @@ -112,7 +112,6 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: vi.fn() })) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 32abc34519eb..2cc445561701 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -135,7 +135,6 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: vi.fn() })) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index bc2eb5068664..0359deca32e9 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -129,7 +129,6 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: vi.fn() })) diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 25e07f101c45..5133e63d75da 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -130,7 +130,6 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: vi.fn() })) diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts index 29a63e34db2a..ccd73716da1a 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts @@ -66,7 +66,6 @@ vi.mock('@/store', () => { }) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: mocks.callRuntimeRpc })) diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 2a8eff35c023..0c47375c585e 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -77,7 +77,6 @@ vi.mock('sonner', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: mocks.callRuntimeRpc })) diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx index 9b22826d6bd3..b1ac9941e7cc 100644 --- a/src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx @@ -56,9 +56,7 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', async () => { - const actual = await import('@/runtime/runtime-client-target') return { - getActiveRuntimeTarget: actual.getActiveRuntimeTarget, callRuntimeRpc: vi.fn(), assertRuntimeEnvironmentCapability: vi.fn(), RuntimeRpcCallError: class RuntimeRpcCallError extends Error { diff --git a/src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx b/src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx index f4f70b4f1264..13d77a5ce849 100644 --- a/src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx +++ b/src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx @@ -47,7 +47,7 @@ vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ })), selectClaudeProviderAccount })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ getActiveRuntimeTarget: () => null })) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('@/lib/windows-terminal-capabilities', () => ({ useWindowsTerminalCapabilities: () => ({ wslDistros: [], isLoading: false }), getWindowsTerminalCapabilityOwnerKey: () => 'local' diff --git a/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx index 37d6046002ca..b1b4df3eed13 100644 --- a/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx +++ b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx @@ -94,9 +94,7 @@ vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ selectClaudeProviderAccount: vi.fn(async () => ({ accounts: [], activeAccountId: null })) })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }) -})) +vi.mock('@/runtime/runtime-rpc-client', () => ({})) vi.mock('@/lib/windows-terminal-capabilities', () => ({ useWindowsTerminalCapabilities: () => ({ wslDistros: [], isLoading: false }), diff --git a/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx b/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx index ba926909272a..0ed7dec18170 100644 --- a/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx +++ b/src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx @@ -49,7 +49,6 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: vi.fn(), RuntimeRpcCallError: class RuntimeRpcCallError extends Error { code?: string diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts index 6b87bcdfd6a9..9ab9f6c72914 100644 --- a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts @@ -70,8 +70,7 @@ vi.mock('@/runtime/remote-browser-tab-ownership', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: mocks.callRuntimeRpc, - getActiveRuntimeTarget: () => ({ kind: 'local' }) + callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('@/runtime/structured-agent-session-close', () => ({ diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index b438f5ecee8c..d739e813b007 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -81,14 +81,6 @@ vi.mock('../../runtime/web-runtime-session', () => ({ vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc, - getActiveRuntimeTarget: ({ - activeRuntimeEnvironmentId - }: { - activeRuntimeEnvironmentId?: string | null - }) => - activeRuntimeEnvironmentId - ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } - : { kind: 'local' }, runtimeEnvironmentSupportsCapability: mocks.runtimeEnvironmentSupportsCapability })) diff --git a/src/renderer/src/components/terminal-workspace-keydown.test.ts b/src/renderer/src/components/terminal-workspace-keydown.test.ts index 4e76d04cc9e8..a253c9b4e658 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.test.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.test.ts @@ -51,7 +51,6 @@ vi.mock('@/runtime/structured-agent-session-close', () => ({ closeStructuredAgentSession: mocks.closeStructuredAgentSession })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'local' }), callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('@/lib/structured-agent-session-launch', () => ({ diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx index 2f0be9a5e69f..b1702dd176f4 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx @@ -15,11 +15,7 @@ const apiMocks = vi.hoisted(() => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn(), - getActiveRuntimeTarget: (settings?: { activeRuntimeEnvironmentId?: string | null } | null) => - settings?.activeRuntimeEnvironmentId - ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } - : { kind: 'local' } + callRuntimeRpc: vi.fn() })) const roots: Root[] = [] diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index 5c2952c3d86b..951913633220 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -27,11 +27,7 @@ vi.mock('@/runtime/runtime-linear-project-client', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: runtimeMocks.callRuntimeRpc, - getActiveRuntimeTarget: (settings?: { activeRuntimeEnvironmentId?: string | null } | null) => - settings?.activeRuntimeEnvironmentId - ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } - : { kind: 'local' } + callRuntimeRpc: runtimeMocks.callRuntimeRpc })) const roots: Root[] = [] diff --git a/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts b/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts index 098dd649f293..aa02b12dcbb2 100644 --- a/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts +++ b/src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts @@ -15,7 +15,6 @@ import { const testState = vi.hoisted(() => ({ appState: null as unknown as NoteSendAppState, callRuntimeRpc: vi.fn(), - getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' })), RuntimeRpcCallError: class RuntimeRpcCallError extends Error { readonly code: string readonly response: unknown @@ -40,7 +39,6 @@ vi.mock('@/store', () => ({ vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: testState.callRuntimeRpc, - getActiveRuntimeTarget: testState.getActiveRuntimeTarget, RuntimeRpcCallError: testState.RuntimeRpcCallError })) @@ -48,8 +46,6 @@ describe('active agent note send', () => { beforeEach(() => { testState.appState = createNoteSendAppState() testState.callRuntimeRpc.mockReset() - testState.getActiveRuntimeTarget.mockClear() - testState.getActiveRuntimeTarget.mockReturnValue({ kind: 'local' }) }) it('sends notes immediately to an explicit note target using bracketed paste and Enter', async () => { diff --git a/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts b/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts index 02f3a86ad637..82f1a6674fdd 100644 --- a/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts +++ b/src/renderer/src/lib/active-agent-note-send-focused-session.test.ts @@ -11,7 +11,6 @@ import { const testState = vi.hoisted(() => ({ appState: null as unknown as NoteSendAppState, callRuntimeRpc: vi.fn(), - getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' })), RuntimeRpcCallError: class RuntimeRpcCallError extends Error { readonly code: string readonly response: unknown @@ -36,7 +35,6 @@ vi.mock('@/store', () => ({ vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: testState.callRuntimeRpc, - getActiveRuntimeTarget: testState.getActiveRuntimeTarget, RuntimeRpcCallError: testState.RuntimeRpcCallError })) @@ -44,8 +42,6 @@ describe('active agent note send', () => { beforeEach(() => { testState.appState = createNoteSendAppState() testState.callRuntimeRpc.mockReset() - testState.getActiveRuntimeTarget.mockClear() - testState.getActiveRuntimeTarget.mockReturnValue({ kind: 'local' }) }) it('sends notes only after the active terminal is verified as an idle agent', async () => { diff --git a/src/renderer/src/lib/active-agent-note-send-target-detection.test.ts b/src/renderer/src/lib/active-agent-note-send-target-detection.test.ts index d0348cd8f237..7519612ba32b 100644 --- a/src/renderer/src/lib/active-agent-note-send-target-detection.test.ts +++ b/src/renderer/src/lib/active-agent-note-send-target-detection.test.ts @@ -19,7 +19,6 @@ const NOW = 1_700_000_000_000 const testState = vi.hoisted(() => ({ appState: null as unknown as NoteSendAppState, callRuntimeRpc: vi.fn(), - getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' })), RuntimeRpcCallError: class RuntimeRpcCallError extends Error { readonly code: string readonly response: unknown @@ -44,7 +43,6 @@ vi.mock('@/store', () => ({ vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: testState.callRuntimeRpc, - getActiveRuntimeTarget: testState.getActiveRuntimeTarget, RuntimeRpcCallError: testState.RuntimeRpcCallError })) @@ -52,8 +50,6 @@ describe('active agent note send', () => { beforeEach(() => { testState.appState = createNoteSendAppState() testState.callRuntimeRpc.mockReset() - testState.getActiveRuntimeTarget.mockClear() - testState.getActiveRuntimeTarget.mockReturnValue({ kind: 'local' }) }) it('resolves the current worktree terminal pane from renderer state', () => { diff --git a/src/renderer/src/lib/automation-session-observer.test.ts b/src/renderer/src/lib/automation-session-observer.test.ts index 3ef489314c54..2e1488abbf4d 100644 --- a/src/renderer/src/lib/automation-session-observer.test.ts +++ b/src/renderer/src/lib/automation-session-observer.test.ts @@ -37,8 +37,7 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: mockCallRuntimeRpc, - getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' })) + callRuntimeRpc: mockCallRuntimeRpc })) vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({ diff --git a/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts b/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts index 8260bceccdb8..0489c3f2501d 100644 --- a/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts +++ b/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { defaultScopeHost } from '@/lib/default-creation-host' // Why: these cases model a switch made on the focused host, as the status bar does. -const focusedOwner = () => getActiveRuntimeTarget(useAppStore.getState().settings) +const focusedOwner = () => defaultScopeHost(useAppStore.getState().settings) import { markLiveCodexSessionsForRestart } from './codex-session-restart' const ACCOUNT_A = 'account-a@example.com' diff --git a/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts b/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts index 031747c3ea47..0ea0800d3587 100644 --- a/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts +++ b/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { defaultScopeHost } from '@/lib/default-creation-host' // Why: these cases model a switch made on the focused host, as the status bar does. -const focusedOwner = () => getActiveRuntimeTarget(useAppStore.getState().settings) +const focusedOwner = () => defaultScopeHost(useAppStore.getState().settings) import { markLiveCodexSessionsForRestart, markRestoredStaleCodexSessionsForRestart diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index d30fade64507..5c0244bda750 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { defaultScopeHost } from '@/lib/default-creation-host' // Why: these cases model a switch made on the focused host, as the status bar does. const markForFocusedHost = ( args: Omit[0], 'owner'> ): Promise => markLiveCodexSessionsForRestart({ - owner: getActiveRuntimeTarget(useAppStore.getState().settings), + owner: defaultScopeHost(useAppStore.getState().settings), ...args }) import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index ab4e4361a896..6874e4f9d312 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -70,7 +70,6 @@ vi.mock('@/runtime/runtime-hooks-client', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: vi.fn().mockReturnValue({ kind: 'local' }), callRuntimeRpc: hostMocks.callRuntimeRpc })) diff --git a/src/renderer/src/lib/open-agent-message-sender.test.ts b/src/renderer/src/lib/open-agent-message-sender.test.ts index a72dc6f9b9e6..dfeb4aef2c1e 100644 --- a/src/renderer/src/lib/open-agent-message-sender.test.ts +++ b/src/renderer/src/lib/open-agent-message-sender.test.ts @@ -22,12 +22,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ ...(await importOriginal>()), - callRuntimeRpc: mocks.callRuntimeRpc, - getActiveRuntimeTarget: ({ - activeRuntimeEnvironmentId - }: { - activeRuntimeEnvironmentId: string - }) => ({ kind: 'environment', environmentId: activeRuntimeEnvironmentId }) + callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('@/components/terminal-pane/terminal-handle-links', () => ({ focusRendererTerminalHandle: mocks.focusRenderer, diff --git a/src/renderer/src/lib/workspace-port-scan-publish.test.ts b/src/renderer/src/lib/workspace-port-scan-publish.test.ts index 2f1386855a88..f1fc4fb16bfa 100644 --- a/src/renderer/src/lib/workspace-port-scan-publish.test.ts +++ b/src/renderer/src/lib/workspace-port-scan-publish.test.ts @@ -8,7 +8,6 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: vi.fn(), callRuntimeRpc: vi.fn(), assertRuntimeEnvironmentCapability: vi.fn(), RuntimeRpcCallError: class RuntimeRpcCallError extends Error { diff --git a/src/renderer/src/runtime/antigravity-generation-compatibility.test.ts b/src/renderer/src/runtime/antigravity-generation-compatibility.test.ts index 8bb19c82f54b..f8b6edc412e9 100644 --- a/src/renderer/src/runtime/antigravity-generation-compatibility.test.ts +++ b/src/renderer/src/runtime/antigravity-generation-compatibility.test.ts @@ -8,7 +8,6 @@ import { const mocks = vi.hoisted(() => ({ supports: vi.fn(), rpc: vi.fn() })) vi.mock('./runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => ({ kind: 'environment', environmentId: 'remote-test' }), runtimeEnvironmentSupportsCapability: mocks.supports, callRuntimeRpc: mocks.rpc })) diff --git a/src/renderer/src/runtime/runtime-client-target.ts b/src/renderer/src/runtime/runtime-client-target.ts index a7d21a05ac9d..0a6f4deb9264 100644 --- a/src/renderer/src/runtime/runtime-client-target.ts +++ b/src/renderer/src/runtime/runtime-client-target.ts @@ -1,4 +1,3 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import { parseRoutableExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { parseHostAuthorityKey, @@ -8,13 +7,6 @@ import { export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } -export function getActiveRuntimeTarget( - settings: Pick | null | undefined -): RuntimeClientTarget { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } -} - /** Transport to an owner's runtime environment; `null` is this app (local or direct SSH). */ export function runtimeTargetForOwnerEnvironment( environmentId: string | null @@ -77,14 +69,3 @@ export function hostRouteForAuthority(authority: HostAuthority): HostRoute { at: authority.at } } - -export function settingsForRuntimeOwner( - settings: Pick | null | undefined, - runtimeEnvironmentId: string | null | undefined -): Pick | null | undefined { - if (runtimeEnvironmentId === null) { - return { activeRuntimeEnvironmentId: null } - } - const ownerId = runtimeEnvironmentId?.trim() - return ownerId ? { activeRuntimeEnvironmentId: ownerId } : settings -} diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 3678743d50dc..72122a2b40b4 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -5,7 +5,6 @@ import { assertRuntimeEnvironmentCapability, clearRecentRuntimeCompatibilityFailure, clearRuntimeCompatibilityCacheForTests, - getActiveRuntimeTarget, runtimeEnvironmentSupportsCapability, RuntimeRpcCallError, unwrapRuntimeRpcResult @@ -40,19 +39,6 @@ beforeEach(() => { }) describe('runtime RPC client routing', () => { - it('uses the local runtime when no active environment is selected', () => { - expect(getActiveRuntimeTarget(null)).toEqual({ kind: 'local' }) - expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: null })).toEqual({ kind: 'local' }) - expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: ' ' })).toEqual({ kind: 'local' }) - }) - - it('uses the active saved environment when one is selected', () => { - expect(getActiveRuntimeTarget({ activeRuntimeEnvironmentId: 'env-1' })).toEqual({ - kind: 'environment', - environmentId: 'env-1' - }) - }) - it('routes local runtime calls through window.api.runtime.call', async () => { runtimeCall.mockResolvedValue({ id: 'local', diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index d39c03114ef1..a2fa5f173c81 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -10,11 +10,7 @@ import { unwrapRuntimeRpcResult } from './runtime-rpc-result' import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision' import type { RuntimeClientTarget } from './runtime-client-target' -export { - getActiveRuntimeTarget, - settingsForRuntimeOwner, - type RuntimeClientTarget -} from './runtime-client-target' +export { type RuntimeClientTarget } from './runtime-client-target' export { hasRuntimeRpcErrorCode, RuntimeRpcCallError, diff --git a/src/renderer/src/store/repos/owner-routing.ts b/src/renderer/src/store/repos/owner-routing.ts index 215a6a00531c..ffda021b1df7 100644 --- a/src/renderer/src/store/repos/owner-routing.ts +++ b/src/renderer/src/store/repos/owner-routing.ts @@ -1,42 +1,12 @@ import type { AppState } from '../types' import { FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' -import { findRepoForHost } from '../slices/repo-host-identity' import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from '../../runtime/runtime-rpc-client' -import type { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../../runtime/runtime-rpc-client' import { translate } from '@/i18n/i18n' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import type { ExecutionHostId } from '../../../../shared/execution-host' - -export function settingsForRepoOwner( - state: Pick, - repoId: string, - hostId?: ExecutionHostId -) { - const repo = findRepoForHost(state.repos, repoId, { settings: state.settings, hostId }) - if (!repo) { - return state.settings - } - if (!repo.executionHostId && !repo.connectionId) { - return state.settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - if ( - (parsed?.kind === 'local' || parsed?.kind === 'ssh') && - state.settings?.activeRuntimeEnvironmentId - ) { - return { ...state.settings, activeRuntimeEnvironmentId: null } - } - return state.settings -} export function getRuntimeEnvironmentDisplayName(state: AppState, environmentId: string): string { const environment = state.runtimeEnvironments.find((entry) => entry.id === environmentId) @@ -44,7 +14,7 @@ export function getRuntimeEnvironmentDisplayName(state: AppState, environmentId: } export async function fetchRuntimeAddProjectPathStatus(args: { - target: Extract, { kind: 'environment' }> + target: Extract path: string }): Promise { await assertRuntimeEnvironmentCapability( diff --git a/src/renderer/src/store/repos/repo-catalog-merge.ts b/src/renderer/src/store/repos/repo-catalog-merge.ts index 1aa5aca87e22..6eb69e52c292 100644 --- a/src/renderer/src/store/repos/repo-catalog-merge.ts +++ b/src/renderer/src/store/repos/repo-catalog-merge.ts @@ -7,7 +7,7 @@ import { reconcileReadoptedSshRepoRows } from '../slices/superseded-ssh-repo-row import type { SshRepoReconciliation } from '../slices/superseded-ssh-repo-rows' import { reconcileReadoptedSshWorktreesByRepo } from '../slices/readopted-ssh-worktree-rows' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' -import type { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../../runtime/runtime-rpc-client' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { RepoSlice } from './repo-state' import { adoptFromEndpoint } from '../adopt-from-endpoint' @@ -26,7 +26,7 @@ export type FetchedRepoCatalog = { } export async function fetchRepoCatalogForTarget( - target: ReturnType + target: RuntimeClientTarget ): Promise { const fetchedRepos = target.kind === 'local' diff --git a/src/renderer/src/store/slices/editor-open-diff.test.ts b/src/renderer/src/store/slices/editor-open-diff.test.ts index 0f4d28e338b7..901def57fd68 100644 --- a/src/renderer/src/store/slices/editor-open-diff.test.ts +++ b/src/renderer/src/store/slices/editor-open-diff.test.ts @@ -88,7 +88,7 @@ describe('createEditorSlice openDiff', () => { const store = createEditorStore() // A remote runtime is globally focused, but wt-1's repo names no explicit // owner. The diff must stamp null (not undefined): null forces a LOCAL read - // in settingsForRuntimeOwner, while undefined would inherit 'focused-env'. + // (local), while undefined used to inherit 'focused-env'. store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-env' } as AppState['settings'] }) diff --git a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts index 16d765fe0d5a..71f2e66043cf 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts @@ -10,13 +10,7 @@ const runtimeRpc = vi.hoisted(() => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: runtimeRpc.callRuntimeRpc, - getActiveRuntimeTarget: ( - settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined - ) => { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } - } + callRuntimeRpc: runtimeRpc.callRuntimeRpc })) const mockApi = { diff --git a/src/renderer/src/store/slices/hosted-review-cache.test.ts b/src/renderer/src/store/slices/hosted-review-cache.test.ts index 630d01e929be..b7116fbc8448 100644 --- a/src/renderer/src/store/slices/hosted-review-cache.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache.test.ts @@ -14,13 +14,7 @@ const runtimeRpc = vi.hoisted(() => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: runtimeRpc.callRuntimeRpc, - getActiveRuntimeTarget: ( - settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined - ) => { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } - } + callRuntimeRpc: runtimeRpc.callRuntimeRpc })) const mockApi = { diff --git a/src/renderer/src/store/slices/hosted-review-request-lifetime.test.ts b/src/renderer/src/store/slices/hosted-review-request-lifetime.test.ts index 747e2fefd03e..344c6f366491 100644 --- a/src/renderer/src/store/slices/hosted-review-request-lifetime.test.ts +++ b/src/renderer/src/store/slices/hosted-review-request-lifetime.test.ts @@ -11,11 +11,7 @@ import { const runtimeRpc = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: runtimeRpc.callRuntimeRpc, - getActiveRuntimeTarget: (settings: AppState['settings']) => { - const environmentId = settings?.activeRuntimeEnvironmentId - return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } - } + callRuntimeRpc: runtimeRpc.callRuntimeRpc })) const forBranch = vi.fn() diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 737c7af62a0f..a0906f30bb14 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -12,13 +12,7 @@ const runtimeRpc = vi.hoisted(() => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: runtimeRpc.callRuntimeRpc, - getActiveRuntimeTarget: ( - settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined - ) => { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } - } + callRuntimeRpc: runtimeRpc.callRuntimeRpc })) const mockApi = { diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index 168f0099fcc6..bd0cea9e05f6 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -20,13 +20,8 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), clearRecentRuntimeCompatibilityFailure: vi.fn(), clearRuntimeCompatibilityCache: vi.fn(), - unwrapRuntimeRpcResult: (response: { result?: T }) => response.result as T, - getActiveRuntimeTarget: ( - settings?: { activeRuntimeEnvironmentId?: string | null } | null - ): { kind: 'local' } | { kind: 'environment'; environmentId: string } => { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } - } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test responses always carry the typed result. + unwrapRuntimeRpcResult: (response: { result?: T }) => response.result as T })) globalThis.window = { diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts index 81bfbf163fa8..e4473556f3e2 100644 --- a/src/renderer/src/store/slices/preflight.ts +++ b/src/renderer/src/store/slices/preflight.ts @@ -1,7 +1,8 @@ import type { StateCreator } from 'zustand' import type { PreflightRuntimeContext, PreflightStatus } from '../../../../preload/api-types' import type { AppState } from '../types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { defaultScopeHost } from '@/lib/default-creation-host' import { getLocalPreflightContext, localPreflightContextKey, @@ -81,7 +82,8 @@ export const createPreflightSlice: StateCreator { it('removeProject of an SSH host row routes local even when a runtime is focused', async () => { // Regression: removing an SSH host's repo (explicit ssh hostId) must NOT route - // repo.rm to the focused runtime env. settingsForRepoOwner clears the focused + // repo.rm to the focused runtime env. Owner routing clears the focused // runtime for SSH owners, so removal stays on the host-scoped local path. const sshDuplicate: Repo = { id: 'same-repo', diff --git a/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts b/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts index 3fc8fdfa79c4..f308b4e4d06b 100644 --- a/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts +++ b/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts @@ -1,8 +1,5 @@ -import { - callRuntimeRpc, - getActiveRuntimeTarget, - runtimeEnvironmentSupportsCapability -} from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { defaultScopeHost } from '@/lib/default-creation-host' import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId, @@ -59,7 +56,8 @@ export async function hydrateOwnerWorktreeVisibilityDefaults( supportedRuntimeEnvironmentId: string | null sourceDefaultsSupportedRuntimeEnvironmentId: string | null }> { - const target = getActiveRuntimeTarget(settings) + // Why: visibility defaults are mirrored from the default scope host until Settings picks one. + const target = defaultScopeHost(settings) if (target.kind !== 'environment') { return { settings, diff --git a/src/renderer/src/store/slices/worktree-visibility-settings-write.ts b/src/renderer/src/store/slices/worktree-visibility-settings-write.ts index 2e339c723a77..1b580c2de339 100644 --- a/src/renderer/src/store/slices/worktree-visibility-settings-write.ts +++ b/src/renderer/src/store/slices/worktree-visibility-settings-write.ts @@ -1,4 +1,5 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { defaultScopeHost } from '@/lib/default-creation-host' import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId @@ -22,7 +23,8 @@ export async function persistVisibilityAwareSettings(args: { shouldPublish = () => true, set } = args - const target = getActiveRuntimeTarget(currentSettings) + // Why: the write lands on the default scope host whose defaults the store mirrors. + const target = defaultScopeHost(currentSettings) if ('worktreeVisibilityDefaults' in normalizedUpdates && target.kind === 'environment') { const { worktreeVisibilityDefaults, ...localUpdates } = normalizedUpdates if (target.environmentId !== supportedRuntimeEnvironmentId) { @@ -53,7 +55,7 @@ export async function persistVisibilityAwareSettings(args: { } catch (error) { if (localSettings && shouldPublish()) { set((state) => { - const currentTarget = getActiveRuntimeTarget(state.settings) + const currentTarget = defaultScopeHost(state.settings) const stillFocused = currentTarget.kind === 'environment' && currentTarget.environmentId === target.environmentId @@ -80,7 +82,7 @@ export async function persistVisibilityAwareSettings(args: { return } set((state) => { - const currentTarget = getActiveRuntimeTarget(state.settings) + const currentTarget = defaultScopeHost(state.settings) const stillFocused = currentTarget.kind === 'environment' && currentTarget.environmentId === target.environmentId return { diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts index 7b745fba253f..c067e3fafb21 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts @@ -3,18 +3,12 @@ import { tryRuntimeTargetForWorktreeOwner } from './worktree-owner-target' import type { Worktree } from '../../../../../../shared/worktree/types' import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' import { getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { findRepoForHost } from '../../repo-host-identity' -import { - getRepoExecutionHostId, - parseExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { resolveWorktreeOperationRoute, resolveWorktreeOperationRouteForHost, settingsForWorktreeOperationRoute } from '@/lib/worktree-operation-route' -import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from './worktree-slice-constants' import { isRuntimeSelectorNotFoundError } from './runtime-worktree-rpc-errors' import { persistWorktreeMeta } from '../metadata/worktree-meta-persist' import type { WorktreeSliceGet } from './worktree-slice-types' @@ -36,55 +30,6 @@ export function replaceWorktreeInRepoLists( } } -export function settingsForRepoOwner( - state: Pick, - repoId: string, - hostId?: ExecutionHostId | null, - honorMissingHostId = false -) { - const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) - if (repo) { - return settingsForKnownRepoOwner(state.settings, repo) - } - const parsedHost = honorMissingHostId && hostId ? parseExecutionHostId(hostId) : null - if (parsedHost?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsedHost.environmentId } - : ({ activeRuntimeEnvironmentId: parsedHost.environmentId } as AppState['settings']) - } - if (parsedHost?.kind === 'local' || parsedHost?.kind === 'ssh') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) - } - return state.settings -} - -export function settingsForKnownRepoOwner( - settings: AppState['settings'], - repo: { connectionId?: string | null; executionHostId?: ExecutionHostId | null } -) { - if (!repo.executionHostId && !repo.connectionId) { - return settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return settings - ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - if (parsed?.kind === 'local' && settings?.activeRuntimeEnvironmentId) { - return { ...settings, activeRuntimeEnvironmentId: null } - } - if (parsed?.kind !== 'ssh') { - return settings - } - // Why: SSH repos are owned by the desktop client/SSH provider, not the focused runtime server. - return settings - ? { ...settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) -} - export function trySettingsForWorktreeOwner( state: Pick< AppState, @@ -111,18 +56,6 @@ export function trySettingsForWorktreeOwner( return settingsForWorktreeOperationRoute(state.settings, route) } -export function settingsForWorktreeOwner( - state: Parameters[0], - worktreeId: string, - executionHostId?: ExecutionHostId -) { - const settings = trySettingsForWorktreeOwner(state, worktreeId, executionHostId) - if (!settings) { - throw new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) - } - return settings -} - // Why: activity bumps fire on every PTY event, so an ambiguous workspace would warn continuously. // One line per workspace is enough to diagnose it (#10634). export const ambiguousOwnerWarnedWorktreeIds = new Set() diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts index d40691c56e71..f8b78d97a5b5 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts @@ -1,7 +1,7 @@ import { showNestedWorktreePreservedBranchesToast } from '@/components/sidebar/nested-worktree-preserved-branches-toast' import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types' -import { callRuntimeRpc, type getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import { callRuntimeRpc, type RuntimeClientTarget } from '../../../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' import type { RemoveWorktreeOptions } from '../../worktree-removal-options' import type { WorktreeSliceGet } from '../listing/worktree-slice-types' @@ -24,7 +24,7 @@ export async function dispatchWorktreeRemoval(args: { force: boolean | undefined skipArchive: boolean get: WorktreeSliceGet - target: ReturnType + target: RuntimeClientTarget options: RemoveWorktreeOptions | undefined /** Re-checks mid-flight ownership immediately before the destructive call. */ assertCurrent: () => void @@ -102,7 +102,7 @@ async function requestWorktreeRemoval( } function qualifyRuntimeCallHost( - target: ReturnType, + target: RuntimeClientTarget, hostId: ExecutionHostId | undefined ): ExecutionHostId | undefined { const parsedHost = parseExecutionHostId(hostId) diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts index 6c0e6e3f688c..ac74780e653d 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-removal.ts @@ -7,7 +7,7 @@ import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types' import type { WorktreeSlice } from '../../worktree-helpers' -import type { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-rpc-client' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { getWorktreeOperationOwnerHostIds, @@ -198,7 +198,7 @@ export async function completeSameIdHostScopedRemoval(args: { requiredExecutionHostId: ExecutionHostId removalResult: RemoveWorktreeResult | undefined removalRoute: WorktreeOperationRoute | null - target: ReturnType + target: RuntimeClientTarget worktreeBeforeRemoval: PreservedBranchWorktree suppressPreservedBranchToast: boolean rowAlreadyDropped?: boolean diff --git a/src/renderer/src/store/slices/worktrees/teardown/preserved-branch-cleanup-target.ts b/src/renderer/src/store/slices/worktrees/teardown/preserved-branch-cleanup-target.ts index 3cc0b25d843f..5d22e979762b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/preserved-branch-cleanup-target.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/preserved-branch-cleanup-target.ts @@ -1,7 +1,7 @@ -import type { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-rpc-client' import type { PreservedBranchCleanup } from '../../../../../../shared/preserved-branch-cleanup' export const preservedBranchRuntimeTargetByCleanupKey = new Map< string, - { cleanup: PreservedBranchCleanup; target: ReturnType } + { cleanup: PreservedBranchCleanup; target: RuntimeClientTarget } >() From 42be4c50d4a523048790c677a186a44a324a4887 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 15:01:12 -0700 Subject: [PATCH 014/110] chore(settings): rename "Active Server" to "Default host for new projects" (#27286) * chore(settings): rename "Active Server" to "Default host for new projects" The setting (still activeRuntimeEnvironmentId) now only picks the host for new projects with no source and the initial host of the Settings host picker, so the copy says that. Refusal and help text say existing projects stay on their own host, the provider-scope hints point at the Settings Host menu, and the Remote Orca Servers search entry keeps "active server" as a keyword. * fix(settings): credential hints point at the setting that still scopes them --------- Co-authored-by: m4air --- docs/site/content/docs/remote-servers.mdx | 2 +- src/cli/handlers/managed-server.test.ts | 3 +- src/cli/specs/managed-server.ts | 2 +- ...ntime-environment-connectivity-handlers.ts | 4 ++- .../ipc/runtime-environments-pairing.test.ts | 2 +- src/main/ipc/settings.test.ts | 2 +- src/main/ipc/settings.ts | 2 +- src/main/ssh/orcad-runtime-decommission.ts | 4 +-- .../settings/AgentDetectionCatalog.tsx | 2 +- .../settings/RuntimeEnvironmentsPane.test.ts | 8 +++-- ...-source-control-integration-cards.test.tsx | 4 +-- .../settings/managed-server-copy.test.ts | 4 ++- .../settings/managed-server-copy.ts | 2 +- .../settings/provider-account-scope.test.ts | 8 ++--- .../settings/provider-account-scope.ts | 8 ++--- .../provider-rate-limit-scope-panels.test.tsx | 4 +-- .../runtime-active-server-section.tsx | 2 +- .../settings/runtime-environment-dialogs.tsx | 4 +-- .../runtime-environment-host-details.ts | 2 +- .../settings/runtime-environments-search.ts | 15 +++++++- .../settings/runtime-server-row.tsx | 2 +- .../task-tracker-integration-cards.test.tsx | 4 +-- ...se-runtime-environment-mutation-actions.ts | 4 +-- .../sidebar/SidebarHostScopeMenuSection.tsx | 2 +- .../src/i18n/en-runtime-required.json | 9 ----- src/renderer/src/i18n/locales/en.json | 35 ++++++++++--------- src/renderer/src/i18n/locales/es.json | 35 ++++++++++--------- src/renderer/src/i18n/locales/fr.json | 35 ++++++++++--------- src/renderer/src/i18n/locales/ja.json | 35 ++++++++++--------- src/renderer/src/i18n/locales/ko.json | 35 ++++++++++--------- src/renderer/src/i18n/locales/zh.json | 35 ++++++++++--------- 31 files changed, 172 insertions(+), 143 deletions(-) diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx index 25ad464f0028..7b11d097f533 100644 --- a/docs/site/content/docs/remote-servers.mdx +++ b/docs/site/content/docs/remote-servers.mdx @@ -94,7 +94,7 @@ On the computer you want to use as the client: caption="Share an Orca desktop runtime with another client over a private network." /> -Adding a server saves it without forcing every new project onto it. Open **Advanced → Active Server** only when you want server-routed projects, terminals, provider checks, and browser or mobile handoff to use that server by default. +Adding a server saves it without forcing every new project onto it. Open **Advanced → Default host for new projects** only when you want server-routed projects, terminals, provider checks, and browser or mobile handoff to use that server by default. ## Use the remote server diff --git a/src/cli/handlers/managed-server.test.ts b/src/cli/handlers/managed-server.test.ts index ceecb0db32e3..57d9b35c8cb3 100644 --- a/src/cli/handlers/managed-server.test.ts +++ b/src/cli/handlers/managed-server.test.ts @@ -111,7 +111,8 @@ describe('managed server CLI verbs', () => { outcome: 'refused', verdict: 'live', code: 'orcad_stop_active_environment', - reason: 'Choose another Active Server in Advanced before stopping this server.' + reason: + 'This server is the default host for new projects. Choose another one in Advanced before stopping it.' } await expect(run('environment stop', client(refusal), [['yes', true]])).rejects.toMatchObject({ code: 'managed_server_refused', diff --git a/src/cli/specs/managed-server.ts b/src/cli/specs/managed-server.ts index eb28ac439504..675f40709c94 100644 --- a/src/cli/specs/managed-server.ts +++ b/src/cli/specs/managed-server.ts @@ -59,7 +59,7 @@ export const MANAGED_SERVER_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Stops orcad on the SSH host and, once the host proves it exited, removes the server from this machine. Its terminals end. Requires --yes.', 'If the host cannot prove orcad exited, the server stays linked and the refusal says why; nothing is removed on a guess. For a host that is gone for good, use `orca environment forget`.', - 'Pick another Active Server first if this one is active. `orca environment cancel-stop` withdraws a stop orcad has not acted on yet.', + 'If this server is the default host for new projects, choose another one first. `orca environment cancel-stop` withdraws a stop orcad has not acted on yet.', SELECTOR_NOTE, DESKTOP_NOTE ], diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 013cdef9ea3a..1b23ccde6487 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -105,7 +105,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ (_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => { const environment = resolveEnvironment(getUserDataPath(), args.selector) if (store.getSettings().activeRuntimeEnvironmentId === environment.id) { - throw new Error('Choose another Active Server in Advanced before removing this server.') + throw new Error( + 'This server is the default host for new projects. Choose another one in Advanced before removing it.' + ) } const removed = removeEnvironment(getUserDataPath(), args.selector) void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, (hostId) => diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 4296904b2bd9..c435a05ab81c 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -476,7 +476,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { ) expect(() => remove(null, { selector: added.environment.id })).toThrow( - 'Choose another Active Server in Advanced' + 'Choose another one in Advanced before removing it.' ) expect(activeRuntimeEnvironmentId).toBe(added.environment.id) expect(store.updateSettings).not.toHaveBeenCalled() diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index b1b4a8cdea47..80757d55c922 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -279,7 +279,7 @@ describe('registerSettingsHandlers', () => { ) expect(() => handler(settingsInvokeEvent, { environmentId: 42 as never })).toThrow( - 'Invalid Active Server preference' + 'Invalid default host for new projects' ) expect(() => handler(settingsInvokeEvent, { environmentId: 'does-not-exist' })).toThrow( 'Runtime environment not found' diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 8442bfd86829..1200a1803d7c 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -310,7 +310,7 @@ export function registerSettingsHandlers( (event, args: { environmentId?: unknown }): GlobalSettings => { const requestedEnvironmentId = args?.environmentId if (requestedEnvironmentId !== null && typeof requestedEnvironmentId !== 'string') { - throw new Error('Invalid Active Server preference') + throw new Error('Invalid default host for new projects') } const requestedId = requestedEnvironmentId?.trim() || null const environmentId = diff --git a/src/main/ssh/orcad-runtime-decommission.ts b/src/main/ssh/orcad-runtime-decommission.ts index a0c5618631c5..0247771b0621 100644 --- a/src/main/ssh/orcad-runtime-decommission.ts +++ b/src/main/ssh/orcad-runtime-decommission.ts @@ -55,7 +55,7 @@ export function stopManagedOrcadEnvironment( return refuse( 'live', 'orcad_stop_active_environment', - 'Choose another Active Server in Advanced before stopping this server.' + 'This server is the default host for new projects. Choose another one in Advanced before stopping it.' ) } const stopped = await stopRemote(userDataPath, environment, deployment, args.signal) @@ -89,7 +89,7 @@ export function forgetManagedOrcadEnvironment( return refuse( 'live', 'orcad_forget_active_environment', - 'Choose another Active Server in Advanced before forgetting this server.' + 'This server is the default host for new projects. Choose another one in Advanced before forgetting it.' ) } await unlinkEnvironmentLocally(userDataPath, environment, deployment, policy) diff --git a/src/renderer/src/components/settings/AgentDetectionCatalog.tsx b/src/renderer/src/components/settings/AgentDetectionCatalog.tsx index e00924d4d4f9..fe199cfbe8dd 100644 --- a/src/renderer/src/components/settings/AgentDetectionCatalog.tsx +++ b/src/renderer/src/components/settings/AgentDetectionCatalog.tsx @@ -71,7 +71,7 @@ export function AgentDetectionCatalog({ activeServerEnvironmentId ? translate( 'auto.components.settings.AgentsPane.25a41a9aad', - 'Re-detect agents installed on the active server' + 'Re-detect agents installed on this server' ) : translate( 'auto.components.settings.AgentsPane.13647f9f80', diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts index 6f0a1a1273ea..25f4ff3a7184 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -289,9 +289,11 @@ describe('RuntimeEnvironmentsPane host details', () => { ).toBe('disconnected') }) - it('explains that selecting a saved server is the explicit default Host mode', () => { - expect(getActiveServerModeDescription(true)).toContain('Use this computer by default') - expect(getActiveServerModeDescription(true)).toContain('browser/mobile handoff') + it('explains that the choice only picks the host for new projects', () => { + expect(getActiveServerModeDescription(true)).toContain( + 'New projects are created on this computer' + ) + expect(getActiveServerModeDescription(true)).toContain('stay on their own host') expect(getActiveServerModeDescription(false)).toContain('default Host') expect(getActiveServerModeDescription(false)).toContain('paired Orca runtime') }) diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx index c5965f17289b..714525782a2d 100644 --- a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx @@ -93,7 +93,7 @@ describe('CLI source-control integration card account scope', () => { expect(rendered.textContent).toContain('Connected') expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`) expect(rendered.textContent).toContain( - 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + 'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.' ) await act(async () => { Array.from(rendered.querySelectorAll('button')) @@ -122,7 +122,7 @@ describe('CLI source-control integration card account scope', () => { expect(rendered.textContent).toContain('GitLab') expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') expect(rendered.textContent).toContain( - 'Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + 'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.' ) expect(rendered.textContent).toContain('glab auth login') }) diff --git a/src/renderer/src/components/settings/managed-server-copy.test.ts b/src/renderer/src/components/settings/managed-server-copy.test.ts index 0329f065b6a8..6529467664f4 100644 --- a/src/renderer/src/components/settings/managed-server-copy.test.ts +++ b/src/renderer/src/components/settings/managed-server-copy.test.ts @@ -36,7 +36,9 @@ describe('managed server action refusals', () => { code: 'orcad_stop_active_environment', verdict: 'live' }) - ).toBe('Not done: choose another Active Server in Advanced before stopping this server.') + ).toBe( + 'Not done: this server is the default host for new projects. Choose another one in Advanced before stopping it.' + ) }) it.each([ diff --git a/src/renderer/src/components/settings/managed-server-copy.ts b/src/renderer/src/components/settings/managed-server-copy.ts index a84757a68c57..3a924b767221 100644 --- a/src/renderer/src/components/settings/managed-server-copy.ts +++ b/src/renderer/src/components/settings/managed-server-copy.ts @@ -144,7 +144,7 @@ export function managedServerOutcomeLabel(result: { if (code === 'orcad_stop_active_environment') { return translate( 'auto.components.settings.managedServers.outcome.activeServer', - 'Not done: choose another Active Server in Advanced before stopping this server.' + 'Not done: this server is the default host for new projects. Choose another one in Advanced before stopping it.' ) } if (result.verdict === 'live' || LIVE_TERMINAL_CODES.has(code)) { diff --git a/src/renderer/src/components/settings/provider-account-scope.test.ts b/src/renderer/src/components/settings/provider-account-scope.test.ts index 7c3e1e3fea46..5036199c868b 100644 --- a/src/renderer/src/components/settings/provider-account-scope.test.ts +++ b/src/renderer/src/components/settings/provider-account-scope.test.ts @@ -13,7 +13,7 @@ describe('getProviderAccountScope', () => { expect(getProviderAccountScope({ activeRuntimeEnvironmentId: null })).toEqual({ label: LOCAL_HOST_LABEL, description: - 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + 'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.' }) }) @@ -21,7 +21,7 @@ describe('getProviderAccountScope', () => { expect(getProviderAccountScope({ activeRuntimeEnvironmentId: ' env-1 ' })).toEqual({ label: 'Remote server: env-1', description: - 'Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + 'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.' }) }) @@ -29,14 +29,14 @@ describe('getProviderAccountScope', () => { expect(getProviderRateLimitScope({ kind: 'local' }, 'GitHub')).toEqual({ label: LOCAL_HOST_LABEL, description: - 'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.' + 'GitHub API budget is fetched from the CLI on this desktop client. Choose a server in the Host menu at the top of Settings to see its budget.' }) expect( getProviderRateLimitScope({ kind: 'environment', environmentId: 'env-1' }, 'GitLab') ).toEqual({ label: 'Remote server: env-1', description: - 'GitLab API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.' + 'GitLab API budget is fetched from the CLI on this remote server. Choose another host in the Host menu at the top of Settings to see its budget.' }) }) }) diff --git a/src/renderer/src/components/settings/provider-account-scope.ts b/src/renderer/src/components/settings/provider-account-scope.ts index cba1e1654078..54bb607268b4 100644 --- a/src/renderer/src/components/settings/provider-account-scope.ts +++ b/src/renderer/src/components/settings/provider-account-scope.ts @@ -50,7 +50,7 @@ export function getProviderAccountScope( ), description: translate( 'auto.components.settings.providerAccountScope.remoteServerCredentials', - 'Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + 'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.' ) } } @@ -58,7 +58,7 @@ export function getProviderAccountScope( label: getLocalExecutionHostLabel(), description: translate( 'auto.components.settings.providerAccountScope.localCredentials', - 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + 'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.' ) } } @@ -77,7 +77,7 @@ export function getProviderRateLimitScope( ), description: translate( 'auto.components.settings.providerAccountScope.remoteServerRateLimit', - '{{value0}} API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.', + '{{value0}} API budget is fetched from the CLI on this remote server. Choose another host in the Host menu at the top of Settings to see its budget.', { value0: providerLabel } ) } @@ -86,7 +86,7 @@ export function getProviderRateLimitScope( label: getLocalExecutionHostLabel(), description: translate( 'auto.components.settings.providerAccountScope.localRateLimit', - '{{value0}} API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.', + '{{value0}} API budget is fetched from the CLI on this desktop client. Choose a server in the Host menu at the top of Settings to see its budget.', { value0: providerLabel } ) } diff --git a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx index 7b1f0a1bcdc7..06dde826e0ed 100644 --- a/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx +++ b/src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx @@ -38,7 +38,7 @@ describe('provider rate-limit panels account scope', () => { expect(markup).toContain(`Budget scope: ${LOCAL_HOST_LABEL}`) expect(markup).toContain( - 'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.' + 'GitHub API budget is fetched from the CLI on this desktop client. Choose a server in the Host menu at the top of Settings to see its budget.' ) expect(markup).toContain('Open Remote Servers') }) @@ -56,7 +56,7 @@ describe('provider rate-limit panels account scope', () => { expect(markup).toContain('Budget scope: Remote server: runtime-1') expect(markup).toContain( - 'GitLab API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.' + 'GitLab API budget is fetched from the CLI on this remote server. Choose another host in the Host menu at the top of Settings to see its budget.' ) }) }) diff --git a/src/renderer/src/components/settings/runtime-active-server-section.tsx b/src/renderer/src/components/settings/runtime-active-server-section.tsx index 0a7c4265999e..f8e48abce1d3 100644 --- a/src/renderer/src/components/settings/runtime-active-server-section.tsx +++ b/src/renderer/src/components/settings/runtime-active-server-section.tsx @@ -80,7 +80,7 @@ export function RuntimeActiveServerSection({

diff --git a/src/renderer/src/components/settings/runtime-environment-dialogs.tsx b/src/renderer/src/components/settings/runtime-environment-dialogs.tsx index 990f9d5d713c..07b520573d5c 100644 --- a/src/renderer/src/components/settings/runtime-environment-dialogs.tsx +++ b/src/renderer/src/components/settings/runtime-environment-dialogs.tsx @@ -104,11 +104,11 @@ export function RuntimeEnvironmentRemoveDialog({ {removingActiveServer ? translate( 'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerDescription', - 'Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.' + 'This server is the default host for new projects. Choose another one in Advanced before removing it. Existing host sessions are left alone.' ) : translate( 'auto.components.settings.RuntimeEnvironmentsPane.ed3e3f069d', - 'This removes the saved server from Orca. It does not change the active server.' + 'This removes the saved server from Orca. It does not change the default host for new projects.' )} diff --git a/src/renderer/src/components/settings/runtime-environment-host-details.ts b/src/renderer/src/components/settings/runtime-environment-host-details.ts index a4ce9cafd825..cab0e5b4fcd0 100644 --- a/src/renderer/src/components/settings/runtime-environment-host-details.ts +++ b/src/renderer/src/components/settings/runtime-environment-host-details.ts @@ -156,7 +156,7 @@ export function getActiveServerModeDescription(allowLocalRuntime: boolean): stri return allowLocalRuntime ? translate( 'auto.components.settings.RuntimeEnvironmentsPane.3f67e8078a', - 'Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.' + 'New projects are created on this computer unless you choose a saved server here. Existing projects, files and terminals stay on their own host.' ) : translate( 'auto.components.settings.RuntimeEnvironmentsPane.2c85efb3e8', diff --git a/src/renderer/src/components/settings/runtime-environments-search.ts b/src/renderer/src/components/settings/runtime-environments-search.ts index 4cd1c92cd232..cd2dcaf6edd5 100644 --- a/src/renderer/src/components/settings/runtime-environments-search.ts +++ b/src/renderer/src/components/settings/runtime-environments-search.ts @@ -12,7 +12,7 @@ export const getRuntimeEnvironmentsSearchEntry = createLocalizedCatalog( ), description: translate( 'auto.components.settings.runtime.environments.search.4575341c77', - 'Add a saved remote Orca server, generate a pairing URL, or adjust the advanced default runtime.' + 'Add a saved remote Orca server, generate a pairing URL, or choose the default host for new projects.' ), keywords: [ ...translateSearchKeyword( @@ -59,6 +59,19 @@ export const getRuntimeEnvironmentsSearchEntry = createLocalizedCatalog( 'auto.components.settings.runtime.environments.search.c6e5a03aa0', 'dev box' ), + ...translateSearchKeyword( + 'auto.components.settings.runtime.environments.search.defaultHostKeyword', + 'default host' + ), + ...translateSearchKeyword( + 'auto.components.settings.runtime.environments.search.newProjectsKeyword', + 'new projects' + ), + // Why: the setting's former name, so existing users can still find it. + ...translateSearchKeyword( + 'auto.components.settings.runtime.environments.search.activeServerKeyword', + 'active server' + ), ...translateSearchKeyword( 'auto.components.settings.managedServers.search.title', 'Managed servers' diff --git a/src/renderer/src/components/settings/runtime-server-row.tsx b/src/renderer/src/components/settings/runtime-server-row.tsx index 3a8f30deecf5..d18598611494 100644 --- a/src/renderer/src/components/settings/runtime-server-row.tsx +++ b/src/renderer/src/components/settings/runtime-server-row.tsx @@ -137,7 +137,7 @@ export function RuntimeServerRow({ : isActive ? translate( 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', - 'Active server for server-routed projects, terminals, and provider checks.' + 'Default host for new projects. Existing projects stay on their own host.' ) : getHostDetailsSummary(effectiveDetails)}

diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 1b3610f84665..83a70438da74 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -129,7 +129,7 @@ describe('LinearIntegrationCard account scope', () => { expect(rendered.querySelector('[data-settings-section="integrations-linear"]')).not.toBeNull() expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`) expect(rendered.textContent).toContain( - 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' + 'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.' ) expect(rendered.textContent).toContain('Open Remote Servers') expect(rendered.textContent).toContain('Add access with a Personal API key') @@ -150,7 +150,7 @@ describe('LinearIntegrationCard account scope', () => { expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') expect(rendered.textContent).toContain( - 'Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.' + 'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.' ) await act(async () => { Array.from(rendered.querySelectorAll('button')) diff --git a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts index 0a9b83d14b7b..641e0ccbd71c 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts @@ -105,7 +105,7 @@ export function useRuntimeEnvironmentMutationActions({ toast.success( translate( 'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df', - 'Connected to {{value0}}. Use Advanced > Active Server to make it the default.', + 'Connected to {{value0}}. To create new projects there by default, choose it under Advanced > Default host for new projects.', { value0: result.environment.name } ) ) @@ -143,7 +143,7 @@ export function useRuntimeEnvironmentMutationActions({ setRemoveError( translate( 'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerBlocked', - 'Choose another Active Server in Advanced before removing this server.' + 'This server is the default host for new projects. Choose another one in Advanced before removing it.' ) ) } diff --git a/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx index b3d4d8e75098..f6f166d18dd0 100644 --- a/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarHostScopeMenuSection.tsx @@ -53,7 +53,7 @@ function getHostMetadata(host: SidebarHostOption): string { host.presence === 'active' ? translate( 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.activeRuntimeHost', - 'Active server' + 'Default for new projects' ) : translate( 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.projectRuntimeHost', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 27ca218c0872..b11ba5983c89 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1458,7 +1458,6 @@ "1826bd0608": "Saved Servers", "54ebacc600": "Server name", "55fcc964cd": "on the server and paste the printed pairing URL.", - "7b5986c8df": "Saved {{value0}}. Use Active Server to switch when ready.", "8cf8790697": "Saved servers route this browser through a paired Orca runtime.", "960e901ae4": "orca serve --pairing-address ", "9bc9b83474": "Pairing code", @@ -1790,14 +1789,6 @@ "vm": "vm" } }, - "runtime": { - "environments": { - "search": { - "3517fb2ec0": "Active Server", - "4575341c77": "Choose local desktop, add a saved remote Orca server, or generate a pairing URL." - } - } - }, "settingOwnership": { "clientDefaultProjectScopes": "Client default + project scopes", "terminalQuickCommands": "Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context." diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bdab634f7c2b..5b9378d6efea 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5746,7 +5746,7 @@ "allHostsDetail": "Show every host", "configuredSshHost": "Configured SSH", "projectSshHost": "Project SSH", - "activeRuntimeHost": "Active server", + "activeRuntimeHost": "Default for new projects", "projectRuntimeHost": "Project server", "95c9754653": "Agent activity layout", "3d4b9c4997": "Hover", @@ -6971,7 +6971,7 @@ "e8da2af684": "Available to install", "ed3e110e61": "detected", "03e1a5081a": "on {{value0}}", - "25a41a9aad": "Re-detect agents installed on the active server", + "25a41a9aad": "Re-detect agents installed on this server", "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", "retryDetection": "Retry", "02e0143be5": "Installed", @@ -8443,16 +8443,16 @@ "6ce4664003": "Refresh servers", "b07070ed3c": "No server connected", "78692becbd": "Local desktop", - "64b6bea541": "Active Server", + "64b6bea541": "Default host for new projects", "99ac81fb43": "Switched to {{value0}}.", "b5b5114cb0": "Removed {{value0}}.", "6cb6eae14f": "Failed to save runtime environment.", - "7b5986c8df": "Saved {{value0}}. Use Active Server to switch when ready.", + "7b5986c8df": "Connected to {{value0}}. To create new projects there by default, choose it under Advanced > Default host for new projects.", "5ef712f407": "A server named \"{{value0}}\" already exists.", "0c55a47480": "Name and pairing code are required.", "e6410d72c3": "Failed to load runtime environments.", "6ef71985da": "No endpoint", - "ed3e3f069d": "This removes the saved server from Orca. It does not change the active server.", + "ed3e3f069d": "This removes the saved server from Orca. It does not change the default host for new projects.", "b2fda48c39": "Removing the active server disconnects this browser from that host. Existing host sessions are left alone.", "9f7665a01b": "Removing the active server first switches Orca back to Local desktop. Existing host sessions are left alone.", "3595fd1948": "New Link", @@ -8467,7 +8467,7 @@ "hostModelCapabilityProjectSetup": "project setup", "hostModelCapabilityTaskSourceContext": "task source context", "hostModelCapabilityWorkspaceRunContext": "workspace run context", - "3f67e8078a": "Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.", + "3f67e8078a": "New projects are created on this computer unless you choose a saved server here. Existing projects, files and terminals stay on their own host.", "2c85efb3e8": "Selecting a saved server makes this browser use that paired Orca runtime as its default Host.", "serverConnected": "Connected", "serverWorkspaceWindowClosed": "Workspace window closed", @@ -8479,7 +8479,7 @@ "disconnectedServer": "Disconnected from {{value0}}.", "connectToRemoteServers": "Connect to remote servers", "connectToRemoteServersHelp": "Pair another Orca runtime, then connect or disconnect it here.", - "activeServerRowHelp": "Active server for server-routed projects, terminals, and provider checks.", + "activeServerRowHelp": "Default host for new projects. Existing projects stay on their own host.", "disconnect": "Disconnect", "connect": "Connect", "advanced": "Advanced", @@ -8494,8 +8494,8 @@ "reviewServerUpdates": "Check for Server Updates", "updatingServers": "Updating servers…", "orcaVersion": "Orca v{{value0}}", - "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", - "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", + "removeActiveServerBlocked": "This server is the default host for new projects. Choose another one in Advanced before removing it.", + "removeActiveServerDescription": "This server is the default host for new projects. Choose another one in Advanced before removing it. Existing host sessions are left alone.", "workflow": "Remote server workflow", "connectWorkflow": "Connect to a host", "connectWorkflowHelp": "This app joins another machine", @@ -10744,12 +10744,15 @@ "ebd5369acf": "environment", "d198440ce3": "runtime", "baec27aa8f": "Connect this browser to a saved Orca server.", - "3517fb2ec0": "Active Server", + "3517fb2ec0": "Remote Orca Servers", "c6e5a03aa0": "dev box", "f1575f1e09": "web client", "81444c4102": "pairing url", "104f4d7dbd": "pairing", - "4575341c77": "Choose local desktop, add a saved remote Orca server, or generate a pairing URL." + "4575341c77": "Add a saved remote Orca server, generate a pairing URL, or choose the default host for new projects.", + "defaultHostKeyword": "default host", + "newProjectsKeyword": "new projects", + "activeServerKeyword": "active server" } } }, @@ -11335,11 +11338,11 @@ }, "providerAccountScope": { "remoteServer": "Remote server: {{value0}}", - "remoteServerCredentials": "Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.", + "remoteServerCredentials": "Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.", "localMac": "Local Mac", - "localCredentials": "Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.", - "remoteServerRateLimit": "{{value0}} API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.", - "localRateLimit": "{{value0}} API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets." + "localCredentials": "Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.", + "remoteServerRateLimit": "{{value0}} API budget is fetched from the CLI on this remote server. Choose another host in the Host menu at the top of Settings to see its budget.", + "localRateLimit": "{{value0}} API budget is fetched from the CLI on this desktop client. Choose a server in the Host menu at the top of Settings to see its budget." }, "settingOwnership": { "clientDefault": "Client default", @@ -12586,7 +12589,7 @@ }, "loadFailed": "Orca couldn’t load SSH hosts and unfinished moves. Reopen Settings to try again.", "outcome": { - "activeServer": "Not done: choose another Active Server in Advanced before stopping this server.", + "activeServer": "Not done: this server is the default host for new projects. Choose another one in Advanced before stopping it.", "live": "Not done: terminals on this server are still running. Close them and try again.", "unverifiable": "Not done: Orca couldn’t confirm what is running on this server. Try again when the host is reachable.", "migration": "Not done: a move onto this server is unfinished. Resume it first.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 171dddaaf12c..456eefb9692d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5520,7 +5520,7 @@ "allHostsDetail": "Mostrar todos los hosts", "configuredSshHost": "SSH configurado", "projectSshHost": "SSH del proyecto", - "activeRuntimeHost": "Servidor activo", + "activeRuntimeHost": "Predeterminado para proyectos nuevos", "projectRuntimeHost": "Servidor del proyecto", "631b97eea9": "Ámbito del host", "2d4f0eb933": "Predeterminado", @@ -6766,7 +6766,7 @@ "codexSessionSourceInfo": "Sobre la importación del historial de Codex", "codexSessionSourceTooltip": "Orca ejecuta Codex en un directorio aislado. Señala aquí a tu directorio Codex existente para importar el historial de sesiones. Dejar vacío usa ~/.codex.", "03e1a5081a": "on {{value0}}", - "25a41a9aad": "Re-detect agents installed on the active server", + "25a41a9aad": "Volver a detectar los agentes instalados en este servidor", "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", "retryDetection": "Retry", "storedDefaultUndetected": "Guardado como predeterminado, pero no detectado en este momento", @@ -8147,16 +8147,16 @@ "6ce4664003": "Actualizar servidores", "b07070ed3c": "Ningún servidor conectado", "78692becbd": "Escritorio local", - "64b6bea541": "Servidor activo", + "64b6bea541": "Host predeterminado para proyectos nuevos", "99ac81fb43": "Cambiado a {{value0}}.", "b5b5114cb0": "Se eliminó {{value0}}.", "6cb6eae14f": "No se pudo guardar el runtime.", - "7b5986c8df": "Se guardó {{value0}}. Usa Servidor activo para cambiar cuando esté listo.", + "7b5986c8df": "Conectado a {{value0}}. Para crear proyectos nuevos allí de forma predeterminada, elígelo en Avanzado > Host predeterminado para proyectos nuevos.", "5ef712f407": "Ya existe un servidor llamado \"{{value0}}\".", "0c55a47480": "Se requieren nombre y código de emparejamiento.", "e6410d72c3": "No se pudieron cargar los runtimes.", "6ef71985da": "Sin punto final", - "ed3e3f069d": "Esto elimina el servidor guardado de Orca. No cambia el servidor activo.", + "ed3e3f069d": "Esto elimina el servidor guardado de Orca. No cambia el host predeterminado para proyectos nuevos.", "b2fda48c39": "Al quitar el servidor activo, este navegador se desconecta de ese host. Las sesiones existentes del host se conservan.", "9f7665a01b": "Al quitar el servidor activo, Orca primero vuelve al Escritorio local. Las sesiones existentes del host se conservan.", "3595fd1948": "Nuevo enlace", @@ -8178,7 +8178,7 @@ "hostModelCapabilityProjectSetup": "configuración del proyecto", "hostModelCapabilityTaskSourceContext": "contexto de origen de tareas", "hostModelCapabilityWorkspaceRunContext": "contexto de ejecución del workspace", - "3f67e8078a": "Usa esta computadora de forma predeterminada. Elige un servidor guardado solo cuando quieras que los proyectos, archivos, terminales, checks de proveedores y traspaso entre navegador/móvil compatibles pasen por ese servidor.", + "3f67e8078a": "Los proyectos nuevos se crean en este equipo a menos que elijas aquí un servidor guardado. Los proyectos, archivos y terminales existentes se quedan en su propio host.", "2c85efb3e8": "Al seleccionar un servidor guardado, este navegador usará ese runtime de Orca emparejado como host predeterminado.", "serverConnected": "Conectado", "serverChecking": "Comprobando…", @@ -8186,7 +8186,7 @@ "disconnectedServer": "Desconectado de {{value0}}.", "connectToRemoteServers": "Conectarse a servidores remotos", "connectToRemoteServersHelp": "Empareja otro runtime de Orca y luego conéctalo o desconéctalo aquí. Usa Avanzado > Servidor activo solo cuando quieras cambiar el host predeterminado.", - "activeServerRowHelp": "Servidor activo para proyectos, terminales y checks de proveedores enrutados por servidor.", + "activeServerRowHelp": "Host predeterminado para proyectos nuevos. Los proyectos existentes se quedan en su propio host.", "disconnect": "Desconectar", "connect": "Conectar", "advanced": "Avanzado", @@ -8200,8 +8200,8 @@ "updateServer": "Update", "reviewServerUpdates": "Server updates", "orcaVersion": "Orca v{{value0}}", - "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", - "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", + "removeActiveServerBlocked": "Este servidor es el host predeterminado para proyectos nuevos. Elige otro en Avanzado antes de eliminarlo.", + "removeActiveServerDescription": "Este servidor es el host predeterminado para proyectos nuevos. Elige otro en Avanzado antes de eliminarlo. Las sesiones existentes del host no se modifican.", "updatingServers": "Updating servers…", "workflow": "Flujo de servidor remoto", "connectWorkflow": "Conectarse a un host", @@ -10446,12 +10446,15 @@ "ebd5369acf": "entorno", "d198440ce3": "runtime", "baec27aa8f": "Conecte este navegador a un servidor Orca guardado.", - "3517fb2ec0": "Servidores Orca remotos", + "3517fb2ec0": "Servidores remotos de Orca", "c6e5a03aa0": "dev box", "f1575f1e09": "cliente web", "81444c4102": "URL de emparejamiento", "104f4d7dbd": "emparejamiento", - "4575341c77": "Agrega un servidor Orca remoto guardado, genera una URL de emparejamiento o ajusta el runtime predeterminado avanzado." + "4575341c77": "Añade un servidor remoto de Orca guardado, genera una URL de emparejamiento o elige el host predeterminado para proyectos nuevos.", + "defaultHostKeyword": "host predeterminado", + "newProjectsKeyword": "proyectos nuevos", + "activeServerKeyword": "servidor activo" } } }, @@ -11101,11 +11104,11 @@ }, "providerAccountScope": { "remoteServer": "Servidor remoto: {{value0}}", - "remoteServerCredentials": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este servidor remoto. Usa Configuración > Servidores Orca remotos > Avanzado para editar otro ámbito de runtime predeterminado.", + "remoteServerCredentials": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este servidor remoto. Para usar otro host, cambia Ajustes > Servidores remotos de Orca > Avanzado > Host predeterminado para proyectos nuevos.", "localMac": "Local Mac", - "localCredentials": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este cliente de escritorio. Usa Configuración > Servidores Orca remotos > Avanzado para editar credenciales propiedad del servidor.", - "remoteServerRateLimit": "El presupuesto de API de {{value0}} se obtiene desde la CLI en este servidor remoto. Usa Configuración > Servidores Orca remotos > Avanzado para ver otro presupuesto de runtime predeterminado.", - "localRateLimit": "El presupuesto de API de {{value0}} se obtiene desde la CLI en este cliente de escritorio. Usa Configuración > Servidores Orca remotos > Avanzado para ver presupuestos propiedad del servidor." + "localCredentials": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este cliente de escritorio. Para usar un servidor, cambia Ajustes > Servidores remotos de Orca > Avanzado > Host predeterminado para proyectos nuevos.", + "remoteServerRateLimit": "El presupuesto de API de {{value0}} se obtiene de la CLI en este servidor remoto. Elige otro host en el menú Host de la parte superior de Ajustes para ver su presupuesto.", + "localRateLimit": "El presupuesto de API de {{value0}} se obtiene de la CLI en este cliente de escritorio. Elige un servidor en el menú Host de la parte superior de Ajustes para ver su presupuesto." }, "settingOwnership": { "clientDefault": "Predeterminado del cliente", @@ -12381,7 +12384,7 @@ }, "loadFailed": "Orca no pudo cargar los hosts SSH ni los movimientos sin terminar. Vuelve a abrir Ajustes e inténtalo de nuevo.", "outcome": { - "activeServer": "No se hizo: elige otro Servidor activo en Avanzado antes de detener este servidor.", + "activeServer": "No se hizo: este servidor es el host predeterminado para proyectos nuevos. Elige otro en Avanzado antes de detenerlo.", "live": "No se hizo: los terminales de este servidor siguen en ejecución. Ciérralos e inténtalo de nuevo.", "unverifiable": "No se hizo: Orca no pudo confirmar qué se ejecuta en este servidor. Inténtalo de nuevo cuando el host esté disponible.", "migration": "No se hizo: un movimiento a este servidor quedó sin terminar. Reanúdalo primero.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 90bbf539e45b..ce52605ddbbc 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -5609,7 +5609,7 @@ "allHostsDetail": "Afficher tous les hôtes", "configuredSshHost": "SSH configuré", "projectSshHost": "SSH du projet", - "activeRuntimeHost": "Serveur actif", + "activeRuntimeHost": "Par défaut pour les nouveaux projets", "projectRuntimeHost": "Serveur du projet", "95c9754653": "Disposition de l'activité des agents", "3d4b9c4997": "Survol", @@ -6833,7 +6833,7 @@ "e8da2af684": "Disponibles à l'installation", "ed3e110e61": "détecté", "03e1a5081a": "sur {{value0}}", - "25a41a9aad": "Redétecter les agents installés sur le serveur actif", + "25a41a9aad": "Détecter à nouveau les agents installés sur ce serveur", "remoteDetectionFailed": "Impossible de détecter les agents installés. Vérifiez la connexion à l'hôte puis réessayez.", "retryDetection": "Réessayer", "02e0143be5": "Installés", @@ -8298,16 +8298,16 @@ "6ce4664003": "Actualiser les serveurs", "b07070ed3c": "Aucun serveur connecté", "78692becbd": "Bureau local", - "64b6bea541": "Serveur actif", + "64b6bea541": "Hôte par défaut des nouveaux projets", "99ac81fb43": "Passé à {{value0}}.", "b5b5114cb0": "{{value0}} supprimé.", "6cb6eae14f": "Échec de l'enregistrement de l'environnement d'exécution.", - "7b5986c8df": "{{value0}} enregistré. Utilisez « Serveur actif » pour basculer quand vous le souhaitez.", + "7b5986c8df": "Connecté à {{value0}}. Pour y créer les nouveaux projets par défaut, choisissez-le dans Avancé > Hôte par défaut des nouveaux projets.", "5ef712f407": "Un serveur nommé « {{value0}} » existe déjà.", "0c55a47480": "Le nom et le code d'appairage sont requis.", "e6410d72c3": "Échec du chargement des environnements d'exécution.", "6ef71985da": "Aucun endpoint", - "ed3e3f069d": "Ceci supprime le serveur enregistré d'Orca. Cela ne change pas le serveur actif.", + "ed3e3f069d": "Cela supprime le serveur enregistré d'Orca. L'hôte par défaut des nouveaux projets ne change pas.", "b2fda48c39": "Supprimer le serveur actif déconnecte ce navigateur de cet hôte. Les sessions existantes de l'hôte ne sont pas touchées.", "9f7665a01b": "Supprimer le serveur actif fait d'abord repasser Orca sur Bureau local. Les sessions existantes de l'hôte ne sont pas touchées.", "3595fd1948": "Nouveau lien", @@ -8322,7 +8322,7 @@ "hostModelCapabilityProjectSetup": "configuration de projet", "hostModelCapabilityTaskSourceContext": "contexte de source de tâche", "hostModelCapabilityWorkspaceRunContext": "contexte d'exécution de l'espace de travail", - "3f67e8078a": "Utilise cet ordinateur par défaut. Ne choisissez un serveur enregistré que si vous voulez faire passer projets, fichiers, terminaux, vérifications de fournisseurs et passation navigateur/mobile pris en charge par ce serveur.", + "3f67e8078a": "Les nouveaux projets sont créés sur cet ordinateur, sauf si vous choisissez ici un serveur enregistré. Les projets, fichiers et terminaux existants restent sur leur propre hôte.", "2c85efb3e8": "Sélectionner un serveur enregistré fait de ce runtime Orca appairé l'Hôte par défaut de ce navigateur.", "serverConnected": "Connecté", "serverChecking": "Vérification…", @@ -8330,7 +8330,7 @@ "disconnectedServer": "Déconnecté de {{value0}}.", "connectToRemoteServers": "Se connecter aux serveurs distants", "connectToRemoteServersHelp": "Appairez un autre runtime Orca, puis connectez-le ou déconnectez-le ici.", - "activeServerRowHelp": "Serveur actif pour les projets, terminaux et vérifications de fournisseurs routés via le serveur.", + "activeServerRowHelp": "Hôte par défaut des nouveaux projets. Les projets existants restent sur leur propre hôte.", "disconnect": "Déconnecter", "connect": "Se connecter", "advanced": "Avancé", @@ -8345,8 +8345,8 @@ "reviewServerUpdates": "Rechercher des mises à jour du serveur", "updatingServers": "Mise à jour des serveurs…", "orcaVersion": "Orca v{{value0}}", - "removeActiveServerBlocked": "Choisissez un autre Serveur actif dans Avancé avant de supprimer ce serveur.", - "removeActiveServerDescription": "Choisissez un autre Serveur actif dans Avancé avant de supprimer ce serveur. Les sessions existantes de l'hôte ne sont pas touchées.", + "removeActiveServerBlocked": "Ce serveur est l'hôte par défaut des nouveaux projets. Choisissez-en un autre dans Avancé avant de le supprimer.", + "removeActiveServerDescription": "Ce serveur est l'hôte par défaut des nouveaux projets. Choisissez-en un autre dans Avancé avant de le supprimer. Les sessions existantes de l'hôte ne sont pas touchées.", "workflow": "Flux de travail avec serveur distant", "connectWorkflow": "Se connecter à un hôte", "connectWorkflowHelp": "Cette app rejoint une autre machine", @@ -10594,12 +10594,15 @@ "ebd5369acf": "environnement", "d198440ce3": "runtime", "baec27aa8f": "Connectez ce navigateur à un serveur Orca enregistré.", - "3517fb2ec0": "Serveur actif", + "3517fb2ec0": "Serveurs Orca distants", "c6e5a03aa0": "dev box", "f1575f1e09": "client web", "81444c4102": "URL d'appairage", "104f4d7dbd": "appairage", - "4575341c77": "Choisissez le bureau local, ajoutez un serveur Orca distant enregistré ou générez une URL d'appairage." + "4575341c77": "Ajoutez un serveur Orca distant enregistré, générez une URL d'appairage ou choisissez l'hôte par défaut des nouveaux projets.", + "defaultHostKeyword": "hôte par défaut", + "newProjectsKeyword": "nouveaux projets", + "activeServerKeyword": "serveur actif" } } }, @@ -11185,11 +11188,11 @@ }, "providerAccountScope": { "remoteServer": "Serveur distant : {{value0}}", - "remoteServerCredentials": "Les identifiants et vérifications de compte pour ce fournisseur appartiennent à ce serveur distant. Utilisez Paramètres > Serveurs distants Orca > Avancé pour modifier la portée d'un autre runtime par défaut.", + "remoteServerCredentials": "Les identifiants et vérifications de compte de ce fournisseur appartiennent à ce serveur distant. Pour utiliser un autre hôte, modifiez Paramètres > Serveurs Orca distants > Avancé > Hôte par défaut des nouveaux projets.", "localMac": "Mac local", - "localCredentials": "Les identifiants et vérifications de compte pour ce fournisseur appartiennent à ce client de bureau. Utilisez Paramètres > Serveurs distants Orca > Avancé pour modifier les identifiants détenus par le serveur.", - "remoteServerRateLimit": "Le budget d'API {{value0}} est récupéré via la CLI sur ce serveur distant. Utilisez Paramètres > Serveurs distants Orca > Avancé pour afficher le budget d'un autre runtime par défaut.", - "localRateLimit": "Le budget d'API {{value0}} est récupéré via la CLI sur ce client de bureau. Utilisez Paramètres > Serveurs distants Orca > Avancé pour afficher les budgets détenus par le serveur." + "localCredentials": "Les identifiants et vérifications de compte de ce fournisseur appartiennent à ce client de bureau. Pour utiliser un serveur, modifiez Paramètres > Serveurs Orca distants > Avancé > Hôte par défaut des nouveaux projets.", + "remoteServerRateLimit": "Le budget d'API de {{value0}} provient de la CLI sur ce serveur distant. Choisissez un autre hôte dans le menu Hôte en haut des Paramètres pour voir son budget.", + "localRateLimit": "Le budget d'API de {{value0}} provient de la CLI sur ce client de bureau. Choisissez un serveur dans le menu Hôte en haut des Paramètres pour voir son budget." }, "settingOwnership": { "clientDefault": "Valeur par défaut du client", @@ -12487,7 +12490,7 @@ }, "loadFailed": "Orca n'a pas pu charger les hôtes SSH et les déplacements inachevés. Rouvrez les Paramètres pour réessayer.", "outcome": { - "activeServer": "Non effectué : choisissez un autre Serveur actif dans Avancé avant d’arrêter ce serveur.", + "activeServer": "Non effectué : ce serveur est l'hôte par défaut des nouveaux projets. Choisissez-en un autre dans Avancé avant de l'arrêter.", "live": "Non effectué : des terminaux sont encore en cours sur ce serveur. Fermez-les et réessayez.", "unverifiable": "Non effectué : Orca n'a pas pu confirmer ce qui tourne sur ce serveur. Réessayez lorsque l'hôte est joignable.", "migration": "Non effectué : un déplacement vers ce serveur est inachevé. Reprenez-le d'abord.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b82025a0a623..025459df0bb0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5501,7 +5501,7 @@ "allHostsDetail": "すべてのホストを表示", "configuredSshHost": "設定済み SSH", "projectSshHost": "プロジェクト SSH", - "activeRuntimeHost": "アクティブサーバー", + "activeRuntimeHost": "新規プロジェクトの既定", "projectRuntimeHost": "プロジェクトサーバー", "631b97eea9": "ホストスコープ", "2d4f0eb933": "デフォルト", @@ -6751,7 +6751,7 @@ "codexSessionSourceInfo": "Codex 履歴のインポートについて", "codexSessionSourceTooltip": "Orca は分離されたホームで Codex を実行します。既存の Codex ホームを指定してセッション履歴をインポートします。空の場合、~/.codexを使用します。", "03e1a5081a": "{{value0}} 上", - "25a41a9aad": "アクティブなサーバーにインストールされている Agent を再検出", + "25a41a9aad": "このサーバーにインストールされている Agent を再検出", "remoteDetectionFailed": "インストールされている Agent を検出できませんでした。ホストへの接続を確認して、もう一度お試しください。", "retryDetection": "再試行", "storedDefaultUndetected": "デフォルトとして保存されていますが、現在は検出されません", @@ -8169,16 +8169,16 @@ "6ce4664003": "サーバーを更新する", "b07070ed3c": "サーバーが接続されていません", "78692becbd": "ローカルデスクトップ", - "64b6bea541": "アクティブサーバー", + "64b6bea541": "新規プロジェクトの既定ホスト", "99ac81fb43": "{{value0}} に切り替えました。", "b5b5114cb0": "{{value0}} を削除しました。", "6cb6eae14f": "実行環境の保存に失敗しました。", - "7b5986c8df": "{{value0}} を保存しました。準備ができたら、アクティブサーバーで切り替えてください。", + "7b5986c8df": "{{value0}} に接続しました。新規プロジェクトを既定でそこに作成するには、[詳細設定] > [新規プロジェクトの既定ホスト] で選択してください。", "5ef712f407": "「{{value0}}」という名前のサーバーはすでに存在します。", "0c55a47480": "名前とペアリングコードは必須です。", "e6410d72c3": "ランタイム環境の読み込みに失敗しました。", "6ef71985da": "エンドポイントなし", - "ed3e3f069d": "これにより、保存されたサーバーが Orca から削除されます。アクティブなサーバーは変更されません。", + "ed3e3f069d": "保存済みサーバーを Orca から削除します。新規プロジェクトの既定ホストは変わりません。", "b2fda48c39": "アクティブサーバーを削除すると、このブラウザはそのホストから切断されます。既存のホストセッションはそのまま残ります。", "9f7665a01b": "アクティブサーバーを削除すると、まず Orca はローカルデスクトップに戻ります。既存のホストセッションはそのまま残ります。", "3595fd1948": "新規リンク", @@ -8200,7 +8200,7 @@ "hostModelCapabilityProjectSetup": "プロジェクト設定", "hostModelCapabilityTaskSourceContext": "タスクソースコンテキスト", "hostModelCapabilityWorkspaceRunContext": "ワークスペース実行コンテキスト", - "3f67e8078a": "既定ではこのコンピュータを使用します。対応するプロジェクト、ファイル、ターミナル、プロバイダーチェック、ブラウザ/モバイルの引き継ぎをそのサーバー経由で実行したい場合にのみ、保存済みサーバーを選択してください。", + "3f67e8078a": "ここで保存済みサーバーを選ばない限り、新規プロジェクトはこのコンピューターに作成されます。既存のプロジェクト、ファイル、ターミナルはそれぞれのホストに残ります。", "2c85efb3e8": "保存済みサーバーを選択すると、このブラウザはペアリング済みの Orca ランタイムを既定のホストとして使用します。", "serverConnected": "接続済み", "serverChecking": "確認中…", @@ -8208,7 +8208,7 @@ "disconnectedServer": "{{value0}} から切断しました。", "connectToRemoteServers": "リモートサーバーに接続", "connectToRemoteServersHelp": "別の Orca ランタイムをペアリングし、ここで接続または切断します。既定のホストを変更したい場合にのみ、詳細 > アクティブサーバーを使用してください。", - "activeServerRowHelp": "サーバー経由のプロジェクト、ターミナル、プロバイダーチェックに使うアクティブサーバーです。", + "activeServerRowHelp": "新規プロジェクトの既定ホストです。既存のプロジェクトはそれぞれのホストに残ります。", "disconnect": "切断", "connect": "接続", "advanced": "詳細設定", @@ -8222,8 +8222,8 @@ "updateServer": "更新", "reviewServerUpdates": "サーバーの更新を確認", "orcaVersion": "Orca v{{value0}}", - "removeActiveServerBlocked": "このサーバーを削除する前に、[詳細設定] で別のアクティブサーバーを選択してください。", - "removeActiveServerDescription": "このサーバーを削除する前に、[詳細設定] で別のアクティブサーバーを選択してください。既存のホストセッションはそのまま残ります。", + "removeActiveServerBlocked": "このサーバーは新規プロジェクトの既定ホストです。削除する前に [詳細設定] で別のホストを選択してください。", + "removeActiveServerDescription": "このサーバーは新規プロジェクトの既定ホストです。削除する前に [詳細設定] で別のホストを選択してください。既存のホストセッションはそのまま残ります。", "updatingServers": "サーバーを更新しています…", "workflow": "リモートサーバーワークフロー", "connectWorkflow": "ホストに接続", @@ -10468,12 +10468,15 @@ "ebd5369acf": "環境", "d198440ce3": "ランタイム", "baec27aa8f": "このブラウザを保存された Orca サーバーに接続します。", - "3517fb2ec0": "アクティブサーバー", + "3517fb2ec0": "リモート Orca サーバー", "c6e5a03aa0": "開発ボックス", "f1575f1e09": "ウェブクライアント", "81444c4102": "ペアリングURL", "104f4d7dbd": "ペアリング", - "4575341c77": "ローカルデスクトップを選択するか、保存されたリモート Orca サーバーを追加するか、ペアリング URL を生成します。" + "4575341c77": "保存済みのリモート Orca サーバーを追加したり、ペアリング URL を生成したり、新規プロジェクトの既定ホストを選択したりします。", + "defaultHostKeyword": "既定ホスト", + "newProjectsKeyword": "新規プロジェクト", + "activeServerKeyword": "アクティブサーバー" } } }, @@ -11101,11 +11104,11 @@ }, "providerAccountScope": { "remoteServer": "リモートサーバー: {{value0}}", - "remoteServerCredentials": "このプロバイダーの認証情報とアカウント確認は、このリモートサーバーが管理します。別の既定ランタイムスコープを編集するには、[設定] > [リモート Orca サーバー] > [詳細設定] を使用してください。", + "remoteServerCredentials": "このプロバイダーの認証情報とアカウントチェックは、このリモートサーバーが管理しています。別のホストを使うには、設定 > リモート Orca サーバー > 詳細 > 新規プロジェクトの既定ホスト を変更してください。", "localMac": "ローカルの Mac", - "localCredentials": "このプロバイダーの認証情報とアカウント確認は、このデスクトップクライアントが管理します。サーバーが管理する認証情報を編集するには、[設定] > [リモート Orca サーバー] > [詳細設定] を使用してください。", - "remoteServerRateLimit": "{{value0}} の API 予算は、このリモートサーバー上の CLI から取得されます。別の既定ランタイムの予算を確認するには、[設定] > [リモート Orca サーバー] > [詳細設定] を使用してください。", - "localRateLimit": "{{value0}} の API 予算は、このデスクトップクライアント上の CLI から取得されます。サーバーが管理する予算を確認するには、[設定] > [リモート Orca サーバー] > [詳細設定] を使用してください。" + "localCredentials": "このプロバイダーの認証情報とアカウントチェックは、このデスクトップクライアントが管理しています。サーバーを使うには、設定 > リモート Orca サーバー > 詳細 > 新規プロジェクトの既定ホスト を変更してください。", + "remoteServerRateLimit": "{{value0}} の API 予算はこのリモートサーバー上の CLI から取得しています。別のホストの予算を見るには、設定上部のホストメニューで選択してください。", + "localRateLimit": "{{value0}} の API 予算はこのデスクトップクライアント上の CLI から取得しています。サーバーの予算を見るには、設定上部のホストメニューで選択してください。" }, "settingOwnership": { "clientDefault": "クライアントのデフォルト", @@ -12381,7 +12384,7 @@ }, "loadFailed": "Orca は SSH ホストと未完了の移動を読み込めませんでした。設定を開き直して、もう一度お試しください。", "outcome": { - "activeServer": "未完了: このサーバーを停止する前に、[詳細設定] で別のアクティブサーバーを選択してください。", + "activeServer": "実行されませんでした: このサーバーは新規プロジェクトの既定ホストです。停止する前に [詳細設定] で別のホストを選択してください。", "live": "未完了: このサーバー上のターミナルがまだ実行中です。閉じてから、もう一度お試しください。", "unverifiable": "未完了: Orca はこのサーバーで何が実行中かを確認できませんでした。ホストに到達できるときに、もう一度お試しください。", "migration": "未完了: このサーバーへの移動が終わっていません。先に再開してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 534106d4df05..fd645794b9a3 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5501,7 +5501,7 @@ "allHostsDetail": "모든 호스트 표시", "configuredSshHost": "구성된 SSH", "projectSshHost": "프로젝트 SSH", - "activeRuntimeHost": "활성 서버", + "activeRuntimeHost": "새 프로젝트 기본값", "projectRuntimeHost": "프로젝트 서버", "631b97eea9": "호스트 범위", "2d4f0eb933": "기본값", @@ -6751,7 +6751,7 @@ "codexSessionSourceInfo": "Codex 기록 가져오기 정보", "codexSessionSourceTooltip": "Orca는 격리된 홈에서 Codex를 실행합니다. 기존 Codex 홈을 지정하여 세션 기록을 가져옵니다. 비워두면 ~/.codex를 사용합니다.", "03e1a5081a": "on {{value0}}", - "25a41a9aad": "Re-detect agents installed on the active server", + "25a41a9aad": "이 서버에 설치된 에이전트 다시 감지", "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", "retryDetection": "Retry", "storedDefaultUndetected": "기본값으로 저장되었지만 지금은 감지되지 않습니다.", @@ -8132,16 +8132,16 @@ "6ce4664003": "서버 새로 고침", "b07070ed3c": "연결된 서버가 없습니다", "78692becbd": "로컬 데스크탑", - "64b6bea541": "활성 서버", + "64b6bea541": "새 프로젝트의 기본 호스트", "99ac81fb43": "{{value0}}(으)로 전환되었습니다.", "b5b5114cb0": "{{value0}}을(를) 삭제했습니다.", "6cb6eae14f": "런타임 환경을 저장하지 못했습니다.", - "7b5986c8df": "{{value0}}이(가) 저장되었습니다. 준비되면 활성 서버에서 전환하세요.", + "7b5986c8df": "{{value0}}에 연결되었습니다. 새 프로젝트를 기본적으로 그곳에 만들려면 고급 > 새 프로젝트의 기본 호스트에서 선택하세요.", "5ef712f407": "\"{{value0}}\"이라는 서버가 이미 존재합니다.", "0c55a47480": "이름과 페어링 코드가 필요합니다.", "e6410d72c3": "런타임 환경을 로드하지 못했습니다.", "6ef71985da": "엔드포인트 없음", - "ed3e3f069d": "그러면 Orca에서 저장된 서버가 제거됩니다. 활성 서버는 변경되지 않습니다.", + "ed3e3f069d": "저장된 서버를 Orca에서 제거합니다. 새 프로젝트의 기본 호스트는 바뀌지 않습니다.", "b2fda48c39": "활성 서버를 제거하면 이 브라우저가 해당 호스트에서 연결 해제됩니다. 기존 호스트 세션은 그대로 유지됩니다.", "9f7665a01b": "활성 서버를 제거하면 먼저 Orca가 로컬 데스크톱으로 다시 전환됩니다. 기존 호스트 세션은 그대로 유지됩니다.", "3595fd1948": "새 링크", @@ -8163,7 +8163,7 @@ "hostModelCapabilityProjectSetup": "프로젝트 설정", "hostModelCapabilityTaskSourceContext": "작업 소스 컨텍스트", "hostModelCapabilityWorkspaceRunContext": "워크스페이스 실행 컨텍스트", - "3f67e8078a": "기본적으로 이 컴퓨터를 사용합니다. 지원되는 프로젝트, 파일, terminals, 공급자 확인, 브라우저/모바일 핸드오프를 해당 서버를 통해 실행하려는 경우에만 저장된 서버를 선택하세요.", + "3f67e8078a": "여기서 저장된 서버를 선택하지 않으면 새 프로젝트는 이 컴퓨터에 만들어집니다. 기존 프로젝트, 파일, 터미널은 각자의 호스트에 그대로 있습니다.", "2c85efb3e8": "저장된 서버를 선택하면 이 브라우저가 페어링된 Orca 런타임을 기본 호스트로 사용합니다.", "serverConnected": "연결됨", "serverChecking": "확인 중…", @@ -8171,7 +8171,7 @@ "disconnectedServer": "{{value0}}에서 연결이 해제되었습니다.", "connectToRemoteServers": "원격 서버에 연결", "connectToRemoteServersHelp": "다른 Orca 런타임을 페어링한 다음 여기에서 연결하거나 연결 해제하세요. 기본 호스트를 변경하려는 경우에만 고급 > 활성 서버를 사용하세요.", - "activeServerRowHelp": "서버를 통해 라우팅되는 프로젝트, terminals, 공급자 확인에 사용할 활성 서버입니다.", + "activeServerRowHelp": "새 프로젝트의 기본 호스트입니다. 기존 프로젝트는 각자의 호스트에 그대로 있습니다.", "disconnect": "연결 해제", "connect": "연결", "advanced": "고급", @@ -8185,8 +8185,8 @@ "updateServer": "Update", "reviewServerUpdates": "Server updates", "orcaVersion": "Orca v{{value0}}", - "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", - "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", + "removeActiveServerBlocked": "이 서버는 새 프로젝트의 기본 호스트입니다. 제거하기 전에 고급에서 다른 호스트를 선택하세요.", + "removeActiveServerDescription": "이 서버는 새 프로젝트의 기본 호스트입니다. 제거하기 전에 고급에서 다른 호스트를 선택하세요. 기존 호스트 세션은 그대로 유지됩니다.", "updatingServers": "Updating servers…", "workflow": "원격 서버 워크플로", "connectWorkflow": "호스트에 연결", @@ -10431,12 +10431,15 @@ "ebd5369acf": "환경", "d198440ce3": "런타임", "baec27aa8f": "이 브라우저를 저장된 Orca 서버에 연결하세요.", - "3517fb2ec0": "활성 서버", + "3517fb2ec0": "원격 Orca 서버", "c6e5a03aa0": "개발 박스", "f1575f1e09": "웹 클라이언트", "81444c4102": "페어링 URL", "104f4d7dbd": "페어링", - "4575341c77": "로컬 데스크톱을 선택하고, 저장된 원격 Orca 서버를 추가하거나, 페어링 URL을 생성하세요." + "4575341c77": "저장된 원격 Orca 서버를 추가하거나, 페어링 URL을 생성하거나, 새 프로젝트의 기본 호스트를 선택합니다.", + "defaultHostKeyword": "기본 호스트", + "newProjectsKeyword": "새 프로젝트", + "activeServerKeyword": "활성 서버" } } }, @@ -11101,11 +11104,11 @@ }, "providerAccountScope": { "remoteServer": "원격 서버: {{value0}}", - "remoteServerCredentials": "이 공급자의 자격 증명과 계정 확인은 이 원격 서버가 소유합니다. 다른 기본 런타임 범위를 편집하려면 설정 > 원격 Orca 서버 > 고급을 사용하세요.", + "remoteServerCredentials": "이 공급자의 자격 증명과 계정 확인은 이 원격 서버가 관리합니다. 다른 호스트를 사용하려면 설정 > 원격 Orca 서버 > 고급 > 새 프로젝트의 기본 호스트를 변경하세요.", "localMac": "로컬 Mac", - "localCredentials": "이 제공자의 자격 증명과 계정 확인은 이 데스크톱 클라이언트가 소유합니다. 서버 소유 자격 증명은 설정 > 원격 Orca 서버 > 고급에서 편집하세요.", - "remoteServerRateLimit": "{{value0}} API 예산은 이 원격 서버의 CLI에서 가져옵니다. 다른 기본 런타임 예산을 보려면 설정 > 원격 Orca 서버 > 고급을 사용하세요.", - "localRateLimit": "{{value0}} API 예산은 이 데스크톱 클라이언트의 CLI에서 가져옵니다. 서버 소유 예산을 보려면 설정 > 원격 Orca 서버 > 고급을 사용하세요." + "localCredentials": "이 공급자의 자격 증명과 계정 확인은 이 데스크톱 클라이언트가 관리합니다. 서버를 사용하려면 설정 > 원격 Orca 서버 > 고급 > 새 프로젝트의 기본 호스트를 변경하세요.", + "remoteServerRateLimit": "{{value0}} API 예산은 이 원격 서버의 CLI에서 가져옵니다. 다른 호스트의 예산을 보려면 설정 상단의 호스트 메뉴에서 선택하세요.", + "localRateLimit": "{{value0}} API 예산은 이 데스크톱 클라이언트의 CLI에서 가져옵니다. 서버의 예산을 보려면 설정 상단의 호스트 메뉴에서 선택하세요." }, "settingOwnership": { "clientDefault": "클라이언트 기본값", @@ -12381,7 +12384,7 @@ }, "loadFailed": "Orca가 SSH 호스트와 미완료 이동을 불러오지 못했습니다. 설정을 다시 열어 시도하세요.", "outcome": { - "activeServer": "완료되지 않음: 이 서버를 중지하기 전에 고급에서 다른 활성 서버를 선택하세요.", + "activeServer": "완료되지 않음: 이 서버는 새 프로젝트의 기본 호스트입니다. 중지하기 전에 고급에서 다른 호스트를 선택하세요.", "live": "완료되지 않음: 이 서버의 터미널이 아직 실행 중입니다. 닫고 다시 시도하세요.", "unverifiable": "완료되지 않음: Orca가 이 서버에서 무엇이 실행 중인지 확인하지 못했습니다. 호스트에 연결할 수 있을 때 다시 시도하세요.", "migration": "완료되지 않음: 이 서버로의 이동이 끝나지 않았습니다. 먼저 재개하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5da9ec3bb789..a78155345c8d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5501,7 +5501,7 @@ "allHostsDetail": "显示所有主机", "configuredSshHost": "已配置 SSH", "projectSshHost": "项目 SSH", - "activeRuntimeHost": "活动服务器", + "activeRuntimeHost": "新项目的默认主机", "projectRuntimeHost": "项目服务器", "631b97eea9": "主机范围", "2d4f0eb933": "默认", @@ -6751,7 +6751,7 @@ "codexSessionSourceInfo": "关于导入 Codex 历史记录", "codexSessionSourceTooltip": "Orca 在隔离的主目录中运行 Codex。将此项指向您现有的 Codex 主目录以导入会话历史记录。留空则使用 ~/.codex。", "03e1a5081a": "on {{value0}}", - "25a41a9aad": "重新检测活动服务器上安装的智能体", + "25a41a9aad": "重新检测此服务器上安装的代理", "remoteDetectionFailed": "无法检测已安装的智能体。请检查主机连接后重试。", "retryDetection": "Retry", "storedDefaultUndetected": "已保存为默认值,但目前未检测到", @@ -8132,16 +8132,16 @@ "6ce4664003": "刷新服务器", "b07070ed3c": "没有连接服务器", "78692becbd": "本地桌面", - "64b6bea541": "活动服务器", + "64b6bea541": "新项目的默认主机", "99ac81fb43": "切换到 {{value0}}。", "b5b5114cb0": "删除了 {{value0}}。", "6cb6eae14f": "无法保存运行时环境。", - "7b5986c8df": "已保存 {{value0}}。准备好后可通过活动服务器进行切换。", + "7b5986c8df": "已连接到 {{value0}}。若要默认在其上创建新项目,请在“高级 > 新项目的默认主机”中选择它。", "5ef712f407": "名为“{{value0}}”的服务器已存在。", "0c55a47480": "需要名称和配对代码。", "e6410d72c3": "无法加载运行时环境。", "6ef71985da": "无终点", - "ed3e3f069d": "这将从 Orca 中删除保存的服务器。它不会更改活动服务器。", + "ed3e3f069d": "这会从 Orca 中移除已保存的服务器,不会更改新项目的默认主机。", "b2fda48c39": "删除活动服务器会使此浏览器与该主机断开连接。现有主机会话会保持不变。", "9f7665a01b": "删除活动服务器会先将 Orca 切换回本地桌面。现有主机会话会保持不变。", "3595fd1948": "新链接", @@ -8163,7 +8163,7 @@ "hostModelCapabilityProjectSetup": "项目设置", "hostModelCapabilityTaskSourceContext": "任务来源上下文", "hostModelCapabilityWorkspaceRunContext": "工作区运行上下文", - "3f67e8078a": "默认使用此电脑。仅当你希望受支持的项目、文件、终端、提供商检查以及浏览器/移动端接续通过某个服务器运行时,才选择已保存的服务器。", + "3f67e8078a": "除非在此选择已保存的服务器,否则新项目会在这台电脑上创建。现有项目、文件和终端仍留在各自的主机上。", "2c85efb3e8": "选择已保存的服务器后,此浏览器会将配对的 Orca 运行时用作默认主机。", "serverConnected": "已连接", "serverChecking": "正在检查…", @@ -8171,7 +8171,7 @@ "disconnectedServer": "已断开与 {{value0}} 的连接。", "connectToRemoteServers": "连接到远程服务器", "connectToRemoteServersHelp": "配对另一个 Orca 运行时,然后在此处连接或断开连接。仅当你想更改默认主机时,才使用高级 > 活动服务器。", - "activeServerRowHelp": "用于通过服务器路由的项目、终端和提供商检查的活动服务器。", + "activeServerRowHelp": "新项目的默认主机。现有项目仍留在各自的主机上。", "disconnect": "断开连接", "connect": "连接", "advanced": "高级", @@ -8185,8 +8185,8 @@ "updateServer": "更新", "reviewServerUpdates": "服务器更新", "orcaVersion": "Orca v{{value0}}", - "removeActiveServerBlocked": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。", - "removeActiveServerDescription": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。现有的主机会话将保留。", + "removeActiveServerBlocked": "此服务器是新项目的默认主机。请先在“高级”中选择另一个主机,然后再移除它。", + "removeActiveServerDescription": "此服务器是新项目的默认主机。请先在“高级”中选择另一个主机,然后再移除它。现有的主机会话将保留。", "updatingServers": "正在更新服务器…", "workflow": "远程服务器工作流程", "connectWorkflow": "连接到主机", @@ -10431,12 +10431,15 @@ "ebd5369acf": "环境", "d198440ce3": "运行时", "baec27aa8f": "将此浏览器连接到已保存的 Orca 服务器。", - "3517fb2ec0": "活动服务器", + "3517fb2ec0": "远程 Orca 服务器", "c6e5a03aa0": "开发盒", "f1575f1e09": "网络客户端", "81444c4102": "配对网址", "104f4d7dbd": "配对", - "4575341c77": "选择本地桌面、添加已保存的远程 Orca 服务器或生成配对 URL。" + "4575341c77": "添加已保存的远程 Orca 服务器、生成配对 URL,或选择新项目的默认主机。", + "defaultHostKeyword": "默认主机", + "newProjectsKeyword": "新项目", + "activeServerKeyword": "活动服务器" } } }, @@ -11101,11 +11104,11 @@ }, "providerAccountScope": { "remoteServer": "远程服务器:{{value0}}", - "remoteServerCredentials": "此提供商的凭据和账户检查由该远程服务器拥有。使用“设置 > 远程 Orca 服务器 > 高级”编辑另一个默认运行时范围。", + "remoteServerCredentials": "此提供商的凭据和账户检查由这台远程服务器管理。要使用其他主机,请更改“设置 > 远程 Orca 服务器 > 高级 > 新项目的默认主机”。", "localMac": "本地 Mac", - "localCredentials": "此提供商的凭据和账户检查由该桌面客户端拥有。使用“设置 > 远程 Orca 服务器 > 高级”编辑服务器拥有的凭据。", - "remoteServerRateLimit": "{{value0}} API 预算从此远程服务器上的 CLI 获取。使用“设置 > 远程 Orca 服务器 > 高级”查看另一个默认运行时预算。", - "localRateLimit": "{{value0}} API 预算从此桌面客户端上的 CLI 获取。使用“设置 > 远程 Orca 服务器 > 高级”查看服务器拥有的预算。" + "localCredentials": "此提供商的凭据和账户检查由这台桌面客户端管理。要使用服务器,请更改“设置 > 远程 Orca 服务器 > 高级 > 新项目的默认主机”。", + "remoteServerRateLimit": "{{value0}} API 额度从这台远程服务器上的 CLI 获取。要查看其他主机的额度,请在“设置”顶部的主机菜单中选择。", + "localRateLimit": "{{value0}} API 额度从这台桌面客户端上的 CLI 获取。要查看服务器的额度,请在“设置”顶部的主机菜单中选择。" }, "settingOwnership": { "clientDefault": "客户端默认", @@ -12381,7 +12384,7 @@ }, "loadFailed": "Orca 无法加载 SSH 主机和未完成的迁移。请重新打开设置以重试。", "outcome": { - "activeServer": "未完成:请先在“高级”中选择另一个活动服务器,再停止此服务器。", + "activeServer": "未完成:此服务器是新项目的默认主机。请先在“高级”中选择另一个主机,然后再停止它。", "live": "未完成:此服务器上的终端仍在运行。请关闭它们后重试。", "unverifiable": "未完成:Orca 无法确认此服务器上正在运行什么。请在主机可访问时重试。", "migration": "未完成:迁移到此服务器的操作尚未完成。请先继续该操作。", From 8ba353a1a7917ee58ee5cefed138ff51f208d811 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:15:03 -0700 Subject: [PATCH 015/110] [CI] Refresh unit timing weights and reduce repeated test setup (#27315) --- config/scripts/ci-shard-timings.json | 21867 +++++++++------- config/scripts/ci-unit-dependency-graph.mjs | 5 +- .../scripts/ci-unit-dependency-graph.test.mjs | 55 + .../orchestration-party-addressing.test.ts | 15 - src/relay/git-handler-push-target.test.ts | 64 - ...ive-chat-runtime-send-launch-draft.test.ts | 17 - src/shared/orcad-artifacts.test.ts | 2 - ...tured-attention-explicit-read.unit.test.ts | 222 - ...ctured-attention-prompt-relay.unit.test.ts | 954 +- ...ttention-read-owner-lifecycle.unit.test.ts | 129 - ...red-attention-read-retirement.unit.test.ts | 814 +- ...ed-attention-relay-settlement.unit.test.ts | 354 - 12 files changed, 13394 insertions(+), 11104 deletions(-) delete mode 100644 tests/e2e/structured-attention-explicit-read.unit.test.ts delete mode 100644 tests/e2e/structured-attention-read-owner-lifecycle.unit.test.ts delete mode 100644 tests/e2e/structured-attention-relay-settlement.unit.test.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index ebff3e03a683..ea8616e99dfe 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1,9825 +1,12212 @@ { "unit": { - "runId": "36294142683", + "runId": "38086200856", "runAttempt": "1", - "sourceSha": "cc35c1b422e9c20197bc59aa27a71019ffc18b5b", + "sourceSha": "311827966ba81ad0e2b40bf5b98a2a5f1b6166eb", "nodeVersion": "24.21.0", + "bunVersion": "1.4.2", "metric": "module-duration-v1", "overheadMs": 0, "timings": { - "config/scripts/adhoc-build-version.test.mjs": 90, - "config/scripts/agent-status-hot-path-benchmark.test.ts": 2954, - "config/scripts/app-store-performance-plugin.test.mjs": 1255, - "config/scripts/assert-github-release-is-draft.test.mjs": 348, - "config/scripts/audit-localization-coverage.test.mjs": 412, - "config/scripts/benchmark-artifact-comparison.test.mjs": 193, - "config/scripts/benchmark-sample-summary.test.mjs": 45, - "config/scripts/build-linux-local.test.mjs": 35, - "config/scripts/build-mac-local.test.mjs": 52, - "config/scripts/build-mobile-web-app-bundle.test.mjs": 213, - "config/scripts/build-native-for-platform.test.mjs": 42, - "config/scripts/build-orcad-prebuilds.test.mjs": 85, - "config/scripts/build-windows-cli-launcher.test.mjs": 84, - "config/scripts/casting-code-quality.test.mjs": 874, - "config/scripts/check-changed-code-quality.test.mjs": 79, - "config/scripts/check-max-lines-ratchet.test.mjs": 945, - "config/scripts/check-react-doctor-changed.test.mjs": 300, - "config/scripts/check-readme-local-links.test.mjs": 520, - "config/scripts/check-reliability-gates.test.mjs": 175, - "config/scripts/check-root-directory-entries.test.mjs": 1241, - "config/scripts/check-runtime-electron-ratchet.test.mjs": 2494, - "config/scripts/check-terminal-perf-report-budgets.test.mjs": 2243, - "config/scripts/check-ts-nocheck-ratchet.test.mjs": 44, - "config/scripts/ci-cache-warmup-workflow.test.mjs": 110, - "config/scripts/ci-dependency-download-cache.test.mjs": 401, - "config/scripts/ci-e2e-shard-selection.test.mjs": 14257, - "config/scripts/ci-native-toolchain.test.mjs": 92, - "config/scripts/ci-shard-assignment.test.mjs": 308, - "config/scripts/ci-unit-timing-reporter.test.mjs": 62, - "config/scripts/cli-main-module-bundle-parity.test.ts": 594, - "config/scripts/client-hosted-browser-package-coverage.test.mjs": 124, - "config/scripts/cloud-security-history-workflow.test.mjs": 344, - "config/scripts/codex-index-heal-contract-workflow.test.mjs": 145, - "config/scripts/codex-primary-home-tripwire.test.ts": 344, - "config/scripts/computer-e2e-workflow.test.mjs": 159, - "config/scripts/computer-use-modifier-safety.test.mjs": 51, - "config/scripts/computer-use-mouse-button-routing.test.mjs": 43, - "config/scripts/computer-use-skill-guidance.test.mjs": 130, - "config/scripts/computer-use-smoke.test.mjs": 580, - "config/scripts/computer-use-windows-horizontal-scroll.test.mjs": 42, - "config/scripts/counterbalanced-benchmark-schedule.test.mjs": 47, - "config/scripts/create-draft-release.test.mjs": 72, - "config/scripts/daily-build-version.test.mjs": 87, - "config/scripts/daily-e2e-dispatch-contract.test.mjs": 168, - "config/scripts/dev-channel-base-version.test.mjs": 60, - "config/scripts/dev-channel-windows-workflow-contract.test.mjs": 141, - "config/scripts/dev-cli-terminal-wrapper.test.mjs": 34, - "config/scripts/dev-electron-bundle-cache.test.ts": 52, - "config/scripts/dev-electron-bundle-identity.test.ts": 82, - "config/scripts/electron-builder-config.test.mjs": 1072, - "config/scripts/electron-builder-mac-channel-config.test.mjs": 1002, - "config/scripts/electron-builder-markdown-associations.test.mjs": 269, - "config/scripts/electron-builder-native-rebuild.test.mjs": 41, - "config/scripts/electron-builder-runtime-resources.test.mjs": 1124, - "config/scripts/electron-builder-speech-config.test.mjs": 485, - "config/scripts/electron-runtime-floor.test.ts": 37, - "config/scripts/electron-vite-output-contract.test.ts": 1042, - "config/scripts/ensure-native-runtime-job-ownership.test.mjs": 35, - "config/scripts/ensure-native-runtime.test.mjs": 771, + "config/scripts/adhoc-build-pinned-commit.test.mjs": 226, + "config/scripts/adhoc-build-version.test.mjs": 95, + "config/scripts/agent-state-rules-bundle.test.mjs": 327, + "config/scripts/app-store-performance-plugin.test.mjs": 1010, + "config/scripts/assert-github-release-is-draft.test.mjs": 352, + "config/scripts/audit-localization-coverage.test.mjs": 270, + "config/scripts/benchmark-artifact-comparison.test.mjs": 171, + "config/scripts/benchmark-sample-summary.test.mjs": 57, + "config/scripts/build-linux-local.test.mjs": 56, + "config/scripts/build-mac-local.test.mjs": 57, + "config/scripts/build-mobile-web-app-bundle.test.mjs": 419, + "config/scripts/build-native-for-platform.test.mjs": 52, + "config/scripts/build-orcad-prebuilds.test.mjs": 105, + "config/scripts/build-orcad-template.test.mjs": 145, + "config/scripts/build-windows-cli-launcher.test.mjs": 117, + "config/scripts/casting-code-quality.test.mjs": 854, + "config/scripts/check-changed-code-quality.test.mjs": 3252, + "config/scripts/check-daemon-protocol-crossing.test.mjs": 194, + "config/scripts/check-max-lines-ratchet.test.mjs": 658, + "config/scripts/check-node-runtime-pin.test.mjs": 696, + "config/scripts/check-owner-routing-ratchet.test.mjs": 86, + "config/scripts/check-process-host-imports.test.mjs": 301, + "config/scripts/check-react-doctor-changed.test.mjs": 229, + "config/scripts/check-readme-local-links.test.mjs": 414, + "config/scripts/check-reliability-gates.test.mjs": 216, + "config/scripts/check-root-directory-entries.test.mjs": 790, + "config/scripts/check-runtime-electron-ratchet-graph.test.mjs": 163, + "config/scripts/check-runtime-electron-ratchet.test.mjs": 3736, + "config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs": 185, + "config/scripts/check-terminal-perf-report-budgets.test.mjs": 1481, + "config/scripts/check-ts-nocheck-ratchet.test.mjs": 68, + "config/scripts/ci-closed-pr-caches.test.mjs": 123, + "config/scripts/ci-closed-pr-cancellation.test.mjs": 128, + "config/scripts/ci-dependency-download-cache.test.mjs": 557, + "config/scripts/ci-e2e-failure-summary.test.mjs": 56, + "config/scripts/ci-e2e-job-selection.test.mjs": 326, + "config/scripts/ci-e2e-shard-selection.test.mjs": 2569, + "config/scripts/ci-native-cache.test.mjs": 267, + "config/scripts/ci-native-toolchain.test.mjs": 118, + "config/scripts/ci-pnpm-store-mode.test.mjs": 550, + "config/scripts/ci-pnpm-verification-cache.test.mjs": 331, + "config/scripts/ci-runner-metrics.test.mjs": 65, + "config/scripts/ci-shard-assignment.test.mjs": 576, + "config/scripts/ci-unit-dependency-graph.test.mjs": 340, + "config/scripts/ci-unit-exclusion-coverage.test.mjs": 936, + "config/scripts/ci-unit-import-resolution-budget.test.mjs": 270, + "config/scripts/ci-unit-plan.test.mjs": 3110, + "config/scripts/ci-unit-selection-review.test.mjs": 43, + "config/scripts/ci-unit-selection.test.mjs": 276, + "config/scripts/ci-unit-sequencer.test.mjs": 318, + "config/scripts/ci-unit-timing-reporter.test.mjs": 268, + "config/scripts/cloud-security-history-workflow.test.mjs": 259, + "config/scripts/codex-primary-home-tripwire.test.ts": 359, + "config/scripts/computer-use-smoke.test.mjs": 491, + "config/scripts/counterbalanced-benchmark-schedule.test.mjs": 57, + "config/scripts/create-draft-release.test.mjs": 71, + "config/scripts/daemon-protocol-facts.test.mjs": 57, + "config/scripts/daily-build-version.test.mjs": 90, + "config/scripts/dev-channel-base-version.test.mjs": 51, + "config/scripts/dev-cli-terminal-wrapper.test.mjs": 57, + "config/scripts/dev-electron-bundle-cache.test.ts": 49, + "config/scripts/dev-electron-bundle-identity.test.ts": 59, + "config/scripts/e2e-cli-artifact-capability.test.mjs": 262, + "config/scripts/electron-builder-config.test.mjs": 906, + "config/scripts/electron-builder-mac-channel-config.test.mjs": 396, + "config/scripts/electron-builder-native-rebuild.test.mjs": 57, + "config/scripts/electron-builder-runtime-resources.test.mjs": 540, + "config/scripts/electron-builder-speech-config.test.mjs": 322, + "config/scripts/electron-runtime-floor.test.ts": 49, + "config/scripts/electron-vite-output-contract.test.ts": 741, + "config/scripts/ensure-native-runtime-job-ownership.test.mjs": 73, + "config/scripts/ensure-native-runtime.test.mjs": 783, "config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts": 42, - "config/scripts/generate-bundled-skill-guides.test.mjs": 510, - "config/scripts/generate-runtime-required-english-catalog.test.mjs": 452, - "config/scripts/generate-skill-bundle-manifest.test.mjs": 721, - "config/scripts/generate-terminal-perf-html-report.test.mjs": 71, - "config/scripts/git-binary-compatibility-workflow.test.mjs": 153, - "config/scripts/git-pull-request-diff-base.test.mjs": 36, - "config/scripts/hang-watchdog-process-metrics.test.mjs": 34, - "config/scripts/happy-dom-mutation-observer-retention.test.ts": 384, - "config/scripts/happy-dom-offscreen-canvas.test.ts": 319, - "config/scripts/headless-serve-shutdown-matrix.test.mjs": 112, - "config/scripts/headless-serve-shutdown-workflow.test.mjs": 148, - "config/scripts/hourly-build-version.test.mjs": 136, - "config/scripts/hourly-preflight-workflow.test.mjs": 165, - "config/scripts/idle-cpu-process-sampling.test.mjs": 60, - "config/scripts/install-electron-package-binary.test.mjs": 3284, - "config/scripts/install-node-dependencies-action.test.mjs": 434, - "config/scripts/lag-probe-failures.test.mjs": 52, - "config/scripts/latest-stable-release.test.mjs": 60, - "config/scripts/lint-staged-worktree-backup.test.mjs": 436, - "config/scripts/linux-package-fixture-cache.test.mjs": 200, - "config/scripts/linux-package-maintainer-scripts.test.mjs": 19, - "config/scripts/live-freeze-bounded-history.test.mjs": 49, - "config/scripts/live-remote-bulk-open-freeze-metrics.test.mjs": 72, - "config/scripts/live-remote-freeze-rpc.test.mjs": 53, - "config/scripts/live-remote-status-watchdog.test.mjs": 285, - "config/scripts/locale-brand-prefilter.test.mjs": 263, - "config/scripts/locale-count-fragment-separator.test.mjs": 200, - "config/scripts/locale-generic-ui-terms.test.mjs": 111, - "config/scripts/locale-ko-frozen-terminal-search-keywords.test.mjs": 127, - "config/scripts/locale-ko-key-overrides.test.mjs": 95, - "config/scripts/locale-repair-catalog-missing-leaves.test.mjs": 124, - "config/scripts/locale-translation-policy-ko-round5.test.mjs": 249, - "config/scripts/locale-translation-policy.es-pr.test.mjs": 132, - "config/scripts/locale-translation-policy.es-round5.test.mjs": 118, - "config/scripts/locale-translation-policy.ja-relocalization.test.mjs": 207, - "config/scripts/locale-translation-policy.ja-round5.test.mjs": 137, - "config/scripts/locale-translation-policy.test.mjs": 283, - "config/scripts/locale-translation-policy.zh-round5.test.mjs": 290, - "config/scripts/locale-translation-policy.zh-status-bar-usage.test.mjs": 138, - "config/scripts/localization-extraction-change-scope.test.mjs": 133, - "config/scripts/localization-package-contract.test.mjs": 40, - "config/scripts/mac-build-compatibility.test.mjs": 41, - "config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs": 33, - "config/scripts/macos-computer-helper-owner-loss-processes.test.mjs": 64, - "config/scripts/macos-tcc-prompt-localization.test.mjs": 213, - "config/scripts/mobile-launch-contract-workflow.test.mjs": 439, - "config/scripts/mobile-mirrored-storage-write-path.test.mjs": 136, - "config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 421, - "config/scripts/mobile-release-check-scope.test.mjs": 610, - "config/scripts/mobile-release-shell-switch-workflow.test.mjs": 227, - "config/scripts/mobile-set-native-props-web-siblings.test.mjs": 959, - "config/scripts/mobile-test-change-scope.test.mjs": 107, - "config/scripts/mobile-web-app-agent-history-render.test.mjs": 695, - "config/scripts/mobile-web-app-browser-pane-render.test.mjs": 416, - "config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs": 380, - "config/scripts/mobile-web-app-clipboard-image-resize-render.test.mjs": 544, - "config/scripts/mobile-web-app-drawer-render.test.mjs": 379, - "config/scripts/mobile-web-app-editable-host-font-size.test.mjs": 590, - "config/scripts/mobile-web-app-external-link-seam.test.mjs": 340, - "config/scripts/mobile-web-app-external-navigation-grant.test.mjs": 81, - "config/scripts/mobile-web-app-files-external-links.test.mjs": 383, - "config/scripts/mobile-web-app-files-render.test.mjs": 315, - "config/scripts/mobile-web-app-frame-budget-sweep.test.ts": 545, - "config/scripts/mobile-web-app-handoff-grants-render.test.mjs": 476, - "config/scripts/mobile-web-app-haptics-seam.test.mjs": 491, - "config/scripts/mobile-web-app-html-preview-render.test.mjs": 1035, - "config/scripts/mobile-web-app-mermaid-render.test.mjs": 567, - "config/scripts/mobile-web-app-native-parity-render.test.mjs": 554, - "config/scripts/mobile-web-app-page-closure-families.test.mjs": 75, - "config/scripts/mobile-web-app-page-grant-call-sites.test.mjs": 567, - "config/scripts/mobile-web-app-push-subsystem-closure.test.mjs": 70, - "config/scripts/mobile-web-app-render.test.mjs": 339, - "config/scripts/mobile-web-app-rich-markdown-render.test.mjs": 991, - "config/scripts/mobile-web-app-route-manifest.test.mjs": 220, - "config/scripts/mobile-web-app-route-snapshot.test.mjs": 95, - "config/scripts/mobile-web-app-safe-area-render.test.mjs": 503, - "config/scripts/mobile-web-app-screencast-lane-grant.test.mjs": 56, - "config/scripts/mobile-web-app-session-dictation-capture.test.mjs": 98, - "config/scripts/mobile-web-app-session-external-links.test.mjs": 210, - "config/scripts/mobile-web-app-session-media-picker.test.mjs": 375, - "config/scripts/mobile-web-app-session-render.test.mjs": 495, - "config/scripts/mobile-web-app-session-terminal-closure.test.mjs": 444, - "config/scripts/mobile-web-app-session-terminal-init.test.mjs": 529, - "config/scripts/mobile-web-app-session-text-inputs.test.mjs": 391, - "config/scripts/mobile-web-app-session-webview-consumers.test.mjs": 76, - "config/scripts/mobile-web-app-source-control-external-links.test.mjs": 317, - "config/scripts/mobile-web-app-source-control-keyboard.test.mjs": 59, - "config/scripts/mobile-web-app-source-control-render.test.mjs": 432, - "config/scripts/mobile-web-app-source-control-text-inputs.test.mjs": 295, - "config/scripts/mobile-web-app-tasks-external-links.test.mjs": 349, - "config/scripts/mobile-web-app-tasks-render.test.mjs": 356, - "config/scripts/mobile-web-app-terminal-live-input-render.test.mjs": 431, - "config/scripts/mobile-web-app-terminal-render-fixture.test.mjs": 548, - "config/scripts/mobile-web-app-terminal-render.test.mjs": 346, - "config/scripts/mobile-web-app-web-overrides.test.mjs": 69, - "config/scripts/mobile-web-bundle-manifest.test.mjs": 54, - "config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs": 1102, - "config/scripts/mobile-web-bundle-serializer-parity.test.mjs": 138, - "config/scripts/mobile-web-page-route-hop-coverage.test.mjs": 4953, - "config/scripts/mobile-web-page-routes.test.mjs": 162, - "config/scripts/mobile-web-source-line-endings.test.mjs": 661, - "config/scripts/mobile-web-terminal-engine-closure.test.mjs": 79, - "config/scripts/node-old-space-limit.test.mjs": 32, - "config/scripts/node-pty-console-list-agent-patch.test.mjs": 46, - "config/scripts/node-pty-master-cloexec-patch.test.mjs": 114, - "config/scripts/node-pty-windows-pty-teardown-patch.test.mjs": 98, - "config/scripts/node-server-change-scope.test.mjs": 3355, - "config/scripts/nsis-process-check.test.mjs": 62, - "config/scripts/orca-cli-skill-guidance.test.mjs": 62, - "config/scripts/orca-dev-bin.test.mjs": 79, - "config/scripts/orca-linear-skill-guidance.test.mjs": 50, - "config/scripts/orcad-artifact-version.test.mjs": 470, - "config/scripts/orcad-operations-restart-safety.test.mjs": 45, - "config/scripts/orcad-terminal-smoke-change-scope.test.mjs": 1510, - "config/scripts/orcad-watcher-package.test.mjs": 132, - "config/scripts/orcad-windows-process-tree.test.mjs": 63, - "config/scripts/orchestration-guide-command-contract.test.mjs": 44, - "config/scripts/orchestration-skill-guidance.test.mjs": 58, - "config/scripts/oxc-cli-invocation.test.mjs": 85, - "config/scripts/oxlint-cli-invocation.test.mjs": 106, - "config/scripts/package-electron-install-owner.test.mjs": 91, - "config/scripts/package-electron-runtime-contract.test.mjs": 447, - "config/scripts/package-linux-formats.test.mjs": 208, - "config/scripts/packaged-browser-lane-contract.test.mjs": 104, - "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 93, - "config/scripts/packaged-source-map-prune.test.mjs": 117, - "config/scripts/patched-dependencies-frozen-install.test.mjs": 900, - "config/scripts/pdfjs-viewer-assets-stream.test.ts": 246, - "config/scripts/plain-node-entry-guard.test.ts": 728, - "config/scripts/playwright-focused-workflow-selection.test.mjs": 3690, - "config/scripts/pnpm-cli-invocation.test.mjs": 38, - "config/scripts/pr-code-change-scope.test.mjs": 638, - "config/scripts/pr-e2e-gate-contract.test.mjs": 409, - "config/scripts/pr-e2e-native-only-routing.test.mjs": 169, - "config/scripts/pr-e2e-pane-close-routing.test.mjs": 55, - "config/scripts/pr-ready-check-gate.test.mjs": 303, - "config/scripts/pr-ready-check-reuse.test.mjs": 190, - "config/scripts/pr-test-loc-summary.test.mjs": 178, - "config/scripts/pr-workflow-lint-parity.test.mjs": 81, - "config/scripts/pr-workflow-parallelism.test.mjs": 1324, - "config/scripts/profile-state-worker-smoke.test.mjs": 2754, - "config/scripts/project-renderer-web-client.test.mjs": 251, - "config/scripts/pty-transcript-secret-scan.test.mjs": 68, - "config/scripts/publish-complete-draft-releases.test.mjs": 230, - "config/scripts/quadratic-buffer-concat-plugin.test.mjs": 2182, - "config/scripts/rebuild-native-deps-node-pty.test.mjs": 4426, - "config/scripts/rebuild-native-deps-windows-process-tree.test.mjs": 40, - "config/scripts/rebuild-native-deps.test.mjs": 1467, - "config/scripts/reclaim-dev-electron-bundles.test.ts": 57, - "config/scripts/regenerate-xterm-patches.test.mjs": 432, - "config/scripts/relay-artifact-manifest.test.mjs": 4048, - "config/scripts/relay-asset-line-ending-pin.test.mjs": 110, - "config/scripts/release-blocker-fixes.test.mjs": 80, - "config/scripts/release-cut-signpath-slack.test.mjs": 95, - "config/scripts/release-cut-sourcemap-publish.test.mjs": 175, - "config/scripts/release-cut-token-permissions.test.mjs": 210, - "config/scripts/release-e2e-dispatch-contract.test.mjs": 184, - "config/scripts/release-mac-build-workflow-dispatch.test.mjs": 45, - "config/scripts/release-rc-history.test.mjs": 244, - "config/scripts/renderer-boot-graph.test.mjs": 98, - "config/scripts/renderer-scrollbar-style-plugin.test.mjs": 2119, - "config/scripts/replace-cached-nsis-elevate.test.mjs": 1919, - "config/scripts/resolve-7za-path.test.mjs": 1815, - "config/scripts/run-codex-real-account-validation.test.ts": 4165, - "config/scripts/run-internal-dev-setup.test.mjs": 54, - "config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs": 71, - "config/scripts/run-terminal-scale-perf-report-gate.test.mjs": 73, - "config/scripts/run-typecheck-projects-in-parallel.test.mjs": 47, - "config/scripts/script-entry-detection.test.mjs": 72, - "config/scripts/script-module-dependencies.test.mjs": 57, - "config/scripts/shared-electron-dist-cache.test.ts": 90, - "config/scripts/shebang-script-line-ending-pin.test.mjs": 119, - "config/scripts/skill-critical-guidance.test.mjs": 37, - "config/scripts/skill-description-length.test.mjs": 83, - "config/scripts/skill-recipe-shell.test.mjs": 76, - "config/scripts/skill-sharing-release-workflow.test.mjs": 130, - "config/scripts/skill-update-roundtrip-workflow.test.mjs": 86, - "config/scripts/skills-cli-package-workflow.test.mjs": 137, - "config/scripts/sort-comparator-performance-plugin.test.mjs": 332, - "config/scripts/space-sharing-copy.test.ts": 110, - "config/scripts/ssh-browser-e2e-routing.test.mjs": 138, - "config/scripts/ssh-docker-ci-sharding.test.mjs": 7004, - "config/scripts/ssh-localhost-e2e-routing.test.mjs": 163, + "config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs": 461, + "config/scripts/generate-bundled-skill-guides.test.mjs": 367, + "config/scripts/generate-monaco-associations.test.mjs": 344, + "config/scripts/generate-runtime-required-english-catalog.test.mjs": 432, + "config/scripts/generate-skill-bundle-manifest.test.mjs": 537, + "config/scripts/generate-terminal-perf-html-report.test.mjs": 107, + "config/scripts/generated-artifact-line-ending-pin.test.mjs": 54, + "config/scripts/git-pull-request-diff-base.test.mjs": 52, + "config/scripts/github-opened-issue-repository.test.ts": 1032, + "config/scripts/github-pr-assignee-repository.test.ts": 560, + "config/scripts/hang-watchdog-process-metrics.test.mjs": 102, + "config/scripts/happy-dom-mutation-observer-retention.test.ts": 206, + "config/scripts/happy-dom-offscreen-canvas.test.ts": 195, + "config/scripts/headless-detector-compiler-cache.test.mjs": 1063, + "config/scripts/headless-serve-shutdown-matrix.test.mjs": 84, + "config/scripts/hourly-build-version.test.mjs": 82, + "config/scripts/hourly-preflight-workflow.test.mjs": 182, + "config/scripts/idle-cpu-process-sampling.test.mjs": 64, + "config/scripts/install-electron-package-binary.test.mjs": 2694, + "config/scripts/install-node-dependencies-action.test.mjs": 336, + "config/scripts/lag-probe-failures.test.mjs": 60, + "config/scripts/latest-stable-release.test.mjs": 53, + "config/scripts/linear-sdk-runtime.test.mjs": 985, + "config/scripts/lint-staged-worktree-backup.test.mjs": 253, + "config/scripts/linux-package-fixture-cache.test.mjs": 204, + "config/scripts/live-freeze-bounded-history.test.mjs": 58, + "config/scripts/live-remote-bulk-open-freeze-metrics.test.mjs": 86, + "config/scripts/live-remote-freeze-rpc.test.mjs": 64, + "config/scripts/live-remote-status-watchdog.test.mjs": 284, + "config/scripts/locale-brand-prefilter.test.mjs": 215, + "config/scripts/locale-count-fragment-separator.test.mjs": 99, + "config/scripts/locale-generic-ui-terms.test.mjs": 103, + "config/scripts/locale-ko-frozen-terminal-search-keywords.test.mjs": 126, + "config/scripts/locale-ko-key-overrides.test.mjs": 68, + "config/scripts/locale-repair-catalog-missing-leaves.test.mjs": 103, + "config/scripts/locale-translation-policy-ko-round5.test.mjs": 118, + "config/scripts/locale-translation-policy.es-pr.test.mjs": 97, + "config/scripts/locale-translation-policy.es-round5.test.mjs": 78, + "config/scripts/locale-translation-policy.ja-relocalization.test.mjs": 119, + "config/scripts/locale-translation-policy.ja-round5.test.mjs": 112, + "config/scripts/locale-translation-policy.test.mjs": 119, + "config/scripts/locale-translation-policy.zh-round5.test.mjs": 191, + "config/scripts/locale-translation-policy.zh-status-bar-usage.test.mjs": 105, + "config/scripts/localization-extraction-call-syntax.test.mjs": 292, + "config/scripts/localization-extraction-change-scope.test.mjs": 308, + "config/scripts/mac-build-compatibility.test.mjs": 47, + "config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs": 64, + "config/scripts/macos-computer-helper-owner-loss-processes.test.mjs": 65, + "config/scripts/macos-tcc-prompt-localization.test.mjs": 287, + "config/scripts/managed-data-account-runtime.test.ts": 1001, + "config/scripts/merge-orcad-prebuilds.test.mjs": 82, + "config/scripts/mobile-entry-static-closure-work-budget.test.mjs": 127, + "config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 374, + "config/scripts/mobile-process-host-tooling.test.mjs": 978, + "config/scripts/mobile-release-check-scope.test.mjs": 701, + "config/scripts/mobile-test-change-scope.test.mjs": 126, + "config/scripts/mobile-web-app-agent-history-render.test.mjs": 363, + "config/scripts/mobile-web-app-browser-pane-render.test.mjs": 365, + "config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs": 374, + "config/scripts/mobile-web-app-clipboard-image-resize-render.test.mjs": 368, + "config/scripts/mobile-web-app-drawer-render.test.mjs": 365, + "config/scripts/mobile-web-app-editable-host-font-size.test.mjs": 291, + "config/scripts/mobile-web-app-external-link-seam.test.mjs": 247, + "config/scripts/mobile-web-app-external-navigation-grant.test.mjs": 68, + "config/scripts/mobile-web-app-files-external-links.test.mjs": 228, + "config/scripts/mobile-web-app-files-render.test.mjs": 356, + "config/scripts/mobile-web-app-frame-budget-sweep.test.ts": 322, + "config/scripts/mobile-web-app-handoff-grants-render.test.mjs": 395, + "config/scripts/mobile-web-app-haptics-seam.test.mjs": 295, + "config/scripts/mobile-web-app-held-press-render.test.mjs": 367, + "config/scripts/mobile-web-app-html-preview-render.test.mjs": 517, + "config/scripts/mobile-web-app-media-preview-render.test.mjs": 360, + "config/scripts/mobile-web-app-mermaid-render.test.mjs": 354, + "config/scripts/mobile-web-app-native-chat-visual-links.test.mjs": 365, + "config/scripts/mobile-web-app-native-parity-render.test.mjs": 399, + "config/scripts/mobile-web-app-page-closure-families.test.mjs": 74, + "config/scripts/mobile-web-app-page-grant-call-sites.test.mjs": 302, + "config/scripts/mobile-web-app-push-subsystem-closure.test.mjs": 64, + "config/scripts/mobile-web-app-render.test.mjs": 403, + "config/scripts/mobile-web-app-rich-markdown-render.test.mjs": 721, + "config/scripts/mobile-web-app-route-manifest.test.mjs": 164, + "config/scripts/mobile-web-app-route-snapshot.test.mjs": 121, + "config/scripts/mobile-web-app-safe-area-render.test.mjs": 365, + "config/scripts/mobile-web-app-screencast-lane-grant.test.mjs": 69, + "config/scripts/mobile-web-app-session-dictation-capture.test.mjs": 248, + "config/scripts/mobile-web-app-session-external-links.test.mjs": 197, + "config/scripts/mobile-web-app-session-media-picker.test.mjs": 270, + "config/scripts/mobile-web-app-session-render.test.mjs": 372, + "config/scripts/mobile-web-app-session-terminal-closure.test.mjs": 231, + "config/scripts/mobile-web-app-session-terminal-init.test.mjs": 352, + "config/scripts/mobile-web-app-session-text-inputs.test.mjs": 237, + "config/scripts/mobile-web-app-session-webview-consumers.test.mjs": 67, + "config/scripts/mobile-web-app-source-control-external-links.test.mjs": 215, + "config/scripts/mobile-web-app-source-control-keyboard.test.mjs": 56, + "config/scripts/mobile-web-app-source-control-render.test.mjs": 325, + "config/scripts/mobile-web-app-source-control-text-inputs.test.mjs": 334, + "config/scripts/mobile-web-app-stack-transition-render.test.mjs": 357, + "config/scripts/mobile-web-app-tasks-external-links.test.mjs": 251, + "config/scripts/mobile-web-app-tasks-render.test.mjs": 369, + "config/scripts/mobile-web-app-terminal-live-input-render.test.mjs": 332, + "config/scripts/mobile-web-app-terminal-render-fixture.test.mjs": 380, + "config/scripts/mobile-web-app-terminal-render.test.mjs": 352, + "config/scripts/mobile-web-app-working-spinner-render.test.mjs": 382, + "config/scripts/mobile-web-bundle-manifest.test.mjs": 224, + "config/scripts/mobile-web-bundle-serializer-parity.test.mjs": 237, + "config/scripts/mobile-web-page-routes.test.mjs": 250, + "config/scripts/mobile-web-source-line-endings.test.mjs": 261, + "config/scripts/mobile-web-terminal-engine-closure.test.mjs": 60, + "config/scripts/moved-code-normalization-budget.test.mjs": 125, + "config/scripts/msbuild-file-tracking.test.mjs": 44, + "config/scripts/native-chat-compaction-pagination.test.mjs": 446, + "config/scripts/node-old-space-limit.test.mjs": 50, + "config/scripts/node-pty-console-list-agent-patch.test.mjs": 57, + "config/scripts/node-pty-master-cloexec-patch.test.mjs": 121, + "config/scripts/node-pty-windows-pty-teardown-patch.test.mjs": 140, + "config/scripts/node-server-change-scope.test.mjs": 2687, + "config/scripts/node-server-concurrency.test.mjs": 173, + "config/scripts/node-server-qualification.test.mjs": 62, + "config/scripts/nsis-process-check.test.mjs": 59, + "config/scripts/orca-dev-bin.test.mjs": 118, + "config/scripts/orcad-artifact-version.test.mjs": 493, + "config/scripts/orcad-daemon-protocol-crossing.test.mjs": 57, + "config/scripts/orcad-e2e-routing.test.mjs": 184, + "config/scripts/orcad-prebuild-slot-contents.test.mjs": 81, + "config/scripts/orcad-terminal-smoke-change-scope.test.mjs": 2562, + "config/scripts/orcad-watcher-package.test.mjs": 107, + "config/scripts/orcad-windows-prebuild-cache.test.mjs": 446, + "config/scripts/orcad-windows-prebuild-workflow.test.mjs": 371, + "config/scripts/orcad-windows-process-tree.test.mjs": 94, + "config/scripts/oxc-cli-invocation.test.mjs": 102, + "config/scripts/oxlint-cli-invocation.test.mjs": 73, + "config/scripts/package-electron-runtime-contract.test.mjs": 169, + "config/scripts/package-linux-formats-appimage.test.mjs": 577, + "config/scripts/package-linux-formats.test.mjs": 315, + "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 68, + "config/scripts/packaged-orcad-template.test.mjs": 185, + "config/scripts/packaged-process-host.test.mjs": 653, + "config/scripts/packaged-source-map-prune.test.mjs": 69, + "config/scripts/patched-dependencies-frozen-install.test.mjs": 574, + "config/scripts/pdfjs-viewer-assets-stream.test.ts": 317, + "config/scripts/plain-node-entry-guard.test.ts": 680, + "config/scripts/playwright-focused-workflow-selection.test.mjs": 3051, + "config/scripts/pnpm-cli-invocation.test.mjs": 63, + "config/scripts/pr-auxiliary-preflight-admission.test.mjs": 417, + "config/scripts/pr-code-change-scope-cross-version-launch-route.test.mjs": 49, + "config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs": 55, + "config/scripts/pr-code-change-scope-daemon-protocol.test.mjs": 44, + "config/scripts/pr-code-change-scope-process-host.test.mjs": 54, + "config/scripts/pr-code-change-scope.test.mjs": 551, + "config/scripts/pr-codex-unit-only-selection.test.mjs": 45, + "config/scripts/pr-e2e-gate-contract.test.mjs": 264, + "config/scripts/pr-e2e-native-only-routing.test.mjs": 153, + "config/scripts/pr-e2e-pane-close-routing.test.mjs": 61, + "config/scripts/pr-preflight-gates.test.mjs": 203, + "config/scripts/pr-ready-check-gate.test.mjs": 338, + "config/scripts/pr-ready-check-reuse.test.mjs": 171, + "config/scripts/pr-test-loc-summary.test.mjs": 179, + "config/scripts/pr-unit-support-routing.test.mjs": 64, + "config/scripts/pr-workflow-parallelism.test.mjs": 151, + "config/scripts/process-failure-message.test.mjs": 44, + "config/scripts/process-host-build-dist.test.mjs": 32253, + "config/scripts/process-host-dev-rebuild.test.ts": 9293, + "config/scripts/profile-state-worker-smoke.test.mjs": 2753, + "config/scripts/project-renderer-web-client.test.mjs": 623, + "config/scripts/pty-transcript-secret-scan.test.mjs": 168, + "config/scripts/publish-complete-draft-releases.test.mjs": 151, + "config/scripts/pullfrog-review-scope.test.mjs": 69, + "config/scripts/quadratic-buffer-concat-plugin.test.mjs": 1652, + "config/scripts/readme-downloads-badge.test.mjs": 37, + "config/scripts/rebuild-native-deps-node-pty.test.mjs": 1261, + "config/scripts/rebuild-native-deps-windows-process-tree.test.mjs": 45, + "config/scripts/rebuild-native-deps.test.mjs": 1263, + "config/scripts/reclaim-dev-electron-bundles.test.ts": 64, + "config/scripts/regenerate-xterm-patches.test.mjs": 249, + "config/scripts/relay-artifact-manifest.test.mjs": 1065, + "config/scripts/relay-asset-line-ending-pin.test.mjs": 144, + "config/scripts/relay-windows-process-tree-prepared-addon.test.mjs": 168, + "config/scripts/relay-windows-process-tree-staging.test.mjs": 82, + "config/scripts/release-cut-token-permissions.test.mjs": 199, + "config/scripts/release-javascript-artifact.test.mjs": 253, + "config/scripts/release-javascript-parity.test.mjs": 98, + "config/scripts/release-mac-build-workflow-dispatch.test.mjs": 56, + "config/scripts/release-policy.test.mjs": 62, + "config/scripts/release-rc-history.test.mjs": 147, + "config/scripts/release-tag-pattern-census.test.mjs": 97, + "config/scripts/renderer-boot-graph.test.mjs": 88, + "config/scripts/renderer-scrollbar-style-plugin.test.mjs": 2564, + "config/scripts/replace-cached-nsis-elevate.test.mjs": 1852, + "config/scripts/resolve-7za-path.test.mjs": 1333, + "config/scripts/run-codex-real-account-validation.test.ts": 3394, + "config/scripts/run-internal-dev-setup.test.mjs": 59, + "config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs": 58, + "config/scripts/run-terminal-scale-perf-report-gate.test.mjs": 77, + "config/scripts/run-typecheck-projects-in-parallel.test.mjs": 59, + "config/scripts/scan-release-artifacts-antivirus.test.mjs": 68, + "config/scripts/script-entry-detection.test.mjs": 91, + "config/scripts/script-module-dependencies.test.mjs": 63, + "config/scripts/session-scanner-service-smoke.test.mjs": 549, + "config/scripts/shared-electron-dist-cache.test.ts": 105, + "config/scripts/shebang-script-line-ending-pin.test.mjs": 103, + "config/scripts/skill-description-length.test.mjs": 107, + "config/scripts/skill-recipe-shell.test.mjs": 103, + "config/scripts/sort-comparator-performance-plugin.test.mjs": 283, + "config/scripts/space-sharing-copy.test.ts": 96, + "config/scripts/ssh-browser-e2e-routing.test.mjs": 176, + "config/scripts/ssh-localhost-e2e-routing.test.mjs": 155, + "config/scripts/ssh-relay-import-boundary.test.mjs": 5796, + "config/scripts/stable-release-tag-selection-budget.test.mjs": 149, "config/scripts/static-appimage-package-contract.test.mjs": 83, - "config/scripts/summarize-terminal-perf-report.test.mjs": 74, - "config/scripts/telemetry-bundle-constant-patterns.test.mjs": 45, - "config/scripts/terminal-ime-e2e-workflow.test.mjs": 79, - "config/scripts/terminal-ime-engagement-receipt.test.mjs": 37, - "config/scripts/terminal-perf-report-annotations.test.mjs": 60, - "config/scripts/trim-windows-icon-source.test.mjs": 289, - "config/scripts/verify-cli-bin.test.mjs": 102, - "config/scripts/verify-dev-channel-packaging.test.mjs": 655, - "config/scripts/verify-linux-glibc-floor.test.mjs": 142, - "config/scripts/verify-linux-glibc-payloads.test.mjs": 223, - "config/scripts/verify-localization-catalog.test.mjs": 671, - "config/scripts/verify-localization-extraction.test.mjs": 36, - "config/scripts/verify-packaged-browser-participation.test.mjs": 43, - "config/scripts/verify-packaged-daemon-entry.test.mjs": 135, - "config/scripts/verify-packaged-mobile-web-bundle.test.mjs": 439, - "config/scripts/verify-packaged-node-pty-job-ownership.test.mjs": 61, - "config/scripts/verify-packaged-orcad-template.test.mjs": 466, - "config/scripts/verify-packaged-plugin-resources.test.mjs": 68, - "config/scripts/verify-release-required-assets.test.mjs": 33, - "config/scripts/verify-skills-cli-runtime.test.mjs": 381, - "config/scripts/verify-windows-inner-signature.test.mjs": 46, - "config/scripts/verify-wsl-e2e-participation.test.mjs": 58, - "config/scripts/websocket-server-loopback-bind.test.ts": 898, - "config/scripts/win-crash-survival-e2e.test.mjs": 775, - "config/scripts/win32-test-lane-registration.test.mjs": 2421, - "config/scripts/windows-bin-spawn-predicate.test.mjs": 363, - "config/scripts/windows-cmd-shim-spawn-boundary.test.mjs": 2079, - "config/scripts/windows-pe-machine.test.mjs": 41, - "config/scripts/windows-process-tree-creation-time.test.mjs": 43, - "config/scripts/windows-process-tree-gyp-path.test.mjs": 47, - "config/scripts/windows-process-tree-gyp-rebuild.test.mjs": 104, - "config/scripts/windows-process-tree-patch-contract.test.mjs": 711, - "config/scripts/windows-pty-native-capability-workflow.test.mjs": 142, - "config/scripts/windows-signing-gate-toolset.test.mjs": 148, - "config/scripts/windows-signing-workflow-contract.test.mjs": 589, - "config/scripts/windows-uninstaller-signing.test.mjs": 65, - "config/scripts/workflow-ref-mirror-case-safety.test.mjs": 435, - "config/scripts/workflow-ref-reachability.test.mjs": 783, - "config/scripts/wsl-e2e-lane-contract.test.mjs": 208, - "config/scripts/xterm-image-lifecycle-contract.test.mjs": 458, - "config/scripts/xterm-image-memory-contract.test.mjs": 240, - "config/scripts/xterm-image-resize-contract.test.mjs": 69, - "config/scripts/xterm-webgl-runtime-contract.test.mjs": 51, - "config/scripts/zip-extractor-command.test.mjs": 83, - "src/cli/agent-context.test.ts": 56, - "src/cli/agent-session-search-format.test.ts": 56, - "src/cli/args-repeatable-flags.test.ts": 51, - "src/cli/args.test.ts": 259, - "src/cli/automation-format.test.ts": 48, - "src/cli/automation-owner-conflict-recovery.test.ts": 236, - "src/cli/base64-payload-byte-count.test.ts": 28, - "src/cli/browser-cookie-credentials-empty-value.test.ts": 190, - "src/cli/browser-storage-empty-value.test.ts": 97, - "src/cli/browser.test.ts": 279, - "src/cli/cli-command-name-parity.test.ts": 80, - "src/cli/cli-version.test.ts": 60, - "src/cli/codex-command-classification.test.ts": 44, - "src/cli/command-suggestion-budget.test.ts": 77, - "src/cli/command-suggestion.test.ts": 103, - "src/cli/computer-format.test.ts": 35, - "src/cli/emulator-logcat-format.test.ts": 41, - "src/cli/execution-host-flag.test.ts": 83, - "src/cli/flags.test.ts": 34, - "src/cli/format-recovery.test.ts": 275, - "src/cli/format.test.ts": 481, - "src/cli/handler-group-manifest.test.ts": 813, - "src/cli/handlers/account.test.ts": 957, - "src/cli/handlers/agent-hooks-load-boundary.test.ts": 282, - "src/cli/handlers/agent-hooks.test.ts": 998, - "src/cli/handlers/artifacts.test.ts": 201, - "src/cli/handlers/automation-destination-fencing.test.ts": 171, - "src/cli/handlers/automation-owner-fencing.test.ts": 306, - "src/cli/handlers/computer-action-routing.test.ts": 137, - "src/cli/handlers/computer-action-validation.test.ts": 204, - "src/cli/handlers/computer-state-formatting.test.ts": 94, - "src/cli/handlers/computer.test.ts": 204, - "src/cli/handlers/core.test.ts": 182, - "src/cli/handlers/emulator.test.ts": 192, - "src/cli/handlers/file-absolute-paths.test.ts": 94, - "src/cli/handlers/file.test.ts": 398, - "src/cli/handlers/interactive-login-interruption.test.ts": 37, - "src/cli/handlers/linear.test.ts": 207, - "src/cli/handlers/orchestration-caller-identity-cli.test.ts": 283, - "src/cli/handlers/orchestration-check-identity.test.ts": 294, - "src/cli/handlers/orchestration-federated-legacy-settlement.test.ts": 251, - "src/cli/handlers/orchestration-gate-cli.test.ts": 385, - "src/cli/handlers/orchestration-legacy-read-only.test.ts": 238, - "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts": 261, - "src/cli/handlers/orchestration-lifecycle-rejection.test.ts": 297, - "src/cli/handlers/orchestration-migration.test.ts": 312, - "src/cli/handlers/orchestration-module-boundaries.test.ts": 358, - "src/cli/handlers/orchestration-request-show-cli.test.ts": 293, - "src/cli/handlers/orchestration-run-cli.test.ts": 200, - "src/cli/handlers/orchestration-send-receipt-warnings.test.ts": 284, - "src/cli/handlers/orchestration-task-create-cli.test.ts": 201, - "src/cli/handlers/orchestration-task-list-brief.test.ts": 155, - "src/cli/handlers/orchestration-timeout-cli.test.ts": 361, - "src/cli/handlers/orchestration-timeout.test.ts": 280, - "src/cli/handlers/orchestration-windows-ask-cli.test.ts": 160, - "src/cli/handlers/orchestration-worker-cli.test.ts": 527, - "src/cli/handlers/orchestration-worker-show-wait-cli.test.ts": 214, - "src/cli/handlers/orchestration.test.ts": 549, - "src/cli/handlers/orchestration/worker-list-run-scope.test.ts": 243, - "src/cli/handlers/orchestration/worker-output.test.ts": 56, - "src/cli/handlers/profile-state-recovery-admission.test.ts": 1544, - "src/cli/handlers/profile-state.test.ts": 783, - "src/cli/handlers/search.test.ts": 251, - "src/cli/handlers/skill-sharing.test.ts": 232, - "src/cli/handlers/terminal.test.ts": 318, - "src/cli/host-selector-alternatives.test.ts": 46, - "src/cli/index-automation-identifiers.test.ts": 189, - "src/cli/index-automation-schedule.test.ts": 151, - "src/cli/index-automation-session-reuse.test.ts": 162, - "src/cli/index-automation-source-context.test.ts": 103, - "src/cli/index-automation-target.test.ts": 197, - "src/cli/index-device-commands.test.ts": 170, - "src/cli/index-environment-commands.test.ts": 139, - "src/cli/index-local-command-routing-flags.test.ts": 430, - "src/cli/index-memory-diagnostics.test.ts": 160, - "src/cli/index-omitted-host-scope-selectors.test.ts": 230, - "src/cli/index-orchestration.test.ts": 201, - "src/cli/index-project-setup.test.ts": 143, - "src/cli/index-serve-command.test.ts": 193, - "src/cli/index-terminal-commands.test.ts": 344, - "src/cli/index-terminal-list-host-scope.test.ts": 161, - "src/cli/index-vm-recipe-doctor.test.ts": 408, - "src/cli/index-worktree-create-agent.test.ts": 143, - "src/cli/index-worktree-create-linear.test.ts": 94, - "src/cli/index-worktree-create-parent.test.ts": 386, - "src/cli/index-worktree-create-target.test.ts": 98, - "src/cli/index-worktree-selector-resolution.test.ts": 352, - "src/cli/index-worktree-set.test.ts": 184, - "src/cli/index.test.ts": 981, - "src/cli/linear-format.test.ts": 47, - "src/cli/orchestration-dispatch-refusal-format.test.ts": 75, - "src/cli/orchestration-mutation-recovery.test.ts": 212, - "src/cli/orchestration-structured-sender-identity.test.ts": 137, - "src/cli/orchestration-structured-session-no-identity.test.ts": 389, - "src/cli/quote-stripped-json-flag.test.ts": 29, - "src/cli/registry-parity.test.ts": 78, - "src/cli/retry-request-flag.test.ts": 367, - "src/cli/runtime-client-deferral.test.ts": 715, - "src/cli/runtime-client.test.ts": 424, - "src/cli/runtime/client-recovery.test.ts": 463, - "src/cli/runtime/client-timeout-policy.test.ts": 250, - "src/cli/runtime/envelope-schema.test.ts": 87, - "src/cli/runtime/environments.test.ts": 217, - "src/cli/runtime/launch.test.ts": 181, - "src/cli/runtime/orchestration-recovery-command.test.ts": 60, - "src/cli/runtime/profile-state-recovery-launch.test.ts": 185, - "src/cli/runtime/runtime-access-denied.test.ts": 122, - "src/cli/runtime/serve-signal-exit-diagnostic.test.ts": 66, - "src/cli/runtime/status.test.ts": 233, - "src/cli/runtime/transport-framing.test.ts": 222, - "src/cli/runtime/transport.test.ts": 656, - "src/cli/runtime/types.test.ts": 38, - "src/cli/runtime/websocket-transport-error.test.ts": 243, - "src/cli/runtime/websocket-transport.test.ts": 355, - "src/cli/search-command-arguments.test.ts": 275, - "src/cli/serve-electron-flag-parity.test.ts": 58, - "src/cli/shell-command-quote.test.ts": 38, - "src/cli/skill-guide-cli-parity.test.ts": 106, - "src/cli/skills-reference-selector.test.ts": 194, - "src/cli/skills.test.ts": 1332, - "src/cli/specs/account.test.ts": 50, - "src/cli/specs/bundled-guide-flags.test.ts": 262, - "src/cli/specs/computer.test.ts": 44, - "src/cli/specs/orchestration.test.ts": 33, - "src/cli/specs/profile-state.test.ts": 43, - "src/cli/specs/search.test.ts": 121, - "src/cli/specs/skills.test.ts": 50, - "src/cli/terminal-format-draft.test.ts": 50, - "src/cli/terminal-format.test.ts": 44, - "src/cli/terminal-list-host-scope-format.test.ts": 71, - "src/cli/terminal-read-screen.test.ts": 126, - "src/cli/vocabulary-policy.test.ts": 67, - "src/cli/worktree-selector-wsl-posix-path.test.ts": 49, - "src/main/active-view-persistence-boundary.test.ts": 783, - "src/main/active-view-preference-sync-flush-veto.test.ts": 788, - "src/main/active-view-preference.test.ts": 64, - "src/main/agent-auth-restart-preservation.test.ts": 71, - "src/main/agent-awake-service-platform-assertions.test.ts": 99, - "src/main/agent-awake-service.test.ts": 106, - "src/main/agent-hooks/branch-rename-failure-output.test.ts": 26, - "src/main/agent-hooks/ended-process-reconciliation.test.ts": 510, - "src/main/agent-hooks/first-work-branch-rename.test.ts": 365, - "src/main/agent-hooks/first-work-folder-rename.test.ts": 87, - "src/main/agent-hooks/grok-replay-guard.test.ts": 101, - "src/main/agent-hooks/hook-script-outside-orca.test.ts": 210, - "src/main/agent-hooks/hook-status-session-tabs-republish.test.ts": 578, - "src/main/agent-hooks/install-telemetry.test.ts": 58, - "src/main/agent-hooks/installer-utils-remote.test.ts": 66, - "src/main/agent-hooks/installer-utils.test.ts": 180, - "src/main/agent-hooks/local-agent-cli-presence.test.ts": 76, - "src/main/agent-hooks/managed-agent-hook-controls.test.ts": 261, - "src/main/agent-hooks/managed-hook-command-contract.test.ts": 309, - "src/main/agent-hooks/managed-hook-detection-commands.test.ts": 64, - "src/main/agent-hooks/managed-hook-install-lock.test.ts": 388, - "src/main/agent-hooks/managed-hook-local-filesystem.test.ts": 327, - "src/main/agent-hooks/managed-hook-owner-identity.test.ts": 84, - "src/main/agent-hooks/managed-hook-runtime.test.ts": 409, - "src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts": 330, - "src/main/agent-hooks/managed-hook-script-refresh.test.ts": 471, - "src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts": 2177, - "src/main/agent-hooks/managed-hook-timeout.test.ts": 1803, - "src/main/agent-hooks/managed-toml-ownership.test.ts": 50, - "src/main/agent-hooks/manual-compact-hook-stream.test.ts": 582, - "src/main/agent-hooks/manual-compact-status-cleanup.test.ts": 553, - "src/main/agent-hooks/migration-unsupported-pty-state.test.ts": 31, - "src/main/agent-hooks/opencode-message-part-flood-bench.test.ts": 1229, - "src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts": 3494, - "src/main/agent-hooks/remote-hook-service-installers.test.ts": 367, - "src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts": 238, - "src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts": 930, - "src/main/agent-hooks/server-ai-vault-liveness.test.ts": 606, - "src/main/agent-hooks/server-amp-normalization.test.ts": 570, - "src/main/agent-hooks/server-authority-evidence.test.ts": 639, - "src/main/agent-hooks/server-claude-cancel-captures.test.ts": 856, - "src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts": 508, - "src/main/agent-hooks/server-claude-normalization.test.ts": 497, - "src/main/agent-hooks/server-claude-permission-visibility.test.ts": 836, - "src/main/agent-hooks/server-claude-statusline.test.ts": 592, - "src/main/agent-hooks/server-closed-tab-suppression.test.ts": 614, - "src/main/agent-hooks/server-codex-normalization.test.ts": 575, - "src/main/agent-hooks/server-codex-subagent-transcript.test.ts": 4597, - "src/main/agent-hooks/server-copilot-normalization.test.ts": 1055, - "src/main/agent-hooks/server-cursor-normalization.test.ts": 550, - "src/main/agent-hooks/server-droid-normalization.test.ts": 592, - "src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts": 568, - "src/main/agent-hooks/server-escape-navigation-inference.test.ts": 739, - "src/main/agent-hooks/server-gemini-normalization.test.ts": 565, - "src/main/agent-hooks/server-grok-background-status.test.ts": 760, - "src/main/agent-hooks/server-grok-cancel.test.ts": 610, - "src/main/agent-hooks/server-grok-discovery.test.ts": 1077, - "src/main/agent-hooks/server-hook-http-ingest.test.ts": 388, - "src/main/agent-hooks/server-ingest-remote.test.ts": 449, - "src/main/agent-hooks/server-ingest-structured-children.test.ts": 590, - "src/main/agent-hooks/server-ingest-structured-state-clock.test.ts": 630, - "src/main/agent-hooks/server-ingest-structured-status.test.ts": 585, - "src/main/agent-hooks/server-ingest-terminal-status.test.ts": 382, - "src/main/agent-hooks/server-interrupt-inference-guards.test.ts": 693, - "src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts": 646, - "src/main/agent-hooks/server-interrupt-inference-validation.test.ts": 419, - "src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts": 713, - "src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts": 389, - "src/main/agent-hooks/server-last-status-hydrate-validation.test.ts": 488, - "src/main/agent-hooks/server-last-status-lead-boundary.test.ts": 647, - "src/main/agent-hooks/server-last-status-main-agent-fact.test.ts": 584, - "src/main/agent-hooks/server-last-status-restored-children.test.ts": 766, - "src/main/agent-hooks/server-last-status-write.test.ts": 699, - "src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts": 702, - "src/main/agent-hooks/server-muse-session-log-poll.test.ts": 2487, - "src/main/agent-hooks/server-observation-provenance.test.ts": 625, - "src/main/agent-hooks/server-omp-retired-ingress.test.ts": 610, - "src/main/agent-hooks/server-omp-retirement-ack.test.ts": 557, - "src/main/agent-hooks/server-opencode-binder.test.ts": 652, - "src/main/agent-hooks/server-opencode-lifecycle.test.ts": 765, - "src/main/agent-hooks/server-opencode-normalization.test.ts": 380, - "src/main/agent-hooks/server-pane-authority.test.ts": 655, - "src/main/agent-hooks/server-pi-normalization.test.ts": 557, - "src/main/agent-hooks/server-prompt-sent-telemetry.test.ts": 643, - "src/main/agent-hooks/server-relay-listener-replay.test.ts": 551, - "src/main/agent-hooks/server-relayed-claude-cancel.test.ts": 678, - "src/main/agent-hooks/server-reminted-pane-key.test.ts": 672, - "src/main/agent-hooks/server-replay-evidence-clock.test.ts": 552, - "src/main/agent-hooks/server-retired-pane-new-turn.test.ts": 383, - "src/main/agent-hooks/server-start-failure-lifecycle.test.ts": 597, - "src/main/agent-hooks/server-status-listener-fanout.test.ts": 739, - "src/main/agent-hooks/server-structured-canonical-status.test.ts": 408, - "src/main/agent-hooks/server-transport-interference.test.ts": 6270, - "src/main/agent-hooks/server.claude-interactive-question.test.ts": 396, - "src/main/agent-hooks/spool.test.ts": 891, - "src/main/agent-hooks/terminal-handle-row-identity.test.ts": 367, - "src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts": 69, - "src/main/agent-hooks/windows-hook-payload-delivery.test.ts": 151, - "src/main/agent-hooks/windows-hook-post-interpreter.test.ts": 330, - "src/main/agent-hooks/windows-powershell-hook-launcher.test.ts": 49, - "src/main/agent-hooks/wsl-guest-plugin-install.test.ts": 68, - "src/main/agent-hooks/wsl-hook-relay-launch.test.ts": 90, - "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts": 4381, - "src/main/agent-hooks/wsl-hook-relay-manager.test.ts": 1974, - "src/main/agent-hooks/wsl-hook-relay-reattach.test.ts": 713, - "src/main/agent-hooks/wsl-hook-relay-recovery.test.ts": 2365, - "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts": 37, - "src/main/agent-launch/agent-launch-executor.test.ts": 330, - "src/main/agent-state-file-reader.test.ts": 97, - "src/main/agent-trust-presets.test.ts": 137, - "src/main/ai-vault-search/session-search-child-service.test.ts": 338, - "src/main/ai-vault-search/session-search-content-hash.test.ts": 71, - "src/main/ai-vault-search/session-search-cwd-key.test.ts": 78, - "src/main/ai-vault-search/session-search-deleted-sources.test.ts": 377, - "src/main/ai-vault-search/session-search-directory-listings.test.ts": 50, - "src/main/ai-vault-search/session-search-engine.test.ts": 3299, - "src/main/ai-vault-search/session-search-file-id.test.ts": 211, - "src/main/ai-vault-search/session-search-file-write.test.ts": 622, - "src/main/ai-vault-search/session-search-fts5-contract.test.ts": 144, - "src/main/ai-vault-search/session-search-hit-ranking.test.ts": 90, - "src/main/ai-vault-search/session-search-host-registration.test.ts": 993, - "src/main/ai-vault-search/session-search-identifier-split.test.ts": 39, - "src/main/ai-vault-search/session-search-index-consumer.test.ts": 255, - "src/main/ai-vault-search/session-search-index-generation.test.ts": 579, - "src/main/ai-vault-search/session-search-index-pass.test.ts": 419, - "src/main/ai-vault-search/session-search-index-writer.test.ts": 480, - "src/main/ai-vault-search/session-search-indexer.test.ts": 4288, - "src/main/ai-vault-search/session-search-instance.test.ts": 869, - "src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts": 3013, - "src/main/ai-vault-search/session-search-live-transcript.test.ts": 289, - "src/main/ai-vault-search/session-search-merged-roots.test.ts": 409, - "src/main/ai-vault-search/session-search-message-rows.test.ts": 420, - "src/main/ai-vault-search/session-search-native-chat-indexing.test.ts": 332, - "src/main/ai-vault-search/session-search-opencode-index.test.ts": 955, - "src/main/ai-vault-search/session-search-orphan-rows.test.ts": 302, - "src/main/ai-vault-search/session-search-paging.test.ts": 540, - "src/main/ai-vault-search/session-search-query-planner.test.ts": 29, - "src/main/ai-vault-search/session-search-read-decision.test.ts": 55, - "src/main/ai-vault-search/session-search-retention-delete.test.ts": 171, - "src/main/ai-vault-search/session-search-retention-policy.test.ts": 76, - "src/main/ai-vault-search/session-search-root-refresh.test.ts": 503, - "src/main/ai-vault-search/session-search-row-filter.test.ts": 190, - "src/main/ai-vault-search/session-search-row-identity.test.ts": 105, - "src/main/ai-vault-search/session-search-scan-roots.test.ts": 195, - "src/main/ai-vault-search/session-search-schema.test.ts": 458, - "src/main/ai-vault-search/session-search-scope-entry-points.test.ts": 3912, - "src/main/ai-vault-search/session-search-scope-resolution.test.ts": 121, - "src/main/ai-vault-search/session-search-scope-service.test.ts": 179, - "src/main/ai-vault-search/session-search-service-registry.test.ts": 217, - "src/main/ai-vault-search/session-search-service.test.ts": 197, - "src/main/ai-vault-search/session-search-sidebar-parity.test.ts": 288, - "src/main/ai-vault-search/session-search-snippet-marks.test.ts": 213, - "src/main/ai-vault-search/session-search-store-is-memory.test.ts": 604, - "src/main/ai-vault-search/session-search-store-scope-catalog.test.ts": 40, - "src/main/ai-vault-search/session-search-synthetic-corpus.test.ts": 252, - "src/main/ai-vault-search/session-search-typo-policy.test.ts": 148, - "src/main/ai-vault-search/session-search-typo-scope.test.ts": 162, - "src/main/ai-vault-search/session-search-write-lifetime.test.ts": 246, - "src/main/ai-vault/ai-vault-scan-cancellation.test.ts": 49, - "src/main/ai-vault/ai-vault-scan-coordinator.test.ts": 81, - "src/main/ai-vault/cached-session-list-wsl-probe.test.ts": 209, - "src/main/ai-vault/cached-session-list.test.ts": 447, - "src/main/ai-vault/claude-project-dir-encoding.test.ts": 28, - "src/main/ai-vault/codex-session-collection.test.ts": 127, - "src/main/ai-vault/codex-session-root-dedup.test.ts": 116, - "src/main/ai-vault/local-log-tail-reader.test.ts": 35, - "src/main/ai-vault/omp-session-root.test.ts": 159, - "src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts": 917, - "src/main/ai-vault/remote-session-content-lines.test.ts": 74, - "src/main/ai-vault/remote-session-large-transcripts.test.ts": 2163, - "src/main/ai-vault/remote-session-parse-cache.test.ts": 229, - "src/main/ai-vault/remote-session-scan-batching.test.ts": 32, - "src/main/ai-vault/remote-session-scanner-muse.test.ts": 167, - "src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts": 172, - "src/main/ai-vault/remote-session-scanner-opencode.test.ts": 238, - "src/main/ai-vault/remote-session-scanner-sources.test.ts": 118, - "src/main/ai-vault/remote-session-scanner.test.ts": 289, - "src/main/ai-vault/remote-session-sidecar-observation.test.ts": 134, - "src/main/ai-vault/remote-session-stream-lifecycle.test.ts": 74, - "src/main/ai-vault/runtime-session-scanner.test.ts": 87, - "src/main/ai-vault/runtime-session-search-call.test.ts": 57, - "src/main/ai-vault/session-cwd-bucket-layouts.test.ts": 26, - "src/main/ai-vault/session-delete-target.test.ts": 112, - "src/main/ai-vault/session-delete.test.ts": 215, - "src/main/ai-vault/session-document-stream-boundaries.test.ts": 140, - "src/main/ai-vault/session-document-stream-parity.test.ts": 115, - "src/main/ai-vault/session-document-stream-projection.test.ts": 100, - "src/main/ai-vault/session-first-user-prompt-opencode.test.ts": 155, - "src/main/ai-vault/session-first-user-prompt-read.test.ts": 110, - "src/main/ai-vault/session-list-result-validation.test.ts": 155, - "src/main/ai-vault/session-list-results.test.ts": 90, - "src/main/ai-vault/session-newest-files.test.ts": 126, - "src/main/ai-vault/session-parse-cache-devin-upgrade.test.ts": 214, - "src/main/ai-vault/session-parse-cache-persistence.test.ts": 568, - "src/main/ai-vault/session-scan-cutoff.test.ts": 68, - "src/main/ai-vault/session-scanner-agent-root-overrides.test.ts": 1121, - "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 72, - "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 330, - "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 245, - "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 170, - "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 137, - "src/main/ai-vault/session-scanner-claude-title.test.ts": 235, - "src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts": 176, - "src/main/ai-vault/session-scanner-cline-parser.test.ts": 247, - "src/main/ai-vault/session-scanner-codex-dual-root.test.ts": 317, - "src/main/ai-vault/session-scanner-codex-fast-path.test.ts": 404, - "src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts": 41, - "src/main/ai-vault/session-scanner-codex-parser.test.ts": 127, - "src/main/ai-vault/session-scanner-codex-title-index.test.ts": 213, - "src/main/ai-vault/session-scanner-codex-tool-records.test.ts": 75, - "src/main/ai-vault/session-scanner-codex-workers.test.ts": 273, - "src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts": 204, - "src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts": 493, - "src/main/ai-vault/session-scanner-cutoff.test.ts": 340, - "src/main/ai-vault/session-scanner-dedup-batches.test.ts": 320, - "src/main/ai-vault/session-scanner-devin-contention.test.ts": 265, - "src/main/ai-vault/session-scanner-devin-db.test.ts": 385, - "src/main/ai-vault/session-scanner-devin-dedup.test.ts": 305, - "src/main/ai-vault/session-scanner-devin-parser.test.ts": 115, - "src/main/ai-vault/session-scanner-directory-reader.test.ts": 66, - "src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts": 101, - "src/main/ai-vault/session-scanner-first-user-prompt.test.ts": 60, - "src/main/ai-vault/session-scanner-fs-import-guard.test.ts": 109, - "src/main/ai-vault/session-scanner-graph-parsers.test.ts": 223, - "src/main/ai-vault/session-scanner-grok-parser.test.ts": 111, - "src/main/ai-vault/session-scanner-grok-user-text.test.ts": 47, - "src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts": 73, - "src/main/ai-vault/session-scanner-injected-title.test.ts": 265, - "src/main/ai-vault/session-scanner-jsonl-reader.test.ts": 355, - "src/main/ai-vault/session-scanner-kimi-index-cache.test.ts": 103, - "src/main/ai-vault/session-scanner-kimi-parser.test.ts": 152, - "src/main/ai-vault/session-scanner-muse.test.ts": 250, - "src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts": 94, - "src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts": 234, - "src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts": 91, - "src/main/ai-vault/session-scanner-omp-title.test.ts": 105, - "src/main/ai-vault/session-scanner-opencode-cancellation.test.ts": 393, - "src/main/ai-vault/session-scanner-opencode-parser.test.ts": 112, - "src/main/ai-vault/session-scanner-opencode-process-admission.test.ts": 264, - "src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts": 103, - "src/main/ai-vault/session-scanner-opencode-sources.test.ts": 65, - "src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts": 656, - "src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts": 373, - "src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts": 3466, - "src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts": 35, - "src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts": 3607, - "src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts": 87, - "src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.test.ts": 103, - "src/main/ai-vault/session-scanner-opencode-sqlite.test.ts": 207, - "src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts": 80, - "src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts": 129, - "src/main/ai-vault/session-scanner-opencode2-message.test.ts": 47, - "src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts": 139, - "src/main/ai-vault/session-scanner-parse-cache-agents.test.ts": 663, - "src/main/ai-vault/session-scanner-parse-cache.test.ts": 294, - "src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts": 230, - "src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts": 115, - "src/main/ai-vault/session-scanner-pi-scope.test.ts": 310, - "src/main/ai-vault/session-scanner-preview-window-truncation.test.ts": 55, - "src/main/ai-vault/session-scanner-record-budget.test.ts": 443, - "src/main/ai-vault/session-scanner-recoverable-empty.test.ts": 316, - "src/main/ai-vault/session-scanner-roots.test.ts": 79, - "src/main/ai-vault/session-scanner-scope.test.ts": 200, - "src/main/ai-vault/session-scanner-service-cancellation.test.ts": 203, + "config/scripts/static-module-specifiers.test.mjs": 277, + "config/scripts/structured-agent-session-start-failure-row-render.test.mjs": 2675, + "config/scripts/summarize-terminal-perf-report.test.mjs": 106, + "config/scripts/telemetry-bundle-constant-patterns.test.mjs": 60, + "config/scripts/terminal-ime-engagement-receipt.test.mjs": 60, + "config/scripts/terminal-partial-escape-native-continuation.test.mjs": 2681, + "config/scripts/terminal-perf-dependency-preparation.test.mjs": 613, + "config/scripts/terminal-perf-report-annotations.test.mjs": 59, + "config/scripts/test-only-source-path.test.mjs": 42, + "config/scripts/trim-windows-icon-source.test.mjs": 234, + "config/scripts/update-node-runtime-pin.test.mjs": 89, + "config/scripts/verify-cli-bin.test.mjs": 106, + "config/scripts/verify-dev-channel-packaging.test.mjs": 285, + "config/scripts/verify-linux-glibc-floor.test.mjs": 124, + "config/scripts/verify-linux-glibc-payloads.test.mjs": 156, + "config/scripts/verify-localization-catalog.test.mjs": 514, + "config/scripts/verify-localization-catalogs.test.mjs": 2042, + "config/scripts/verify-localization-extraction.test.mjs": 49, + "config/scripts/verify-packaged-browser-participation.test.mjs": 61, + "config/scripts/verify-packaged-daemon-entry.test.mjs": 132, + "config/scripts/verify-packaged-mobile-web-bundle.test.mjs": 330, + "config/scripts/verify-packaged-node-pty-job-ownership.test.mjs": 109, + "config/scripts/verify-packaged-orcad-template.test.mjs": 443, + "config/scripts/verify-packaged-plugin-resources.test.mjs": 91, + "config/scripts/verify-release-required-assets.test.mjs": 55, + "config/scripts/verify-skills-cli-runtime.test.mjs": 289, + "config/scripts/verify-windows-inner-signature.test.mjs": 68, + "config/scripts/verify-wsl-e2e-participation.test.mjs": 77, + "config/scripts/vitest-node-runtime-boundary.test.ts": 2060, + "config/scripts/vitest-node-runtime-pool.test.ts": 341, + "config/scripts/vitest-real-agent-home-write-guard.test.ts": 66, + "config/scripts/vitest-runtime-boundary.test.ts": 114, + "config/scripts/vitest-runtime-sequencer.test.mjs": 194, + "config/scripts/vitest-sqlite-runtime-boundary.test.ts": 3901, + "config/scripts/websocket-server-loopback-bind.test.ts": 536, + "config/scripts/win-crash-survival-e2e.test.mjs": 584, + "config/scripts/win32-test-lane-registration.test.mjs": 2948, + "config/scripts/windows-bin-spawn-predicate.test.mjs": 236, + "config/scripts/windows-cmd-shim-spawn-boundary.test.mjs": 1437, + "config/scripts/windows-pe-imports.test.mjs": 48, + "config/scripts/windows-pe-machine.test.mjs": 66, + "config/scripts/windows-process-tree-creation-time.test.mjs": 45, + "config/scripts/windows-process-tree-gyp-path.test.mjs": 39, + "config/scripts/windows-process-tree-gyp-rebuild.test.mjs": 80, + "config/scripts/windows-process-tree-patch-contract.test.mjs": 562, + "config/scripts/windows-pty-table-stress-observer.test.mjs": 97, + "config/scripts/windows-uninstaller-signing.test.mjs": 63, + "config/scripts/workflow-ref-mirror-case-safety.test.mjs": 534, + "config/scripts/workflow-ref-reachability.test.mjs": 662, + "config/scripts/workspace-source-exports.test.mjs": 120, + "config/scripts/wsl-e2e-lane-contract.test.mjs": 166, + "config/scripts/xterm-image-lifecycle-contract.test.mjs": 231, + "config/scripts/xterm-image-memory-contract.test.mjs": 380, + "config/scripts/xterm-image-resize-contract.test.mjs": 73, + "config/scripts/xterm-image-wasm-budget-contract.test.mjs": 479, + "config/scripts/zip-extractor-command.test.mjs": 76, + "src/cli/agent-context.test.ts": 125, + "src/cli/agent-session-search-format.test.ts": 71, + "src/cli/args-repeatable-flags.test.ts": 69, + "src/cli/args.test.ts": 123, + "src/cli/automation-format.test.ts": 102, + "src/cli/automation-owner-conflict-recovery.test.ts": 482, + "src/cli/browser-cookie-credentials-empty-value.test.ts": 206, + "src/cli/browser-storage-empty-value.test.ts": 185, + "src/cli/browser.test.ts": 309, + "src/cli/cli-version.test.ts": 56, + "src/cli/codex-command-classification.test.ts": 56, + "src/cli/command-suggestion-budget.test.ts": 55, + "src/cli/command-suggestion.test.ts": 53, + "src/cli/computer-format.test.ts": 102, + "src/cli/emulator-logcat-format.test.ts": 48, + "src/cli/execution-host-flag.test.ts": 94, + "src/cli/format-recovery.test.ts": 355, + "src/cli/format.test.ts": 618, + "src/cli/handler-group-manifest.test.ts": 1856, + "src/cli/handlers/account.test.ts": 924, + "src/cli/handlers/agent-hooks-load-boundary.test.ts": 533, + "src/cli/handlers/agent-hooks-prepare-codex-isolated-home.test.ts": 531, + "src/cli/handlers/agent-hooks.test.ts": 1290, + "src/cli/handlers/artifacts.test.ts": 475, + "src/cli/handlers/automation-destination-fencing.test.ts": 515, + "src/cli/handlers/automation-owner-fencing.test.ts": 506, + "src/cli/handlers/computer-action-routing.test.ts": 200, + "src/cli/handlers/computer-action-validation.test.ts": 250, + "src/cli/handlers/computer-state-formatting.test.ts": 191, + "src/cli/handlers/computer.test.ts": 224, + "src/cli/handlers/core.test.ts": 385, + "src/cli/handlers/data-account-commands.test.ts": 419, + "src/cli/handlers/emulator.test.ts": 207, + "src/cli/handlers/file-absolute-paths.test.ts": 200, + "src/cli/handlers/file.test.ts": 181, + "src/cli/handlers/interactive-login-interruption.test.ts": 69, + "src/cli/handlers/linear.test.ts": 267, + "src/cli/handlers/managed-server.test.ts": 432, + "src/cli/handlers/orchestration-caller-identity-cli.test.ts": 378, + "src/cli/handlers/orchestration-check-identity.test.ts": 473, + "src/cli/handlers/orchestration-federated-legacy-settlement.test.ts": 504, + "src/cli/handlers/orchestration-gate-cli.test.ts": 263, + "src/cli/handlers/orchestration-legacy-read-only.test.ts": 476, + "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts": 433, + "src/cli/handlers/orchestration-lifecycle-rejection.test.ts": 484, + "src/cli/handlers/orchestration-migration.test.ts": 555, + "src/cli/handlers/orchestration-module-boundaries.test.ts": 534, + "src/cli/handlers/orchestration-request-show-cli.test.ts": 442, + "src/cli/handlers/orchestration-run-cli.test.ts": 455, + "src/cli/handlers/orchestration-send-receipt-warnings.test.ts": 375, + "src/cli/handlers/orchestration-task-create-cli.test.ts": 561, + "src/cli/handlers/orchestration-task-list-brief.test.ts": 436, + "src/cli/handlers/orchestration-windows-ask-cli.test.ts": 496, + "src/cli/handlers/orchestration-worker-cli.test.ts": 472, + "src/cli/handlers/orchestration-worker-show-wait-cli.test.ts": 376, + "src/cli/handlers/orchestration.test.ts": 805, + "src/cli/handlers/orchestration/mutation-request.test.ts": 3412, + "src/cli/handlers/orchestration/worker-list-run-scope.test.ts": 534, + "src/cli/handlers/orchestration/worker-output.test.ts": 66, + "src/cli/handlers/profile-state-recovery-admission.test.ts": 2338, + "src/cli/handlers/profile-state.test.ts": 1185, + "src/cli/handlers/repo-visibility.test.ts": 584, + "src/cli/handlers/search.test.ts": 359, + "src/cli/handlers/skill-sharing.test.ts": 492, + "src/cli/handlers/terminal.test.ts": 571, + "src/cli/handlers/worktree-gitlab-link.test.ts": 347, + "src/cli/host-selector-alternatives.test.ts": 82, + "src/cli/index-automation-extra-agent-args.test.ts": 318, + "src/cli/index-automation-identifiers.test.ts": 293, + "src/cli/index-automation-schedule.test.ts": 256, + "src/cli/index-automation-session-reuse.test.ts": 312, + "src/cli/index-automation-source-context.test.ts": 254, + "src/cli/index-automation-target.test.ts": 246, + "src/cli/index-device-commands.test.ts": 180, + "src/cli/index-environment-commands.test.ts": 408, + "src/cli/index-local-command-routing-flags.test.ts": 764, + "src/cli/index-memory-diagnostics.test.ts": 244, + "src/cli/index-omitted-host-scope-selectors.test.ts": 317, + "src/cli/index-orchestration.test.ts": 292, + "src/cli/index-project-setup.test.ts": 245, + "src/cli/index-reference.test.ts": 233, + "src/cli/index-serve-command.test.ts": 444, + "src/cli/index-terminal-commands.test.ts": 233, + "src/cli/index-terminal-list-host-scope.test.ts": 229, + "src/cli/index-vm-recipe-doctor.test.ts": 469, + "src/cli/index-worktree-create-agent.test.ts": 253, + "src/cli/index-worktree-create-linear.test.ts": 235, + "src/cli/index-worktree-create-parent.test.ts": 310, + "src/cli/index-worktree-create-target.test.ts": 273, + "src/cli/index-worktree-gitlab-links.test.ts": 225, + "src/cli/index-worktree-linked-items.test.ts": 299, + "src/cli/index-worktree-selector-resolution.test.ts": 527, + "src/cli/index-worktree-set.test.ts": 281, + "src/cli/index.test.ts": 1022, + "src/cli/linear-format.test.ts": 66, + "src/cli/orchestration-dispatch-refusal-format.test.ts": 101, + "src/cli/orchestration-mutation-recovery.test.ts": 461, + "src/cli/orchestration-session-caller-cli.test.ts": 608, + "src/cli/orchestration-structured-sender-identity.test.ts": 285, + "src/cli/orchestration-structured-session-no-identity.test.ts": 448, + "src/cli/quote-stripped-json-flag.test.ts": 62, + "src/cli/reference-command-arguments.test.ts": 132, + "src/cli/retry-request-flag.test.ts": 513, + "src/cli/runtime-client-deferral.test.ts": 939, + "src/cli/runtime-client.test.ts": 532, + "src/cli/runtime/client-recovery.test.ts": 1089, + "src/cli/runtime/client-timeout-policy.test.ts": 371, + "src/cli/runtime/envelope-schema.test.ts": 261, + "src/cli/runtime/environments.test.ts": 409, + "src/cli/runtime/launch-serve-runtime.test.ts": 328, + "src/cli/runtime/launch-user-data-path.test.ts": 264, + "src/cli/runtime/launch.test.ts": 369, + "src/cli/runtime/orchestration-recovery-command.test.ts": 59, + "src/cli/runtime/profile-state-recovery-launch.test.ts": 301, + "src/cli/runtime/runtime-access-denied.test.ts": 433, + "src/cli/runtime/runtime-source-fence.test.ts": 454, + "src/cli/runtime/serve-orcad-launch.test.ts": 287, + "src/cli/runtime/serve-signal-exit-diagnostic.test.ts": 92, + "src/cli/runtime/status-caller.test.ts": 660, + "src/cli/runtime/status.test.ts": 377, + "src/cli/runtime/transport-framing.test.ts": 337, + "src/cli/runtime/transport-ssh-bridge-credential.test.ts": 248, + "src/cli/runtime/transport.test.ts": 789, + "src/cli/runtime/types.test.ts": 48, + "src/cli/runtime/websocket-transport-error.test.ts": 375, + "src/cli/runtime/websocket-transport.test.ts": 552, + "src/cli/search-command-arguments.test.ts": 352, + "src/cli/serve-electron-flag-parity.test.ts": 85, + "src/cli/shell-command-quote.test.ts": 53, + "src/cli/skills-reference-selector.test.ts": 206, + "src/cli/skills.test.ts": 1353, + "src/cli/specs/account.test.ts": 94, + "src/cli/specs/bundled-guide-flags.test.ts": 201, + "src/cli/specs/file.test.ts": 97, + "src/cli/specs/profile-state.test.ts": 87, + "src/cli/specs/search.test.ts": 181, + "src/cli/ssh-bridge-command-policy.test.ts": 70, + "src/cli/terminal-format-draft.test.ts": 51, + "src/cli/terminal-format.test.ts": 78, + "src/cli/terminal-list-host-scope-format.test.ts": 80, + "src/cli/terminal-read-screen.test.ts": 215, + "src/cli/vocabulary-policy.test.ts": 110, + "src/cli/worktree-ps-terminal-verdict.test.ts": 48, + "src/cli/worktree-selector-caller-host.test.ts": 69, + "src/cli/worktree-selector-wsl-posix-path.test.ts": 78, + "src/main/acp/acp-agent-connection.test.ts": 354, + "src/main/acp/acp-compaction-journal-fixture.test.ts": 298, + "src/main/acp/acp-dialects/grok-subagents.test.ts": 307, + "src/main/acp/acp-dialects/omp-dialect.test.ts": 258, + "src/main/acp/acp-fixture-privacy.test.ts": 137, + "src/main/acp/acp-json-rpc-peer.test.ts": 274, + "src/main/acp/acp-model-catalog-probe.test.ts": 426, + "src/main/acp/acp-permission-requests.test.ts": 355, + "src/main/acp/acp-prompt-content.test.ts": 641, + "src/main/acp/acp-recovery-history.test.ts": 502, + "src/main/acp/acp-session-agent-turns.test.ts": 297, + "src/main/acp/acp-session-lifecycle.test.ts": 273, + "src/main/acp/acp-session-notifications.test.ts": 323, + "src/main/acp/acp-session-runtime.test.ts": 459, + "src/main/acp/acp-structured-auth.test.ts": 1129, + "src/main/acp/acp-structured-background-task-stop.test.ts": 4451, + "src/main/acp/acp-structured-broken-stream.test.ts": 10364, + "src/main/acp/acp-structured-child-session-end.test.ts": 2649, + "src/main/acp/acp-structured-child-stop.test.ts": 2928, + "src/main/acp/acp-structured-child-work-admission.test.ts": 1464, + "src/main/acp/acp-structured-child-work.test.ts": 2920, + "src/main/acp/acp-structured-compaction.test.ts": 895, + "src/main/acp/acp-structured-context-clear.test.ts": 490, + "src/main/acp/acp-structured-electron-free.test.ts": 276, + "src/main/acp/acp-structured-grok-subagent-recordings.test.ts": 1106, + "src/main/acp/acp-structured-host-configured-default.test.ts": 1598, + "src/main/acp/acp-structured-host-crash.test.ts": 1006, + "src/main/acp/acp-structured-host-lifecycle.test.ts": 2036, + "src/main/acp/acp-structured-host-live-listing.test.ts": 1281, + "src/main/acp/acp-structured-host-option-hang.test.ts": 1480, + "src/main/acp/acp-structured-host-restore-failed.test.ts": 1530, + "src/main/acp/acp-structured-host-start-aborts.test.ts": 1092, + "src/main/acp/acp-structured-host-stop.test.ts": 1968, + "src/main/acp/acp-structured-host-subagent-stop.test.ts": 1665, + "src/main/acp/acp-structured-image-prompts.test.ts": 806, + "src/main/acp/acp-structured-launch-resolution-omp.test.ts": 415, + "src/main/acp/acp-structured-launch-resolution-opencode.test.ts": 317, + "src/main/acp/acp-structured-launch-resolution.test.ts": 524, + "src/main/acp/acp-structured-launch-visuals.test.ts": 364, + "src/main/acp/acp-structured-missing-model.test.ts": 361, + "src/main/acp/acp-structured-omp-recordings.test.ts": 953, + "src/main/acp/acp-structured-opencode-recordings.test.ts": 690, + "src/main/acp/acp-structured-options.test.ts": 398, + "src/main/acp/acp-structured-prompt-failure.test.ts": 759, + "src/main/acp/acp-structured-request-admission.test.ts": 824, + "src/main/acp/acp-structured-session-adapter-lifecycle.test.ts": 4918, + "src/main/acp/acp-structured-session-adapter-recordings.test.ts": 684, + "src/main/acp/acp-structured-session-adapter-resume.test.ts": 5492, + "src/main/acp/acp-structured-session-adapter-teardown.test.ts": 1211, + "src/main/acp/acp-structured-session-adapter.test.ts": 1251, + "src/main/acp/acp-structured-sign-in.test.ts": 813, + "src/main/acp/acp-structured-steer.test.ts": 1740, + "src/main/acp/acp-subagent-timeline.test.ts": 476, + "src/main/acp/acp-text-message-ownership.test.ts": 1219, + "src/main/acp/acp-text-stream-identity.test.ts": 817, + "src/main/acp/acp-timeline-background-task-evidence.test.ts": 698, + "src/main/acp/acp-timeline-background-task-results.test.ts": 594, + "src/main/acp/acp-timeline-background-task-words.test.ts": 1032, + "src/main/acp/acp-timeline-background-tasks.test.ts": 1081, + "src/main/acp/acp-timeline-dialect.test.ts": 618, + "src/main/acp/acp-timeline-generic.test.ts": 974, + "src/main/acp/acp-timeline-grok-subagent-retention.test.ts": 1206, + "src/main/acp/acp-timeline-grok-subagents.test.ts": 1171, + "src/main/acp/acp-timeline-joins.test.ts": 561, + "src/main/acp/acp-timeline-open-enums.test.ts": 585, + "src/main/acp/acp-timeline-opencode-dialect.test.ts": 415, + "src/main/acp/acp-timeline-recordings.test.ts": 610, + "src/main/acp/acp-timeline-recovery.test.ts": 929, + "src/main/acp/acp-timeline-routing.test.ts": 725, + "src/main/acp/acp-timeline-turn-failures.test.ts": 863, + "src/main/acp/acp-visuals-skill.test.ts": 53, + "src/main/acp/acp-write-queue-retention.test.ts": 466, + "src/main/active-view-persistence-boundary.test.ts": 1716, + "src/main/active-view-preference-sync-flush-veto.test.ts": 784, + "src/main/active-view-preference.test.ts": 62, + "src/main/agent-auth-restart-preservation.test.ts": 75, + "src/main/agent-awake-service-platform-assertions.test.ts": 93, + "src/main/agent-awake-service.test.ts": 127, + "src/main/agent-cli-version-probe.test.ts": 63, + "src/main/agent-hooks/branch-rename-failure-output.test.ts": 51, + "src/main/agent-hooks/ended-process-reconciliation.test.ts": 638, + "src/main/agent-hooks/first-work-branch-rename.test.ts": 870, + "src/main/agent-hooks/first-work-folder-rename.test.ts": 118, + "src/main/agent-hooks/grok-replay-guard.test.ts": 149, + "src/main/agent-hooks/hook-process-capture.test.ts": 100, + "src/main/agent-hooks/hook-script-outside-orca.test.ts": 277, + "src/main/agent-hooks/hook-status-session-tabs-republish.test.ts": 647, + "src/main/agent-hooks/install-telemetry.test.ts": 50, + "src/main/agent-hooks/installer-utils-remote.test.ts": 71, + "src/main/agent-hooks/installer-utils.test.ts": 189, + "src/main/agent-hooks/local-agent-cli-presence.test.ts": 64, + "src/main/agent-hooks/local-tmux-managed-pty.test.ts": 49, + "src/main/agent-hooks/managed-agent-hook-controls.test.ts": 127, + "src/main/agent-hooks/managed-hook-command-contract.test.ts": 389, + "src/main/agent-hooks/managed-hook-detection-commands.test.ts": 68, + "src/main/agent-hooks/managed-hook-install-lock.test.ts": 332, + "src/main/agent-hooks/managed-hook-local-filesystem.test.ts": 547, + "src/main/agent-hooks/managed-hook-owner-identity.test.ts": 99, + "src/main/agent-hooks/managed-hook-runtime.test.ts": 464, + "src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts": 410, + "src/main/agent-hooks/managed-hook-script-refresh.test.ts": 498, + "src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts": 1579, + "src/main/agent-hooks/managed-hook-timeout.test.ts": 1842, + "src/main/agent-hooks/managed-toml-ownership.test.ts": 65, + "src/main/agent-hooks/manual-compact-hook-stream.test.ts": 806, + "src/main/agent-hooks/manual-compact-status-cleanup.test.ts": 635, + "src/main/agent-hooks/migration-unsupported-pty-state.test.ts": 44, + "src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts": 3465, + "src/main/agent-hooks/remote-hook-service-installers.test.ts": 453, + "src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts": 430, + "src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts": 821, + "src/main/agent-hooks/server-agent-presence.test.ts": 732, + "src/main/agent-hooks/server-ai-vault-liveness.test.ts": 669, + "src/main/agent-hooks/server-amp-normalization.test.ts": 693, + "src/main/agent-hooks/server-authority-evidence.test.ts": 729, + "src/main/agent-hooks/server-claude-cancel-captures.test.ts": 761, + "src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts": 713, + "src/main/agent-hooks/server-claude-normalization.test.ts": 621, + "src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts": 758, + "src/main/agent-hooks/server-claude-permission-visibility.test.ts": 827, + "src/main/agent-hooks/server-claude-statusline.test.ts": 737, + "src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts": 6962, + "src/main/agent-hooks/server-claude-terminal-interrupt.test.ts": 728, + "src/main/agent-hooks/server-closed-tab-suppression.test.ts": 755, + "src/main/agent-hooks/server-codex-ctrl-c-inference.test.ts": 663, + "src/main/agent-hooks/server-codex-interrupt-hook.test.ts": 558, + "src/main/agent-hooks/server-codex-normalization.test.ts": 684, + "src/main/agent-hooks/server-codex-subagent-transcript.test.ts": 4877, + "src/main/agent-hooks/server-codex-turn-interruption.test.ts": 4222, + "src/main/agent-hooks/server-copilot-normalization.test.ts": 940, + "src/main/agent-hooks/server-cursor-normalization.test.ts": 653, + "src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts": 770, + "src/main/agent-hooks/server-escape-navigation-inference.test.ts": 696, + "src/main/agent-hooks/server-grok-background-status.test.ts": 650, + "src/main/agent-hooks/server-grok-cancel.test.ts": 677, + "src/main/agent-hooks/server-grok-discovery.test.ts": 1216, + "src/main/agent-hooks/server-hook-http-ingest.test.ts": 742, + "src/main/agent-hooks/server-ingest-remote.test.ts": 648, + "src/main/agent-hooks/server-ingest-structured-children.test.ts": 615, + "src/main/agent-hooks/server-ingest-structured-state-clock.test.ts": 679, + "src/main/agent-hooks/server-ingest-structured-status.test.ts": 719, + "src/main/agent-hooks/server-ingest-terminal-status.test.ts": 711, + "src/main/agent-hooks/server-interrupt-inference-guards.test.ts": 580, + "src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts": 644, + "src/main/agent-hooks/server-interrupt-inference-validation.test.ts": 670, + "src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts": 814, + "src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts": 742, + "src/main/agent-hooks/server-last-status-hydrate-validation.test.ts": 854, + "src/main/agent-hooks/server-last-status-lead-boundary.test.ts": 852, + "src/main/agent-hooks/server-last-status-main-agent-fact.test.ts": 802, + "src/main/agent-hooks/server-last-status-restored-children.test.ts": 776, + "src/main/agent-hooks/server-last-status-write.test.ts": 1017, + "src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts": 1703, + "src/main/agent-hooks/server-muse-session-log-poll.test.ts": 2581, + "src/main/agent-hooks/server-observation-provenance.test.ts": 656, + "src/main/agent-hooks/server-omp-retired-ingress.test.ts": 649, + "src/main/agent-hooks/server-omp-retirement-ack.test.ts": 606, + "src/main/agent-hooks/server-opencode-binder.test.ts": 884, + "src/main/agent-hooks/server-opencode-lifecycle.test.ts": 867, + "src/main/agent-hooks/server-opencode-tui-identity.test.ts": 914, + "src/main/agent-hooks/server-pane-authority.test.ts": 875, + "src/main/agent-hooks/server-pi-normalization.test.ts": 868, + "src/main/agent-hooks/server-process-lifetime-status.test.ts": 964, + "src/main/agent-hooks/server-prompt-sent-telemetry.test.ts": 691, + "src/main/agent-hooks/server-relay-listener-replay.test.ts": 657, + "src/main/agent-hooks/server-relayed-claude-cancel.test.ts": 960, + "src/main/agent-hooks/server-reminted-pane-key.test.ts": 739, + "src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts": 739, + "src/main/agent-hooks/server-removed-worktree-status.test.ts": 574, + "src/main/agent-hooks/server-replay-evidence-clock.test.ts": 631, + "src/main/agent-hooks/server-retired-pane-new-turn.test.ts": 650, + "src/main/agent-hooks/server-start-failure-lifecycle.test.ts": 656, + "src/main/agent-hooks/server-startup-prompt-control.test.ts": 534, + "src/main/agent-hooks/server-status-listener-fanout.test.ts": 725, + "src/main/agent-hooks/server-status-persist-throttle.test.ts": 962, + "src/main/agent-hooks/server-structured-canonical-status.test.ts": 678, + "src/main/agent-hooks/server-tmux-canonical.test.ts": 675, + "src/main/agent-hooks/server-tmux-wire.test.ts": 644, + "src/main/agent-hooks/server-transport-interference.test.ts": 11601, + "src/main/agent-hooks/server-turn-started-at.test.ts": 691, + "src/main/agent-hooks/server.claude-interactive-question.test.ts": 717, + "src/main/agent-hooks/server.test-fixtures.test.ts": 473, + "src/main/agent-hooks/server/server-row-ownership.test.ts": 612, + "src/main/agent-hooks/spool.test.ts": 974, + "src/main/agent-hooks/terminal-handle-row-identity.test.ts": 656, + "src/main/agent-hooks/windows-batch-hook-launcher.test.ts": 99, + "src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts": 124, + "src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts": 107, + "src/main/agent-hooks/windows-hook-command-hosts.test.ts": 75, + "src/main/agent-hooks/windows-hook-payload-delivery.test.ts": 129, + "src/main/agent-hooks/windows-hook-post-interpreter.test.ts": 419, + "src/main/agent-hooks/windows-powershell-hook-launcher.test.ts": 50, + "src/main/agent-hooks/wsl-guest-plugin-install.test.ts": 56, + "src/main/agent-hooks/wsl-hook-relay-launch.test.ts": 134, + "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts": 2021, + "src/main/agent-hooks/wsl-hook-relay-manager.test.ts": 2367, + "src/main/agent-hooks/wsl-hook-relay-reattach.test.ts": 971, + "src/main/agent-hooks/wsl-hook-relay-recovery.test.ts": 49, + "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts": 64, + "src/main/agent-hooks/wsl-opencode-install-race.test.ts": 869, + "src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts": 1862, + "src/main/agent-hooks/wsl-opencode-settings.test.ts": 1034, + "src/main/agent-launch/agent-launch-decided-mode-contract.test.ts": 48, + "src/main/agent-launch/agent-launch-executor.test.ts": 1177, + "src/main/agent-launch/agent-launch-legacy-host-policy.test.ts": 894, + "src/main/agent-launch/agent-launch-opencode-model.test.ts": 757, + "src/main/agent-launch/agent-launch-pane-attachment.test.ts": 273, + "src/main/agent-launch/startup-agent-worktree-create.test.ts": 1128, + "src/main/agent-state-file-reader.test.ts": 103, + "src/main/agent-trust-presets.test.ts": 300, + "src/main/agent-workspace-trust-guard-failure.test.ts": 119, + "src/main/agent-workspace-trust-spawn.test.ts": 274, + "src/main/agent-workspace-trust.test.ts": 113, + "src/main/ai-vault-search/session-search-agent-compatibility.test.ts": 319, + "src/main/ai-vault-search/session-search-child-service.test.ts": 534, + "src/main/ai-vault-search/session-search-content-hash.test.ts": 65, + "src/main/ai-vault-search/session-search-cwd-key.test.ts": 72, + "src/main/ai-vault-search/session-search-deleted-sources.test.ts": 416, + "src/main/ai-vault-search/session-search-directory-listings.test.ts": 47, + "src/main/ai-vault-search/session-search-engine.test.ts": 1393, + "src/main/ai-vault-search/session-search-file-id.test.ts": 274, + "src/main/ai-vault-search/session-search-file-write.test.ts": 500, + "src/main/ai-vault-search/session-search-fts5-contract.test.ts": 152, + "src/main/ai-vault-search/session-search-hit-ranking.test.ts": 73, + "src/main/ai-vault-search/session-search-host-registration.test.ts": 1096, + "src/main/ai-vault-search/session-search-identifier-split.test.ts": 51, + "src/main/ai-vault-search/session-search-index-consumer.test.ts": 280, + "src/main/ai-vault-search/session-search-index-generation.test.ts": 421, + "src/main/ai-vault-search/session-search-index-pass.test.ts": 468, + "src/main/ai-vault-search/session-search-index-writer.test.ts": 482, + "src/main/ai-vault-search/session-search-indexer.test.ts": 2536, + "src/main/ai-vault-search/session-search-instance.test.ts": 1147, + "src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts": 2756, + "src/main/ai-vault-search/session-search-live-transcript.test.ts": 423, + "src/main/ai-vault-search/session-search-merged-roots.test.ts": 489, + "src/main/ai-vault-search/session-search-message-rows.test.ts": 149, + "src/main/ai-vault-search/session-search-native-chat-indexing.test.ts": 476, + "src/main/ai-vault-search/session-search-opencode-index.test.ts": 807, + "src/main/ai-vault-search/session-search-orphan-rows.test.ts": 235, + "src/main/ai-vault-search/session-search-paging.test.ts": 601, + "src/main/ai-vault-search/session-search-query-planner.test.ts": 59, + "src/main/ai-vault-search/session-search-read-decision.test.ts": 49, + "src/main/ai-vault-search/session-search-retention-delete.test.ts": 198, + "src/main/ai-vault-search/session-search-retention-policy.test.ts": 275, + "src/main/ai-vault-search/session-search-retrieval-statement-compilation.test.ts": 501, + "src/main/ai-vault-search/session-search-root-refresh.test.ts": 623, + "src/main/ai-vault-search/session-search-row-filter.test.ts": 195, + "src/main/ai-vault-search/session-search-row-identity.test.ts": 148, + "src/main/ai-vault-search/session-search-scan-roots.test.ts": 230, + "src/main/ai-vault-search/session-search-schema.test.ts": 531, + "src/main/ai-vault-search/session-search-scope-entry-points.test.ts": 4400, + "src/main/ai-vault-search/session-search-scope-resolution.test.ts": 106, + "src/main/ai-vault-search/session-search-scope-service.test.ts": 288, + "src/main/ai-vault-search/session-search-service-registry.test.ts": 297, + "src/main/ai-vault-search/session-search-service.test.ts": 311, + "src/main/ai-vault-search/session-search-sidebar-parity.test.ts": 241, + "src/main/ai-vault-search/session-search-snippet-marks.test.ts": 179, + "src/main/ai-vault-search/session-search-sqlite-support.test.ts": 62, + "src/main/ai-vault-search/session-search-store-is-memory.test.ts": 827, + "src/main/ai-vault-search/session-search-store-scope-catalog.test.ts": 52, + "src/main/ai-vault-search/session-search-synthetic-corpus.test.ts": 313, + "src/main/ai-vault-search/session-search-typo-policy.test.ts": 160, + "src/main/ai-vault-search/session-search-typo-scope.test.ts": 148, + "src/main/ai-vault-search/session-search-write-lifetime.test.ts": 351, + "src/main/ai-vault/ai-vault-scan-cancellation.test.ts": 46, + "src/main/ai-vault/ai-vault-scan-coordinator.test.ts": 55, + "src/main/ai-vault/antigravity-ide-history.test.ts": 322, + "src/main/ai-vault/antigravity-index-file-admission.test.ts": 115, + "src/main/ai-vault/antigravity-index-reader.test.ts": 179, + "src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts": 157, + "src/main/ai-vault/antigravity-recorded-history.test.ts": 281, + "src/main/ai-vault/cached-session-list-wsl-probe.test.ts": 472, + "src/main/ai-vault/cached-session-list.test.ts": 626, + "src/main/ai-vault/claude-project-dir-encoding.test.ts": 52, + "src/main/ai-vault/codex-session-collection.test.ts": 149, + "src/main/ai-vault/codex-session-root-dedup.test.ts": 156, + "src/main/ai-vault/local-log-tail-reader.test.ts": 69, + "src/main/ai-vault/omp-session-root.test.ts": 234, + "src/main/ai-vault/openclaw-session-layout.test.ts": 172, + "src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts": 1150, + "src/main/ai-vault/remote-session-content-lines.test.ts": 84, + "src/main/ai-vault/remote-session-large-transcripts.test.ts": 1388, + "src/main/ai-vault/remote-session-parse-cache.test.ts": 305, + "src/main/ai-vault/remote-session-scan-batching.test.ts": 61, + "src/main/ai-vault/remote-session-scanner-kiro.test.ts": 168, + "src/main/ai-vault/remote-session-scanner-muse.test.ts": 234, + "src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts": 261, + "src/main/ai-vault/remote-session-scanner-opencode.test.ts": 235, + "src/main/ai-vault/remote-session-scanner-sources.test.ts": 187, + "src/main/ai-vault/remote-session-scanner.test.ts": 388, + "src/main/ai-vault/remote-session-sidecar-observation.test.ts": 235, + "src/main/ai-vault/remote-session-stream-lifecycle.test.ts": 98, + "src/main/ai-vault/runtime-session-scanner.test.ts": 256, + "src/main/ai-vault/runtime-session-search-call.test.ts": 44, + "src/main/ai-vault/session-cwd-bucket-layouts.test.ts": 59, + "src/main/ai-vault/session-delete-target.test.ts": 224, + "src/main/ai-vault/session-delete.test.ts": 220, + "src/main/ai-vault/session-document-stream-boundaries.test.ts": 155, + "src/main/ai-vault/session-document-stream-contract.test.ts": 681, + "src/main/ai-vault/session-document-stream-parity.test.ts": 201, + "src/main/ai-vault/session-document-stream-projection.test.ts": 145, + "src/main/ai-vault/session-first-user-prompt-opencode.test.ts": 232, + "src/main/ai-vault/session-first-user-prompt-read.test.ts": 302, + "src/main/ai-vault/session-list-result-validation.test.ts": 235, + "src/main/ai-vault/session-list-results.test.ts": 136, + "src/main/ai-vault/session-newest-files.test.ts": 120, + "src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts": 278, + "src/main/ai-vault/session-parse-cache-bounds.test.ts": 498, + "src/main/ai-vault/session-parse-cache-devin-upgrade.test.ts": 244, + "src/main/ai-vault/session-parse-cache-persistence.test.ts": 348, + "src/main/ai-vault/session-scan-cutoff.test.ts": 125, + "src/main/ai-vault/session-scanner-agent-root-overrides.test.ts": 601, + "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 114, + "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 337, + "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 265, + "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 326, + "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 186, + "src/main/ai-vault/session-scanner-claude-title.test.ts": 333, + "src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts": 353, + "src/main/ai-vault/session-scanner-cline-parser.test.ts": 311, + "src/main/ai-vault/session-scanner-codebuddy-scope.test.ts": 236, + "src/main/ai-vault/session-scanner-codebuddy-source.test.ts": 212, + "src/main/ai-vault/session-scanner-codex-dual-root.test.ts": 327, + "src/main/ai-vault/session-scanner-codex-fast-path.test.ts": 352, + "src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts": 99, + "src/main/ai-vault/session-scanner-codex-parser.test.ts": 152, + "src/main/ai-vault/session-scanner-codex-title-index.test.ts": 255, + "src/main/ai-vault/session-scanner-codex-tool-records.test.ts": 156, + "src/main/ai-vault/session-scanner-codex-workers.test.ts": 306, + "src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts": 318, + "src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts": 433, + "src/main/ai-vault/session-scanner-cutoff.test.ts": 599, + "src/main/ai-vault/session-scanner-dedup-batches.test.ts": 510, + "src/main/ai-vault/session-scanner-devin-contention.test.ts": 367, + "src/main/ai-vault/session-scanner-devin-db.test.ts": 493, + "src/main/ai-vault/session-scanner-devin-dedup.test.ts": 383, + "src/main/ai-vault/session-scanner-devin-parser.test.ts": 127, + "src/main/ai-vault/session-scanner-directory-reader.test.ts": 97, + "src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts": 120, + "src/main/ai-vault/session-scanner-first-user-prompt.test.ts": 63, + "src/main/ai-vault/session-scanner-graph-parsers.test.ts": 138, + "src/main/ai-vault/session-scanner-grok-parser.test.ts": 124, + "src/main/ai-vault/session-scanner-grok-user-text.test.ts": 51, + "src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts": 121, + "src/main/ai-vault/session-scanner-injected-title.test.ts": 295, + "src/main/ai-vault/session-scanner-jcode-parser.test.ts": 127, + "src/main/ai-vault/session-scanner-jsonl-reader.test.ts": 324, + "src/main/ai-vault/session-scanner-kimi-index-cache.test.ts": 119, + "src/main/ai-vault/session-scanner-kimi-parser.test.ts": 201, + "src/main/ai-vault/session-scanner-kiro-parser.test.ts": 124, + "src/main/ai-vault/session-scanner-muse.test.ts": 277, + "src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts": 156, + "src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts": 361, + "src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts": 122, + "src/main/ai-vault/session-scanner-omp-title.test.ts": 161, + "src/main/ai-vault/session-scanner-opencode-cancellation.test.ts": 678, + "src/main/ai-vault/session-scanner-opencode-native-page-limit.test.ts": 64, + "src/main/ai-vault/session-scanner-opencode-native-worker.test.ts": 721, + "src/main/ai-vault/session-scanner-opencode-parser.test.ts": 126, + "src/main/ai-vault/session-scanner-opencode-process-admission.test.ts": 275, + "src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts": 124, + "src/main/ai-vault/session-scanner-opencode-sources.test.ts": 53, + "src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts": 1475, + "src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts": 398, + "src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts": 3525, + "src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts": 53, + "src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts": 3438, + "src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts": 54, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts": 148, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.test.ts": 118, + "src/main/ai-vault/session-scanner-opencode-sqlite.test.ts": 432, + "src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts": 144, + "src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts": 192, + "src/main/ai-vault/session-scanner-opencode2-message.test.ts": 86, + "src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts": 332, + "src/main/ai-vault/session-scanner-parse-cache-agents.test.ts": 541, + "src/main/ai-vault/session-scanner-parse-cache.test.ts": 310, + "src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts": 275, + "src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts": 172, + "src/main/ai-vault/session-scanner-pi-scope.test.ts": 394, + "src/main/ai-vault/session-scanner-preview-window-truncation.test.ts": 111, + "src/main/ai-vault/session-scanner-qoder.test.ts": 319, + "src/main/ai-vault/session-scanner-record-budget.test.ts": 393, + "src/main/ai-vault/session-scanner-recoverable-empty.test.ts": 338, + "src/main/ai-vault/session-scanner-roots.test.ts": 184, + "src/main/ai-vault/session-scanner-scope.test.ts": 339, + "src/main/ai-vault/session-scanner-service-cancellation.test.ts": 417, "src/main/ai-vault/session-scanner-service-client.test.ts": 502, - "src/main/ai-vault/session-scanner-service-entry-path.test.ts": 43, - "src/main/ai-vault/session-scanner-service-entry.test.ts": 541, - "src/main/ai-vault/session-scanner-service-env.test.ts": 63, - "src/main/ai-vault/session-scanner-service-restart-policy.test.ts": 47, - "src/main/ai-vault/session-scanner-service-root-request.test.ts": 31, - "src/main/ai-vault/session-scanner-service-root-response.test.ts": 228, - "src/main/ai-vault/session-scanner-service-search-roots.test.ts": 577, - "src/main/ai-vault/session-scanner-service-search.test.ts": 1214, - "src/main/ai-vault/session-scanner-service-spawn.test.ts": 435, - "src/main/ai-vault/session-scanner-timeline.test.ts": 75, - "src/main/ai-vault/session-scanner-unlimited-dedup.test.ts": 3562, - "src/main/ai-vault/session-scanner-values.test.ts": 92, - "src/main/ai-vault/session-scanner.test.ts": 343, - "src/main/ai-vault/session-sidecar-stat.test.ts": 25, - "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 173, - "src/main/ai-vault/session-title-file-reader.test.ts": 130, - "src/main/ai-vault/session-transcript-consumers.test.ts": 384, - "src/main/ai-vault/session-transcript-every-agent-capture.test.ts": 551, - "src/main/ai-vault/session-transcript-message-content.test.ts": 48, - "src/main/ai-vault/session-transcript-record-budget.test.ts": 63, - "src/main/ai-vault/ssh-session-list.test.ts": 288, - "src/main/ai-vault/structured-session-ownership.test.ts": 106, - "src/main/amp/hook-service.test.ts": 83, - "src/main/antigravity/hook-service.test.ts": 144, - "src/main/antigravity/windows-hook-payload-delivery.test.ts": 84, - "src/main/app-icon.test.ts": 79, - "src/main/app-relaunch.test.ts": 60, - "src/main/appimage-runtime-identity.test.ts": 91, - "src/main/appkit-scene-mutation.test.ts": 28, - "src/main/artifacts/artifact-cloud-config.test.ts": 54, - "src/main/artifacts/artifact-cloud-recovery.test.ts": 477, - "src/main/artifacts/artifact-cloud-service-races.test.ts": 642, - "src/main/artifacts/artifact-cloud-service.test.ts": 1148, - "src/main/artifacts/artifact-create-intent-store.test.ts": 411, - "src/main/artifacts/artifact-recovery-directory-fsync.test.ts": 76, - "src/main/artifacts/artifact-share-record-allocation.test.ts": 181, - "src/main/artifacts/artifact-share-record-store.test.ts": 300, - "src/main/asar-transparent-fs.test.ts": 58, - "src/main/automations/automation-dispatch-host-fence.test.ts": 5212, - "src/main/automations/automation-owner-fencing.test.ts": 3123, - "src/main/automations/automation-owner-migration.test.ts": 64, - "src/main/automations/automation-run-terminal-surface.test.ts": 3148, - "src/main/automations/automation-run-writer.test.ts": 190, - "src/main/automations/automation-skip-coalescing.test.ts": 5292, - "src/main/automations/automation-ssh-readoption-migration.test.ts": 63, - "src/main/automations/automation-update-host-retarget.test.ts": 2563, - "src/main/automations/automation-worker-durability.test.ts": 4821, - "src/main/automations/automation-workspace-host-attribution.test.ts": 4398, - "src/main/automations/automation-zero-grace-tick-latency.test.ts": 4426, - "src/main/automations/dispatch-refusal.test.ts": 64, - "src/main/automations/dispatch-tokens.test.ts": 62, - "src/main/automations/external-automation-manager-cache.test.ts": 111, - "src/main/automations/external-automation-owner-guard.test.ts": 73, - "src/main/automations/external-automation-probe-scheduler.test.ts": 150, - "src/main/automations/external-job-run-sorting.test.ts": 136, - "src/main/automations/external-manager-scoped.test.ts": 90, - "src/main/automations/external-manager.test.ts": 157, - "src/main/automations/headless-dispatch-durability.test.ts": 348, - "src/main/automations/headless-workspace-create.test.ts": 55, - "src/main/automations/hermes-cron-output.test.ts": 196, - "src/main/automations/precheck-runner.test.ts": 1128, - "src/main/automations/refused-manual-run.test.ts": 3358, - "src/main/automations/retained-run-reconciliation.test.ts": 4350, - "src/main/automations/run-completion-watcher.test.ts": 4513, - "src/main/automations/run-target-resolution.test.ts": 51, - "src/main/automations/runtime-terminal-run-observer.test.ts": 51, - "src/main/automations/schedule-drift-report.test.ts": 62, - "src/main/automations/service-precheck.test.ts": 1864, - "src/main/automations/service.test.ts": 10184, - "src/main/azure-devops/azure-devops-api-request.test.ts": 93, - "src/main/azure-devops/client.test.ts": 232, - "src/main/azure-devops/pull-request-creation.test.ts": 167, - "src/main/azure-devops/pull-request-mappers.test.ts": 39, - "src/main/azure-devops/repository-ref.test.ts": 57, - "src/main/bitbucket/client.test.ts": 248, - "src/main/bitbucket/credential-connection.test.ts": 186, - "src/main/bitbucket/credential-store.test.ts": 147, - "src/main/bitbucket/pull-request-creation.test.ts": 163, - "src/main/bitbucket/pull-request-mappers.test.ts": 43, - "src/main/bitbucket/repository-ref.test.ts": 47, - "src/main/bitbucket/status-no-decrypt.test.ts": 188, - "src/main/browser/agent-browser-bridge-capture-paint-hold.test.ts": 263, - "src/main/browser/agent-browser-bridge-command-transport.test.ts": 550, - "src/main/browser/agent-browser-bridge-keypress-input.test.ts": 246, - "src/main/browser/agent-browser-bridge-mouse-input.test.ts": 323, - "src/main/browser/agent-browser-bridge-navigation.test.ts": 484, - "src/main/browser/agent-browser-bridge-pointer-input.test.ts": 342, - "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts": 608, - "src/main/browser/agent-browser-bridge-tab-routing.test.ts": 501, - "src/main/browser/agent-browser-bridge-text-input.test.ts": 609, + "src/main/ai-vault/session-scanner-service-entry-path.test.ts": 50, + "src/main/ai-vault/session-scanner-service-entry-subagents.test.ts": 649, + "src/main/ai-vault/session-scanner-service-entry.test.ts": 813, + "src/main/ai-vault/session-scanner-service-env.test.ts": 69, + "src/main/ai-vault/session-scanner-service-restart-policy.test.ts": 54, + "src/main/ai-vault/session-scanner-service-root-request.test.ts": 50, + "src/main/ai-vault/session-scanner-service-root-response.test.ts": 219, + "src/main/ai-vault/session-scanner-service-search-roots.test.ts": 604, + "src/main/ai-vault/session-scanner-service-search.test.ts": 1251, + "src/main/ai-vault/session-scanner-service-spawn.test.ts": 501, + "src/main/ai-vault/session-scanner-timeline.test.ts": 140, + "src/main/ai-vault/session-scanner-unlimited-dedup.test.ts": 3871, + "src/main/ai-vault/session-scanner-values.test.ts": 114, + "src/main/ai-vault/session-scanner-zcode-sources.test.ts": 131, + "src/main/ai-vault/session-scanner-zcode.test.ts": 313, + "src/main/ai-vault/session-scanner.test.ts": 425, + "src/main/ai-vault/session-sidecar-stat.test.ts": 68, + "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 275, + "src/main/ai-vault/session-title-file-reader.test.ts": 126, + "src/main/ai-vault/session-transcript-consumers.test.ts": 386, + "src/main/ai-vault/session-transcript-every-agent-capture.test.ts": 680, + "src/main/ai-vault/session-transcript-message-content.test.ts": 212, + "src/main/ai-vault/session-transcript-record-budget.test.ts": 41, + "src/main/ai-vault/ssh-session-list.test.ts": 303, + "src/main/ai-vault/structured-session-ownership.test.ts": 283, + "src/main/amp/hook-service.test.ts": 70, + "src/main/antigravity/hook-service.test.ts": 210, + "src/main/antigravity/native-account-host.test.ts": 43, + "src/main/antigravity/native-account-launch.test.ts": 107, + "src/main/antigravity/native-account-service.test.ts": 84, + "src/main/antigravity/native-account-store.test.ts": 255, + "src/main/antigravity/native-credential-backend.test.ts": 71, + "src/main/antigravity/native-credential-codec.test.ts": 44, + "src/main/antigravity/native-macos-credentials.real.test.ts": 73, + "src/main/antigravity/native-macos-credentials.test.ts": 75, + "src/main/antigravity/posix-hook-invocation.test.ts": 5420, + "src/main/antigravity/windows-hook-json-post.test.ts": 9898, + "src/main/antigravity/windows-hook-payload-delivery.test.ts": 126, + "src/main/app-icon.test.ts": 80, + "src/main/app-relaunch.test.ts": 64, + "src/main/appimage-runtime-identity.test.ts": 87, + "src/main/appkit-scene-mutation.test.ts": 49, + "src/main/artifacts/artifact-cloud-config.test.ts": 60, + "src/main/artifacts/artifact-cloud-recovery.test.ts": 577, + "src/main/artifacts/artifact-cloud-service-races.test.ts": 512, + "src/main/artifacts/artifact-cloud-service.test.ts": 1194, + "src/main/artifacts/artifact-create-intent-store.test.ts": 245, + "src/main/artifacts/artifact-recovery-directory-fsync.test.ts": 90, + "src/main/artifacts/artifact-share-record-allocation.test.ts": 179, + "src/main/artifacts/artifact-share-record-store.test.ts": 240, + "src/main/asar-transparent-fs.test.ts": 53, + "src/main/automations/automation-dispatch-host-fence.test.ts": 1715, + "src/main/automations/automation-owner-fencing.test.ts": 1763, + "src/main/automations/automation-owner-migration.test.ts": 80, + "src/main/automations/automation-run-terminal-surface.test.ts": 4629, + "src/main/automations/automation-run-writer.test.ts": 292, + "src/main/automations/automation-skip-coalescing.test.ts": 3604, + "src/main/automations/automation-ssh-readoption-migration.test.ts": 68, + "src/main/automations/automation-update-host-retarget.test.ts": 1497, + "src/main/automations/automation-worker-durability.test.ts": 5967, + "src/main/automations/automation-workspace-host-attribution.test.ts": 1786, + "src/main/automations/automation-zero-grace-tick-latency.test.ts": 1950, + "src/main/automations/dispatch-refusal.test.ts": 71, + "src/main/automations/dispatch-tokens.test.ts": 88, + "src/main/automations/external-automation-manager-cache.test.ts": 83, + "src/main/automations/external-automation-owner-guard.test.ts": 320, + "src/main/automations/external-automation-probe-scheduler.test.ts": 175, + "src/main/automations/external-manager-scoped.test.ts": 404, + "src/main/automations/external-manager.test.ts": 359, + "src/main/automations/headless-automation-agent-launch.test.ts": 845, + "src/main/automations/headless-dispatch-durability.test.ts": 414, + "src/main/automations/headless-run-terminal-retention.test.ts": 120, + "src/main/automations/headless-workspace-create.test.ts": 49, + "src/main/automations/hermes-cron-output.test.ts": 118, + "src/main/automations/precheck-runner.test.ts": 1267, + "src/main/automations/refused-manual-run.test.ts": 6693, + "src/main/automations/retained-run-reconciliation.test.ts": 1759, + "src/main/automations/run-completion-watcher.test.ts": 2643, + "src/main/automations/run-target-resolution.test.ts": 69, + "src/main/automations/runtime-automation-service-retention.test.ts": 1414, + "src/main/automations/runtime-automation-service.test.ts": 1101, + "src/main/automations/runtime-terminal-run-observer-evidence.test.ts": 64, + "src/main/automations/runtime-terminal-run-observer.test.ts": 82, + "src/main/automations/schedule-drift-report.test.ts": 63, + "src/main/automations/service-precheck.test.ts": 1669, + "src/main/automations/service.test.ts": 1985, + "src/main/azure-devops/azure-devops-api-request.test.ts": 62, + "src/main/azure-devops/client.test.ts": 566, + "src/main/azure-devops/pull-request-creation.test.ts": 351, + "src/main/azure-devops/pull-request-mappers.test.ts": 46, + "src/main/azure-devops/repository-ref.test.ts": 362, + "src/main/bitbucket/client.test.ts": 411, + "src/main/bitbucket/credential-connection.test.ts": 130, + "src/main/bitbucket/credential-store.test.ts": 178, + "src/main/bitbucket/pull-request-creation.test.ts": 420, + "src/main/bitbucket/pull-request-mappers.test.ts": 40, + "src/main/bitbucket/repository-ref.test.ts": 360, + "src/main/bitbucket/status-no-decrypt.test.ts": 196, + "src/main/browser/agent-browser-bridge-capture-paint-hold.test.ts": 581, + "src/main/browser/agent-browser-bridge-command-transport.test.ts": 563, + "src/main/browser/agent-browser-bridge-keypress-input.test.ts": 336, + "src/main/browser/agent-browser-bridge-mouse-input.test.ts": 372, + "src/main/browser/agent-browser-bridge-navigation.test.ts": 473, + "src/main/browser/agent-browser-bridge-pointer-input.test.ts": 335, + "src/main/browser/agent-browser-bridge-raw-process-retention.test.ts": 485, + "src/main/browser/agent-browser-bridge-raw-process.test.ts": 1056, + "src/main/browser/agent-browser-bridge-screenshot-format.test.ts": 318, + "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts": 751, + "src/main/browser/agent-browser-bridge-tab-routing.test.ts": 488, + "src/main/browser/agent-browser-bridge-text-input.test.ts": 633, "src/main/browser/agent-browser-orphan-sweep.test.ts": 63, - "src/main/browser/agent-browser-process-environment.test.ts": 55, - "src/main/browser/agent-browser-session-reset.test.ts": 66, - "src/main/browser/browser-certificate-trust-controller.test.ts": 382, - "src/main/browser/browser-clicked-link-routing.test.ts": 565, - "src/main/browser/browser-client-download-relay.test.ts": 168, - "src/main/browser/browser-client-download-routing.test.ts": 185, - "src/main/browser/browser-client-file-channel-negotiation.test.ts": 185, - "src/main/browser/browser-client-file-channel-reconnect.test.ts": 486, - "src/main/browser/browser-client-host-attach-request.test.ts": 153, - "src/main/browser/browser-client-host-authority-replacement.test.ts": 138, - "src/main/browser/browser-client-host-command-dispatcher.test.ts": 154, - "src/main/browser/browser-client-host-command-retention.test.ts": 436, - "src/main/browser/browser-client-host-id.test.ts": 85, - "src/main/browser/browser-client-host-placement-preparation.test.ts": 93, - "src/main/browser/browser-client-host-published-url-wiring.test.ts": 592, - "src/main/browser/browser-client-host-reconciliation-command-order.test.ts": 214, - "src/main/browser/browser-client-network-route-registry.test.ts": 118, - "src/main/browser/browser-client-page-automation-runtime.test.ts": 52, - "src/main/browser/browser-client-page-command-executor-fencing.test.ts": 284, - "src/main/browser/browser-client-page-command-executor.test.ts": 216, - "src/main/browser/browser-client-page-command-integration.test.ts": 231, - "src/main/browser/browser-client-page-execution-host-supersession.test.ts": 203, - "src/main/browser/browser-client-page-inventory.test.ts": 212, - "src/main/browser/browser-client-page-metadata-transport.test.ts": 147, - "src/main/browser/browser-client-page-published-url.test.ts": 118, - "src/main/browser/browser-client-page-reconciliation-adapters.test.ts": 239, - "src/main/browser/browser-client-page-renderer-bridge.test.ts": 109, - "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts": 843, - "src/main/browser/browser-client-page-renderer-runtime.test.ts": 161, - "src/main/browser/browser-client-page-unavailability.test.ts": 212, - "src/main/browser/browser-client-page-upload-routing.test.ts": 273, - "src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts": 220, - "src/main/browser/browser-client-route-cookie-import.test.ts": 68, - "src/main/browser/browser-client-upload-command.test.ts": 139, - "src/main/browser/browser-client-upload-staging.test.ts": 346, - "src/main/browser/browser-client-upload-transfer.test.ts": 7621, - "src/main/browser/browser-cookie-clear-preserve.test.ts": 125, - "src/main/browser/browser-cookie-clear-store-lifecycle.test.ts": 89, - "src/main/browser/browser-cookie-clear-store.test.ts": 68, - "src/main/browser/browser-cookie-decryption.test.ts": 74, - "src/main/browser/browser-cookie-import-app-bound-prefix.test.ts": 400, - "src/main/browser/browser-cookie-import-clear-atomicity.test.ts": 71, - "src/main/browser/browser-cookie-import-concurrency.test.ts": 490, - "src/main/browser/browser-cookie-import-google-exclusion.test.ts": 358, - "src/main/browser/browser-cookie-import-partition-fidelity.test.ts": 482, - "src/main/browser/browser-cookie-import-partition-reland.electron.test.ts": 2260, - "src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts": 675, - "src/main/browser/browser-cookie-import-partition-success.electron.test.ts": 893, - "src/main/browser/browser-cookie-import-partition.electron.test.ts": 814, - "src/main/browser/browser-cookie-import-plan-writes.test.ts": 74, - "src/main/browser/browser-cookie-import-policy.test.ts": 248, - "src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts": 1224, - "src/main/browser/browser-cookie-import-replacement.test.ts": 204, - "src/main/browser/browser-cookie-import-route-partition-staging.test.ts": 314, - "src/main/browser/browser-cookie-import-scope.test.ts": 285, - "src/main/browser/browser-cookie-import-undecryptable.test.ts": 443, - "src/main/browser/browser-cookie-import-validated-partition.electron.test.ts": 1035, - "src/main/browser/browser-cookie-import-write.test.ts": 38, - "src/main/browser/browser-cookie-import.comet.test.ts": 737, - "src/main/browser/browser-cookie-import.helium.test.ts": 567, - "src/main/browser/browser-cookie-import.test.ts": 3476, - "src/main/browser/browser-cookie-public-suffix-scope.test.ts": 42, - "src/main/browser/browser-cookie-registrable-family.test.ts": 57, - "src/main/browser/browser-cookie-samesite.electron.test.ts": 898, - "src/main/browser/browser-cookie-source-partition.test.ts": 42, - "src/main/browser/browser-cookie-validation.test.ts": 40, - "src/main/browser/browser-download-destination.test.ts": 55, - "src/main/browser/browser-execution-host-storage-identity.test.ts": 146, - "src/main/browser/browser-google-auth-ua.test.ts": 32, - "src/main/browser/browser-grab-payload.test.ts": 39, - "src/main/browser/browser-guest-shortcut-forwarding.test.ts": 333, - "src/main/browser/browser-guest-wheel-zoom-scroll.test.ts": 34, - "src/main/browser/browser-host-client-identity.test.ts": 97, - "src/main/browser/browser-host-lease-reconnect-delay.test.ts": 34, - "src/main/browser/browser-identity-mode-record.test.ts": 82, - "src/main/browser/browser-identity-mode-store.test.ts": 100, - "src/main/browser/browser-manager-annotation-bridge.test.ts": 214, - "src/main/browser/browser-manager-auth-user-agent.test.ts": 263, - "src/main/browser/browser-manager-client-hosted-downloads.test.ts": 366, - "src/main/browser/browser-manager-destroyed-guest-downloads.test.ts": 165, - "src/main/browser/browser-manager-destroyed-guest.test.ts": 445, - "src/main/browser/browser-manager-downloads.test.ts": 320, - "src/main/browser/browser-manager-grab-capture.test.ts": 216, - "src/main/browser/browser-manager-grab-mode.test.ts": 305, - "src/main/browser/browser-manager-grab-selection.test.ts": 169, - "src/main/browser/browser-manager-grab-shortcuts.test.ts": 249, - "src/main/browser/browser-manager-guest-lifecycle.test.ts": 412, - "src/main/browser/browser-manager-guest-policy-profile.test.ts": 279, - "src/main/browser/browser-manager-guest-shortcuts.test.ts": 329, - "src/main/browser/browser-manager-guest-visibility.test.ts": 178, - "src/main/browser/browser-manager-load-failure-replay.test.ts": 280, - "src/main/browser/browser-manager-popup-child.test.ts": 189, - "src/main/browser/browser-manager-popup-routing.test.ts": 552, - "src/main/browser/browser-manager-viewport-override.test.ts": 1098, - "src/main/browser/browser-manager-viewport-ownership.test.ts": 298, - "src/main/browser/browser-manager-viewport-partial-failure.test.ts": 249, - "src/main/browser/browser-manager-worker-request-user-agent.test.ts": 208, - "src/main/browser/browser-network-deferred-socket.test.ts": 71, - "src/main/browser/browser-network-execution-route.test.ts": 102, - "src/main/browser/browser-network-tunnel-client-memory-budget.test.ts": 77, - "src/main/browser/browser-network-tunnel-client.test.ts": 107, - "src/main/browser/browser-network-tunnel-conformance.test.ts": 162, - "src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts": 71, - "src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts": 32, - "src/main/browser/browser-network-tunnel-session-stream-scoped-failure.test.ts": 47, - "src/main/browser/browser-network-tunnel-session.test.ts": 93, - "src/main/browser/browser-page-initiated-tab-budget.test.ts": 44, - "src/main/browser/browser-process-user-agent.test.ts": 40, - "src/main/browser/browser-route-dns-prefetch.electron.test.ts": 4575, - "src/main/browser/browser-route-guest-popups.test.ts": 126, - "src/main/browser/browser-route-h3-egress.electron.test.ts": 14983, - "src/main/browser/browser-route-identity.test.ts": 57, - "src/main/browser/browser-route-partition-binding-capacity.test.ts": 70, - "src/main/browser/browser-route-partition-binding-store.test.ts": 93, - "src/main/browser/browser-route-partition-migration.test.ts": 112, - "src/main/browser/browser-route-partition-stability.test.ts": 188, - "src/main/browser/browser-route-partition-storage-lifecycle.test.ts": 80, - "src/main/browser/browser-route-partition-storage-retirement.test.ts": 151, - "src/main/browser/browser-route-partition-storage-runtime.test.ts": 57, - "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts": 3440, - "src/main/browser/browser-route-prepared-page-rekey.test.ts": 45, - "src/main/browser/browser-route-session-registry.test.ts": 147, - "src/main/browser/browser-route-tcp-egress.electron.test.ts": 1248, - "src/main/browser/browser-route-webcontents-registry.test.ts": 264, - "src/main/browser/browser-route-webrtc-egress.electron.test.ts": 10714, - "src/main/browser/browser-screencast-dialog-settlement.test.ts": 116, - "src/main/browser/browser-screencast-lifecycle.test.ts": 69, - "src/main/browser/browser-screencast-snapshot-scaling.test.ts": 192, - "src/main/browser/browser-screencast-stream.test.ts": 729, - "src/main/browser/browser-session-cookie-staging.scoped.test.ts": 370, - "src/main/browser/browser-session-cookie-staging.test.ts": 204, - "src/main/browser/browser-session-partition-policies.test.ts": 102, - "src/main/browser/browser-session-partition-proxy-install.test.ts": 327, - "src/main/browser/browser-session-proxy.test.ts": 205, - "src/main/browser/browser-session-registry-identity.persistence.test.ts": 400, - "src/main/browser/browser-session-registry-import-boundary.test.ts": 33, - "src/main/browser/browser-session-registry.persistence.test.ts": 353, - "src/main/browser/browser-session-registry.test.ts": 745, - "src/main/browser/browser-session-startup.test.ts": 77, - "src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts": 657, - "src/main/browser/browser-session-ua-wire-identity-cross-context.electron.test.ts": 6292, - "src/main/browser/browser-session-ua-wire-identity.electron.test.ts": 9753, - "src/main/browser/browser-session-user-agent-migration-inspection.test.ts": 131, - "src/main/browser/browser-text-insertion.test.ts": 76, - "src/main/browser/browser-viewport-user-agent.test.ts": 43, - "src/main/browser/browser-webauthn-access.test.ts": 29, - "src/main/browser/browser-webauthn-account-picker.test.ts": 74, - "src/main/browser/browser-webauthn-profile-delete.test.ts": 305, - "src/main/browser/cdp-bridge-integration.test.ts": 7871, - "src/main/browser/cdp-bridge-state.test.ts": 86, - "src/main/browser/cdp-client-response-writer.test.ts": 856, - "src/main/browser/cdp-keyboard-us-layout.test.ts": 64, - "src/main/browser/cdp-print-to-pdf.test.ts": 63, - "src/main/browser/cdp-screenshot.test.ts": 105, - "src/main/browser/cdp-ws-proxy-focus-replay.test.ts": 336, - "src/main/browser/cdp-ws-proxy.test.ts": 2626, - "src/main/browser/chromium-cookie-snapshot.test.ts": 889, - "src/main/browser/client-route-cookie-import-source-store.test.ts": 103, - "src/main/browser/doc-preview-download-block-notice.test.ts": 47, - "src/main/browser/doc-preview-failure-notice.test.ts": 42, - "src/main/browser/doc-preview-file-reader.test.ts": 107, - "src/main/browser/doc-preview-grant-registry.test.ts": 101, - "src/main/browser/doc-preview-guest-policy.test.ts": 277, - "src/main/browser/doc-preview-protocol.test.ts": 171, - "src/main/browser/electron-debugger-lease.test.ts": 37, - "src/main/browser/electron-probe-display-launch.test.ts": 57, - "src/main/browser/execution-route-socket-duplex.test.ts": 103, - "src/main/browser/grab-guest-script.test.ts": 107, - "src/main/browser/local-ssh-browser-partition-identity.test.ts": 321, - "src/main/browser/local-ssh-browser-partitions.probe-cache.test.ts": 60, - "src/main/browser/local-ssh-browser-route.test.ts": 104, - "src/main/browser/offscreen-browser-backend-lifecycle.test.ts": 259, - "src/main/browser/offscreen-browser-backend.web-preferences.test.ts": 45, - "src/main/browser/paired-runtime-browser-client-host-composition.test.ts": 143, - "src/main/browser/paired-runtime-browser-client-host-identity-wiring.test.ts": 583, - "src/main/browser/paired-runtime-browser-client-host-reconnect.test.ts": 482, - "src/main/browser/paired-runtime-browser-client-host-registry.test.ts": 33, - "src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts": 503, - "src/main/browser/paired-runtime-browser-client-host-runtime.test.ts": 766, - "src/main/browser/paired-runtime-browser-client-host.test.ts": 294, - "src/main/browser/paired-runtime-browser-host-command-admission.test.ts": 260, - "src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts": 880, - "src/main/browser/paired-runtime-browser-host-lease.test.ts": 856, - "src/main/browser/paired-runtime-browser-host-reconciliation-negotiation.test.ts": 269, - "src/main/browser/paired-runtime-browser-network-route.test.ts": 1212, - "src/main/browser/popup-origin-bar-window.test.ts": 96, - "src/main/browser/remote-browser-socks-buffering.test.ts": 1704, - "src/main/browser/remote-browser-socks-server.test.ts": 290, - "src/main/browser/snapshot-engine-iframe-sessions.test.ts": 92, - "src/main/browser/snapshot-engine.test.ts": 45, - "src/main/browser/ssh-browser-network-execution-route.test.ts": 532, - "src/main/browser/system-ssh-socks-client-socket.test.ts": 49, - "src/main/browser/wsl-browser-network-execution-route.test.ts": 269, - "src/main/browser/wsl-browser-network-relay-launch.test.ts": 65, - "src/main/claude-accounts/claude-account-service-account-selection.test.ts": 175, - "src/main/claude-accounts/claude-account-service-add-account.test.ts": 190, - "src/main/claude-accounts/claude-account-service-api-parity.test.ts": 206, - "src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts": 235, - "src/main/claude-accounts/claude-account-service-credential-capture.test.ts": 214, - "src/main/claude-accounts/claude-account-service-login-process.test.ts": 499, - "src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts": 165, - "src/main/claude-accounts/claude-duplicate-account.test.ts": 27, - "src/main/claude-accounts/claude-login-completion.oracle.test.ts": 162, - "src/main/claude-accounts/claude-structured-auth-policy.test.ts": 57, - "src/main/claude-accounts/claude-windows-interactive-login.test.ts": 238, - "src/main/claude-accounts/keychain-config-directory-aliases.test.ts": 98, - "src/main/claude-accounts/keychain.test.ts": 108, - "src/main/claude-accounts/live-pty-gate.test.ts": 42, - "src/main/claude-accounts/oauth-refresh.test.ts": 95, - "src/main/claude-accounts/runtime-auth-path-materialization.test.ts": 294, - "src/main/claude-accounts/runtime-auth-service-account-switching.test.ts": 447, - "src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts": 472, - "src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts": 695, - "src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts": 255, - "src/main/claude-accounts/runtime-auth-service-materialization.test.ts": 355, - "src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts": 598, - "src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts": 278, - "src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts": 427, - "src/main/claude-accounts/runtime-paths.test.ts": 48, - "src/main/claude-accounts/runtime-selection.test.ts": 39, - "src/main/claude-accounts/windows-command-invocation.test.ts": 39, - "src/main/claude-usage/claude-model-pricing.test.ts": 47, - "src/main/claude-usage/claude-usage-report-aggregation.test.ts": 57, - "src/main/claude-usage/scanner-large-directory.test.ts": 480, - "src/main/claude-usage/scanner-scan.test.ts": 128, - "src/main/claude-usage/scanner.test.ts": 61, - "src/main/claude-usage/store.test.ts": 157, - "src/main/claude-usage/transcript-record-parser-prefilter.test.ts": 62, - "src/main/claude-usage/worktree-attribution-scaling.test.ts": 63, - "src/main/claude/claude-agent-sdk-contract-pins.test.ts": 3181, - "src/main/claude/claude-agent-sdk-control-requests.test.ts": 28, - "src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts": 125, - "src/main/claude/claude-agent-sdk-exit-proof.test.ts": 12835, - "src/main/claude/claude-agent-sdk-import-boundary.test.ts": 1974, - "src/main/claude/claude-agent-sdk-process-spawn.test.ts": 87, - "src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts": 137, - "src/main/claude/claude-agent-sdk-user-message-queue.test.ts": 133, - "src/main/claude/claude-background-task-resume.test.ts": 66, - "src/main/claude/claude-background-task-retention.test.ts": 585, - "src/main/claude/claude-background-task-row-journal.test.ts": 142, - "src/main/claude/claude-background-task-rows.test.ts": 316, - "src/main/claude/claude-background-task-terminal-frames.test.ts": 64, - "src/main/claude/claude-background-task-tracker.test.ts": 147, - "src/main/claude/claude-background-task-turn-resumption.test.ts": 108, - "src/main/claude/claude-child-process-environment.test.ts": 41, - "src/main/claude/claude-child-work-decoder.test.ts": 46, - "src/main/claude/claude-command-lifecycle-frames.test.ts": 109, - "src/main/claude/claude-config-dir-pin.test.ts": 40, - "src/main/claude/claude-context-model-switch.test.ts": 165, - "src/main/claude/claude-context-usage-restart.test.ts": 376, - "src/main/claude/claude-context-usage-unloaded-turn.test.ts": 1419, - "src/main/claude/claude-context-usage.test.ts": 124, - "src/main/claude/claude-context-window-hint.test.ts": 185, - "src/main/claude/claude-descendant-escalation-boundary.test.ts": 514, - "src/main/claude/claude-history-window-source-budget.test.ts": 610, - "src/main/claude/claude-model-catalog-probe.test.ts": 293, - "src/main/claude/claude-provisional-row-corrections.test.ts": 39, - "src/main/claude/claude-session-end-hook-capability.test.ts": 33, - "src/main/claude/claude-session-end-install.test.ts": 73, - "src/main/claude/claude-slash-command-catalog.test.ts": 64, - "src/main/claude/claude-stream-json-connection-close.test.ts": 462, - "src/main/claude/claude-stream-json-connection.test.ts": 24821, - "src/main/claude/claude-streamed-text-checkpoints.test.ts": 41, - "src/main/claude/claude-structured-applied-options.test.ts": 472, - "src/main/claude/claude-structured-auth-parity.test.ts": 638, - "src/main/claude/claude-structured-child-work-captures.test.ts": 755, - "src/main/claude/claude-structured-child-work-producer.test.ts": 835, - "src/main/claude/claude-structured-compaction.test.ts": 382, - "src/main/claude/claude-structured-content-parts.test.ts": 124, - "src/main/claude/claude-structured-control-actions.test.ts": 443, - "src/main/claude/claude-structured-dispatch-admission.test.ts": 405, - "src/main/claude/claude-structured-dispatch-boundary.test.ts": 459, - "src/main/claude/claude-structured-dispatch-content.test.ts": 46, - "src/main/claude/claude-structured-dispatch-queue-boundary.test.ts": 473, - "src/main/claude/claude-structured-dispatch.test.ts": 1776, - "src/main/claude/claude-structured-effort-reporting.test.ts": 496, - "src/main/claude/claude-structured-history-window.test.ts": 211, - "src/main/claude/claude-structured-inbound-control.test.ts": 78, - "src/main/claude/claude-structured-journal-prompt-retry.test.ts": 157, - "src/main/claude/claude-structured-journal-translation-background-tasks.test.ts": 346, - "src/main/claude/claude-structured-journal-translation-context-usage.test.ts": 417, - "src/main/claude/claude-structured-journal-translation-subagents.test.ts": 589, - "src/main/claude/claude-structured-journal-translation-turn-timing.test.ts": 524, - "src/main/claude/claude-structured-journal-translation.plan.test.ts": 87, - "src/main/claude/claude-structured-journal-translation.test.ts": 369, - "src/main/claude/claude-structured-launch-resolution.test.ts": 396, - "src/main/claude/claude-structured-location-support.test.ts": 65, - "src/main/claude/claude-structured-model-confirmation.test.ts": 332, - "src/main/claude/claude-structured-model-preflight.test.ts": 125, - "src/main/claude/claude-structured-option-confirmation.test.ts": 379, - "src/main/claude/claude-structured-options-context-window.test.ts": 452, - "src/main/claude/claude-structured-options.test.ts": 296, - "src/main/claude/claude-structured-owner-identity.test.ts": 64, - "src/main/claude/claude-structured-permission-mode.test.ts": 57, - "src/main/claude/claude-structured-prompt-items.test.ts": 157, - "src/main/claude/claude-structured-prompt-ownership.test.ts": 592, - "src/main/claude/claude-structured-provider-fallback.test.ts": 338, - "src/main/claude/claude-structured-real-cli.test.ts": 257, - "src/main/claude/claude-structured-resume-point.test.ts": 426, - "src/main/claude/claude-structured-session-acquisition-processless.test.ts": 471, - "src/main/claude/claude-structured-session-adapter-turns.test.ts": 333, - "src/main/claude/claude-structured-session-adapter.test.ts": 1001, - "src/main/claude/claude-structured-session-close.test.ts": 504, - "src/main/claude/claude-structured-session-commands.test.ts": 263, - "src/main/claude/claude-structured-session-reading-control.test.ts": 446, - "src/main/claude/claude-structured-session-recovery.test.ts": 282, - "src/main/claude/claude-structured-session-startup.test.ts": 309, - "src/main/claude/claude-structured-turn-resume-point.test.ts": 390, - "src/main/claude/claude-subagent-group-row.test.ts": 37, - "src/main/claude/claude-subagent-id-aliases.test.ts": 45, - "src/main/claude/claude-subagent-roster.test.ts": 105, - "src/main/claude/claude-subagent-task-frames.test.ts": 55, - "src/main/claude/claude-transcript-branch-graph.test.ts": 60, - "src/main/claude/claude-transcript-branch-streaming.test.ts": 224, - "src/main/claude/claude-transcript-entry-uuid.test.ts": 36, - "src/main/claude/claude-transcript-marker-branch-proof.test.ts": 86, - "src/main/claude/claude-transcript-rewind-proof.test.ts": 56, - "src/main/claude/claude-turn-outcome.test.ts": 121, - "src/main/claude/claude-turn-ownership.test.ts": 3385, - "src/main/claude/claude-turn-resumption.test.ts": 185, - "src/main/claude/compact-status-registration.test.ts": 175, - "src/main/claude/hook-service.test.ts": 401, - "src/main/claude/statusline-script.test.ts": 207, - "src/main/cli/appimage-extracted-root.test.ts": 198, - "src/main/cli/appimage-extraction-pruning.test.ts": 90, - "src/main/cli/appimage-payload-removal.reentrancy.test.ts": 49, - "src/main/cli/appimage-payload-removal.test.ts": 66, - "src/main/cli/appimage-registration-lock.test.ts": 57, - "src/main/cli/appimage-stable-launcher.test.ts": 231, - "src/main/cli/cli-command-installation-races.test.ts": 187, - "src/main/cli/cli-command-privileged-transaction.test.ts": 133, - "src/main/cli/cli-installer-appimage-ownership.test.ts": 206, - "src/main/cli/cli-installer-appimage-removal.test.ts": 281, - "src/main/cli/cli-installer-command-conflicts.test.ts": 230, - "src/main/cli/cli-installer-macos-command-path.test.ts": 250, - "src/main/cli/cli-installer-windows-path.test.ts": 148, - "src/main/cli/cli-installer.test.ts": 323, - "src/main/cli/cli-privileged-processes.test.ts": 51, - "src/main/cli/keyed-promise-queue.test.ts": 33, - "src/main/cli/legacy-appimage-cli-wrapper.test.ts": 57, - "src/main/cli/linux-bare-orca-dispatcher.test.ts": 173, - "src/main/cli/linux-terminal-orca-cli-shim.test.ts": 154, - "src/main/cli/native-cli-powershell-argv-boundary.test.ts": 54, - "src/main/cli/orca-cli-child-path.test.ts": 71, - "src/main/cli/packaged-cli-assets.test.ts": 863, - "src/main/cli/windows-launcher-asset.test.ts": 42, - "src/main/cli/windows-user-path-registry.test.ts": 63, + "src/main/browser/agent-browser-process-environment.test.ts": 53, + "src/main/browser/agent-browser-session-reset.test.ts": 60, + "src/main/browser/browser-certificate-trust-controller.test.ts": 436, + "src/main/browser/browser-client-download-relay.test.ts": 257, + "src/main/browser/browser-client-download-routing.test.ts": 239, + "src/main/browser/browser-client-file-channel-negotiation.test.ts": 307, + "src/main/browser/browser-client-file-channel-reconnect.test.ts": 536, + "src/main/browser/browser-client-host-attach-request.test.ts": 257, + "src/main/browser/browser-client-host-authority-replacement.test.ts": 250, + "src/main/browser/browser-client-host-command-dispatcher.test.ts": 268, + "src/main/browser/browser-client-host-command-retention.test.ts": 837, + "src/main/browser/browser-client-host-id.test.ts": 63, + "src/main/browser/browser-client-host-placement-preparation.test.ts": 95, + "src/main/browser/browser-client-host-published-url-wiring.test.ts": 537, + "src/main/browser/browser-client-host-reconciliation-command-order.test.ts": 257, + "src/main/browser/browser-client-network-route-registry.test.ts": 292, + "src/main/browser/browser-client-page-automation-runtime.test.ts": 61, + "src/main/browser/browser-client-page-command-executor-fencing.test.ts": 274, + "src/main/browser/browser-client-page-command-executor.test.ts": 311, + "src/main/browser/browser-client-page-command-integration.test.ts": 345, + "src/main/browser/browser-client-page-execution-host-supersession.test.ts": 414, + "src/main/browser/browser-client-page-inventory.test.ts": 232, + "src/main/browser/browser-client-page-metadata-transport.test.ts": 260, + "src/main/browser/browser-client-page-published-url.test.ts": 257, + "src/main/browser/browser-client-page-reconciliation-adapters.test.ts": 394, + "src/main/browser/browser-client-page-renderer-bridge.test.ts": 258, + "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts": 840, + "src/main/browser/browser-client-page-renderer-runtime.test.ts": 230, + "src/main/browser/browser-client-page-unavailability.test.ts": 316, + "src/main/browser/browser-client-page-upload-routing.test.ts": 366, + "src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts": 388, + "src/main/browser/browser-client-route-cookie-import.test.ts": 89, + "src/main/browser/browser-client-upload-buffer-retention.test.ts": 530, + "src/main/browser/browser-client-upload-command.test.ts": 261, + "src/main/browser/browser-client-upload-staging.test.ts": 311, + "src/main/browser/browser-client-upload-transfer.test.ts": 247, + "src/main/browser/browser-cookie-clear-preserve.test.ts": 136, + "src/main/browser/browser-cookie-clear-store-lifecycle.test.ts": 81, + "src/main/browser/browser-cookie-clear-store.test.ts": 83, + "src/main/browser/browser-cookie-decryption.test.ts": 67, + "src/main/browser/browser-cookie-import-clear-atomicity.test.ts": 75, + "src/main/browser/browser-cookie-import-concurrency.test.ts": 550, + "src/main/browser/browser-cookie-import-google-exclusion.test.ts": 472, + "src/main/browser/browser-cookie-import-partition-fidelity.test.ts": 603, + "src/main/browser/browser-cookie-import-partition-reland.electron.test.ts": 1241, + "src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts": 643, + "src/main/browser/browser-cookie-import-partition-success.electron.test.ts": 948, + "src/main/browser/browser-cookie-import-partition.electron.test.ts": 782, + "src/main/browser/browser-cookie-import-plan-writes.test.ts": 80, + "src/main/browser/browser-cookie-import-policy.test.ts": 202, + "src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts": 918, + "src/main/browser/browser-cookie-import-replacement.test.ts": 539, + "src/main/browser/browser-cookie-import-route-partition-staging.test.ts": 378, + "src/main/browser/browser-cookie-import-scope.test.ts": 403, + "src/main/browser/browser-cookie-import-snapshot-ownership.test.ts": 528, + "src/main/browser/browser-cookie-import-undecryptable.test.ts": 753, + "src/main/browser/browser-cookie-import-validated-partition.electron.test.ts": 723, + "src/main/browser/browser-cookie-import-write.test.ts": 75, + "src/main/browser/browser-cookie-import.comet.test.ts": 939, + "src/main/browser/browser-cookie-import.helium.test.ts": 892, + "src/main/browser/browser-cookie-import.test.ts": 1375, + "src/main/browser/browser-cookie-public-suffix-scope.test.ts": 71, + "src/main/browser/browser-cookie-registrable-family.test.ts": 77, + "src/main/browser/browser-cookie-samesite.electron.test.ts": 796, + "src/main/browser/browser-cookie-source-partition.test.ts": 67, + "src/main/browser/browser-cookie-validation.test.ts": 79, + "src/main/browser/browser-download-destination.test.ts": 72, + "src/main/browser/browser-execution-host-storage-identity.test.ts": 256, + "src/main/browser/browser-google-auth-ua.test.ts": 52, + "src/main/browser/browser-grab-payload.test.ts": 64, + "src/main/browser/browser-guest-shortcut-forwarding.test.ts": 346, + "src/main/browser/browser-guest-wheel-zoom-scroll.test.ts": 54, + "src/main/browser/browser-host-client-identity.test.ts": 81, + "src/main/browser/browser-host-lease-reconnect-delay.test.ts": 45, + "src/main/browser/browser-identity-mode-record.test.ts": 52, + "src/main/browser/browser-identity-mode-store.test.ts": 140, + "src/main/browser/browser-manager-annotation-bridge.test.ts": 382, + "src/main/browser/browser-manager-auth-user-agent.test.ts": 378, + "src/main/browser/browser-manager-client-hosted-downloads.test.ts": 467, + "src/main/browser/browser-manager-destroyed-guest-downloads.test.ts": 499, + "src/main/browser/browser-manager-destroyed-guest.test.ts": 424, + "src/main/browser/browser-manager-downloads.test.ts": 391, + "src/main/browser/browser-manager-grab-capture.test.ts": 365, + "src/main/browser/browser-manager-grab-mode.test.ts": 397, + "src/main/browser/browser-manager-grab-selection.test.ts": 446, + "src/main/browser/browser-manager-grab-shortcuts.test.ts": 413, + "src/main/browser/browser-manager-guest-lifecycle.test.ts": 356, + "src/main/browser/browser-manager-guest-policy-profile.test.ts": 394, + "src/main/browser/browser-manager-guest-shortcuts.test.ts": 388, + "src/main/browser/browser-manager-guest-visibility.test.ts": 398, + "src/main/browser/browser-manager-load-failure-replay.test.ts": 412, + "src/main/browser/browser-manager-popup-child.test.ts": 442, + "src/main/browser/browser-manager-popup-routing.test.ts": 398, + "src/main/browser/browser-manager-tab-identity.test.ts": 387, + "src/main/browser/browser-manager-viewport-crashed-guest.test.ts": 322, + "src/main/browser/browser-manager-viewport-override.test.ts": 422, + "src/main/browser/browser-manager-viewport-ownership.test.ts": 409, + "src/main/browser/browser-manager-viewport-partial-failure.test.ts": 372, + "src/main/browser/browser-manager-viewport-touch-reset.test.ts": 317, + "src/main/browser/browser-manager-worker-request-user-agent.test.ts": 376, + "src/main/browser/browser-network-deferred-socket.test.ts": 45, + "src/main/browser/browser-network-execution-route.test.ts": 236, + "src/main/browser/browser-network-tunnel-client-memory-budget.test.ts": 93, + "src/main/browser/browser-network-tunnel-client.test.ts": 129, + "src/main/browser/browser-network-tunnel-conformance.test.ts": 169, + "src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts": 52, + "src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts": 68, + "src/main/browser/browser-network-tunnel-session-stream-scoped-failure.test.ts": 103, + "src/main/browser/browser-network-tunnel-session.test.ts": 140, + "src/main/browser/browser-page-initiated-tab-budget.test.ts": 45, + "src/main/browser/browser-popup-new-tab-intent.test.ts": 38, + "src/main/browser/browser-process-user-agent.test.ts": 55, + "src/main/browser/browser-route-dns-prefetch.electron.test.ts": 4477, + "src/main/browser/browser-route-guest-popups.test.ts": 348, + "src/main/browser/browser-route-h3-egress.electron.test.ts": 18009, + "src/main/browser/browser-route-identity.test.ts": 63, + "src/main/browser/browser-route-partition-binding-capacity.test.ts": 84, + "src/main/browser/browser-route-partition-binding-store.test.ts": 122, + "src/main/browser/browser-route-partition-migration.test.ts": 105, + "src/main/browser/browser-route-partition-stability.test.ts": 292, + "src/main/browser/browser-route-partition-storage-lifecycle.test.ts": 116, + "src/main/browser/browser-route-partition-storage-retirement.test.ts": 270, + "src/main/browser/browser-route-partition-storage-runtime.test.ts": 58, + "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts": 3825, + "src/main/browser/browser-route-prepared-page-rekey.test.ts": 51, + "src/main/browser/browser-route-session-registry.test.ts": 111, + "src/main/browser/browser-route-tcp-egress.electron.test.ts": 2122, + "src/main/browser/browser-route-webcontents-registry.test.ts": 428, + "src/main/browser/browser-route-webrtc-egress.electron.test.ts": 13343, + "src/main/browser/browser-screencast-dialog-settlement.test.ts": 115, + "src/main/browser/browser-screencast-first-frame-timeout.test.ts": 93, + "src/main/browser/browser-screencast-lifecycle.test.ts": 104, + "src/main/browser/browser-screencast-live-frame-allocation.test.ts": 100, + "src/main/browser/browser-screencast-snapshot-scaling.test.ts": 202, + "src/main/browser/browser-screencast-stream.test.ts": 639, + "src/main/browser/browser-session-cookie-staging.scoped.test.ts": 475, + "src/main/browser/browser-session-cookie-staging.test.ts": 316, + "src/main/browser/browser-session-partition-policies.test.ts": 95, + "src/main/browser/browser-session-partition-proxy-install.test.ts": 499, + "src/main/browser/browser-session-proxy.test.ts": 223, + "src/main/browser/browser-session-registry-identity.persistence.test.ts": 504, + "src/main/browser/browser-session-registry.persistence.test.ts": 887, + "src/main/browser/browser-session-registry.test.ts": 839, + "src/main/browser/browser-session-startup.test.ts": 71, + "src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts": 645, + "src/main/browser/browser-session-ua-wire-identity-cross-context.electron.test.ts": 5588, + "src/main/browser/browser-session-ua-wire-identity.electron.test.ts": 6689, + "src/main/browser/browser-session-user-agent-migration-inspection.test.ts": 249, + "src/main/browser/browser-tab-identity.test.ts": 44, + "src/main/browser/browser-text-insertion.test.ts": 72, + "src/main/browser/browser-webauthn-access.test.ts": 55, + "src/main/browser/browser-webauthn-account-picker.test.ts": 70, + "src/main/browser/browser-webauthn-profile-delete.test.ts": 474, + "src/main/browser/cdp-bridge-integration.test.ts": 6649, + "src/main/browser/cdp-bridge-state.test.ts": 73, + "src/main/browser/cdp-client-response-writer.test.ts": 319, + "src/main/browser/cdp-keyboard-us-layout.test.ts": 73, + "src/main/browser/cdp-print-to-pdf.test.ts": 64, + "src/main/browser/cdp-screenshot.test.ts": 89, + "src/main/browser/cdp-ws-proxy-focus-replay.test.ts": 293, + "src/main/browser/cdp-ws-proxy.test.ts": 2808, + "src/main/browser/chromium-cookie-snapshot.test.ts": 521, + "src/main/browser/client-route-cookie-import-source-store.test.ts": 114, + "src/main/browser/doc-preview-download-block-notice.test.ts": 65, + "src/main/browser/doc-preview-failure-notice.test.ts": 47, + "src/main/browser/doc-preview-file-reader.test.ts": 106, + "src/main/browser/doc-preview-grant-registry.test.ts": 74, + "src/main/browser/doc-preview-guest-policy.test.ts": 255, + "src/main/browser/doc-preview-protocol.test.ts": 86, + "src/main/browser/doc-preview-response-allocation.test.ts": 155, + "src/main/browser/electron-debugger-lease.test.ts": 46, + "src/main/browser/electron-probe-display-launch.test.ts": 44, + "src/main/browser/execution-route-socket-duplex.test.ts": 91, + "src/main/browser/grab-guest-script.test.ts": 91, + "src/main/browser/guest-cdp-command.test.ts": 41, + "src/main/browser/local-ssh-browser-partition-identity.test.ts": 466, + "src/main/browser/local-ssh-browser-partitions.probe-cache.test.ts": 56, + "src/main/browser/local-ssh-browser-route-target.test.ts": 50, + "src/main/browser/local-ssh-browser-route.test.ts": 84, + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts": 362, + "src/main/browser/paired-runtime-browser-client-host-composition.test.ts": 261, + "src/main/browser/paired-runtime-browser-client-host-identity-wiring.test.ts": 995, + "src/main/browser/paired-runtime-browser-client-host-reconnect.test.ts": 510, + "src/main/browser/paired-runtime-browser-client-host-registry.test.ts": 79, + "src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts": 574, + "src/main/browser/paired-runtime-browser-client-host-runtime.test.ts": 576, + "src/main/browser/paired-runtime-browser-client-host.test.ts": 414, + "src/main/browser/paired-runtime-browser-host-command-admission.test.ts": 266, + "src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts": 967, + "src/main/browser/paired-runtime-browser-host-lease.test.ts": 943, + "src/main/browser/paired-runtime-browser-host-reconciliation-negotiation.test.ts": 338, + "src/main/browser/paired-runtime-browser-network-route.test.ts": 1296, + "src/main/browser/popup-origin-bar-window.test.ts": 106, + "src/main/browser/remote-browser-socks-buffering.test.ts": 372, + "src/main/browser/remote-browser-socks-server.test.ts": 309, + "src/main/browser/snapshot-engine-iframe-sessions.test.ts": 96, + "src/main/browser/snapshot-engine.test.ts": 60, + "src/main/browser/ssh-browser-network-execution-route.test.ts": 554, + "src/main/browser/system-ssh-socks-client-socket.test.ts": 71, + "src/main/browser/wsl-browser-network-execution-route.test.ts": 354, + "src/main/browser/wsl-browser-network-relay-launch.test.ts": 83, + "src/main/claude-accounts/claude-account-folder.test.ts": 275, + "src/main/claude-accounts/claude-account-service.test.ts": 792, + "src/main/claude-accounts/claude-command-process.test.ts": 81, + "src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts": 608, + "src/main/claude-accounts/claude-duplicate-account.test.ts": 52, + "src/main/claude-accounts/claude-launch-routing.test.ts": 540, + "src/main/claude-accounts/claude-profile-history-windows.test.ts": 132, + "src/main/claude-accounts/claude-profile-history.test.ts": 107, + "src/main/claude-accounts/claude-profile-paths.test.ts": 80, + "src/main/claude-accounts/claude-profile-provisioning.test.ts": 762, + "src/main/claude-accounts/claude-profile-router.test.ts": 7997, + "src/main/claude-accounts/claude-profile-setup-worker.test.ts": 240, + "src/main/claude-accounts/claude-profile-setup.test.ts": 181, + "src/main/claude-accounts/claude-profile-shell-composition.test.ts": 72, + "src/main/claude-accounts/claude-profile-wsl-router.test.ts": 787, + "src/main/claude-accounts/claude-sign-in-browser.test.ts": 319, + "src/main/claude-accounts/claude-structured-auth-policy.test.ts": 61, + "src/main/claude-accounts/keychain-config-directory-aliases.test.ts": 57, + "src/main/claude-accounts/keychain.test.ts": 56, + "src/main/claude-accounts/runtime-auth-service.test.ts": 297, + "src/main/claude-accounts/runtime-paths.test.ts": 87, + "src/main/claude-accounts/runtime-selection.test.ts": 69, + "src/main/claude-accounts/windows-command-invocation.test.ts": 44, + "src/main/claude-usage/claude-model-pricing.test.ts": 68, + "src/main/claude-usage/claude-usage-report-aggregation.test.ts": 47, + "src/main/claude-usage/persisted-source-authority.test.ts": 320, + "src/main/claude-usage/persisted-token-columns-store.test.ts": 467, + "src/main/claude-usage/persisted-token-columns.test.ts": 91, + "src/main/claude-usage/scanner-incremental-append.test.ts": 191, + "src/main/claude-usage/scanner-incremental-ownership.test.ts": 414, + "src/main/claude-usage/scanner-incremental-reader.test.ts": 666, + "src/main/claude-usage/scanner-large-directory.test.ts": 285, + "src/main/claude-usage/scanner-scan.test.ts": 242, + "src/main/claude-usage/scanner.test.ts": 91, + "src/main/claude-usage/source-cache-compression.test.ts": 89, + "src/main/claude-usage/source-cache-write-retention.test.ts": 305, + "src/main/claude-usage/store-checkpoint-persistence.test.ts": 590, + "src/main/claude-usage/store-source-authority.test.ts": 557, + "src/main/claude-usage/store.test.ts": 403, + "src/main/claude-usage/transcript-file-discovery-errors.test.ts": 60, + "src/main/claude-usage/transcript-projection-integrity.test.ts": 207, + "src/main/claude-usage/transcript-reader-lifetime.test.ts": 66, + "src/main/claude-usage/transcript-record-parser-prefilter.test.ts": 57, + "src/main/claude-usage/transcript-tuple-append.test.ts": 270, + "src/main/claude-usage/worktree-attribution-scaling.test.ts": 60, + "src/main/claude/claude-adapter-capture-replay.test.ts": 669, + "src/main/claude/claude-agent-sdk-contract-pins.test.ts": 3228, + "src/main/claude/claude-agent-sdk-control-requests.test.ts": 54, + "src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts": 113, + "src/main/claude/claude-agent-sdk-exit-proof.test.ts": 8144, + "src/main/claude/claude-agent-sdk-import-boundary.test.ts": 1579, + "src/main/claude/claude-agent-sdk-process-spawn.test.ts": 180, + "src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts": 130, + "src/main/claude/claude-agent-sdk-user-message-queue.test.ts": 151, + "src/main/claude/claude-api-retry-idle-sweep.test.ts": 1549, + "src/main/claude/claude-api-retry-row.test.ts": 651, + "src/main/claude/claude-background-stop-records.test.ts": 828, + "src/main/claude/claude-background-task-resume.test.ts": 70, + "src/main/claude/claude-background-task-retention.test.ts": 476, + "src/main/claude/claude-background-task-row-journal.test.ts": 113, + "src/main/claude/claude-background-task-rows.test.ts": 166, + "src/main/claude/claude-background-task-terminal-frames.test.ts": 94, + "src/main/claude/claude-background-task-tracker.test.ts": 155, + "src/main/claude/claude-background-task-turn-resumption.test.ts": 376, + "src/main/claude/claude-background-wake-retention.test.ts": 1040, + "src/main/claude/claude-child-exit-proof-ladder.test.ts": 5645, + "src/main/claude/claude-child-process-environment.test.ts": 65, + "src/main/claude/claude-child-work-decoder.test.ts": 85, + "src/main/claude/claude-child-work-evidence-retention.test.ts": 483, + "src/main/claude/claude-cli-flag-prewarm.test.ts": 246, + "src/main/claude/claude-cli-flag-support.test.ts": 399, + "src/main/claude/claude-close-turn-end.test.ts": 554, + "src/main/claude/claude-command-lifecycle-frames.test.ts": 378, + "src/main/claude/claude-command-lifecycle.test.ts": 636, + "src/main/claude/claude-command-turn.test.ts": 328, + "src/main/claude/claude-config-dir-pin.test.ts": 65, + "src/main/claude/claude-context-model-switch.test.ts": 435, + "src/main/claude/claude-context-usage-restart.test.ts": 832, + "src/main/claude/claude-context-usage-unloaded-turn.test.ts": 3083, + "src/main/claude/claude-context-usage.test.ts": 298, + "src/main/claude/claude-context-window-hint.test.ts": 473, + "src/main/claude/claude-descendant-escalation-boundary.test.ts": 564, + "src/main/claude/claude-fold-steer-receipt.test.ts": 684, + "src/main/claude/claude-folder-trust-file-wsl-guest.test.ts": 60, + "src/main/claude/claude-folder-trust-file.test.ts": 737, + "src/main/claude/claude-history-window-source-budget.test.ts": 594, + "src/main/claude/claude-hook-event-install.test.ts": 142, + "src/main/claude/claude-hook-event-versions.test.ts": 87, + "src/main/claude/claude-informational-row.test.ts": 615, + "src/main/claude/claude-journal-turn-scope.test.ts": 286, + "src/main/claude/claude-model-catalog-probe.test.ts": 2112, + "src/main/claude/claude-profile-hooks.test.ts": 193, + "src/main/claude/claude-provisional-row-corrections.test.ts": 61, + "src/main/claude/claude-request-end-wait.test.ts": 49, + "src/main/claude/claude-settings-schema-install.test.ts": 181, + "src/main/claude/claude-slash-command-catalog.test.ts": 59, + "src/main/claude/claude-status-line-install.test.ts": 124, + "src/main/claude/claude-stop-ack-race.test.ts": 1348, + "src/main/claude/claude-stream-json-connection-close.test.ts": 653, + "src/main/claude/claude-stream-json-connection.test.ts": 10922, + "src/main/claude/claude-streamed-text-checkpoints.test.ts": 64, + "src/main/claude/claude-structured-applied-options.test.ts": 631, + "src/main/claude/claude-structured-auth-parity.test.ts": 458, + "src/main/claude/claude-structured-child-work-captures.test.ts": 1096, + "src/main/claude/claude-structured-child-work-producer.test.ts": 1124, + "src/main/claude/claude-structured-configured-default.test.ts": 833, + "src/main/claude/claude-structured-content-parts.test.ts": 352, + "src/main/claude/claude-structured-control-actions.test.ts": 393, + "src/main/claude/claude-structured-conversation-stop.test.ts": 562, + "src/main/claude/claude-structured-dispatch-admission.test.ts": 567, + "src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts": 291, + "src/main/claude/claude-structured-dispatch-boundary.test.ts": 622, + "src/main/claude/claude-structured-dispatch-content.test.ts": 270, + "src/main/claude/claude-structured-dispatch-queue-boundary.test.ts": 659, + "src/main/claude/claude-structured-dispatch.test.ts": 1937, + "src/main/claude/claude-structured-effort-default-at-rest.test.ts": 714, + "src/main/claude/claude-structured-effort-reporting.test.ts": 715, + "src/main/claude/claude-structured-history-window.test.ts": 378, + "src/main/claude/claude-structured-in-turn-stop.test.ts": 607, + "src/main/claude/claude-structured-inbound-control.test.ts": 75, + "src/main/claude/claude-structured-journal-prompt-cards.test.ts": 60, + "src/main/claude/claude-structured-journal-prompt-retry.test.ts": 332, + "src/main/claude/claude-structured-journal-translation-background-tasks.test.ts": 506, + "src/main/claude/claude-structured-journal-translation-context-usage.test.ts": 456, + "src/main/claude/claude-structured-journal-translation-subagents.test.ts": 400, + "src/main/claude/claude-structured-journal-translation-turn-timing.test.ts": 730, + "src/main/claude/claude-structured-journal-translation.plan.test.ts": 328, + "src/main/claude/claude-structured-journal-translation.test.ts": 726, + "src/main/claude/claude-structured-launch-account-routing.test.ts": 631, + "src/main/claude/claude-structured-launch-args.test.ts": 40, + "src/main/claude/claude-structured-launch-resolution.test.ts": 647, + "src/main/claude/claude-structured-launch-visuals.test.ts": 477, + "src/main/claude/claude-structured-live-provider-process.test.ts": 587, + "src/main/claude/claude-structured-model-confirmation.test.ts": 673, + "src/main/claude/claude-structured-model-preflight.test.ts": 343, + "src/main/claude/claude-structured-option-confirmation.test.ts": 758, + "src/main/claude/claude-structured-options-context-window.test.ts": 414, + "src/main/claude/claude-structured-options.test.ts": 431, + "src/main/claude/claude-structured-owner-identity.test.ts": 90, + "src/main/claude/claude-structured-permission-mode.test.ts": 58, + "src/main/claude/claude-structured-prompt-items.test.ts": 316, + "src/main/claude/claude-structured-prompt-ownership.test.ts": 700, + "src/main/claude/claude-structured-provider-fallback.test.ts": 550, + "src/main/claude/claude-structured-queued-stop.test.ts": 538, + "src/main/claude/claude-structured-rate-limit-retry.test.ts": 264, + "src/main/claude/claude-structured-real-cli-fold.test.ts": 490, + "src/main/claude/claude-structured-real-cli-visuals.test.ts": 584, + "src/main/claude/claude-structured-real-cli.test.ts": 787, + "src/main/claude/claude-structured-reasoning-lifecycle.test.ts": 350, + "src/main/claude/claude-structured-reasoning.test.ts": 289, + "src/main/claude/claude-structured-requested-stop.test.ts": 557, + "src/main/claude/claude-structured-resume-point.test.ts": 758, + "src/main/claude/claude-structured-retired-model.test.ts": 531, + "src/main/claude/claude-structured-session-acquisition-processless.test.ts": 679, + "src/main/claude/claude-structured-session-adapter-turns.test.ts": 726, + "src/main/claude/claude-structured-session-adapter.test.ts": 824, + "src/main/claude/claude-structured-session-close.test.ts": 635, + "src/main/claude/claude-structured-session-commands.test.ts": 694, + "src/main/claude/claude-structured-session-exit-blame.test.ts": 672, + "src/main/claude/claude-structured-session-reading-control.test.ts": 886, + "src/main/claude/claude-structured-session-recovery.test.ts": 641, + "src/main/claude/claude-structured-session-startup.test.ts": 723, + "src/main/claude/claude-structured-sign-in.test.ts": 317, + "src/main/claude/claude-structured-spawn-options.test.ts": 442, + "src/main/claude/claude-structured-start-fast-mode.test.ts": 554, + "src/main/claude/claude-structured-turn-resume-point.test.ts": 695, + "src/main/claude/claude-subagent-group-row.test.ts": 55, + "src/main/claude/claude-subagent-id-aliases.test.ts": 53, + "src/main/claude/claude-subagent-journaled-roster.test.ts": 55, + "src/main/claude/claude-subagent-permission-request.test.ts": 2224, + "src/main/claude/claude-subagent-resume-after-restart.test.ts": 798, + "src/main/claude/claude-subagent-roster-groups.test.ts": 70, + "src/main/claude/claude-subagent-roster-retention.test.ts": 116, + "src/main/claude/claude-subagent-roster.test.ts": 371, + "src/main/claude/claude-subagent-task-frames.test.ts": 83, + "src/main/claude/claude-supervised-stop-real-cli.test.ts": 450, + "src/main/claude/claude-supervised-stop.integration.test.ts": 3745, + "src/main/claude/claude-transcript-branch-graph.test.ts": 46, + "src/main/claude/claude-transcript-branch-streaming.test.ts": 162, + "src/main/claude/claude-transcript-entry-uuid.test.ts": 45, + "src/main/claude/claude-transcript-marker-branch-proof.test.ts": 82, + "src/main/claude/claude-transcript-rewind-proof.test.ts": 57, + "src/main/claude/claude-turn-outcome.test.ts": 450, + "src/main/claude/claude-turn-ownership.test.ts": 743, + "src/main/claude/claude-turn-resumption.test.ts": 453, + "src/main/claude/compact-status-registration.test.ts": 220, + "src/main/claude/hook-service.test.ts": 206, + "src/main/claude/statusline-script.test.ts": 179, + "src/main/claude/windows-hook-files.test.ts": 151, + "src/main/cli/appimage-extracted-root.test.ts": 139, + "src/main/cli/appimage-extraction-pruning.test.ts": 102, + "src/main/cli/appimage-payload-removal.reentrancy.test.ts": 51, + "src/main/cli/appimage-payload-removal.test.ts": 62, + "src/main/cli/appimage-registration-lock.test.ts": 71, + "src/main/cli/appimage-stable-launcher.test.ts": 221, + "src/main/cli/cli-command-installation-races.test.ts": 152, + "src/main/cli/cli-command-privileged-transaction.test.ts": 112, + "src/main/cli/cli-installer-appimage-ownership.test.ts": 174, + "src/main/cli/cli-installer-appimage-removal.test.ts": 290, + "src/main/cli/cli-installer-command-conflicts.test.ts": 246, + "src/main/cli/cli-installer-macos-command-path.test.ts": 190, + "src/main/cli/cli-installer-windows-path.test.ts": 178, + "src/main/cli/cli-installer.test.ts": 258, + "src/main/cli/cli-privileged-processes.test.ts": 54, + "src/main/cli/keyed-promise-queue.test.ts": 55, + "src/main/cli/legacy-appimage-cli-wrapper.test.ts": 53, + "src/main/cli/linux-bare-orca-dispatcher.test.ts": 144, + "src/main/cli/linux-terminal-orca-cli-shim.test.ts": 148, + "src/main/cli/native-cli-powershell-argv-boundary.test.ts": 51, + "src/main/cli/orca-cli-child-path.test.ts": 82, + "src/main/cli/packaged-cli-assets.test.ts": 6578, + "src/main/cli/windows-user-path-registry.test.ts": 53, "src/main/cli/wsl-cli-installer.test.ts": 298, - "src/main/cli/wsl-cli-powershell-boundary.test.ts": 104, - "src/main/cli/wsl-cli-registration-operation.test.ts": 90, - "src/main/cli/wsl-cli-registration-reconciliation.test.ts": 264, - "src/main/cli/wsl-cli-registration-registry.test.ts": 174, - "src/main/cli/wsl-managed-cli.test.ts": 51, - "src/main/cli/wsl-managed-cli.wsl.test.ts": 93, - "src/main/codex-accounts/async-file-rename.test.ts": 1055, - "src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts": 49, - "src/main/codex-accounts/codex-auth-workspace-identity.test.ts": 57, - "src/main/codex-accounts/codex-config-mirror.test.ts": 218, - "src/main/codex-accounts/codex-credential-absence-grace.test.ts": 138, - "src/main/codex-accounts/codex-login-auth-url.test.ts": 49, - "src/main/codex-accounts/codex-reset-credit-ledger.test.ts": 274, - "src/main/codex-accounts/codex-windows-interactive-login.test.ts": 343, - "src/main/codex-accounts/fs-utils.test.ts": 174, - "src/main/codex-accounts/host-codex-managed-home-ownership.test.ts": 40, - "src/main/codex-accounts/legacy-shared-auth-migration.test.ts": 121, - "src/main/codex-accounts/legacy-shared-config-compatibility.test.ts": 124, - "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts": 17191, - "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts": 622, - "src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts": 77, - "src/main/codex-accounts/managed-codex-auth-readiness.test.ts": 76, - "src/main/codex-accounts/runtime-home-legacy-migration.test.ts": 608, - "src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts": 326, - "src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts": 224, - "src/main/codex-accounts/runtime-home-per-account-homes.test.ts": 722, - "src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts": 848, - "src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts": 445, - "src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts": 295, - "src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts": 1201, - "src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts": 482, - "src/main/codex-accounts/runtime-home-session-migration-pass.test.ts": 408, - "src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts": 1006, - "src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts": 740, - "src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts": 490, - "src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts": 136, - "src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts": 1054, - "src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts": 890, - "src/main/codex-accounts/runtime-home-wsl-system-default.test.ts": 503, - "src/main/codex-accounts/runtime-selection.test.ts": 73, - "src/main/codex-accounts/service-abandoned-login-supersede.test.ts": 861, - "src/main/codex-accounts/service-account-add-login.test.ts": 509, - "src/main/codex-accounts/service-account-selection-and-removal.test.ts": 846, - "src/main/codex-accounts/service-add-account-from-home.test.ts": 444, - "src/main/codex-accounts/service-login-process-teardown.test.ts": 426, - "src/main/codex-accounts/service-managed-home-removal-retries.test.ts": 186, - "src/main/codex-accounts/service-quota-refresh-decoupling.test.ts": 280, - "src/main/codex-accounts/service-reauthenticate-activation.test.ts": 268, - "src/main/codex-accounts/service-reset-credit-durability.test.ts": 703, - "src/main/codex-accounts/service-reset-credit-home-ownership.test.ts": 473, - "src/main/codex-accounts/service-reset-credit-target-routing.test.ts": 580, - "src/main/codex-accounts/service-system-default-identity.test.ts": 490, - "src/main/codex-accounts/service-wsl-accounts.test.ts": 660, - "src/main/codex-accounts/service.test.ts": 260, - "src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts": 409, - "src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts": 292, - "src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts": 392, - "src/main/codex-accounts/wsl-codex-command.test.ts": 40, - "src/main/codex-cli/codex-home-process-lock.test.ts": 54, - "src/main/codex-cli/command.test.ts": 88, - "src/main/codex-usage/codex-rollout-resume-state.test.ts": 67, - "src/main/codex-usage/codex-usage-cost-estimate.test.ts": 46, - "src/main/codex-usage/codex-usage-long-context-billing.test.ts": 242, - "src/main/codex-usage/scanner-attribution-memo.test.ts": 170, - "src/main/codex-usage/scanner-file-identity.test.ts": 211, - "src/main/codex-usage/scanner-incremental-append.test.ts": 1002, - "src/main/codex-usage/scanner-large-directory.test.ts": 1168, - "src/main/codex-usage/scanner-paths.test.ts": 340, - "src/main/codex-usage/scanner.test.ts": 158, - "src/main/codex-usage/store-automation-usage.test.ts": 117, - "src/main/codex-usage/store-model-pricing.test.ts": 215, - "src/main/codex-usage/store-orca-scope.test.ts": 185, - "src/main/codex-usage/store-persistence.test.ts": 125, - "src/main/codex-usage/store-snapshot.benchmark.test.ts": 310, - "src/main/codex/codex-account-session-bridge.test.ts": 77, - "src/main/codex/codex-ai-vault-session-resume.test.ts": 127, - "src/main/codex/codex-app-server-capability-cache.test.ts": 64, - "src/main/codex/codex-app-server-client.test.ts": 1502, - "src/main/codex/codex-app-server-connection.test.ts": 5199, - "src/main/provider-process/provider-process-supervisor.test.ts": 38, - "src/main/provider-process/provider-process-teardown.test.ts": 71, - "src/main/codex/codex-app-server-session.test.ts": 129, - "src/main/codex/codex-app-server-teardown.integration.test.ts": 21677, - "src/main/codex/codex-background-command-tracker.test.ts": 221, - "src/main/codex/codex-background-task-tracker.test.ts": 163, - "src/main/codex/codex-config-mirror-mcp-ownership.test.ts": 138, - "src/main/codex/codex-config-mirror.test.ts": 181, - "src/main/codex/codex-config-settings-removal.test.ts": 49, - "src/main/codex/codex-configured-launch-defaults.test.ts": 30, - "src/main/codex/codex-daemon-socket-path-guard.test.ts": 153, - "src/main/codex/codex-goal-journal-rows.test.ts": 90, - "src/main/codex/codex-home-paths.test.ts": 76, - "src/main/codex/codex-hook-legacy-profile-block.test.ts": 132, - "src/main/codex/codex-hook-trust-grant.test.ts": 1397, - "src/main/codex/codex-hooks-read-denial-followup.test.ts": 179, - "src/main/codex/codex-index-heal-binary-contract.test.ts": 49, - "src/main/codex/codex-legacy-session-resume.test.ts": 208, - "src/main/codex/codex-model-catalog-probe.test.ts": 3179, - "src/main/codex/codex-model-provider-config.test.ts": 32, - "src/main/codex/codex-notice-item-translation.test.ts": 226, - "src/main/codex/codex-pane-account-registry-mutations.test.ts": 50, - "src/main/codex/codex-pane-launch-account.test.ts": 64, - "src/main/codex/codex-persistent-command-retention.test.ts": 276, - "src/main/codex/codex-pinned-rollout-proof.test.ts": 53, - "src/main/codex/codex-prompt-registry-retention.test.ts": 376, - "src/main/codex/codex-real-home-path.test.ts": 57, - "src/main/codex/codex-requested-close-turn-timing.test.ts": 134, - "src/main/codex/codex-rollout-session-meta.test.ts": 81, - "src/main/codex/codex-server-request-disposition.test.ts": 112, - "src/main/codex/codex-session-backfill-marker.test.ts": 89, - "src/main/codex/codex-session-backfill-scan-dates.test.ts": 83, - "src/main/codex/codex-session-backfill.test.ts": 647, - "src/main/codex/codex-session-bridge.test.ts": 112, - "src/main/codex/codex-session-index-heal-state.test.ts": 345, - "src/main/codex/codex-session-index-heal.test.ts": 1408, - "src/main/codex/codex-session-migration-recent-exits.test.ts": 61, - "src/main/codex/codex-session-migration-scheduler.test.ts": 401, - "src/main/codex/codex-session-resume-home.test.ts": 66, - "src/main/codex/codex-session-resume-preparation.test.ts": 65, - "src/main/codex/codex-session-resume-windows.test.ts": 59, - "src/main/codex/codex-session-resume-wrong-account.test.ts": 60, - "src/main/codex/codex-session-source-home.test.ts": 30, - "src/main/codex/codex-stale-pane-accounts.test.ts": 130, - "src/main/codex/codex-state-db-backfill-recovery.test.ts": 307, - "src/main/codex/codex-state-db.test.ts": 70, - "src/main/codex/codex-structured-acquisition-exit-proof.test.ts": 417, - "src/main/codex/codex-structured-child-environment.test.ts": 134, - "src/main/codex/codex-structured-dispatch-admission.test.ts": 507, - "src/main/codex/codex-structured-dispatch-boundary.test.ts": 414, - "src/main/codex/codex-structured-dispatch-echo.test.ts": 57, - "src/main/codex/codex-structured-service-tier.test.ts": 320, - "src/main/codex/codex-structured-item-translation.test.ts": 161, - "src/main/codex/codex-structured-journal-compactions.test.ts": 267, - "src/main/codex/codex-structured-journal-goal-admission.test.ts": 258, - "src/main/codex/codex-structured-journal-goal-resume.test.ts": 327, - "src/main/codex/codex-structured-journal-goal-revision.test.ts": 242, - "src/main/codex/codex-structured-journal-goal-rows.test.ts": 107, - "src/main/codex/codex-structured-journal-subagent-readers.test.ts": 292, - "src/main/codex/codex-structured-journal-translation-settlement.test.ts": 1118, - "src/main/codex/codex-structured-journal-translation-streams.test.ts": 275, - "src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts": 245, - "src/main/codex/codex-structured-journal-translation-subagents.test.ts": 186, - "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts": 398, - "src/main/codex/codex-structured-journal-translation-turn-state.test.ts": 35, - "src/main/codex/codex-structured-journal-translation.test.ts": 282, - "src/main/codex/codex-structured-launch-resolution.test.ts": 102, - "src/main/codex/codex-structured-location-support.test.ts": 46, - "src/main/codex/codex-structured-owner-identity.test.ts": 45, - "src/main/codex/codex-structured-permission-policy.test.ts": 63, - "src/main/codex/codex-structured-prompt-items.test.ts": 224, - "src/main/codex/codex-structured-prompt-ownership.test.ts": 512, - "src/main/codex/codex-structured-prompt-replies.test.ts": 165, - "src/main/codex/codex-structured-rewind.test.ts": 306, - "src/main/codex/codex-structured-session-adapter-lifecycle.test.ts": 367, - "src/main/codex/codex-structured-session-adapter.test.ts": 318, - "src/main/codex/codex-structured-session-background-tasks.test.ts": 502, - "src/main/codex/codex-structured-session-cancel.test.ts": 456, - "src/main/codex/codex-structured-session-close.test.ts": 420, - "src/main/codex/codex-structured-session-options-catalog.test.ts": 246, - "src/main/codex/codex-structured-session-options.test.ts": 356, - "src/main/codex/codex-structured-session-shutdown.test.ts": 471, - "src/main/codex/codex-structured-thread-goal.test.ts": 73, - "src/main/codex/codex-structured-thread-open.test.ts": 300, - "src/main/codex/codex-subagent-execution-projection.test.ts": 202, - "src/main/codex/codex-subagent-executions.test.ts": 71, - "src/main/codex/codex-subagent-roster.test.ts": 118, - "src/main/codex/codex-tool-identity-translation.test.ts": 117, - "src/main/codex/codex-trust-config-concurrent-launch.test.ts": 225, - "src/main/codex/codex-trust-config-mutation-queue.test.ts": 45, - "src/main/codex/codex-trust-config-rollback.test.ts": 76, - "src/main/codex/codex-trust-grant-host.test.ts": 94, - "src/main/codex/codex-trust-grant-ledger.test.ts": 54, - "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 43, - "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, - "src/main/codex/codex-turn-ordinals.test.ts": 96, - "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, - "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, - "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, - "src/main/codex/config-plugin-registration-promotion.test.ts": 370, - "src/main/codex/config-settings-baseline-upgrade.test.ts": 104, - "src/main/codex/config-settings-promotion.test.ts": 508, - "src/main/codex/config-sync-stall.test.ts": 161, - "src/main/codex/config-toml-hook-trust-scaling.test.ts": 126, - "src/main/codex/config-toml-mcp-servers.test.ts": 37, - "src/main/codex/config-toml-trust-api-parity.test.ts": 91, - "src/main/codex/config-toml-trust-hash.test.ts": 44, - "src/main/codex/config-toml-trust-hook-read.test.ts": 125, - "src/main/codex/config-toml-trust-hook-removal.test.ts": 108, - "src/main/codex/config-toml-trust-hook-upsert.test.ts": 163, - "src/main/codex/config-toml-trust-key.test.ts": 54, - "src/main/codex/config-toml-trust-paths.test.ts": 86, - "src/main/codex/config-toml-trust-project.test.ts": 105, - "src/main/codex/hook-service-concurrent-launch-install.test.ts": 749, - "src/main/codex/hook-service-legacy-cleanup.test.ts": 1004, - "src/main/codex/hook-service-managed-install.test.ts": 286, - "src/main/codex/hook-service-runtime-trust-repair.test.ts": 330, - "src/main/codex/hook-service-trust-grant.test.ts": 355, - "src/main/codex/hook-service-user-hook-mirroring.test.ts": 371, - "src/main/codex/hook-service-wsl-runtime.test.ts": 389, - "src/main/codex/hook-trust-promotion.test.ts": 569, - "src/main/codex/managed-home-shell-preflight.test.ts": 161, + "src/main/cli/wsl-cli-powershell-boundary.test.ts": 97, + "src/main/cli/wsl-cli-registration-operation.test.ts": 104, + "src/main/cli/wsl-cli-registration-reconciliation.test.ts": 240, + "src/main/cli/wsl-cli-registration-registry.test.ts": 131, + "src/main/cli/wsl-managed-cli.test.ts": 64, + "src/main/cli/wsl-managed-cli.wsl.test.ts": 114, + "src/main/codebuddy/hook-service.test.ts": 102, + "src/main/codex-accounts/async-file-rename.test.ts": 1080, + "src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts": 69, + "src/main/codex-accounts/codex-auth-workspace-identity.test.ts": 58, + "src/main/codex-accounts/codex-config-mirror.test.ts": 315, + "src/main/codex-accounts/codex-credential-absence-grace.test.ts": 162, + "src/main/codex-accounts/codex-login-auth-url.test.ts": 54, + "src/main/codex-accounts/codex-reset-credit-ledger.test.ts": 294, + "src/main/codex-accounts/codex-windows-interactive-login.test.ts": 464, + "src/main/codex-accounts/fs-utils.test.ts": 126, + "src/main/codex-accounts/host-codex-managed-home-ownership.test.ts": 69, + "src/main/codex-accounts/legacy-shared-auth-migration.test.ts": 156, + "src/main/codex-accounts/legacy-shared-config-compatibility.test.ts": 147, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts": 12427, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-interruption-source.test.ts": 61, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts": 520, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts": 78, + "src/main/codex-accounts/managed-codex-auth-readiness.test.ts": 89, + "src/main/codex-accounts/runtime-home-catalog-account-key.test.ts": 1493, + "src/main/codex-accounts/runtime-home-catalog-probe-prep.test.ts": 381, + "src/main/codex-accounts/runtime-home-legacy-migration.test.ts": 869, + "src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts": 526, + "src/main/codex-accounts/runtime-home-mirror-login-copy.test.ts": 721, + "src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts": 350, + "src/main/codex-accounts/runtime-home-per-account-homes.test.ts": 1014, + "src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts": 480, + "src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts": 645, + "src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts": 470, + "src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts": 1229, + "src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts": 479, + "src/main/codex-accounts/runtime-home-session-migration-pass.test.ts": 555, + "src/main/codex-accounts/runtime-home-status-hook-independence.test.ts": 545, + "src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts": 1406, + "src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts": 949, + "src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts": 604, + "src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts": 273, + "src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts": 878, + "src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts": 875, + "src/main/codex-accounts/runtime-home-wsl-system-default.test.ts": 661, + "src/main/codex-accounts/runtime-selection.test.ts": 47, + "src/main/codex-accounts/service-abandoned-login-supersede.test.ts": 1252, + "src/main/codex-accounts/service-account-add-login.test.ts": 699, + "src/main/codex-accounts/service-account-selection-and-removal.test.ts": 701, + "src/main/codex-accounts/service-add-account-from-home.test.ts": 519, + "src/main/codex-accounts/service-login-process-teardown.test.ts": 465, + "src/main/codex-accounts/service-managed-home-removal-retries.test.ts": 516, + "src/main/codex-accounts/service-quota-refresh-decoupling.test.ts": 395, + "src/main/codex-accounts/service-reauthenticate-activation.test.ts": 433, + "src/main/codex-accounts/service-reset-credit-durability.test.ts": 932, + "src/main/codex-accounts/service-reset-credit-home-ownership.test.ts": 608, + "src/main/codex-accounts/service-reset-credit-target-routing.test.ts": 652, + "src/main/codex-accounts/service-system-default-identity.test.ts": 511, + "src/main/codex-accounts/service-wsl-accounts.test.ts": 740, + "src/main/codex-accounts/service.test.ts": 615, + "src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts": 525, + "src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts": 465, + "src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts": 528, + "src/main/codex-accounts/wsl-codex-command.test.ts": 50, + "src/main/codex-cli/codex-home-process-lock.test.ts": 53, + "src/main/codex-cli/command.test.ts": 90, + "src/main/codex-usage/codex-rollout-file-lifetime.test.ts": 160, + "src/main/codex-usage/codex-rollout-resume-state.test.ts": 75, + "src/main/codex-usage/codex-usage-cost-estimate.test.ts": 51, + "src/main/codex-usage/codex-usage-long-context-billing.test.ts": 491, + "src/main/codex-usage/scanner-attribution-memo.test.ts": 224, + "src/main/codex-usage/scanner-checkpoint-races.test.ts": 240, + "src/main/codex-usage/scanner-file-generation.test.ts": 197, + "src/main/codex-usage/scanner-file-identity.test.ts": 168, + "src/main/codex-usage/scanner-incremental-append.test.ts": 502, + "src/main/codex-usage/scanner-large-directory.test.ts": 673, + "src/main/codex-usage/scanner-paths.test.ts": 400, + "src/main/codex-usage/scanner.test.ts": 138, + "src/main/codex-usage/session-file-discovery-errors.test.ts": 71, + "src/main/codex-usage/store-automation-usage.test.ts": 385, + "src/main/codex-usage/store-model-pricing.test.ts": 431, + "src/main/codex-usage/store-orca-scope.test.ts": 404, + "src/main/codex-usage/store-persistence.test.ts": 360, + "src/main/codex-usage/store-snapshot.benchmark.test.ts": 637, + "src/main/codex/codex-account-session-bridge.test.ts": 376, + "src/main/codex/codex-account-session-index-heal.test.ts": 141, + "src/main/codex/codex-ai-vault-session-resume.test.ts": 145, + "src/main/codex/codex-app-server-capability-cache.test.ts": 65, + "src/main/codex/codex-app-server-client.test.ts": 11768, + "src/main/codex/codex-app-server-connection-exit-order.test.ts": 272, + "src/main/codex/codex-app-server-connection-tree-unproven.test.ts": 247, + "src/main/codex/codex-app-server-connection.test.ts": 1093, + "src/main/codex/codex-app-server-exit-error.test.ts": 202, + "src/main/codex/codex-app-server-session-stop.test.ts": 96, + "src/main/codex/codex-app-server-session-supervision.test.ts": 17428, + "src/main/codex/codex-app-server-session.test.ts": 278, + "src/main/codex/codex-app-server-teardown.integration.test.ts": 20731, + "src/main/codex/codex-background-command-tracker.test.ts": 377, + "src/main/codex/codex-background-task-tracker.test.ts": 326, + "src/main/codex/codex-background-terminals.test.ts": 476, + "src/main/codex/codex-child-work-evidence.test.ts": 416, + "src/main/codex/codex-child-work-translation-retention.test.ts": 424, + "src/main/codex/codex-close-turn-end.test.ts": 350, + "src/main/codex/codex-collab-call-delegation.test.ts": 435, + "src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts": 522, + "src/main/codex/codex-command-turn-claim.test.ts": 332, + "src/main/codex/codex-config-mirror-hook-trust-carry.test.ts": 149, + "src/main/codex/codex-config-mirror-mcp-ownership.test.ts": 151, + "src/main/codex/codex-config-mirror.test.ts": 225, + "src/main/codex/codex-config-settings-removal.test.ts": 68, + "src/main/codex/codex-configured-launch-defaults.test.ts": 58, + "src/main/codex/codex-daemon-socket-path-guard.test.ts": 189, + "src/main/codex/codex-default-mode-subagents.test.ts": 763, + "src/main/codex/codex-goal-journal-rows.test.ts": 82, + "src/main/codex/codex-home-paths.test.ts": 116, + "src/main/codex/codex-hook-approval-first-write.test.ts": 89, + "src/main/codex/codex-hook-command-form.test.ts": 121, + "src/main/codex/codex-hook-file-entry-binary-contract.test.ts": 228, + "src/main/codex/codex-hook-hash-lookup.test.ts": 180, + "src/main/codex/codex-hook-legacy-profile-block.test.ts": 144, + "src/main/codex/codex-hook-real-home-status.test.ts": 321, + "src/main/codex/codex-hook-reconcile.test.ts": 2030, + "src/main/codex/codex-hook-retired-commands.test.ts": 81, + "src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts": 211, + "src/main/codex/codex-hook-toggle-off-wins.test.ts": 276, + "src/main/codex/codex-hook-trust-cleanup.test.ts": 140, + "src/main/codex/codex-hook-trust-derivation.test.ts": 165, + "src/main/codex/codex-hook-trust-failure-memo.test.ts": 359, + "src/main/codex/codex-hook-trust-grant.test.ts": 594, + "src/main/codex/codex-hook-trust-memo.test.ts": 170, + "src/main/codex/codex-hooks-read-denial-followup.test.ts": 284, + "src/main/codex/codex-index-heal-binary-contract.test.ts": 146, + "src/main/codex/codex-interrupt-hook-trust.test.ts": 69, + "src/main/codex/codex-legacy-session-resume.test.ts": 453, + "src/main/codex/codex-model-catalog-probe.test.ts": 3579, + "src/main/codex/codex-model-provider-config.test.ts": 55, + "src/main/codex/codex-notice-item-translation.test.ts": 341, + "src/main/codex/codex-pane-account-registry-mutations.test.ts": 48, + "src/main/codex/codex-pane-launch-account.test.ts": 58, + "src/main/codex/codex-persistent-command-retention.test.ts": 416, + "src/main/codex/codex-pinned-rollout-proof.test.ts": 87, + "src/main/codex/codex-prompt-registry-retention.test.ts": 711, + "src/main/codex/codex-provider-retry-idle-sweep.test.ts": 1708, + "src/main/codex/codex-provider-retry-row.test.ts": 419, + "src/main/codex/codex-real-home-hook-entries.test.ts": 383, + "src/main/codex/codex-real-home-hook-key-spellings.test.ts": 283, + "src/main/codex/codex-real-home-hook-removal.test.ts": 214, + "src/main/codex/codex-real-home-path.test.ts": 70, + "src/main/codex/codex-real-home-shared-entry.test.ts": 433, + "src/main/codex/codex-real-home-windows-key-spellings.test.ts": 158, + "src/main/codex/codex-requested-close-turn-timing.test.ts": 341, + "src/main/codex/codex-rollout-session-meta.test.ts": 108, + "src/main/codex/codex-server-request-disposition.test.ts": 253, + "src/main/codex/codex-session-backfill-marker.test.ts": 92, + "src/main/codex/codex-session-backfill-scan-dates.test.ts": 68, + "src/main/codex/codex-session-backfill.test.ts": 314, + "src/main/codex/codex-session-bridge.test.ts": 122, + "src/main/codex/codex-session-index-heal-state.test.ts": 506, + "src/main/codex/codex-session-index-heal.test.ts": 2477, + "src/main/codex/codex-session-migration-recent-exits.test.ts": 44, + "src/main/codex/codex-session-migration-scheduler.test.ts": 436, + "src/main/codex/codex-session-resume-home.test.ts": 217, + "src/main/codex/codex-session-resume-preparation.test.ts": 68, + "src/main/codex/codex-session-resume-windows.test.ts": 60, + "src/main/codex/codex-session-resume-wrong-account.test.ts": 75, + "src/main/codex/codex-session-source-home.test.ts": 47, + "src/main/codex/codex-shared-server-fix.test.ts": 74, + "src/main/codex/codex-shared-server-pane.test.ts": 89, + "src/main/codex/codex-shared-server-probe.test.ts": 104, + "src/main/codex/codex-short-lived-app-server-spawn.test.ts": 431, + "src/main/codex/codex-stale-pane-accounts.test.ts": 118, + "src/main/codex/codex-state-db-backfill-recovery-process.test.ts": 75, + "src/main/codex/codex-state-db-backfill-recovery-supervision.test.ts": 8520, + "src/main/codex/codex-state-db-backfill-recovery.test.ts": 286, + "src/main/codex/codex-state-db.test.ts": 127, + "src/main/codex/codex-structured-acquisition-exit-proof.test.ts": 540, + "src/main/codex/codex-structured-child-environment.test.ts": 504, + "src/main/codex/codex-structured-child-work-producer.test.ts": 625, + "src/main/codex/codex-structured-compaction-refusal.test.ts": 377, + "src/main/codex/codex-structured-conversation-stop.test.ts": 808, + "src/main/codex/codex-structured-dispatch-admission.test.ts": 772, + "src/main/codex/codex-structured-dispatch-boundary.test.ts": 696, + "src/main/codex/codex-structured-dispatch-echo.test.ts": 76, + "src/main/codex/codex-structured-item-translation.test.ts": 205, + "src/main/codex/codex-structured-journal-compactions.test.ts": 406, + "src/main/codex/codex-structured-journal-exit-convergence.test.ts": 821, + "src/main/codex/codex-structured-journal-goal-admission.test.ts": 341, + "src/main/codex/codex-structured-journal-goal-resume.test.ts": 386, + "src/main/codex/codex-structured-journal-goal-revision.test.ts": 603, + "src/main/codex/codex-structured-journal-goal-rows.test.ts": 320, + "src/main/codex/codex-structured-journal-repeated-questions.test.ts": 446, + "src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts": 337, + "src/main/codex/codex-structured-journal-settlement-rollback.test.ts": 631, + "src/main/codex/codex-structured-journal-subagent-readers.test.ts": 744, + "src/main/codex/codex-structured-journal-translation-settlement.test.ts": 673, + "src/main/codex/codex-structured-journal-translation-streams.test.ts": 507, + "src/main/codex/codex-structured-journal-translation-subagent-linkage.test.ts": 337, + "src/main/codex/codex-structured-journal-translation-subagents.test.ts": 404, + "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts": 692, + "src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts": 363, + "src/main/codex/codex-structured-journal-translation-turn-state.test.ts": 71, + "src/main/codex/codex-structured-journal-translation.test.ts": 447, + "src/main/codex/codex-structured-journal-turn-endings-replay.test.ts": 314, + "src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts": 472, + "src/main/codex/codex-structured-launch-args.test.ts": 54, + "src/main/codex/codex-structured-launch-resolution.test.ts": 482, + "src/main/codex/codex-structured-live-provider-process.test.ts": 397, + "src/main/codex/codex-structured-owner-identity.test.ts": 83, + "src/main/codex/codex-structured-permission-policy.test.ts": 73, + "src/main/codex/codex-structured-prompt-items.test.ts": 304, + "src/main/codex/codex-structured-prompt-ownership.test.ts": 648, + "src/main/codex/codex-structured-prompt-replies.test.ts": 370, + "src/main/codex/codex-structured-question-order.test.ts": 1100, + "src/main/codex/codex-structured-real-cli-visuals.test.ts": 228, + "src/main/codex/codex-structured-reasoning-lifecycle.test.ts": 268, + "src/main/codex/codex-structured-rewind.test.ts": 502, + "src/main/codex/codex-structured-service-tier.test.ts": 649, + "src/main/codex/codex-structured-session-adapter-lifecycle.test.ts": 558, + "src/main/codex/codex-structured-session-adapter.test.ts": 645, + "src/main/codex/codex-structured-session-background-tasks.test.ts": 661, + "src/main/codex/codex-structured-session-cancel.test.ts": 566, + "src/main/codex/codex-structured-session-close.test.ts": 579, + "src/main/codex/codex-structured-session-exit-blame.test.ts": 486, + "src/main/codex/codex-structured-session-options-catalog.test.ts": 541, + "src/main/codex/codex-structured-session-options.test.ts": 609, + "src/main/codex/codex-structured-session-prompts.test.ts": 483, + "src/main/codex/codex-structured-session-shutdown.test.ts": 649, + "src/main/codex/codex-structured-sign-in.test.ts": 301, + "src/main/codex/codex-structured-thread-goal.test.ts": 339, + "src/main/codex/codex-structured-thread-open.test.ts": 392, + "src/main/codex/codex-structured-turn-end-settlement.test.ts": 1386, + "src/main/codex/codex-structured-turn-open-wait.test.ts": 1451, + "src/main/codex/codex-structured-turn-steer.test.ts": 800, + "src/main/codex/codex-structured-visuals.test.ts": 479, + "src/main/codex/codex-subagent-execution-projection.test.ts": 490, + "src/main/codex/codex-subagent-executions.test.ts": 83, + "src/main/codex/codex-subagent-interrupts.test.ts": 503, + "src/main/codex/codex-subagent-roster-retention.test.ts": 3130, + "src/main/codex/codex-subagent-roster.test.ts": 383, + "src/main/codex/codex-thread-token-totals.test.ts": 39, + "src/main/codex/codex-tool-identity-translation.test.ts": 105, + "src/main/codex/codex-trust-config-concurrent-launch.test.ts": 250, + "src/main/codex/codex-trust-config-mutation-queue.test.ts": 63, + "src/main/codex/codex-trust-grant-host.test.ts": 117, + "src/main/codex/codex-trust-grant-ledger.test.ts": 102, + "src/main/codex/codex-trust-grant-telemetry.test.ts": 161, + "src/main/codex/codex-turn-ordinals.test.ts": 89, + "src/main/codex/codex-user-hook-trust-moves.test.ts": 122, + "src/main/codex/codex-wsl-hook-install-plan.test.ts": 71, + "src/main/codex/config-features-settings-promotion.test.ts": 155, + "src/main/codex/config-plugin-registration-promotion.test.ts": 285, + "src/main/codex/config-settings-baseline-upgrade.test.ts": 169, + "src/main/codex/config-settings-promotion.test.ts": 280, + "src/main/codex/config-sync-stall.test.ts": 155, + "src/main/codex/config-toml-hook-state-duplicate-repair.test.ts": 123, + "src/main/codex/config-toml-hook-trust-scaling.test.ts": 120, + "src/main/codex/config-toml-mcp-servers.test.ts": 65, + "src/main/codex/config-toml-project-duplicate-repair.test.ts": 95, + "src/main/codex/config-toml-project-trust-spellings.test.ts": 113, + "src/main/codex/config-toml-promoted-setting-values.test.ts": 43, + "src/main/codex/config-toml-trust-hash.test.ts": 78, + "src/main/codex/config-toml-trust-hook-read.test.ts": 145, + "src/main/codex/config-toml-trust-hook-removal.test.ts": 152, + "src/main/codex/config-toml-trust-hook-upsert.test.ts": 194, + "src/main/codex/config-toml-trust-key.test.ts": 116, + "src/main/codex/config-toml-trust-loadability.test.ts": 182, + "src/main/codex/config-toml-trust-paths.test.ts": 100, + "src/main/codex/config-toml-trust-project.test.ts": 114, + "src/main/codex/config-toml-trust-stale-writes.test.ts": 71, + "src/main/codex/hook-service-concurrent-launch-install.test.ts": 1027, + "src/main/codex/hook-service-legacy-cleanup.test.ts": 907, + "src/main/codex/hook-service-managed-codex-hashes.test.ts": 646, + "src/main/codex/hook-service-managed-install.test.ts": 438, + "src/main/codex/hook-service-runtime-trust-repair.test.ts": 362, + "src/main/codex/hook-service-trust-grant.test.ts": 230, + "src/main/codex/hook-service-user-hook-mirroring.test.ts": 469, + "src/main/codex/hook-service-user-hook-trust-spellings.test.ts": 414, + "src/main/codex/hook-service-windows-key-spellings.test.ts": 220, + "src/main/codex/hook-service-wsl-runtime.test.ts": 366, + "src/main/codex/hook-trust-promotion.test.ts": 474, "src/main/codex/retained-codex-hook-state.test.ts": 41, - "src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts": 82, - "src/main/codex/sta-4737-unreadable-is-not-absent.test.ts": 179, - "src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts": 226, - "src/main/codex/windows-hook-command.test.ts": 161, - "src/main/codex/windows-hook-upgrade.test.ts": 122, - "src/main/codex/wsl-codex-session-bridge.test.ts": 133, - "src/main/command-code/hook-service.test.ts": 407, - "src/main/computer/computer-action-verification-normalization.test.ts": 37, - "src/main/computer/computer-provider-lifecycle.test.ts": 159, - "src/main/computer/computer-provider-unavailable-message.test.ts": 40, - "src/main/computer/computer-sidecar-diagnostics.test.ts": 37, - "src/main/computer/desktop-script-provider-action-errors.test.ts": 162, - "src/main/computer/desktop-script-provider-actions.test.ts": 167, - "src/main/computer/desktop-script-provider-bridge.test.ts": 45, - "src/main/computer/desktop-script-provider-cache-lifecycle.test.ts": 114, - "src/main/computer/desktop-script-provider-cache.test.ts": 191, - "src/main/computer/desktop-script-provider-client.test.ts": 205, - "src/main/computer/desktop-script-provider-errors.test.ts": 205, - "src/main/computer/desktop-script-provider-paste-validation.test.ts": 107, - "src/main/computer/desktop-script-provider-runtime-host-routing.test.ts": 204, - "src/main/computer/desktop-script-runtime-host.test.ts": 199, - "src/main/computer/desktop-script-runtime-host.win32.test.ts": 94, - "src/main/computer/desktop-script-serve-channel.test.ts": 170, - "src/main/computer/macos-computer-use-permission-status.test.ts": 97, - "src/main/computer/macos-computer-use-permissions.test.ts": 90, - "src/main/computer/macos-native-provider-client.test.ts": 1206, - "src/main/computer/macos-native-provider-line-buffer.test.ts": 62, - "src/main/computer/macos-native-provider-paste-validation.test.ts": 192, - "src/main/computer/macos-native-provider-process-reaping.test.ts": 94, - "src/main/computer/macos-native-provider-reaping.integration.test.ts": 4215, - "src/main/computer/macos-native-provider-socket.test.ts": 71, - "src/main/computer/macos-native-provider-startup-cleanup.test.ts": 73, - "src/main/computer/sidecar-client.test.ts": 96, - "src/main/computer/sidecar-provider-reaping.integration.test.ts": 313, - "src/main/computer/windows-powershell-execution-policy.test.ts": 62, - "src/main/copilot/hook-service.test.ts": 164, - "src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts": 34, - "src/main/crash-reporting/crash-breadcrumb-store-leak.test.ts": 111, - "src/main/crash-reporting/crash-breadcrumb-store-orphan-cleanup.test.ts": 108, - "src/main/crash-reporting/crash-breadcrumb-store.test.ts": 166, - "src/main/crash-reporting/crash-report-copy-text.test.ts": 56, - "src/main/crash-reporting/crash-report-store.test.ts": 453, - "src/main/crash-reporting/crashpad-capture-read-limit.test.ts": 267, - "src/main/crash-reporting/crashpad-capture.test.ts": 94, - "src/main/crash-reporting/durable-crash-breadcrumb.test.ts": 71, - "src/main/crash-reporting/expected-teardown-state.test.ts": 38, - "src/main/crash-reporting/gpu-crash-diagnostics.test.ts": 49, - "src/main/crash-reporting/gpu-crash-fallback-decision.test.ts": 38, - "src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts": 40, - "src/main/crash-reporting/gpu-fallback-engagement.test.ts": 60, - "src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts": 69, - "src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts": 39, - "src/main/crash-reporting/main-process-lifecycle-identity.test.ts": 68, - "src/main/crash-reporting/minidump-crash-signature.test.ts": 59, - "src/main/crash-reporting/minidump-file-source.test.ts": 200, - "src/main/crash-reporting/pre-gone-host-memory.test.ts": 96, - "src/main/crash-reporting/process-gone-classification.test.ts": 51, - "src/main/crash-reporting/process-gone-dedupe.test.ts": 33, - "src/main/crash-reporting/process-gone-diagnostics.test.ts": 114, - "src/main/crash-reporting/process-gone-killed-one-ordering.test.ts": 108, - "src/main/crash-reporting/process-gone-recorder.test.ts": 967, - "src/main/crash-reporting/process-gone-sibling-correlation.test.ts": 686, - "src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts": 48, - "src/main/crash-reporting/self-initiated-tree-kill-log.test.ts": 296, - "src/main/crash-reporting/suppressed-process-gone-breadcrumb.test.ts": 24, - "src/main/cursor/hook-service.test.ts": 281, - "src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts": 2349, - "src/main/daemon/bash-prompt-command-composition.test.ts": 2955, - "src/main/daemon/client.test.ts": 523, - "src/main/daemon/cold-restore-payload-cache.test.ts": 34, - "src/main/daemon/daemon-adoption-telemetry-event.test.ts": 269, - "src/main/daemon/daemon-audit-eligibility-event.test.ts": 807, - "src/main/daemon/daemon-authenticated-client-activity.test.ts": 408, - "src/main/daemon/daemon-background-transient-facts.test.ts": 89, - "src/main/daemon/daemon-bundle-staleness.test.ts": 461, - "src/main/daemon/daemon-cgroup-scope.test.ts": 87, - "src/main/daemon/daemon-checkpoint-session-queue.test.ts": 243, - "src/main/daemon/daemon-client-notify-settlement.test.ts": 66, - "src/main/daemon/daemon-client-rpc-request.test.ts": 97, - "src/main/daemon/daemon-client-stream-keepalive.test.ts": 43, - "src/main/daemon/daemon-durable-checkpoint-live-rebase.test.ts": 1035, - "src/main/daemon/daemon-endpoint-ownership.test.ts": 525, - "src/main/daemon/daemon-endpoint-publish.test.ts": 114, - "src/main/daemon/daemon-endpoint-windows.test.ts": 50, - "src/main/daemon/daemon-entry-path-layouts.test.ts": 353, - "src/main/daemon/daemon-entry.test.ts": 517, - "src/main/daemon/daemon-errors.test.ts": 159, - "src/main/daemon/daemon-file-log.test.ts": 63, - "src/main/daemon/daemon-folder-access-mismatch.test.ts": 241, - "src/main/daemon/daemon-folder-access-probe-script.test.ts": 198, - "src/main/daemon/daemon-folder-access-probe.test.ts": 68, - "src/main/daemon/daemon-folder-access-reset.test.ts": 215, - "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts": 50, - "src/main/daemon/daemon-health-endpoint-entry.test.ts": 78, - "src/main/daemon/daemon-health-socket-cleanup.test.ts": 83, - "src/main/daemon/daemon-health.test.ts": 3556, - "src/main/daemon/daemon-host-relocation.test.ts": 486, - "src/main/daemon/daemon-idle-shutdown.test.ts": 1312, - "src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts": 52, - "src/main/daemon/daemon-incarnation-evidence.test.ts": 62, - "src/main/daemon/daemon-init-child-readiness.test.ts": 488, - "src/main/daemon/daemon-init-child-startup-failure.test.ts": 630, - "src/main/daemon/daemon-init-endpoint-adoption.test.ts": 1191, - "src/main/daemon/daemon-init-live-session-preservation.test.ts": 667, - "src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts": 303, - "src/main/daemon/daemon-init-provider-installation.test.ts": 493, - "src/main/daemon/daemon-init-replacement-reporting.test.ts": 568, - "src/main/daemon/daemon-init-restart-sequence.test.ts": 865, - "src/main/daemon/daemon-init-wedged-daemon-grace.test.ts": 452, - "src/main/daemon/daemon-launched-child-identity.test.ts": 119, - "src/main/daemon/daemon-launched-child-spawn.test.ts": 64, - "src/main/daemon/daemon-lifecycle-event.test.ts": 196, - "src/main/daemon/daemon-mac-code-identity.test.ts": 76, - "src/main/daemon/daemon-main.test.ts": 421, - "src/main/daemon/daemon-native-pty-exception.test.ts": 39, - "src/main/daemon/daemon-pid-file-parse.test.ts": 41, - "src/main/daemon/daemon-preflight-client-replacement.test.ts": 704, - "src/main/daemon/daemon-process-identity-time-zone.test.ts": 90, - "src/main/daemon/daemon-process-inspection.test.ts": 63, - "src/main/daemon/daemon-protocol-version.test.ts": 34, - "src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts": 1246, - "src/main/daemon/daemon-pty-adapter-cold-restore-seed.test.ts": 1188, - "src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts": 887, - "src/main/daemon/daemon-pty-adapter-daemon-recovery.test.ts": 1496, - "src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts": 2334, - "src/main/daemon/daemon-pty-adapter-history-recovery.test.ts": 1236, - "src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts": 593, - "src/main/daemon/daemon-pty-adapter-live-rebased-checkpoint.test.ts": 1713, - "src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts": 2069, - "src/main/daemon/daemon-pty-adapter-replacement-exit-race.test.ts": 767, - "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts": 1235, - "src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts": 468, - "src/main/daemon/daemon-pty-adapter.test.ts": 4255, - "src/main/daemon/daemon-pty-router-history-handoff.test.ts": 1329, - "src/main/daemon/daemon-pty-router.test.ts": 658, - "src/main/daemon/daemon-pty-startup-delivery.test.ts": 2827, - "src/main/daemon/daemon-pty-upgrade-adoption.test.ts": 679, - "src/main/daemon/daemon-pty-write-settlement-recovery.test.ts": 410, - "src/main/daemon/daemon-ready-identity.test.ts": 55, - "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts": 10993, - "src/main/daemon/daemon-respawn-throttle.test.ts": 58, - "src/main/daemon/daemon-restore-scrollback-depth.test.ts": 3415, - "src/main/daemon/daemon-scope-death-watch.test.ts": 70, - "src/main/daemon/daemon-self-retirement-respawn.test.ts": 786, - "src/main/daemon/daemon-server-async-spawn-cancellation.test.ts": 1078, - "src/main/daemon/daemon-server-attach-only.test.ts": 466, - "src/main/daemon/daemon-server-attachment-lifecycle.test.ts": 631, - "src/main/daemon/daemon-server-error-handling.test.ts": 457, - "src/main/daemon/daemon-server-kill-attribution.test.ts": 256, - "src/main/daemon/daemon-server.test.ts": 2072, - "src/main/daemon/daemon-session-owner-resolution.test.ts": 60, - "src/main/daemon/daemon-session-scrollback-window.test.ts": 408, - "src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts": 667, - "src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts": 2293, - "src/main/daemon/daemon-spawner.test.ts": 472, - "src/main/daemon/daemon-stream-backpressure-socket.test.ts": 305, - "src/main/daemon/daemon-stream-backpressure.test.ts": 42, - "src/main/daemon/daemon-stream-data-batcher.test.ts": 196, - "src/main/daemon/daemon-stream-data-split.test.ts": 1339, - "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts": 485, - "src/main/daemon/daemon-stream-droppable-membership.test.ts": 127, - "src/main/daemon/daemon-stream-producer-stall-watchdog.test.ts": 74, - "src/main/daemon/daemon-tcc-attribution-main-thread.test.ts": 91, - "src/main/daemon/daemon-tcc-attribution.test.ts": 75, - "src/main/daemon/daemon-transport-attachment-release.test.ts": 791, - "src/main/daemon/degraded-daemon-pty-provider.test.ts": 151, - "src/main/daemon/hangul-cell-width-agreement.test.ts": 91, - "src/main/daemon/headless-emulator-fidelity.fuzz.test.ts": 18873, - "src/main/daemon/headless-emulator-restored-osc-links.test.ts": 124, - "src/main/daemon/headless-emulator-shrink-snapshot.test.ts": 177, - "src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts": 53, - "src/main/daemon/headless-emulator-unicode-width.test.ts": 108, - "src/main/daemon/headless-emulator-wide-char-repaint.test.ts": 1505, - "src/main/daemon/headless-emulator-wide-char-snapshot.test.ts": 265, - "src/main/daemon/headless-emulator.test.ts": 232, - "src/main/daemon/headless-osc-link-ranges.test.ts": 141, - "src/main/daemon/hibernation-cold-restore-repro.test.ts": 202, - "src/main/daemon/history-manager-disabled-sessions-leak.test.ts": 150, - "src/main/daemon/history-manager.test.ts": 437, - "src/main/daemon/history-reader-memory.test.ts": 366, - "src/main/daemon/history-reader.test.ts": 306, - "src/main/daemon/issue-6814-daemon-failure-classification.test.ts": 6483, - "src/main/daemon/macos-login-session-death-watch.test.ts": 93, - "src/main/daemon/ndjson.test.ts": 180, - "src/main/daemon/node-pty-error-hints.test.ts": 46, - "src/main/daemon/node-pty-windows-input-error.win32.test.ts": 37, - "src/main/daemon/osc7-file-uri.test.ts": 48, - "src/main/daemon/osc7-uri-extraction.test.ts": 34, - "src/main/daemon/post-ready-flush-gate.test.ts": 43, - "src/main/daemon/process-boundary-ground.test.ts": 256, - "src/main/daemon/process-boundary-host-focus.test.ts": 314, - "src/main/daemon/pty-session-id.test.ts": 54, - "src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts": 277, - "src/main/daemon/pty-subprocess-env-inheritance.test.ts": 312, - "src/main/daemon/pty-subprocess-env-retention.test.ts": 248, - "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts": 235, - "src/main/daemon/pty-subprocess-foreground-identity.test.ts": 589, - "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts": 336, - "src/main/daemon/pty-subprocess-git-credential-guard.test.ts": 249, - "src/main/daemon/pty-subprocess-handle-lifecycle.test.ts": 413, - "src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts": 10411, - "src/main/daemon/pty-subprocess-io-failure-native.test.ts": 2523, - "src/main/daemon/pty-subprocess-managed-agent-env.test.ts": 337, - "src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts": 440, - "src/main/daemon/pty-subprocess-windows-shell-launch.test.ts": 310, - "src/main/daemon/pty-subprocess-wsl-launch.test.ts": 370, - "src/main/daemon/pty-subprocess.test.ts": 269, - "src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts": 83, - "src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts": 102, - "src/main/daemon/reattach-snapshot.test.ts": 878, - "src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts": 156, - "src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts": 122, - "src/main/daemon/serialize-addon-edge-cases.test.ts": 71, - "src/main/daemon/serialize-grid-transcript-replay.test.ts": 14365, - "src/main/daemon/serialize-grid.differential.fuzz.test.ts": 582, - "src/main/daemon/session-ingest-throughput.bench.test.ts": 191, - "src/main/daemon/session-pending-output.test.ts": 426, - "src/main/daemon/session-producer-pause.test.ts": 33, - "src/main/daemon/session-shell-recovery.test.ts": 338, - "src/main/daemon/session-terminal-control.test.ts": 245, - "src/main/daemon/session.test.ts": 626, - "src/main/daemon/shell-ready-bash-wrapper.test.ts": 337, - "src/main/daemon/slow-daemon-session-verification.test.ts": 14043, - "src/main/daemon/startup-device-attributes-responder.test.ts": 38, - "src/main/daemon/terminal-attach-cancellation.test.ts": 24, - "src/main/daemon/terminal-checkpoint-serializer.test.ts": 127, - "src/main/daemon/terminal-checkpoint-writer-bounds.test.ts": 237, - "src/main/daemon/terminal-cursor-line-context.test.ts": 124, - "src/main/daemon/terminal-descendant-shutdown.test.ts": 97, - "src/main/daemon/terminal-history-incremental-restore.test.ts": 505, - "src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts": 7048, - "src/main/daemon/terminal-history-log-encode-allocation.test.ts": 96, - "src/main/daemon/terminal-history-log.test.ts": 54, - "src/main/daemon/terminal-history-permissions.test.ts": 202, - "src/main/daemon/terminal-history-restorable-retention.test.ts": 34, - "src/main/daemon/terminal-history-seed-chunks.test.ts": 64, - "src/main/daemon/terminal-history-seed-segments.test.ts": 78, - "src/main/daemon/terminal-history-seed-transfer-registry.test.ts": 62, - "src/main/daemon/terminal-history-session-tombstone-retry.test.ts": 68, - "src/main/daemon/terminal-host-agent-session.test.ts": 227, - "src/main/daemon/terminal-host-attach-only.test.ts": 297, - "src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts": 196, - "src/main/daemon/terminal-host-concurrent-create.test.ts": 240, - "src/main/daemon/terminal-host-cwd-readability.test.ts": 261, - "src/main/daemon/terminal-host-non-agent-foreground.test.ts": 189, - "src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts": 163, - "src/main/daemon/terminal-host-process-inspection.test.ts": 236, - "src/main/daemon/terminal-host-pty-owner-backend.test.ts": 324, - "src/main/daemon/terminal-host-readiness-reporting.test.ts": 317, - "src/main/daemon/terminal-host-session-reaping-leak.test.ts": 460, - "src/main/daemon/terminal-host-spawn-input-retention.test.ts": 543, - "src/main/daemon/terminal-host-startup.test.ts": 299, - "src/main/daemon/terminal-host-teardown-recreate.test.ts": 484, - "src/main/daemon/terminal-host-wsl-context.test.ts": 249, - "src/main/daemon/terminal-host.test.ts": 691, - "src/main/daemon/terminal-session-teardown.test.ts": 95, - "src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts": 215, - "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 56, - "src/main/daemon/terminal-shell-recovery-barrier.test.ts": 748, - "src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts": 61, - "src/main/daemon/terminal-snapshot-color-parity.test.ts": 147, - "src/main/daemon/terminal-snapshot-osc8-roundtrip.test.ts": 102, - "src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts": 286, - "src/main/daemon/windows-conpty-warmup.test.ts": 113, - "src/main/daemon/wsl-cold-restore-cwd.test.ts": 54, - "src/main/destination-serialized-local-rename.test.ts": 3857, - "src/main/devin/hook-config-json.test.ts": 89, - "src/main/devin/hook-service.test.ts": 138, - "src/main/diagnostics/main-thread-churn-probe.test.ts": 63, - "src/main/dock/unread-badge.test.ts": 59, - "src/main/droid/hook-service.test.ts": 53, - "src/main/durable-file-write-syscall-proof.test.ts": 72, - "src/main/durable-file-write.test.ts": 167, - "src/main/emulator/android/adb-devices.test.ts": 54, - "src/main/emulator/android/android-app-control.test.ts": 53, - "src/main/emulator/android/android-capability-operations.test.ts": 82, - "src/main/emulator/android/android-device-inventory.test.ts": 48, - "src/main/emulator/android/android-input-commands.test.ts": 47, + "src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts": 110, + "src/main/codex/sta-4737-unreadable-is-not-absent.test.ts": 161, + "src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts": 193, + "src/main/codex/windows-hook-command.test.ts": 119, + "src/main/codex/windows-hook-upgrade.test.ts": 269, + "src/main/codex/wsl-codex-session-bridge.test.ts": 125, + "src/main/command-code/hook-service.test.ts": 255, + "src/main/computer/computer-action-verification-normalization.test.ts": 38, + "src/main/computer/computer-provider-lifecycle.test.ts": 152, + "src/main/computer/computer-provider-unavailable-message.test.ts": 45, + "src/main/computer/computer-sidecar-diagnostics.test.ts": 53, + "src/main/computer/desktop-script-provider-action-errors.test.ts": 149, + "src/main/computer/desktop-script-provider-actions.test.ts": 136, + "src/main/computer/desktop-script-provider-bridge.test.ts": 50, + "src/main/computer/desktop-script-provider-cache-lifecycle.test.ts": 163, + "src/main/computer/desktop-script-provider-cache.test.ts": 148, + "src/main/computer/desktop-script-provider-client.test.ts": 216, + "src/main/computer/desktop-script-provider-errors.test.ts": 290, + "src/main/computer/desktop-script-provider-paste-validation.test.ts": 123, + "src/main/computer/desktop-script-provider-runtime-host-routing.test.ts": 231, + "src/main/computer/desktop-script-runtime-host.test.ts": 248, + "src/main/computer/desktop-script-runtime-host.win32.test.ts": 80, + "src/main/computer/desktop-script-serve-channel.test.ts": 112, + "src/main/computer/macos-computer-use-permission-status.test.ts": 101, + "src/main/computer/macos-computer-use-permissions.test.ts": 115, + "src/main/computer/macos-native-provider-client.test.ts": 1179, + "src/main/computer/macos-native-provider-line-buffer.test.ts": 63, + "src/main/computer/macos-native-provider-paste-validation.test.ts": 166, + "src/main/computer/macos-native-provider-process-reaping.test.ts": 103, + "src/main/computer/macos-native-provider-reaping.integration.test.ts": 4160, + "src/main/computer/macos-native-provider-socket.test.ts": 60, + "src/main/computer/macos-native-provider-startup-cleanup.test.ts": 82, + "src/main/computer/sidecar-client.test.ts": 130, + "src/main/computer/sidecar-provider-reaping.integration.test.ts": 335, + "src/main/computer/windows-powershell-execution-policy.test.ts": 54, + "src/main/copilot/hook-service.test.ts": 112, + "src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts": 61, + "src/main/crash-reporting/crash-breadcrumb-store-leak.test.ts": 123, + "src/main/crash-reporting/crash-breadcrumb-store-orphan-cleanup.test.ts": 96, + "src/main/crash-reporting/crash-breadcrumb-store.test.ts": 156, + "src/main/crash-reporting/crash-report-copy-text.test.ts": 58, + "src/main/crash-reporting/crash-report-store.test.ts": 455, + "src/main/crash-reporting/crashpad-capture-read-limit.test.ts": 274, + "src/main/crash-reporting/crashpad-capture.test.ts": 102, + "src/main/crash-reporting/durable-crash-breadcrumb.test.ts": 67, + "src/main/crash-reporting/expected-teardown-state.test.ts": 57, + "src/main/crash-reporting/gpu-crash-diagnostics.test.ts": 63, + "src/main/crash-reporting/gpu-crash-fallback-decision.test.ts": 49, + "src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts": 48, + "src/main/crash-reporting/gpu-fallback-engagement.test.ts": 78, + "src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts": 53, + "src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts": 55, + "src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts": 61, + "src/main/crash-reporting/main-process-lifecycle-identity.test.ts": 51, + "src/main/crash-reporting/minidump-crash-signature.test.ts": 88, + "src/main/crash-reporting/minidump-file-source.test.ts": 248, + "src/main/crash-reporting/pre-gone-host-memory.test.ts": 114, + "src/main/crash-reporting/process-gone-classification.test.ts": 56, + "src/main/crash-reporting/process-gone-dedupe.test.ts": 52, + "src/main/crash-reporting/process-gone-diagnostics.test.ts": 112, + "src/main/crash-reporting/process-gone-killed-one-ordering.test.ts": 172, + "src/main/crash-reporting/process-gone-recorder.test.ts": 767, + "src/main/crash-reporting/process-gone-sibling-correlation.test.ts": 643, + "src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts": 66, + "src/main/crash-reporting/self-initiated-tree-kill-log.test.ts": 126, + "src/main/crash-reporting/suppressed-process-gone-breadcrumb.test.ts": 49, + "src/main/credential-file-protection.test.ts": 44, + "src/main/cursor/hook-command.live-shell.test.ts": 562, + "src/main/cursor/hook-service.test.ts": 264, + "src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts": 2622, + "src/main/daemon/bash-prompt-command-composition.test.ts": 3412, + "src/main/daemon/client.test.ts": 426, + "src/main/daemon/cold-restore-payload-cache.test.ts": 58, + "src/main/daemon/daemon-adoption-telemetry-event.test.ts": 399, + "src/main/daemon/daemon-audit-eligibility-event.test.ts": 962, + "src/main/daemon/daemon-authenticated-client-activity.test.ts": 666, + "src/main/daemon/daemon-background-transient-facts.test.ts": 97, + "src/main/daemon/daemon-bundle-staleness.test.ts": 425, + "src/main/daemon/daemon-cgroup-scope.test.ts": 105, + "src/main/daemon/daemon-checkpoint-session-queue.test.ts": 227, + "src/main/daemon/daemon-client-frame-validation.test.ts": 552, + "src/main/daemon/daemon-client-notify-settlement.test.ts": 52, + "src/main/daemon/daemon-client-reader-handoff.test.ts": 61, + "src/main/daemon/daemon-client-rpc-request.test.ts": 80, + "src/main/daemon/daemon-client-stream-keepalive.test.ts": 53, + "src/main/daemon/daemon-color-query-reply-colors.test.ts": 857, + "src/main/daemon/daemon-durable-checkpoint-live-rebase.test.ts": 990, + "src/main/daemon/daemon-endpoint-ownership.test.ts": 648, + "src/main/daemon/daemon-endpoint-publish.test.ts": 219, + "src/main/daemon/daemon-endpoint-windows.test.ts": 61, + "src/main/daemon/daemon-entry-path-layouts.test.ts": 546, + "src/main/daemon/daemon-entry.test.ts": 811, + "src/main/daemon/daemon-errors.test.ts": 293, + "src/main/daemon/daemon-file-log.test.ts": 83, + "src/main/daemon/daemon-folder-access-mismatch.test.ts": 300, + "src/main/daemon/daemon-folder-access-probe-script.test.ts": 194, + "src/main/daemon/daemon-folder-access-probe.test.ts": 66, + "src/main/daemon/daemon-folder-access-reset.test.ts": 296, + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts": 60, + "src/main/daemon/daemon-health-endpoint-entry.test.ts": 121, + "src/main/daemon/daemon-health-socket-cleanup.test.ts": 124, + "src/main/daemon/daemon-health.test.ts": 3789, + "src/main/daemon/daemon-host-current-version-reclaim.test.ts": 200, + "src/main/daemon/daemon-host-entry-layout.test.ts": 175, + "src/main/daemon/daemon-host-file-hash-cache.test.ts": 164, + "src/main/daemon/daemon-host-process-package.test.ts": 315, + "src/main/daemon/daemon-host-profile-isolation.test.ts": 158, + "src/main/daemon/daemon-host-relocation.test.ts": 333, + "src/main/daemon/daemon-idle-inventory-budget.test.ts": 651, + "src/main/daemon/daemon-idle-shutdown.test.ts": 9146, + "src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts": 127, + "src/main/daemon/daemon-incarnation-evidence.test.ts": 130, + "src/main/daemon/daemon-init-child-readiness.test.ts": 674, + "src/main/daemon/daemon-init-child-startup-failure.test.ts": 1005, + "src/main/daemon/daemon-init-endpoint-adoption.test.ts": 1604, + "src/main/daemon/daemon-init-live-session-preservation.test.ts": 961, + "src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts": 539, + "src/main/daemon/daemon-init-provider-installation.test.ts": 509, + "src/main/daemon/daemon-init-replacement-reporting.test.ts": 896, + "src/main/daemon/daemon-init-restart-sequence.test.ts": 1523, + "src/main/daemon/daemon-init-wedged-daemon-grace.test.ts": 801, + "src/main/daemon/daemon-launched-child-identity.test.ts": 82, + "src/main/daemon/daemon-launched-child-spawn.test.ts": 65, + "src/main/daemon/daemon-lifecycle-event.test.ts": 312, + "src/main/daemon/daemon-mac-code-identity.test.ts": 70, + "src/main/daemon/daemon-main.test.ts": 781, + "src/main/daemon/daemon-native-pty-exception.test.ts": 62, + "src/main/daemon/daemon-out-of-process-launcher-host-prune.test.ts": 218, + "src/main/daemon/daemon-pid-file-parse.test.ts": 44, + "src/main/daemon/daemon-preflight-client-replacement.test.ts": 927, + "src/main/daemon/daemon-process-identity-time-zone.test.ts": 57, + "src/main/daemon/daemon-process-inspection.test.ts": 95, + "src/main/daemon/daemon-provider-census.test.ts": 120, + "src/main/daemon/daemon-provider-routing.test.ts": 331, + "src/main/daemon/daemon-pty-adapter-closing-session-inventory.test.ts": 822, + "src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts": 1311, + "src/main/daemon/daemon-pty-adapter-cold-restore-seed.test.ts": 1180, + "src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts": 1076, + "src/main/daemon/daemon-pty-adapter-daemon-recovery.test.ts": 1414, + "src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts": 2079, + "src/main/daemon/daemon-pty-adapter-history-recovery.test.ts": 1304, + "src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts": 827, + "src/main/daemon/daemon-pty-adapter-live-rebased-checkpoint.test.ts": 1578, + "src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts": 1037, + "src/main/daemon/daemon-pty-adapter-replacement-exit-race.test.ts": 870, + "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts": 1432, + "src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts": 649, + "src/main/daemon/daemon-pty-adapter.test.ts": 1757, + "src/main/daemon/daemon-pty-router-history-handoff.test.ts": 1347, + "src/main/daemon/daemon-pty-router.test.ts": 485, + "src/main/daemon/daemon-pty-startup-delivery.test.ts": 3028, + "src/main/daemon/daemon-pty-upgrade-adoption.test.ts": 836, + "src/main/daemon/daemon-pty-write-settlement-recovery.test.ts": 684, + "src/main/daemon/daemon-ready-identity.test.ts": 56, + "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts": 949, + "src/main/daemon/daemon-respawn-throttle.test.ts": 106, + "src/main/daemon/daemon-restore-scrollback-depth.test.ts": 2335, + "src/main/daemon/daemon-router-retirement.test.ts": 67, + "src/main/daemon/daemon-scope-death-watch.test.ts": 89, + "src/main/daemon/daemon-self-retirement-respawn.test.ts": 1065, + "src/main/daemon/daemon-server-async-spawn-cancellation.test.ts": 1280, + "src/main/daemon/daemon-server-attach-only.test.ts": 738, + "src/main/daemon/daemon-server-attachment-lifecycle.test.ts": 919, + "src/main/daemon/daemon-server-error-handling.test.ts": 818, + "src/main/daemon/daemon-server-kill-attribution.test.ts": 693, + "src/main/daemon/daemon-server.test.ts": 1370, + "src/main/daemon/daemon-session-owner-resolution.test.ts": 89, + "src/main/daemon/daemon-session-scrollback-window.test.ts": 477, + "src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts": 856, + "src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts": 2550, + "src/main/daemon/daemon-socket-endpoint-path.test.ts": 428, + "src/main/daemon/daemon-spawner.test.ts": 728, + "src/main/daemon/daemon-stream-backpressure-socket.test.ts": 454, + "src/main/daemon/daemon-stream-backpressure.test.ts": 71, + "src/main/daemon/daemon-stream-binary-framing.test.ts": 69, + "src/main/daemon/daemon-stream-binary-negotiation.test.ts": 550, + "src/main/daemon/daemon-stream-binary-unicode.test.ts": 60, + "src/main/daemon/daemon-stream-data-batcher.test.ts": 235, + "src/main/daemon/daemon-stream-data-split.test.ts": 700, + "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts": 644, + "src/main/daemon/daemon-stream-droppable-membership.test.ts": 131, + "src/main/daemon/daemon-stream-framing-replacement.test.ts": 58, + "src/main/daemon/daemon-stream-producer-stall-watchdog.test.ts": 126, + "src/main/daemon/daemon-tcc-attribution-main-thread.test.ts": 132, + "src/main/daemon/daemon-tcc-attribution.test.ts": 101, + "src/main/daemon/daemon-transport-attachment-release.test.ts": 790, + "src/main/daemon/daemon-user-manager-lifetime.test.ts": 77, + "src/main/daemon/degraded-daemon-pty-provider.test.ts": 126, + "src/main/daemon/hangul-cell-width-agreement.test.ts": 100, + "src/main/daemon/headless-emulator-fidelity.fuzz.test.ts": 2924, + "src/main/daemon/headless-emulator-restored-osc-links.test.ts": 161, + "src/main/daemon/headless-emulator-shrink-snapshot.test.ts": 175, + "src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts": 101, + "src/main/daemon/headless-emulator-unicode-width.test.ts": 141, + "src/main/daemon/headless-emulator-wide-char-repaint.test.ts": 1711, + "src/main/daemon/headless-emulator-wide-char-snapshot.test.ts": 273, + "src/main/daemon/headless-emulator.test.ts": 341, + "src/main/daemon/headless-osc-link-ranges.test.ts": 185, + "src/main/daemon/hibernation-cold-restore-repro.test.ts": 203, + "src/main/daemon/history-manager-disabled-sessions-leak.test.ts": 173, + "src/main/daemon/history-manager.test.ts": 371, + "src/main/daemon/history-reader-memory.test.ts": 440, + "src/main/daemon/history-reader.test.ts": 242, + "src/main/daemon/issue-6814-daemon-failure-classification.test.ts": 835, + "src/main/daemon/macos-login-session-death-watch.test.ts": 89, + "src/main/daemon/ndjson.test.ts": 135, + "src/main/daemon/node-pty-error-hints.test.ts": 50, + "src/main/daemon/node-pty-windows-input-error.win32.test.ts": 52, + "src/main/daemon/osc7-file-uri.test.ts": 49, + "src/main/daemon/osc7-uri-extraction.test.ts": 49, + "src/main/daemon/post-ready-flush-gate.test.ts": 67, + "src/main/daemon/process-boundary-ground.test.ts": 230, + "src/main/daemon/process-boundary-host-focus.test.ts": 296, + "src/main/daemon/pty-session-id.test.ts": 68, + "src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts": 454, + "src/main/daemon/pty-subprocess-env-inheritance.test.ts": 436, + "src/main/daemon/pty-subprocess-env-retention.test.ts": 361, + "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts": 528, + "src/main/daemon/pty-subprocess-foreground-identity.test.ts": 719, + "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts": 428, + "src/main/daemon/pty-subprocess-git-credential-guard.test.ts": 439, + "src/main/daemon/pty-subprocess-handle-lifecycle.test.ts": 525, + "src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts": 1307, + "src/main/daemon/pty-subprocess-io-failure-native.test.ts": 2779, + "src/main/daemon/pty-subprocess-managed-agent-env.test.ts": 559, + "src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts": 479, + "src/main/daemon/pty-subprocess-windows-shell-launch.test.ts": 434, + "src/main/daemon/pty-subprocess-wsl-launch.test.ts": 482, + "src/main/daemon/pty-subprocess.test.ts": 441, + "src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts": 87, + "src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts": 148, + "src/main/daemon/pty-subprocess/spawn-preflight-health-probe.test.ts": 91, + "src/main/daemon/reattach-snapshot.test.ts": 889, + "src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts": 284, + "src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts": 163, + "src/main/daemon/serialize-addon-edge-cases.test.ts": 104, + "src/main/daemon/serialize-grid-cell-descriptors.test.ts": 78, + "src/main/daemon/serialize-grid-transcript-replay.test.ts": 24288, + "src/main/daemon/serialize-grid.differential.fuzz.test.ts": 559, + "src/main/daemon/session-ingest-throughput.bench.test.ts": 203, + "src/main/daemon/session-pending-output.test.ts": 350, + "src/main/daemon/session-producer-pause.test.ts": 59, + "src/main/daemon/session-reset-input-modes.test.ts": 243, + "src/main/daemon/session-shell-recovery.test.ts": 332, + "src/main/daemon/session-terminal-control.test.ts": 275, + "src/main/daemon/session.test.ts": 359, + "src/main/daemon/shell-ready-bash-wrapper.test.ts": 429, + "src/main/daemon/slow-daemon-session-verification.test.ts": 14993, + "src/main/daemon/startup-device-attributes-responder.test.ts": 57, + "src/main/daemon/terminal-attach-cancellation.test.ts": 40, + "src/main/daemon/terminal-checkpoint-serializer.test.ts": 255, + "src/main/daemon/terminal-checkpoint-writer-bounds.test.ts": 310, + "src/main/daemon/terminal-cursor-line-context.test.ts": 146, + "src/main/daemon/terminal-descendant-shutdown.test.ts": 124, + "src/main/daemon/terminal-history-incremental-restore.test.ts": 443, + "src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts": 1922, + "src/main/daemon/terminal-history-log-encode-allocation.test.ts": 87, + "src/main/daemon/terminal-history-log.test.ts": 60, + "src/main/daemon/terminal-history-permissions.test.ts": 251, + "src/main/daemon/terminal-history-restorable-retention.test.ts": 55, + "src/main/daemon/terminal-history-seed-chunks.test.ts": 76, + "src/main/daemon/terminal-history-seed-segments.test.ts": 143, + "src/main/daemon/terminal-history-seed-transfer-registry.test.ts": 49, + "src/main/daemon/terminal-history-session-tombstone-retry.test.ts": 81, + "src/main/daemon/terminal-host-agent-session.test.ts": 391, + "src/main/daemon/terminal-host-attach-only.test.ts": 359, + "src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts": 237, + "src/main/daemon/terminal-host-closing-session-listing.test.ts": 644, + "src/main/daemon/terminal-host-concurrent-create.test.ts": 397, + "src/main/daemon/terminal-host-cwd-readability.test.ts": 422, + "src/main/daemon/terminal-host-non-agent-foreground.test.ts": 179, + "src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts": 239, + "src/main/daemon/terminal-host-process-inspection.test.ts": 360, + "src/main/daemon/terminal-host-pty-owner-backend.test.ts": 366, + "src/main/daemon/terminal-host-readiness-reporting.test.ts": 447, + "src/main/daemon/terminal-host-session-reaping-leak.test.ts": 411, + "src/main/daemon/terminal-host-spawn-input-retention.test.ts": 641, + "src/main/daemon/terminal-host-startup-staging.test.ts": 355, + "src/main/daemon/terminal-host-startup.test.ts": 374, + "src/main/daemon/terminal-host-teardown-recreate.test.ts": 501, + "src/main/daemon/terminal-host-wsl-context.test.ts": 380, + "src/main/daemon/terminal-host.test.ts": 605, + "src/main/daemon/terminal-session-teardown.test.ts": 77, + "src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts": 256, + "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 69, + "src/main/daemon/terminal-shell-recovery-barrier.test.ts": 1248, + "src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts": 72, + "src/main/daemon/terminal-snapshot-color-parity.test.ts": 136, + "src/main/daemon/terminal-snapshot-mouse-encoding.test.ts": 139, + "src/main/daemon/terminal-snapshot-osc8-roundtrip.test.ts": 130, + "src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts": 222, + "src/main/daemon/windows-conpty-warmup.test.ts": 56, + "src/main/daemon/wsl-cold-restore-cwd.test.ts": 47, + "src/main/destination-serialized-local-rename.test.ts": 2512, + "src/main/devin/hook-config-json.test.ts": 86, + "src/main/devin/hook-service.test.ts": 131, + "src/main/diagnostics/main-thread-churn-probe.test.ts": 69, + "src/main/dock/unread-badge.test.ts": 69, + "src/main/droid/hook-service.test.ts": 89, + "src/main/dsh/dsh-home-patch.test.ts": 45, + "src/main/dsh/hook-service.test.ts": 106, + "src/main/durable-file-write-syscall-proof.test.ts": 67, + "src/main/durable-file-write.test.ts": 139, + "src/main/emulator/android/adb-devices.test.ts": 65, + "src/main/emulator/android/android-app-control.test.ts": 48, + "src/main/emulator/android/android-capability-operations.test.ts": 100, + "src/main/emulator/android/android-device-inventory.test.ts": 66, + "src/main/emulator/android/android-input-commands.test.ts": 49, "src/main/emulator/android/android-input-mapping.test.ts": 63, - "src/main/emulator/android/android-logcat.test.ts": 35, - "src/main/emulator/android/android-permissions.test.ts": 32, - "src/main/emulator/android/android-sdk-discovery.test.ts": 58, - "src/main/emulator/android/avd-manager.test.ts": 51, - "src/main/emulator/android/scrcpy-server-deploy.test.ts": 31, - "src/main/emulator/android/scrcpy-stream-session.test.ts": 2989, - "src/main/emulator/android/scrcpy-video-frame-parser.test.ts": 238, - "src/main/emulator/android/uiautomator-tree.test.ts": 33, - "src/main/emulator/backends/android-emulator-backend.test.ts": 176, - "src/main/emulator/backends/ios-emulator-backend.test.ts": 162, - "src/main/emulator/emulator-availability.test.ts": 52, - "src/main/emulator/emulator-bridge.test.ts": 417, - "src/main/emulator/emulator-gesture-sender.test.ts": 194, - "src/main/emulator/emulator-session-registry.test.ts": 38, - "src/main/emulator/emulator-start-lease-registry.test.ts": 116, - "src/main/emulator/mjpeg-frame-parser.test.ts": 50, - "src/main/emulator/mjpeg-frame-stream.test.ts": 143, - "src/main/emulator/scrcpy-video-registry.test.ts": 43, - "src/main/emulator/serve-sim-accessibility-tree.test.ts": 134, - "src/main/emulator/serve-sim-ax-normalization.test.ts": 50, - "src/main/emulator/serve-sim-detached-session.test.ts": 32, - "src/main/emulator/serve-sim-execution.test.ts": 100, - "src/main/emulator/serve-sim-helper-processes.test.ts": 64, - "src/main/emulator/serve-sim-runtime-materializer.test.ts": 98, - "src/main/emulator/simctl-simulator-devices.test.ts": 61, - "src/main/ephemeral-vm-recipe-runner.test.ts": 516, - "src/main/ephemeral-vm-resume-integrity.test.ts": 163, - "src/main/ephemeral-vm-runtime-service.test.ts": 944, - "src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts": 133, - "src/main/external-editor-launch.test.ts": 58, - "src/main/fish-history-session.test.ts": 91, - "src/main/folder-upgrade-worktree-path.test.ts": 45, - "src/main/gemini/hook-service.test.ts": 59, - "src/main/ghostty/discovery.test.ts": 79, - "src/main/ghostty/index.test.ts": 89, - "src/main/ghostty/mapper-extended.test.ts": 56, - "src/main/ghostty/mapper.test.ts": 74, - "src/main/ghostty/parser.test.ts": 40, - "src/main/ghostty/theme-import.test.ts": 100, - "src/main/ghostty/theme-resolution.test.ts": 75, - "src/main/git-bash.test.ts": 49, - "src/main/git/add-sparse-worktree.test.ts": 166, - "src/main/git/admission-tier-plumbing.test.ts": 38, - "src/main/git/branch-rename.test.ts": 50, - "src/main/git/canonical-repo-key.test.ts": 112, - "src/main/git/check-ignored-paths.test.ts": 45, - "src/main/git/coalesced-probe.test.ts": 54, - "src/main/git/command-runner/gh-exec-file-deadline.test.ts": 135, - "src/main/git/command-runner/gh-spawn-boundary.test.ts": 802, - "src/main/git/command-runner/git-admission-output-parity.test.ts": 246, - "src/main/git/command-runner/git-admission-span.test.ts": 324, - "src/main/git/command-runner/git-admission-storm-measurement.test.ts": 26034, - "src/main/git/command-runner/git-command-timeout-behavior.test.ts": 1860, - "src/main/git/command-runner/git-command-timeout.test.ts": 55, - "src/main/git/command-runner/git-exec-admission-lifetime.test.ts": 485, - "src/main/git/command-runner/git-operation-executor.test.ts": 86, - "src/main/git/command-runner/git-spawn-admission-lifetime.test.ts": 108, - "src/main/git/command-runner/git-stream-admission-lifetime.test.ts": 224, - "src/main/git/command-runner/git-subprocess-admission.test.ts": 364, - "src/main/git/command-runner/hosted-cli-deadline-log.test.ts": 103, - "src/main/git/command-runner/spawned-command-tree-kill.test.ts": 43, - "src/main/git/command-runner/wsl-command-resolution-windows-binary.test.ts": 95, - "src/main/git/command-runner/wsl-host-failure.test.ts": 155, + "src/main/emulator/android/android-logcat.test.ts": 54, + "src/main/emulator/android/android-permissions.test.ts": 49, + "src/main/emulator/android/android-sdk-discovery.test.ts": 61, + "src/main/emulator/android/avd-manager.test.ts": 61, + "src/main/emulator/android/scrcpy-server-deploy.test.ts": 53, + "src/main/emulator/android/scrcpy-stream-session.test.ts": 419, + "src/main/emulator/android/scrcpy-video-frame-parser.test.ts": 162, + "src/main/emulator/android/uiautomator-tree.test.ts": 63, + "src/main/emulator/backends/android-emulator-backend.test.ts": 220, + "src/main/emulator/backends/ios-emulator-backend.test.ts": 74, + "src/main/emulator/emulator-availability.test.ts": 57, + "src/main/emulator/emulator-bridge.test.ts": 335, + "src/main/emulator/emulator-gesture-sender.test.ts": 191, + "src/main/emulator/emulator-session-registry.test.ts": 62, + "src/main/emulator/emulator-start-lease-registry.test.ts": 111, + "src/main/emulator/mjpeg-frame-parser.test.ts": 52, + "src/main/emulator/mjpeg-frame-stream.test.ts": 92, + "src/main/emulator/scrcpy-video-registry.test.ts": 51, + "src/main/emulator/serve-sim-accessibility-tree.test.ts": 74, + "src/main/emulator/serve-sim-ax-normalization.test.ts": 62, + "src/main/emulator/serve-sim-detached-session.test.ts": 72, + "src/main/emulator/serve-sim-execution.test.ts": 90, + "src/main/emulator/serve-sim-helper-processes.test.ts": 58, + "src/main/emulator/serve-sim-runtime-materializer.test.ts": 168, + "src/main/emulator/simctl-simulator-devices.test.ts": 66, + "src/main/ephemeral-vm-recipe-runner.test.ts": 625, + "src/main/ephemeral-vm-resume-integrity.test.ts": 271, + "src/main/ephemeral-vm-runtime-service.test.ts": 834, + "src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts": 272, + "src/main/execution-host-workspace-trust.test.ts": 164, + "src/main/external-editor-launch.test.ts": 121, + "src/main/fish-history-session.test.ts": 88, + "src/main/fish-xdg-data-dirs-handoff.test.ts": 66, + "src/main/fish-xdg-data-dirs-no-config-launch.test.ts": 301, + "src/main/folder-upgrade-worktree-path.test.ts": 57, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts": 768, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts": 245, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts": 258, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.test.ts": 53, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts": 2385, + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.test.ts": 49, + "src/main/foreign-sqlite-readers/readers/cursor-profile.test.ts": 517, + "src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts": 131, + "src/main/gemini/hook-service.test.ts": 87, + "src/main/ghostty/discovery.test.ts": 78, + "src/main/ghostty/index.test.ts": 118, + "src/main/ghostty/mapper-extended.test.ts": 82, + "src/main/ghostty/mapper.test.ts": 85, + "src/main/ghostty/parser.test.ts": 55, + "src/main/ghostty/theme-import.test.ts": 108, + "src/main/ghostty/theme-resolution.test.ts": 71, + "src/main/git-bash.test.ts": 61, + "src/main/git/add-sparse-worktree.test.ts": 219, + "src/main/git/admission-tier-plumbing.test.ts": 61, + "src/main/git/base-ref-search-selector.test.ts": 200, + "src/main/git/branch-rename.test.ts": 67, + "src/main/git/canonical-repo-key.test.ts": 174, + "src/main/git/check-ignored-paths.test.ts": 60, + "src/main/git/coalesced-probe.test.ts": 61, + "src/main/git/command-runner/gh-exec-file-deadline.test.ts": 151, + "src/main/git/command-runner/git-admission-output-parity.test.ts": 196, + "src/main/git/command-runner/git-admission-span.test.ts": 323, + "src/main/git/command-runner/git-admission-storm-measurement.test.ts": 940, + "src/main/git/command-runner/git-command-timeout-behavior.test.ts": 1957, + "src/main/git/command-runner/git-command-timeout.test.ts": 56, + "src/main/git/command-runner/git-exec-admission-exempt.test.ts": 124, + "src/main/git/command-runner/git-exec-admission-lifetime.test.ts": 665, + "src/main/git/command-runner/git-operation-executor.test.ts": 72, + "src/main/git/command-runner/git-spawn-admission-lifetime.test.ts": 170, + "src/main/git/command-runner/git-stream-admission-lifetime.test.ts": 263, + "src/main/git/command-runner/git-subprocess-admission.test.ts": 373, + "src/main/git/command-runner/hosted-cli-deadline-log.test.ts": 145, + "src/main/git/command-runner/spawned-command-tree-kill.test.ts": 74, + "src/main/git/command-runner/wsl-command-resolution-windows-binary.test.ts": 75, + "src/main/git/command-runner/wsl-host-failure.test.ts": 186, "src/main/git/commit-object-ref.test.ts": 46, - "src/main/git/commit.test.ts": 129, - "src/main/git/exact-ref-probe.test.ts": 45, - "src/main/git/fetch-error-classification.test.ts": 43, - "src/main/git/fork-remote-refspec.test.ts": 50, - "src/main/git/fork-remote-stale-branch-refspec.test.ts": 54, - "src/main/git/gh-rate-limit-breaker.test.ts": 80, - "src/main/git/git-capability-state.test.ts": 51, - "src/main/git/git-status-read-lease-owner.test.ts": 33, - "src/main/git/git-upstream-status-read-owner.test.ts": 40, - "src/main/git/git-username.test.ts": 173, - "src/main/git/hosted-remote-url.test.ts": 44, - "src/main/git/huge-folder-ignore.test.ts": 59, - "src/main/git/local-repo-ref-maintenance.test.ts": 298, - "src/main/git/pack-refs-lock-ownership.test.ts": 80, - "src/main/git/porcelain-v1-records.test.ts": 197, - "src/main/git/remote-name-listing.test.ts": 59, - "src/main/git/remote-ref-probe-cache.test.ts": 46, - "src/main/git/remote-url-probe.test.ts": 73, - "src/main/git/remote.test.ts": 564, - "src/main/git/remove-worktree-branch-cleanup.test.ts": 163, - "src/main/git/remove-worktree-clean-preflight.test.ts": 125, - "src/main/git/remove-worktree.test.ts": 159, - "src/main/git/repo-branch-conflict-batched-probe.test.ts": 73, - "src/main/git/repo-branch-conflict-real-git.test.ts": 351, - "src/main/git/repo-branch-conflict.test.ts": 214, - "src/main/git/repo-clone-path.test.ts": 50, - "src/main/git/repo-default-base-timeout.test.ts": 66, - "src/main/git/repo-default-remote.test.ts": 73, - "src/main/git/repo-detection.test.ts": 697, - "src/main/git/repo-ref-maintenance-real-git.test.ts": 1124, - "src/main/git/repo-remote-drift-real.test.ts": 320, - "src/main/git/repo-remote-drift.test.ts": 190, - "src/main/git/repo-search-ref-compat.test.ts": 93, - "src/main/git/repo-username.test.ts": 190, - "src/main/git/repo.test.ts": 2242, - "src/main/git/runner-command-exec.test.ts": 708, - "src/main/git/runner-gh-account-binding.test.ts": 199, - "src/main/git/runner-gh-host-args.test.ts": 184, - "src/main/git/runner-gh-rate-limit-breaker.test.ts": 204, - "src/main/git/runner-windows-host-environment.test.ts": 285, - "src/main/git/runner-wsl-direct-read.test.ts": 1893, - "src/main/git/runner-wsl-gh-fallback.test.ts": 924, - "src/main/git/runner-wsl-linked-gitdir-timeout.test.ts": 175, - "src/main/git/runner-wsl-linked-gitdir-windows.test.ts": 292, - "src/main/git/runner-wsl-login-shell-capture.test.ts": 125, - "src/main/git/runner-wsl-read-routing.test.ts": 169, - "src/main/git/runner.test.ts": 120, - "src/main/git/settled-diff-cache-bounds.test.ts": 66, - "src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts": 218, - "src/main/git/source-control/resolve-git-dir.test.ts": 42, - "src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts": 171, - "src/main/git/status-branch-compare-real-ref.test.ts": 229, - "src/main/git/status-branch-compare.test.ts": 157, - "src/main/git/status-branch-line-total-exec-contract.test.ts": 1673, - "src/main/git/status-branch-line-total-real-git.test.ts": 1107, - "src/main/git/status-branch-line-total-relay-parity.test.ts": 590, - "src/main/git/status-conflict-operations.test.ts": 216, - "src/main/git/status-conflict-overlap.bench.test.ts": 272, - "src/main/git/status-cquoted-paths.test.ts": 237, - "src/main/git/status-diff-settled-cache.test.ts": 426, - "src/main/git/status-diff.test.ts": 235, - "src/main/git/status-discard-and-bulk-staging.test.ts": 300, - "src/main/git/status-discard-symlink.test.ts": 661, - "src/main/git/status-line-stats-host-paths.test.ts": 78, - "src/main/git/status-pathspec-literals.test.ts": 464, - "src/main/git/status-porcelain-parser.test.ts": 47, - "src/main/git/status-read-coalescing.test.ts": 223, - "src/main/git/status-shared-symlinks.test.ts": 803, - "src/main/git/status-submodule-path-cache.test.ts": 895, - "src/main/git/status-submodule.test.ts": 231, - "src/main/git/status-symlink-probe-budget.test.ts": 145, - "src/main/git/status-upstream-negative-cache.test.ts": 1303, - "src/main/git/status-upstream-probe-churn.test.ts": 169, + "src/main/git/commit.test.ts": 231, + "src/main/git/create-event-repo-probe.test.ts": 73, + "src/main/git/exact-ref-probe.test.ts": 65, + "src/main/git/fetch-error-classification.test.ts": 53, + "src/main/git/fork-remote-refspec.test.ts": 64, + "src/main/git/fork-remote-stale-branch-refspec.test.ts": 55, + "src/main/git/gh-rate-limit-breaker.test.ts": 70, + "src/main/git/git-capability-state.test.ts": 43, + "src/main/git/git-network-safety-real-git.test.ts": 642, + "src/main/git/git-status-read-lease-owner.test.ts": 68, + "src/main/git/git-upstream-status-read-owner.test.ts": 52, + "src/main/git/git-username.test.ts": 211, + "src/main/git/hosted-remote-url.test.ts": 48, + "src/main/git/huge-folder-ignore.test.ts": 63, + "src/main/git/local-repo-ref-maintenance-probe-retry.test.ts": 244, + "src/main/git/local-repo-ref-maintenance.test.ts": 339, + "src/main/git/pack-refs-lock-ownership.test.ts": 77, + "src/main/git/porcelain-v1-records.test.ts": 132, + "src/main/git/remote-name-listing.test.ts": 307, + "src/main/git/remote-ref-probe-cache.test.ts": 342, + "src/main/git/remote-url-probe.test.ts": 376, + "src/main/git/remote.test.ts": 345, + "src/main/git/remove-worktree-branch-cleanup.test.ts": 337, + "src/main/git/remove-worktree-clean-preflight.test.ts": 188, + "src/main/git/remove-worktree.test.ts": 240, + "src/main/git/repo-branch-conflict-batched-probe.test.ts": 83, + "src/main/git/repo-branch-conflict-real-git.test.ts": 238, + "src/main/git/repo-branch-conflict.test.ts": 235, + "src/main/git/repo-clone-path.test.ts": 63, + "src/main/git/repo-default-base-timeout.test.ts": 108, + "src/main/git/repo-default-remote.test.ts": 134, + "src/main/git/repo-detection-async.test.ts": 507, + "src/main/git/repo-detection-batching.test.ts": 351, + "src/main/git/repo-detection-command.test.ts": 42, + "src/main/git/repo-detection-failure-parity.test.ts": 158, + "src/main/git/repo-detection.test.ts": 501, + "src/main/git/repo-maintenance-admission.test.ts": 647, + "src/main/git/repo-pack-index-maintenance.test.ts": 156, + "src/main/git/repo-pack-index-state.test.ts": 61, + "src/main/git/repo-ref-maintenance-real-git.test.ts": 3586, + "src/main/git/repo-remote-drift-real.test.ts": 266, + "src/main/git/repo-remote-drift.test.ts": 241, + "src/main/git/repo-search-ref-compat.test.ts": 129, + "src/main/git/repo-username.test.ts": 240, + "src/main/git/repo.test.ts": 1920, + "src/main/git/runner-buffer-cancellation.test.ts": 291, + "src/main/git/runner-command-exec.test.ts": 653, + "src/main/git/runner-gh-account-binding.test.ts": 214, + "src/main/git/runner-gh-host-args.test.ts": 189, + "src/main/git/runner-gh-rate-limit-breaker.test.ts": 217, + "src/main/git/runner-network-ssh-policy.test.ts": 159, + "src/main/git/runner-windows-host-environment.test.ts": 249, + "src/main/git/runner-wsl-direct-read.test.ts": 1856, + "src/main/git/runner-wsl-gh-fallback.test.ts": 1079, + "src/main/git/runner-wsl-linked-gitdir-timeout.test.ts": 195, + "src/main/git/runner-wsl-linked-gitdir-windows.test.ts": 248, + "src/main/git/runner-wsl-login-shell-capture.test.ts": 184, + "src/main/git/runner-wsl-network-ssh-policy.test.ts": 197, + "src/main/git/runner-wsl-read-routing.test.ts": 183, + "src/main/git/runner.test.ts": 212, + "src/main/git/saved-clone-probe-budget.test.ts": 81, + "src/main/git/saved-clone-target.test.ts": 612, + "src/main/git/settled-diff-cache-bounds.test.ts": 76, + "src/main/git/source-control/blob-absence-real-git.test.ts": 447, + "src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts": 192, + "src/main/git/source-control/bulk-pathspec-stdin.test.ts": 492, + "src/main/git/source-control/resolve-git-dir.test.ts": 59, + "src/main/git/source-control/stage-worktree-changes.test.ts": 1305, + "src/main/git/source-control/staging-discard-index-safety.test.ts": 941, + "src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts": 193, + "src/main/git/status-branch-compare-real-ref.test.ts": 403, + "src/main/git/status-branch-compare.test.ts": 239, + "src/main/git/status-branch-line-total-exec-contract.test.ts": 892, + "src/main/git/status-branch-line-total-real-git.test.ts": 836, + "src/main/git/status-branch-line-total-relay-parity.test.ts": 528, + "src/main/git/status-conflict-operations.test.ts": 163, + "src/main/git/status-conflict-overlap.bench.test.ts": 312, + "src/main/git/status-cquoted-paths.test.ts": 374, + "src/main/git/status-diff-settled-cache.test.ts": 322, + "src/main/git/status-diff.test.ts": 260, + "src/main/git/status-discard-and-bulk-staging.test.ts": 296, + "src/main/git/status-discard-symlink.test.ts": 544, + "src/main/git/status-line-stats-host-paths.test.ts": 77, + "src/main/git/status-pathspec-literals.test.ts": 362, + "src/main/git/status-porcelain-parser.test.ts": 78, + "src/main/git/status-read-coalescing.test.ts": 218, + "src/main/git/status-shared-symlinks.test.ts": 579, + "src/main/git/status-submodule-path-cache.test.ts": 367, + "src/main/git/status-submodule.test.ts": 264, + "src/main/git/status-symlink-probe-budget.test.ts": 179, + "src/main/git/status-upstream-negative-cache.test.ts": 718, + "src/main/git/status-upstream-probe-churn.test.ts": 280, "src/main/git/status-upstream-ref.test.ts": 42, - "src/main/git/status-wsl-pathspecs.test.ts": 132, - "src/main/git/status.test.ts": 212, - "src/main/git/upstream-deferred-fork-remote-real.test.ts": 217, - "src/main/git/upstream.test.ts": 388, - "src/main/git/worktree-add-creation-config.test.ts": 237, - "src/main/git/worktree-add-local-base-refresh.test.ts": 281, - "src/main/git/worktree-add-local-base-suggestion.test.ts": 178, - "src/main/git/worktree-add-timeout-override.test.ts": 199, - "src/main/git/worktree-base-divergence-real-git.test.ts": 1614, - "src/main/git/worktree-base-divergence.test.ts": 93, - "src/main/git/worktree-base-ref-probe.test.ts": 38, - "src/main/git/worktree-common-dir-comparison.test.ts": 177, - "src/main/git/worktree-configured-paths-concurrency.test.ts": 86, - "src/main/git/worktree-create-admission-tier.test.ts": 245, - "src/main/git/worktree-create-git-executor-real-git.test.ts": 356, - "src/main/git/worktree-create-preparation-real-git.test.ts": 1406, - "src/main/git/worktree-create-preparation-real-wsl.test.ts": 271, - "src/main/git/worktree-created-description-real-git.test.ts": 1340, - "src/main/git/worktree-created-disk-witness.test.ts": 149, - "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 40, - "src/main/git/worktree-diff-stamp-host-paths.test.ts": 55, - "src/main/git/worktree-git-capabilities.test.ts": 248, - "src/main/git/worktree-graph-listing.test.ts": 205, - "src/main/git/worktree-include-file.test.ts": 104, - "src/main/git/worktree-list-paths.test.ts": 571, - "src/main/git/worktree-list-porcelain.test.ts": 163, - "src/main/git/worktree-listing-created-sparse-distro.test.ts": 291, - "src/main/git/worktree-listing-sparse-distro.test.ts": 188, - "src/main/git/worktree-move.test.ts": 195, - "src/main/git/worktree-mutation-route-invalidation.test.ts": 145, - "src/main/git/worktree-porcelain-parsing.test.ts": 210, - "src/main/git/worktree-posix-path-case-sensitivity.test.ts": 134, - "src/main/git/worktree-preparation-base-oid.test.ts": 99, - "src/main/git/worktree-preparation-cancel-latency.bench.test.ts": 248, - "src/main/git/worktree-removal-large-tree.bench.test.ts": 291, - "src/main/git/worktree-removal-real-git.test.ts": 5000, - "src/main/git/worktree-remove-branch-deletion.test.ts": 194, - "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 241, - "src/main/git/worktree-scan-cache-sharing.test.ts": 284, - "src/main/git/worktree-separate-git-dir.test.ts": 614, - "src/main/git/worktree-shared-directories.test.ts": 1301, - "src/main/git/worktree-sparse-checkout-cache.test.ts": 234, - "src/main/git/worktree-sparse-checkout.test.ts": 675, - "src/main/git/worktree-sparse-state-host-paths.test.ts": 252, - "src/main/git/worktree-symlink-detection.test.ts": 55, - "src/main/git/wsl-direct-git-read-commands.test.ts": 54, - "src/main/git/wsl-linked-worktree-git-route-invalidation.test.ts": 81, - "src/main/git/wsl-linked-worktree-git-routing.test.ts": 105, - "src/main/git/wsl-process-group-termination.test.ts": 44, - "src/main/gitea/client-scan-invalidation.test.ts": 181, - "src/main/gitea/client.test.ts": 192, - "src/main/gitea/pull-request-creation.test.ts": 228, - "src/main/gitea/pull-request-mappers.test.ts": 56, - "src/main/gitea/repository-ref.test.ts": 71, - "src/main/github/auth-diagnose.test.ts": 51, - "src/main/github/client-create-pr.test.ts": 356, - "src/main/github/client-file-viewed.test.ts": 430, - "src/main/github/client-issue-origin-preference.test.ts": 247, - "src/main/github/client-issue-source.test.ts": 1603, - "src/main/github/client-merge-queue-auto-merge.test.ts": 342, - "src/main/github/client-merged-pr-visibility.test.ts": 343, - "src/main/github/client-pr-branch-discovery.test.ts": 352, - "src/main/github/client-pr-check-details.test.ts": 228, - "src/main/github/client-pr-checks.test.ts": 383, - "src/main/github/client-pr-comment-reactions.test.ts": 281, - "src/main/github/client-pr-conflict-summary.test.ts": 374, - "src/main/github/client-pr-fallback-number.test.ts": 347, - "src/main/github/client-pr-linked-lookup.test.ts": 310, - "src/main/github/client-pr-local-runtime.test.ts": 291, - "src/main/github/client-pr-push-target.test.ts": 222, - "src/main/github/client-pr-state.test.ts": 355, - "src/main/github/client-rate-limit-block.test.ts": 298, - "src/main/github/client-ssh-provider-execution-boundary.test.ts": 341, - "src/main/github/client-stack-merge-guard.test.ts": 422, - "src/main/github/client-starred.test.ts": 335, - "src/main/github/client-tracked-upstream-fork-owner.test.ts": 366, - "src/main/github/client-tracked-upstream-snapshot.test.ts": 618, - "src/main/github/client-work-item-check-summary.test.ts": 193, - "src/main/github/client-work-items-query-paging.test.ts": 354, - "src/main/github/client-work-items.test.ts": 235, - "src/main/github/comment-reactions.test.ts": 28, - "src/main/github/conflict-summary.test.ts": 50, - "src/main/github/default-branch-stale-pr.test.ts": 295, - "src/main/github/gh-account-token.test.ts": 66, - "src/main/github/gh-capability-state.test.ts": 37, - "src/main/github/gh-utils-concurrency.test.ts": 95, - "src/main/github/gh-utils.test.ts": 172, - "src/main/github/github-api-repository-validation.test.ts": 33, - "src/main/github/github-api-repository.test.ts": 276, - "src/main/github/github-enterprise-repository.test.ts": 144, - "src/main/github/github-pr-stack.test.ts": 79, - "src/main/github/github-remote-identity-parsing.test.ts": 40, - "src/main/github/github-repository-identity.fork-owner-repo.test.ts": 404, - "src/main/github/github-repository-identity.gh-account.test.ts": 164, - "src/main/github/github-repository-identity.signed-cache.test.ts": 171, - "src/main/github/github-repository-identity.ssh-host-alias.test.ts": 171, - "src/main/github/issues.test.ts": 109, - "src/main/github/pr-head-tracking-ref.test.ts": 74, - "src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts": 202, - "src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts": 297, - "src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts": 329, - "src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts": 302, - "src/main/github/pr-refresh-coordinator-refresh-events.test.ts": 331, - "src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts": 280, - "src/main/github/pr-refresh-error-classification.test.ts": 45, - "src/main/github/pr-refresh-queue-growth-bound.test.ts": 623, - "src/main/github/pr-refresh-validation-backoff.test.ts": 58, - "src/main/github/pr-review-comment-lines.test.ts": 36, - "src/main/github/pr-start-point-compare-base.test.ts": 76, + "src/main/git/status-wsl-pathspecs.test.ts": 185, + "src/main/git/status.test.ts": 246, + "src/main/git/upstream-deferred-fork-remote-real.test.ts": 243, + "src/main/git/upstream.test.ts": 381, + "src/main/git/worktree-add-creation-config.test.ts": 290, + "src/main/git/worktree-add-local-base-refresh.test.ts": 291, + "src/main/git/worktree-add-local-base-suggestion.test.ts": 274, + "src/main/git/worktree-add-pending-removal.test.ts": 239, + "src/main/git/worktree-add-timeout-override.test.ts": 318, + "src/main/git/worktree-base-divergence-real-git.test.ts": 781, + "src/main/git/worktree-base-divergence.test.ts": 107, + "src/main/git/worktree-base-ref-probe.test.ts": 66, + "src/main/git/worktree-base-refresh-contention.test.ts": 239, + "src/main/git/worktree-base-refresh-lock-real-git.test.ts": 1272, + "src/main/git/worktree-branch-removal-host.test.ts": 185, + "src/main/git/worktree-common-dir-comparison.test.ts": 273, + "src/main/git/worktree-configured-paths-concurrency.test.ts": 91, + "src/main/git/worktree-create-admission-tier.test.ts": 305, + "src/main/git/worktree-create-git-executor-real-git.test.ts": 477, + "src/main/git/worktree-create-preparation-real-git.test.ts": 1597, + "src/main/git/worktree-create-preparation-real-wsl.test.ts": 327, + "src/main/git/worktree-created-description-real-git.test.ts": 1158, + "src/main/git/worktree-created-disk-witness.test.ts": 218, + "src/main/git/worktree-delete-limit.test.ts": 44, + "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 62, + "src/main/git/worktree-diff-stamp-host-paths.test.ts": 60, + "src/main/git/worktree-git-capabilities.test.ts": 262, + "src/main/git/worktree-graph-listing.test.ts": 349, + "src/main/git/worktree-include-file.test.ts": 157, + "src/main/git/worktree-list-paths.test.ts": 464, + "src/main/git/worktree-list-porcelain.test.ts": 236, + "src/main/git/worktree-list-reader-cancellation.test.ts": 64, + "src/main/git/worktree-listing-created-sparse-distro.test.ts": 223, + "src/main/git/worktree-listing-sparse-cancellation.test.ts": 176, + "src/main/git/worktree-listing-sparse-distro.test.ts": 191, + "src/main/git/worktree-move.test.ts": 278, + "src/main/git/worktree-mutation-route-invalidation.test.ts": 177, + "src/main/git/worktree-porcelain-parsing.test.ts": 248, + "src/main/git/worktree-posix-path-case-sensitivity.test.ts": 169, + "src/main/git/worktree-preparation-add.test.ts": 194, + "src/main/git/worktree-preparation-base-oid.test.ts": 239, + "src/main/git/worktree-preparation-cancel-latency.bench.test.ts": 286, + "src/main/git/worktree-preparation-discard-real-git.test.ts": 466, + "src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts": 485, + "src/main/git/worktree-preparation-lock-real-git.test.ts": 922, + "src/main/git/worktree-preparation-lock.test.ts": 87, + "src/main/git/worktree-preparation-tip-refresh-real-git.test.ts": 488, + "src/main/git/worktree-preparation-tip-refresh.test.ts": 108, + "src/main/git/worktree-rebase-update-refs-real-git.test.ts": 273, + "src/main/git/worktree-removal-large-tree.bench.test.ts": 284, + "src/main/git/worktree-removal-real-git.test.ts": 834, + "src/main/git/worktree-remove-branch-deletion.test.ts": 258, + "src/main/git/worktree-safety-real-git.test.ts": 989, + "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 312, + "src/main/git/worktree-scan-cache-sharing.test.ts": 312, + "src/main/git/worktree-separate-git-dir.test.ts": 543, + "src/main/git/worktree-shared-directories.test.ts": 911, + "src/main/git/worktree-sparse-checkout-cache.test.ts": 206, + "src/main/git/worktree-sparse-checkout.test.ts": 574, + "src/main/git/worktree-sparse-state-host-paths.test.ts": 264, + "src/main/git/worktree-symlink-detection.test.ts": 61, + "src/main/git/wsl-direct-git-read-commands.test.ts": 72, + "src/main/git/wsl-linked-worktree-git-route-invalidation.test.ts": 61, + "src/main/git/wsl-linked-worktree-git-routing.test.ts": 118, + "src/main/git/wsl-process-group-termination.test.ts": 57, + "src/main/gitea/client-scan-invalidation.test.ts": 377, + "src/main/gitea/client.test.ts": 413, + "src/main/gitea/pull-request-creation.test.ts": 395, + "src/main/gitea/pull-request-mappers.test.ts": 44, + "src/main/gitea/repository-ref.test.ts": 373, + "src/main/github/auth-diagnose.test.ts": 66, + "src/main/github/client-create-pr.test.ts": 312, + "src/main/github/client-file-viewed.test.ts": 465, + "src/main/github/client-issue-origin-preference.test.ts": 405, + "src/main/github/client-issue-source.test.ts": 849, + "src/main/github/client-merge-queue-auto-merge.test.ts": 437, + "src/main/github/client-merged-pr-visibility.test.ts": 487, + "src/main/github/client-pr-branch-discovery.test.ts": 441, + "src/main/github/client-pr-check-details.test.ts": 427, + "src/main/github/client-pr-checks.test.ts": 464, + "src/main/github/client-pr-comment-reactions.test.ts": 449, + "src/main/github/client-pr-conflict-summary.test.ts": 478, + "src/main/github/client-pr-fallback-number.test.ts": 442, + "src/main/github/client-pr-linked-lookup.test.ts": 385, + "src/main/github/client-pr-local-runtime.test.ts": 511, + "src/main/github/client-pr-lookup-coalescing.test.ts": 622, + "src/main/github/client-pr-push-target.test.ts": 427, + "src/main/github/client-pr-state.test.ts": 408, + "src/main/github/client-rate-limit-block.test.ts": 377, + "src/main/github/client-ssh-provider-execution-boundary.test.ts": 441, + "src/main/github/client-stack-merge-guard.test.ts": 456, + "src/main/github/client-starred.test.ts": 340, + "src/main/github/client-tracked-upstream-fork-owner.test.ts": 438, + "src/main/github/client-tracked-upstream-snapshot.test.ts": 1192, + "src/main/github/client-work-item-check-summary.test.ts": 444, + "src/main/github/client-work-items-query-paging.test.ts": 651, + "src/main/github/client-work-items.test.ts": 579, + "src/main/github/comment-reactions.test.ts": 74, + "src/main/github/conflict-summary.test.ts": 97, + "src/main/github/default-branch-stale-pr.test.ts": 378, + "src/main/github/gh-account-token.test.ts": 60, + "src/main/github/gh-capability-state.test.ts": 48, + "src/main/github/gh-utils-concurrency.test.ts": 434, + "src/main/github/gh-utils.test.ts": 529, + "src/main/github/github-api-repository-validation.test.ts": 50, + "src/main/github/github-api-repository.test.ts": 563, + "src/main/github/github-enterprise-repository.test.ts": 403, + "src/main/github/github-pr-stack.test.ts": 376, + "src/main/github/github-remote-identity-parsing.test.ts": 58, + "src/main/github/github-repository-identity.fork-owner-repo.test.ts": 538, + "src/main/github/github-repository-identity.gh-account.test.ts": 408, + "src/main/github/github-repository-identity.signed-cache.test.ts": 372, + "src/main/github/github-repository-identity.ssh-host-alias.test.ts": 620, + "src/main/github/issue-mutation-source.test.ts": 436, + "src/main/github/issues.test.ts": 479, + "src/main/github/pr-head-tracking-ref.test.ts": 67, + "src/main/github/pr-mutation-review-invalidation.test.ts": 2411, + "src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts": 660, + "src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts": 575, + "src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts": 859, + "src/main/github/pr-refresh-coordinator-completion-order.test.ts": 602, + "src/main/github/pr-refresh-coordinator-error-cadence.test.ts": 574, + "src/main/github/pr-refresh-coordinator-foreground-pacing.test.ts": 469, + "src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts": 858, + "src/main/github/pr-refresh-coordinator-refresh-events.test.ts": 726, + "src/main/github/pr-refresh-coordinator-visibility-policy.test.ts": 1217, + "src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts": 935, + "src/main/github/pr-refresh-error-classification.test.ts": 60, + "src/main/github/pr-refresh-queue-growth-bound.test.ts": 1057, + "src/main/github/pr-refresh-validation-backoff.test.ts": 56, + "src/main/github/pr-review-comment-lines.test.ts": 39, + "src/main/github/pr-start-point-compare-base.test.ts": 64, "src/main/github/pr-start-point.test.ts": 95, - "src/main/github/project-view-host-auth.test.ts": 116, - "src/main/github/project-view.test.ts": 113, - "src/main/github/project-view/mutations.test.ts": 57, - "src/main/github/project-view/project-field-mutations.test.ts": 58, - "src/main/github/project-view/project-view-table.test.ts": 175, - "src/main/github/project-view/repository-field-options.test.ts": 44, - "src/main/github/rate-limit.test.ts": 68, - "src/main/github/review-head-remote.test.ts": 54, - "src/main/github/stacked-pr-creation.test.ts": 62, - "src/main/github/work-item-details-api-parity.test.ts": 295, - "src/main/github/work-item-details-concurrency.test.ts": 50, - "src/main/github/work-item-details-enterprise-host.test.ts": 203, - "src/main/github/work-item-details-file-viewed.test.ts": 236, - "src/main/github/work-item-details-pr-files.test.ts": 225, - "src/main/github/work-item-details.test.ts": 249, - "src/main/github/work-item-search-fallback-environment.test.ts": 208, - "src/main/github/work-item-search-freshness.test.ts": 1026, - "src/main/github/work-item-search-isolation.test.ts": 434, - "src/main/github/work-item-search-pagination.test.ts": 284, - "src/main/github/work-item-search-semantics.test.ts": 372, - "src/main/gitlab/client-mr-auth-rate-limit.test.ts": 142, - "src/main/gitlab/client-mr-branch-lookup.test.ts": 184, - "src/main/gitlab/client-mr-job-ci.test.ts": 83, - "src/main/gitlab/client-mr-listing.test.ts": 138, - "src/main/gitlab/client-mr-review-actions.test.ts": 154, - "src/main/gitlab/client-work-items.test.ts": 230, - "src/main/gitlab/client.test.ts": 260, - "src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts": 180, - "src/main/gitlab/gitlab-known-host-probe.test.ts": 71, - "src/main/gitlab/gitlab-known-host-retirement.test.ts": 235, - "src/main/gitlab/gl-utils.test.ts": 120, - "src/main/gitlab/issues.test.ts": 257, - "src/main/gitlab/mappers-workitem.test.ts": 62, - "src/main/gitlab/mappers.test.ts": 68, - "src/main/gitlab/merge-request-creation.test.ts": 70, - "src/main/gitlab/mr-head-tracking-ref.test.ts": 75, - "src/main/gitlab/project-ref-parser.test.ts": 58, - "src/main/gitlab/work-item-details.test.ts": 114, - "src/main/global-fetch-call-site-audit.test.ts": 314, - "src/main/grok-accounts/status.test.ts": 56, - "src/main/grok/grok-hook-config-file.test.ts": 123, - "src/main/grok/grok-hook-owners.test.ts": 97, - "src/main/grok/grok-hook-remnant-removal.test.ts": 116, - "src/main/grok/grok-hook-stdin-no-eof.test.ts": 472, - "src/main/grok/hook-service.test.ts": 174, - "src/main/grok/windows-grok-hook-script.test.ts": 133, - "src/main/grok/windows-hook-launcher-chain.test.ts": 97, - "src/main/hang-watchdog/hang-detection-marker.test.ts": 43, - "src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts": 61, - "src/main/hang-watchdog/hang-watchdog-worker-path.test.ts": 59, - "src/main/hang-watchdog/main-thread-hang-telemetry.test.ts": 175, - "src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts": 58, - "src/main/hang-watchdog/main-thread-hang-watchdog.test.ts": 78, - "src/main/headless-automation-dispatcher-source-boundary.test.ts": 39, - "src/main/hermes/hook-service.test.ts": 434, - "src/main/hook-archive-timeout-observation.test.ts": 1092, - "src/main/hook-termination-real-process.test.ts": 8201, - "src/main/hooks-archive-exit-observation.test.ts": 187, - "src/main/hooks-effective-hook-resolution.test.ts": 209, - "src/main/hooks-issue-command.test.ts": 340, - "src/main/hooks-orca-yaml-parsing.test.ts": 201, - "src/main/hooks-runner.test.ts": 197, - "src/main/hooks-setup-runner-script.test.ts": 219, - "src/main/hooks.test.ts": 344, - "src/main/host-tree-removal-asar.electron.test.ts": 359, - "src/main/host/deferred-secret-protection-report.test.ts": 51, - "src/main/host/electron-secret-store.test.ts": 66, - "src/main/host/secret-protection-report.test.ts": 74, - "src/main/i18n/main-i18n-lazy-locale.test.ts": 323, - "src/main/ipc/agent-hooks.test.ts": 661, - "src/main/ipc/agent-pane-authority-ownership.test.ts": 31, - "src/main/ipc/agent-pane-retirement-ipc.test.ts": 74, - "src/main/ipc/ai-vault-scan-coalescing.test.ts": 824, - "src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts": 1337, - "src/main/ipc/ai-vault-search-all-hosts.test.ts": 194, - "src/main/ipc/ai-vault-search-merged-cursor.test.ts": 135, - "src/main/ipc/ai-vault-search.test.ts": 517, - "src/main/ipc/ai-vault.test.ts": 1998, - "src/main/ipc/app.test.ts": 458, - "src/main/ipc/automations-external-scope.test.ts": 184, - "src/main/ipc/bounded-warning-dedupe.test.ts": 35, - "src/main/ipc/browser-client-page-metadata-ipc.test.ts": 144, - "src/main/ipc/browser-preview-tool-authorization.test.ts": 582, - "src/main/ipc/browser-session-profile-ipc.test.ts": 227, - "src/main/ipc/browser-tab-registration-wait.test.ts": 133, - "src/main/ipc/browser.test.ts": 824, - "src/main/ipc/cli-appimage-stale-registration.test.ts": 285, - "src/main/ipc/cli.test.ts": 162, - "src/main/ipc/codex-config-sync.test.ts": 46, - "src/main/ipc/command-path-resolver.test.ts": 39, - "src/main/ipc/computer-use-permissions.test.ts": 73, - "src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts": 100, - "src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts": 67, - "src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts": 56, - "src/main/ipc/crash-reporting.test.ts": 155, - "src/main/ipc/created-worktree-reconciliation.test.ts": 118, - "src/main/ipc/created-worktree-root-prune.test.ts": 307, - "src/main/ipc/dashboard-payload-validation.test.ts": 241, - "src/main/ipc/dashboard-popout.test.ts": 97, - "src/main/ipc/deferred-emoji-shortcode-dataset.test.ts": 94, - "src/main/ipc/desktop-renderer-runtime-capabilities.test.ts": 597, - "src/main/ipc/developer-permissions.test.ts": 132, - "src/main/ipc/diagnostics.test.ts": 78, - "src/main/ipc/doc-preview-grant-ipc.test.ts": 92, - "src/main/ipc/dropped-path-resolution.test.ts": 81, - "src/main/ipc/emulator-frame-stream-socket-lifetime.test.ts": 139, - "src/main/ipc/emulator-renderer-lifetime.test.ts": 113, - "src/main/ipc/emulator-stream-listener-cleanup.test.ts": 52, - "src/main/ipc/ephemeral-vm-provision-cancel.test.ts": 2605, - "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts": 1162, - "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts": 816, - "src/main/ipc/ephemeral-vm.test.ts": 1095, - "src/main/ipc/feedback-image-attachments.test.ts": 78, - "src/main/ipc/feedback.test.ts": 175, - "src/main/ipc/filesystem-allowed-roots.test.ts": 437, - "src/main/ipc/filesystem-auth.test.ts": 1145, - "src/main/ipc/filesystem-branch-compare-diff.test.ts": 2519, - "src/main/ipc/filesystem-commit-message-generation.test.ts": 3728, - "src/main/ipc/filesystem-commit-message-model-discovery.test.ts": 4612, - "src/main/ipc/filesystem-conflict-operation-routing.test.ts": 3748, - "src/main/ipc/filesystem-download-lifetime.test.ts": 673, - "src/main/ipc/filesystem-download-transfers.test.ts": 4793, - "src/main/ipc/filesystem-git-commit-dispatch.test.ts": 2595, - "src/main/ipc/filesystem-git-status-staging.test.ts": 3013, - "src/main/ipc/filesystem-import-local-rollback.test.ts": 86, - "src/main/ipc/filesystem-import-ssh-ops.test.ts": 545, - "src/main/ipc/filesystem-import-ssh-path-safety.test.ts": 94, - "src/main/ipc/filesystem-import-ssh.test.ts": 490, - "src/main/ipc/filesystem-import.test.ts": 6057, - "src/main/ipc/filesystem-list-files-handler-name-filter.test.ts": 3031, - "src/main/ipc/filesystem-list-files-name-filter.test.ts": 314, - "src/main/ipc/filesystem-list-files.test.ts": 320, - "src/main/ipc/filesystem-markdown-document-listing.test.ts": 3120, - "src/main/ipc/filesystem-mutations-runtime-upload.test.ts": 2064, - "src/main/ipc/filesystem-mutations.test.ts": 3581, - "src/main/ipc/filesystem-path-containment-remote-enoent.test.ts": 32, - "src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts": 218, - "src/main/ipc/filesystem-pull-request-field-generation.test.ts": 3228, - "src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts": 2896, - "src/main/ipc/filesystem-runtime-upload-staging.test.ts": 140, - "src/main/ipc/filesystem-search-file-paths.test.ts": 426, - "src/main/ipc/filesystem-search-rg-timeout.test.ts": 1022, - "src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts": 435, - "src/main/ipc/filesystem-watcher-document-lifetime.test.ts": 309, - "src/main/ipc/filesystem-watcher-dormant-rearm.test.ts": 188, - "src/main/ipc/filesystem-watcher-event-batch.test.ts": 38, - "src/main/ipc/filesystem-watcher-ignore.test.ts": 53, - "src/main/ipc/filesystem-watcher-large-batch.test.ts": 423, - "src/main/ipc/filesystem-watcher-local-events.test.ts": 482, - "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts": 1128, - "src/main/ipc/filesystem-watcher-native-capacity.test.ts": 198, - "src/main/ipc/filesystem-watcher-real.test.ts": 699, - "src/main/ipc/filesystem-watcher-remote-batch.test.ts": 127, - "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts": 457, - "src/main/ipc/filesystem-watcher-remote-capacity.test.ts": 188, - "src/main/ipc/filesystem-watcher-remote-rearm.test.ts": 133, - "src/main/ipc/filesystem-watcher-removal-deadline.test.ts": 397, - "src/main/ipc/filesystem-watcher-terminal-resync.test.ts": 353, - "src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts": 125, - "src/main/ipc/filesystem-watcher-wsl.test.ts": 90, - "src/main/ipc/filesystem-watcher.test.ts": 2230, - "src/main/ipc/filesystem.test.ts": 2871, - "src/main/ipc/floating-workspace-directory.test.ts": 82, - "src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts": 103, - "src/main/ipc/folder-repo-git-upgrade.test.ts": 2578, - "src/main/ipc/git-status-upstream-ref-watch-request.test.ts": 58, - "src/main/ipc/github-ipc-channel-parity.test.ts": 396, - "src/main/ipc/github-issue-source-preference.test.ts": 273, - "src/main/ipc/github-pr-refresh-routing.test.ts": 206, - "src/main/ipc/github-repo-access-guards.test.ts": 398, - "src/main/ipc/github-ssh-connection-routing.test.ts": 340, - "src/main/ipc/github-star-telemetry.test.ts": 319, - "src/main/ipc/github-work-item-args.test.ts": 36, - "src/main/ipc/github-wsl-runtime-routing.test.ts": 360, - "src/main/ipc/gitlab-repo-access.test.ts": 84, - "src/main/ipc/gitlab.test.ts": 122, - "src/main/ipc/hooks/worktree-hook-execution-host.test.ts": 516, - "src/main/ipc/hosted-review.test.ts": 419, - "src/main/ipc/jira-cancellable-requests.test.ts": 55, - "src/main/ipc/keybindings.test.ts": 46, - "src/main/ipc/linear.test.ts": 72, - "src/main/ipc/local-log-tail-lifetime.test.ts": 78, - "src/main/ipc/local-log-tail.test.ts": 63, - "src/main/ipc/local-network-connection-test.test.ts": 221, - "src/main/ipc/local-worktree-runtime-options.test.ts": 46, - "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts": 46, - "src/main/ipc/macos-keyboard-layout-snapshot.test.ts": 50, - "src/main/ipc/markdown-documents.test.ts": 34, - "src/main/ipc/minimax-credentials.test.ts": 94, - "src/main/ipc/mobile.test.ts": 15914, - "src/main/ipc/native-chat-renderer-lifetime.test.ts": 57, - "src/main/ipc/native-chat-renderer-watcher-cleanup.test.ts": 327, - "src/main/ipc/native-chat-subscribe-lifecycle.test.ts": 209, - "src/main/ipc/native-chat.test.ts": 1810, - "src/main/ipc/notebook.test.ts": 102, - "src/main/ipc/notifications-custom-sound.test.ts": 167, - "src/main/ipc/notifications-delivery-gating.test.ts": 226, - "src/main/ipc/notifications-message-formatting.test.ts": 81, - "src/main/ipc/notifications-mobile-fanout.test.ts": 141, - "src/main/ipc/notifications-permission-onboarding.test.ts": 216, - "src/main/ipc/notifications-retention-lifecycle.test.ts": 378, - "src/main/ipc/notifications-subject-retirement.test.ts": 56, - "src/main/ipc/orca-profile-auth-handlers.test.ts": 461, - "src/main/ipc/orca-profile-org-members-handlers.test.ts": 57, - "src/main/ipc/orca-profiles-switch-persistence.test.ts": 1121, - "src/main/ipc/orca-profiles.test.ts": 419, - "src/main/ipc/parcel-watcher-child-launch.test.ts": 73, - "src/main/ipc/parcel-watcher-child-registry.test.ts": 91, - "src/main/ipc/parcel-watcher-crash-fuse.test.ts": 42, - "src/main/ipc/parcel-watcher-disconnect-termination.test.ts": 97, - "src/main/ipc/parcel-watcher-entry-path.test.ts": 36, - "src/main/ipc/parcel-watcher-event-cancellation.test.ts": 61, - "src/main/ipc/parcel-watcher-event-delivery.test.ts": 37, - "src/main/ipc/parcel-watcher-module-loader.test.ts": 39, - "src/main/ipc/parcel-watcher-process-entry.test.ts": 234, - "src/main/ipc/parcel-watcher-process.test.ts": 678, - "src/main/ipc/parcel-watcher-root-path-rewrite.test.ts": 88, - "src/main/ipc/parcel-watcher-shallow-subscription.test.ts": 54, - "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts": 254, - "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts": 244, - "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts": 113, - "src/main/ipc/pet-bundle.test.ts": 60, - "src/main/ipc/pet.test.ts": 252, - "src/main/ipc/plugin-marketplaces.test.ts": 227, - "src/main/ipc/plugins.test.ts": 258, - "src/main/ipc/preflight-agent-detection-no-subprocess.test.ts": 249, - "src/main/ipc/preflight-agent-detection.test.ts": 212, - "src/main/ipc/preflight-agent-refresh.test.ts": 190, - "src/main/ipc/preflight-command-exec.test.ts": 59, - "src/main/ipc/preflight-host-cli-status.test.ts": 311, - "src/main/ipc/preflight-remote-ssh.test.ts": 137, - "src/main/ipc/preflight-windows-path-refresh.repro.test.ts": 69, - "src/main/ipc/preflight-wsl-agent-detection.test.ts": 151, - "src/main/ipc/preflight-wsl-command.test.ts": 43, - "src/main/ipc/pty-activation-inventory-scope.test.ts": 1650, - "src/main/ipc/pty-applied-size-reporting.test.ts": 1519, - "src/main/ipc/pty-buffer-snapshot-dispatch.test.ts": 1738, - "src/main/ipc/pty-codex-account-attribution.test.ts": 1477, - "src/main/ipc/pty-controller-owner-recovery.test.ts": 1749, - "src/main/ipc/pty-controller-ownership-routing.test.ts": 1531, - "src/main/ipc/pty-controller-process-inventory.test.ts": 1860, - "src/main/ipc/pty-controller-spawn-admission.test.ts": 3580, - "src/main/ipc/pty-cumulative-ack-accounting.test.ts": 1219, - "src/main/ipc/pty-daemon-controller-teardown.test.ts": 2798, - "src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts": 2463, - "src/main/ipc/pty-daemon-spawn-codex-auth.test.ts": 1802, - "src/main/ipc/pty-daemon-spawn-session-identity.test.ts": 2434, - "src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts": 2656, - "src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts": 2110, - "src/main/ipc/pty-dead-owner-respawn.test.ts": 1289, - "src/main/ipc/pty-delivery-health-heal.test.ts": 1942, - "src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts": 1892, - "src/main/ipc/pty-encoding.test.ts": 26, - "src/main/ipc/pty-global-renderer-credit.test.ts": 1376, - "src/main/ipc/pty-hidden-at-spawn-mark.test.ts": 3652, - "src/main/ipc/pty-hidden-delivery-gate.test.ts": 57, - "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts": 3043, - "src/main/ipc/pty-ipc-producer-flow-control.test.ts": 1520, - "src/main/ipc/pty-listener-teardown-and-orphans.test.ts": 3968, - "src/main/ipc/pty-login-shell-startup-commands.test.ts": 1723, - "src/main/ipc/pty-management.test.ts": 102, - "src/main/ipc/pty-output-batching-drain.test.ts": 1696, - "src/main/ipc/pty-output-drain-rounds.test.ts": 1804, - "src/main/ipc/pty-pane-claim-arbitration.test.ts": 4553, - "src/main/ipc/pty-pane-materialization-race.test.ts": 2447, - "src/main/ipc/pty-pane-reservation-settlement.test.ts": 1450, - "src/main/ipc/pty-pane-restart-replace.test.ts": 1575, - "src/main/ipc/pty-pending-data-drain-queue-differential.test.ts": 2040, - "src/main/ipc/pty-pending-data-drain-queue.test.ts": 59, - "src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts": 99, - "src/main/ipc/pty-pending-output-cap.test.ts": 1823, - "src/main/ipc/pty-pending-projection-admissions.test.ts": 95, - "src/main/ipc/pty-persisted-incarnation-repair.test.ts": 940, - "src/main/ipc/pty-producer-flow-control.test.ts": 44, - "src/main/ipc/pty-renderer-inflight-credit.test.ts": 1965, - "src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts": 1732, - "src/main/ipc/pty-renderer-liveness-guard.test.ts": 1889, - "src/main/ipc/pty-renderer-send-failure-recovery.test.ts": 1697, - "src/main/ipc/pty-restore-record-seeding.test.ts": 4503, - "src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts": 1001, - "src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts": 2902, - "src/main/ipc/pty-runtime-kill-and-exit.test.ts": 1637, - "src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts": 1369, - "src/main/ipc/pty-serializer-settlement-mapping.test.ts": 4239, - "src/main/ipc/pty-session-liveness-and-ownership.test.ts": 1832, - "src/main/ipc/pty-spawn-codex-home-unavailable.test.ts": 880, - "src/main/ipc/pty-spawn-cwd-fallback.test.ts": 2294, - "src/main/ipc/pty-spawn-env-agent-overlays.test.ts": 1925, - "src/main/ipc/pty-spawn-env-codex-home-routing.test.ts": 1578, - "src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts": 1188, - "src/main/ipc/pty-spawn-env-terminal-basics.test.ts": 2674, - "src/main/ipc/pty-spawn-pi-owner-env.test.ts": 2317, - "src/main/ipc/pty-spawn-runtime-handle-binding.test.ts": 2425, - "src/main/ipc/pty-spawn-timing.test.ts": 74, - "src/main/ipc/pty-ssh-stop-verdict.test.ts": 1688, - "src/main/ipc/pty-ssh-undelivered-kill.test.ts": 1930, - "src/main/ipc/pty-startup-barrier-and-listing.test.ts": 1269, - "src/main/ipc/pty-startup-barrier-ordering.test.ts": 32, - "src/main/ipc/pty-startup-swap-window-presence.test.ts": 1724, - "src/main/ipc/pty-windows-shell-selection.test.ts": 1559, - "src/main/ipc/pty-write-ipc-validation.test.ts": 1475, - "src/main/ipc/pty-wsl-cwd-validation.test.ts": 1804, - "src/main/ipc/pty/configured-terminal-shell-args.test.ts": 52, - "src/main/ipc/pty/daemon-late-exit.test.ts": 3152, - "src/main/ipc/pty/delivery/attached-pty-size.test.ts": 36, - "src/main/ipc/pty/host-env/omp-launch-environment.test.ts": 84, - "src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts": 1617, - "src/main/ipc/pty/ipc/spawn-options-terminal-shell-args.test.ts": 672, - "src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts": 811, - "src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts": 67, - "src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts": 814, - "src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts": 66, - "src/main/ipc/pty/pane/launch-authority.test.ts": 82, - "src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts": 2925, - "src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts": 692, - "src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts": 127, + "src/main/github/project-view-host-auth.test.ts": 102, + "src/main/github/project-view.test.ts": 482, + "src/main/github/project-view/mutations.test.ts": 63, + "src/main/github/project-view/project-field-mutations.test.ts": 51, + "src/main/github/project-view/project-view-config.test.ts": 427, + "src/main/github/project-view/project-view-table.test.ts": 461, + "src/main/github/project-view/repository-field-options.test.ts": 51, + "src/main/github/rate-limit.test.ts": 88, + "src/main/github/review-head-remote.test.ts": 60, + "src/main/github/stacked-pr-creation.test.ts": 437, + "src/main/github/work-item-details-concurrency.test.ts": 71, + "src/main/github/work-item-details-config-change.test.ts": 426, + "src/main/github/work-item-details-enterprise-host.test.ts": 450, + "src/main/github/work-item-details-file-viewed.test.ts": 468, + "src/main/github/work-item-details-issue-source.test.ts": 72, + "src/main/github/work-item-details-pr-files.test.ts": 418, + "src/main/github/work-item-details.test.ts": 472, + "src/main/github/work-item-search-fallback-environment.test.ts": 603, + "src/main/github/work-item-search-freshness.test.ts": 1299, + "src/main/github/work-item-search-isolation.test.ts": 679, + "src/main/github/work-item-search-pagination.test.ts": 501, + "src/main/github/work-item-search-semantics.test.ts": 638, + "src/main/gitlab/client-mr-auth-rate-limit.test.ts": 411, + "src/main/gitlab/client-mr-branch-lookup.test.ts": 473, + "src/main/gitlab/client-mr-job-ci.test.ts": 408, + "src/main/gitlab/client-mr-listing.test.ts": 409, + "src/main/gitlab/client-mr-review-actions.test.ts": 387, + "src/main/gitlab/client-work-items.test.ts": 451, + "src/main/gitlab/client.test.ts": 456, + "src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts": 375, + "src/main/gitlab/gitlab-known-host-probe.test.ts": 383, + "src/main/gitlab/gitlab-known-host-retirement.test.ts": 728, + "src/main/gitlab/gl-utils.test.ts": 452, + "src/main/gitlab/issues.test.ts": 490, + "src/main/gitlab/mappers-workitem.test.ts": 45, + "src/main/gitlab/mappers.test.ts": 71, + "src/main/gitlab/merge-request-creation.test.ts": 446, + "src/main/gitlab/mr-head-tracking-ref.test.ts": 61, + "src/main/gitlab/project-ref-parser.test.ts": 60, + "src/main/gitlab/work-item-details.test.ts": 100, + "src/main/grok-accounts/status.test.ts": 69, + "src/main/grok/grok-hook-config-file.test.ts": 153, + "src/main/grok/grok-hook-owners.test.ts": 61, + "src/main/grok/grok-hook-remnant-removal.test.ts": 89, + "src/main/grok/grok-hook-stdin-no-eof.test.ts": 465, + "src/main/grok/hook-service.test.ts": 154, + "src/main/grok/windows-grok-hook-script.test.ts": 87, + "src/main/grok/windows-hook-launcher-chain.test.ts": 104, + "src/main/hang-watchdog/hang-detection-marker.test.ts": 71, + "src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts": 64, + "src/main/hang-watchdog/hang-watchdog-worker-path.test.ts": 53, + "src/main/hang-watchdog/main-thread-hang-telemetry.test.ts": 299, + "src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts": 107, + "src/main/hang-watchdog/main-thread-hang-watchdog.test.ts": 134, + "src/main/hang-watchdog/previous-hang-detection.test.ts": 118, + "src/main/hermes/hermes-config-comment-boundaries.test.ts": 513, + "src/main/hermes/hermes-config-document.test.ts": 197, + "src/main/hermes/hermes-config-flow-insertion.test.ts": 417, + "src/main/hermes/hermes-config-preservation.test.ts": 197, + "src/main/hermes/hermes-config-remote-adapter.test.ts": 186, + "src/main/hermes/hermes-config-source-edits.test.ts": 275, + "src/main/hermes/hook-service.test.ts": 307, + "src/main/hook-archive-timeout-observation.test.ts": 1099, + "src/main/hook-termination-real-process.test.ts": 8247, + "src/main/hooks-archive-exit-observation.test.ts": 277, + "src/main/hooks-effective-hook-resolution.test.ts": 471, + "src/main/hooks-issue-command.test.ts": 500, + "src/main/hooks-orca-yaml-parsing.test.ts": 485, + "src/main/hooks-runner.test.ts": 274, + "src/main/hooks-setup-runner-script.test.ts": 238, + "src/main/hooks.test.ts": 493, + "src/main/host-tree-removal-asar.electron.test.ts": 259, + "src/main/host/deferred-secret-protection-report.test.ts": 99, + "src/main/host/electron-secret-store.test.ts": 91, + "src/main/host/secret-protection-report.test.ts": 79, + "src/main/host/structured-attention-localization.test.ts": 333, + "src/main/i18n/main-i18n-lazy-locale.test.ts": 278, + "src/main/ipc/agent-hooks.test.ts": 733, + "src/main/ipc/agent-pane-authority-ownership.test.ts": 56, + "src/main/ipc/agent-pane-retirement-ipc.test.ts": 62, + "src/main/ipc/agent-session-attachment-upload.test.ts": 7519, + "src/main/ipc/ai-vault-host-leg-cache-retention.test.ts": 553, + "src/main/ipc/ai-vault-scan-coalescing.test.ts": 1267, + "src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts": 1776, + "src/main/ipc/ai-vault-search-all-hosts.test.ts": 260, + "src/main/ipc/ai-vault-search-merged-cursor.test.ts": 223, + "src/main/ipc/ai-vault-search.test.ts": 1049, + "src/main/ipc/ai-vault-unresolved-owner.test.ts": 589, + "src/main/ipc/ai-vault.test.ts": 847, + "src/main/ipc/app.test.ts": 585, + "src/main/ipc/automations-external-scope.test.ts": 481, + "src/main/ipc/bounded-warning-dedupe.test.ts": 45, + "src/main/ipc/browser-client-page-metadata-ipc.test.ts": 222, + "src/main/ipc/browser-preview-tool-authorization.test.ts": 669, + "src/main/ipc/browser-session-profile-ipc.test.ts": 342, + "src/main/ipc/browser-tab-registration-wait.test.ts": 259, + "src/main/ipc/browser.test.ts": 731, + "src/main/ipc/cli-appimage-stale-registration.test.ts": 272, + "src/main/ipc/cli.test.ts": 145, + "src/main/ipc/codex-config-sync.test.ts": 86, + "src/main/ipc/command-path-resolver.test.ts": 84, + "src/main/ipc/computer-use-permissions.test.ts": 62, + "src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts": 201, + "src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts": 95, + "src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts": 86, + "src/main/ipc/crash-reporting.test.ts": 194, + "src/main/ipc/created-worktree-reconciliation.test.ts": 142, + "src/main/ipc/created-worktree-root-prune.test.ts": 404, + "src/main/ipc/credential-change-rate-limit-refresh.test.ts": 129, + "src/main/ipc/dashboard-payload-validation.test.ts": 200, + "src/main/ipc/dashboard-popout.test.ts": 134, + "src/main/ipc/deferred-emoji-shortcode-dataset.test.ts": 105, + "src/main/ipc/developer-permissions.test.ts": 262, + "src/main/ipc/diagnostics.test.ts": 104, + "src/main/ipc/doc-preview-grant-ipc.test.ts": 77, + "src/main/ipc/dropped-path-resolution.test.ts": 74, + "src/main/ipc/emulator-frame-stream-socket-lifetime.test.ts": 163, + "src/main/ipc/emulator-renderer-lifetime.test.ts": 117, + "src/main/ipc/emulator-stream-listener-cleanup.test.ts": 66, + "src/main/ipc/ephemeral-vm-provision-cancel.test.ts": 5904, + "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts": 3154, + "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts": 1695, + "src/main/ipc/ephemeral-vm.test.ts": 938, + "src/main/ipc/feedback-image-attachments.test.ts": 95, + "src/main/ipc/feedback-request-ownership.test.ts": 72, + "src/main/ipc/feedback.test.ts": 140, + "src/main/ipc/filesystem-allowed-roots.test.ts": 577, + "src/main/ipc/filesystem-auth.test.ts": 686, + "src/main/ipc/filesystem-branch-compare-diff.test.ts": 1287, + "src/main/ipc/filesystem-canonical-owner.test.ts": 135, + "src/main/ipc/filesystem-commit-message-generation.test.ts": 1529, + "src/main/ipc/filesystem-commit-message-model-discovery.test.ts": 2164, + "src/main/ipc/filesystem-conflict-operation-routing.test.ts": 1720, + "src/main/ipc/filesystem-content-search-cancellation.test.ts": 1714, + "src/main/ipc/filesystem-document-folder-writes.test.ts": 1153, + "src/main/ipc/filesystem-download-lifetime.test.ts": 722, + "src/main/ipc/filesystem-download-transfers.test.ts": 1825, + "src/main/ipc/filesystem-git-commit-dispatch.test.ts": 1766, + "src/main/ipc/filesystem-git-status-staging.test.ts": 1611, + "src/main/ipc/filesystem-import-local-rollback.test.ts": 87, + "src/main/ipc/filesystem-import-ssh-ops.test.ts": 1162, + "src/main/ipc/filesystem-import-ssh-path-safety.test.ts": 101, + "src/main/ipc/filesystem-import-ssh.test.ts": 1053, + "src/main/ipc/filesystem-import.test.ts": 2156, + "src/main/ipc/filesystem-list-files-handler-name-filter.test.ts": 1379, + "src/main/ipc/filesystem-list-files-name-filter.test.ts": 426, + "src/main/ipc/filesystem-list-files.test.ts": 778, + "src/main/ipc/filesystem-markdown-document-listing.test.ts": 1718, + "src/main/ipc/filesystem-mutations-runtime-upload.test.ts": 386, + "src/main/ipc/filesystem-mutations.test.ts": 1142, + "src/main/ipc/filesystem-path-containment-remote-enoent.test.ts": 46, + "src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts": 492, + "src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts": 2316, + "src/main/ipc/filesystem-quick-open-options.integration.test.ts": 1700, + "src/main/ipc/filesystem-runtime-upload-staging.test.ts": 79, + "src/main/ipc/filesystem-search-file-paths.test.ts": 346, + "src/main/ipc/filesystem-search-rg-timeout.test.ts": 1670, + "src/main/ipc/filesystem-symlink-directory-entries.test.ts": 44, + "src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts": 509, + "src/main/ipc/filesystem-watcher-document-lifetime.test.ts": 381, + "src/main/ipc/filesystem-watcher-dormant-rearm.test.ts": 384, + "src/main/ipc/filesystem-watcher-event-batch.test.ts": 44, + "src/main/ipc/filesystem-watcher-ignore-real.test.ts": 424, + "src/main/ipc/filesystem-watcher-ignore.test.ts": 67, + "src/main/ipc/filesystem-watcher-large-batch.test.ts": 173, + "src/main/ipc/filesystem-watcher-local-events.test.ts": 406, + "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts": 1181, + "src/main/ipc/filesystem-watcher-native-capacity.test.ts": 174, + "src/main/ipc/filesystem-watcher-real.test.ts": 546, + "src/main/ipc/filesystem-watcher-remote-batch.test.ts": 198, + "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts": 474, + "src/main/ipc/filesystem-watcher-remote-capacity.test.ts": 195, + "src/main/ipc/filesystem-watcher-remote-rearm.test.ts": 160, + "src/main/ipc/filesystem-watcher-removal-deadline.test.ts": 396, + "src/main/ipc/filesystem-watcher-terminal-resync.test.ts": 167, + "src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts": 189, + "src/main/ipc/filesystem-watcher-wsl.test.ts": 95, + "src/main/ipc/filesystem-watcher.test.ts": 2015, + "src/main/ipc/filesystem.test.ts": 2109, + "src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts": 50, + "src/main/ipc/filesystem/local-file-range-read.test.ts": 64, + "src/main/ipc/filesystem/local-regular-file-read.test.ts": 83, + "src/main/ipc/floating-workspace-directory.test.ts": 59, + "src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts": 100, + "src/main/ipc/folder-repo-git-upgrade.test.ts": 2130, + "src/main/ipc/freebuff-detection.test.ts": 42, + "src/main/ipc/git-status-upstream-ref-watch-request.test.ts": 321, + "src/main/ipc/github-issue-source-preference.test.ts": 602, + "src/main/ipc/github-pr-refresh-routing.test.ts": 729, + "src/main/ipc/github-repo-access-guards.test.ts": 690, + "src/main/ipc/github-ssh-connection-routing.test.ts": 658, + "src/main/ipc/github-star-telemetry.test.ts": 592, + "src/main/ipc/github-work-item-args.test.ts": 56, + "src/main/ipc/github-wsl-runtime-routing.test.ts": 680, + "src/main/ipc/gitlab-repo-access.test.ts": 105, + "src/main/ipc/gitlab.test.ts": 203, + "src/main/ipc/hooks/worktree-hook-execution-host.test.ts": 783, + "src/main/ipc/hosted-review.test.ts": 668, + "src/main/ipc/jira-cancellable-requests.test.ts": 58, + "src/main/ipc/keybindings.test.ts": 57, + "src/main/ipc/linear.test.ts": 110, + "src/main/ipc/local-file-access-resolution-network-path.test.ts": 176, + "src/main/ipc/local-file-access-resolution.test.ts": 435, + "src/main/ipc/local-log-tail-lifetime.test.ts": 131, + "src/main/ipc/local-log-tail.test.ts": 65, + "src/main/ipc/local-network-connection-test.test.ts": 323, + "src/main/ipc/local-worktree-runtime-options.test.ts": 105, + "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts": 66, + "src/main/ipc/macos-keyboard-layout-snapshot.test.ts": 47, + "src/main/ipc/managed-host-status-publication.test.ts": 1003, + "src/main/ipc/markdown-documents-remote-paths.test.ts": 180, + "src/main/ipc/markdown-documents-ripgrep-real.test.ts": 190, + "src/main/ipc/markdown-documents-ripgrep.test.ts": 250, + "src/main/ipc/markdown-documents.test.ts": 212, + "src/main/ipc/minimax-credentials.test.ts": 76, + "src/main/ipc/mobile.test.ts": 27111, + "src/main/ipc/native-chat-renderer-lifetime.test.ts": 910, + "src/main/ipc/native-chat-renderer-watcher-cleanup.test.ts": 1169, + "src/main/ipc/native-chat-subscribe-lifecycle.test.ts": 1282, + "src/main/ipc/native-chat.test.ts": 2590, + "src/main/ipc/notebook-project-link.test.ts": 365, + "src/main/ipc/notebook.test.ts": 507, + "src/main/ipc/notifications-custom-sound.test.ts": 328, + "src/main/ipc/notifications-delivery-gating.test.ts": 426, + "src/main/ipc/notifications-message-formatting.test.ts": 402, + "src/main/ipc/notifications-mobile-fanout.test.ts": 319, + "src/main/ipc/notifications-permission-onboarding.test.ts": 333, + "src/main/ipc/notifications-retention-lifecycle.test.ts": 506, + "src/main/ipc/notifications-subject-retirement.test.ts": 411, + "src/main/ipc/opencode-go-credentials.test.ts": 81, + "src/main/ipc/orca-profile-auth-handlers.test.ts": 473, + "src/main/ipc/orca-profile-org-members-handlers.test.ts": 61, + "src/main/ipc/orca-profiles-switch-persistence.test.ts": 2035, + "src/main/ipc/orca-profiles.test.ts": 706, + "src/main/ipc/orcad-delta-move-handlers.test.ts": 750, + "src/main/ipc/orcad-runtime-conversion-handlers.test.ts": 650, + "src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts": 47, + "src/main/ipc/orcad-runtime-maintenance-handlers.test.ts": 826, + "src/main/ipc/orcad-ssh-provisioning-handlers.test.ts": 51, + "src/main/ipc/parcel-watcher-child-launch.test.ts": 66, + "src/main/ipc/parcel-watcher-child-registry.test.ts": 98, + "src/main/ipc/parcel-watcher-crash-fuse.test.ts": 48, + "src/main/ipc/parcel-watcher-disconnect-termination.test.ts": 142, + "src/main/ipc/parcel-watcher-entry-path.test.ts": 61, + "src/main/ipc/parcel-watcher-event-cancellation.test.ts": 64, + "src/main/ipc/parcel-watcher-event-delivery.test.ts": 119, + "src/main/ipc/parcel-watcher-module-loader.test.ts": 51, + "src/main/ipc/parcel-watcher-native-error-retirement.test.ts": 106, + "src/main/ipc/parcel-watcher-owned-children.test.ts": 52, + "src/main/ipc/parcel-watcher-process-dispose.test.ts": 55, + "src/main/ipc/parcel-watcher-process-entry.test.ts": 215, + "src/main/ipc/parcel-watcher-process.test.ts": 562, + "src/main/ipc/parcel-watcher-root-path-rewrite.test.ts": 114, + "src/main/ipc/parcel-watcher-shallow-subscription.test.ts": 146, + "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts": 173, + "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts": 232, + "src/main/ipc/parcel-watcher-supervisor-disposal.test.ts": 90, + "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts": 112, + "src/main/ipc/pet-bundle.test.ts": 57, + "src/main/ipc/pet.test.ts": 319, + "src/main/ipc/plugin-marketplaces.test.ts": 275, + "src/main/ipc/plugins.test.ts": 437, + "src/main/ipc/preflight-agent-detection-no-subprocess.test.ts": 688, + "src/main/ipc/preflight-agent-detection.test.ts": 725, + "src/main/ipc/preflight-agent-refresh.test.ts": 717, + "src/main/ipc/preflight-command-exec.test.ts": 89, + "src/main/ipc/preflight-host-cli-status.test.ts": 731, + "src/main/ipc/preflight-provider-command-selection.test.ts": 352, + "src/main/ipc/preflight-remote-ssh.test.ts": 631, + "src/main/ipc/preflight-runnable-local-cli.test.ts": 9174, + "src/main/ipc/preflight-windows-path-refresh.repro.test.ts": 77, + "src/main/ipc/preflight-wsl-agent-detection.test.ts": 118, + "src/main/ipc/preflight-wsl-command.test.ts": 64, + "src/main/ipc/pty-activation-inventory-scope.test.ts": 1905, + "src/main/ipc/pty-agent-launch-pane-attach.test.ts": 2906, + "src/main/ipc/pty-applied-size-reporting.test.ts": 2036, + "src/main/ipc/pty-buffer-snapshot-dispatch.test.ts": 2028, + "src/main/ipc/pty-codex-account-attribution.test.ts": 1676, + "src/main/ipc/pty-controller-owner-recovery.test.ts": 1924, + "src/main/ipc/pty-controller-ownership-routing.test.ts": 2013, + "src/main/ipc/pty-controller-process-inventory.test.ts": 2060, + "src/main/ipc/pty-controller-spawn-admission.test.ts": 4498, + "src/main/ipc/pty-cumulative-ack-accounting.test.ts": 2167, + "src/main/ipc/pty-daemon-controller-teardown.test.ts": 2056, + "src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts": 2293, + "src/main/ipc/pty-daemon-spawn-codex-auth.test.ts": 2131, + "src/main/ipc/pty-daemon-spawn-session-identity.test.ts": 2097, + "src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts": 2152, + "src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts": 1871, + "src/main/ipc/pty-dead-owner-respawn.test.ts": 2153, + "src/main/ipc/pty-delivery-health-heal.test.ts": 2042, + "src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts": 2140, + "src/main/ipc/pty-encoding.test.ts": 43, + "src/main/ipc/pty-global-renderer-credit.test.ts": 2232, + "src/main/ipc/pty-hidden-at-spawn-mark.test.ts": 5138, + "src/main/ipc/pty-hidden-delivery-gate.test.ts": 59, + "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts": 2060, + "src/main/ipc/pty-ipc-producer-flow-control.test.ts": 1883, + "src/main/ipc/pty-listener-teardown-and-orphans.test.ts": 4358, + "src/main/ipc/pty-login-shell-startup-commands.test.ts": 2204, + "src/main/ipc/pty-management.test.ts": 138, + "src/main/ipc/pty-output-batching-drain.test.ts": 1870, + "src/main/ipc/pty-output-drain-rounds.test.ts": 1637, + "src/main/ipc/pty-pane-claim-arbitration.test.ts": 7990, + "src/main/ipc/pty-pane-materialization-race.test.ts": 2686, + "src/main/ipc/pty-pane-reservation-settlement.test.ts": 2440, + "src/main/ipc/pty-pane-restart-replace.test.ts": 2428, + "src/main/ipc/pty-pending-data-drain-queue-differential.test.ts": 1638, + "src/main/ipc/pty-pending-data-drain-queue.test.ts": 65, + "src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts": 93, + "src/main/ipc/pty-pending-output-cap.test.ts": 1952, + "src/main/ipc/pty-pending-projection-admissions.test.ts": 91, + "src/main/ipc/pty-persisted-incarnation-repair.test.ts": 1941, + "src/main/ipc/pty-producer-flow-control.test.ts": 60, + "src/main/ipc/pty-renderer-inflight-credit.test.ts": 2177, + "src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts": 2053, + "src/main/ipc/pty-renderer-liveness-guard.test.ts": 2073, + "src/main/ipc/pty-renderer-send-failure-recovery.test.ts": 2046, + "src/main/ipc/pty-reset-input-modes.test.ts": 1487, + "src/main/ipc/pty-restore-record-seeding.test.ts": 4523, + "src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts": 2019, + "src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts": 4718, + "src/main/ipc/pty-runtime-kill-and-exit.test.ts": 2264, + "src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts": 1912, + "src/main/ipc/pty-serializer-settlement-mapping.test.ts": 5457, + "src/main/ipc/pty-session-liveness-and-ownership.test.ts": 2154, + "src/main/ipc/pty-spawn-codex-home-unavailable.test.ts": 1758, + "src/main/ipc/pty-spawn-cwd-fallback.test.ts": 2244, + "src/main/ipc/pty-spawn-env-agent-overlays.test.ts": 2493, + "src/main/ipc/pty-spawn-env-codex-home-routing.test.ts": 2025, + "src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts": 2070, + "src/main/ipc/pty-spawn-env-terminal-basics.test.ts": 2141, + "src/main/ipc/pty-spawn-pi-owner-env.test.ts": 2128, + "src/main/ipc/pty-spawn-placement-threading.test.ts": 2049, + "src/main/ipc/pty-spawn-runtime-handle-binding.test.ts": 5122, + "src/main/ipc/pty-spawn-timing.test.ts": 57, + "src/main/ipc/pty-ssh-stop-verdict.test.ts": 2449, + "src/main/ipc/pty-ssh-undelivered-kill.test.ts": 2026, + "src/main/ipc/pty-startup-barrier-and-listing.test.ts": 2108, + "src/main/ipc/pty-startup-swap-window-presence.test.ts": 1988, + "src/main/ipc/pty-windows-shell-selection.test.ts": 2490, + "src/main/ipc/pty-write-ipc-validation.test.ts": 2150, + "src/main/ipc/pty-wsl-cwd-validation.test.ts": 2113, + "src/main/ipc/pty/configured-terminal-shell-args.test.ts": 54, + "src/main/ipc/pty/daemon-late-exit.test.ts": 4940, + "src/main/ipc/pty/delivery/attached-pty-size.test.ts": 53, + "src/main/ipc/pty/delivery/interactive-typing-burst.test.ts": 51, + "src/main/ipc/pty/host-env/codex-hook-reconcile-spawn.test.ts": 291, + "src/main/ipc/pty/host-env/omp-launch-environment.test.ts": 75, + "src/main/ipc/pty/host-env/opencode-hook-installation.test.ts": 669, + "src/main/ipc/pty/ipc/claude-old-terminal.test.ts": 48, + "src/main/ipc/pty/ipc/codex-shared-server.test.ts": 76, + "src/main/ipc/pty/ipc/leaf-move.test.ts": 628, + "src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts": 819, + "src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts": 1887, + "src/main/ipc/pty/ipc/spawn-options-codex-isolation.test.ts": 880, + "src/main/ipc/pty/ipc/spawn-options-terminal-shell-args.test.ts": 1183, + "src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts": 1122, + "src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts": 77, + "src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts": 1409, + "src/main/ipc/pty/ipc/terminal-view-attributes-ipc.test.ts": 400, + "src/main/ipc/pty/ipc/write-input-acceptance.test.ts": 77, + "src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts": 69, + "src/main/ipc/pty/ipc/write-input-user-input.test.ts": 69, + "src/main/ipc/pty/managed-data-account-spawn-environment.test.ts": 770, + "src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts": 790, + "src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts": 721, + "src/main/ipc/pty/pane/join-pane-spawn.test.ts": 924, + "src/main/ipc/pty/pane/launch-authority.test.ts": 71, + "src/main/ipc/pty/pane/pane-owner-replacement.test.ts": 1478, + "src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts": 5118, + "src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts": 814, + "src/main/ipc/pty/provider/registry-color-query-reply-colors.test.ts": 398, + "src/main/ipc/pty/provider/registry-host-routing.test.ts": 417, "src/main/ipc/pty/register-headless-runtime.test.ts": 43, - "src/main/ipc/pty/register-without-renderer.test.ts": 3683, - "src/main/ipc/pty/runtime/queried-host-kinds.test.ts": 212, - "src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts": 1027, - "src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts": 970, - "src/main/ipc/rate-limits.test.ts": 73, - "src/main/ipc/readdir-error-diagnostics.test.ts": 27, - "src/main/ipc/register-core-handlers/register-core-handlers.test.ts": 1578, - "src/main/ipc/registered-worktree-root-ownership.test.ts": 366, - "src/main/ipc/registered-worktree-root-refresh.test.ts": 122, - "src/main/ipc/remote-watcher-event-batch.test.ts": 49, - "src/main/ipc/remote-workspace-cache.test.ts": 2439, - "src/main/ipc/remote-workspace-connected-clients.test.ts": 70, - "src/main/ipc/remote-workspace-patch-queue.test.ts": 474, - "src/main/ipc/remote-workspace-snapshot-normalization.test.ts": 43, - "src/main/ipc/remote-workspace-stale-resync.test.ts": 1371, - "src/main/ipc/remote-workspace.test.ts": 292, - "src/main/ipc/renderer-lifetime-abort.test.ts": 53, - "src/main/ipc/renderer-shutdown-checkpoint.test.ts": 62, - "src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 59, - "src/main/ipc/repos-add-linked-worktree.test.ts": 1070, - "src/main/ipc/repos-create.test.ts": 966, - "src/main/ipc/repos-execution-host-catalog.test.ts": 943, - "src/main/ipc/repos-local-add-and-project-setup.test.ts": 1193, - "src/main/ipc/repos-local-clone-lifecycle.test.ts": 1467, - "src/main/ipc/repos-nested-import.test.ts": 1374, - "src/main/ipc/repos-nested-scan.test.ts": 586, - "src/main/ipc/repos-picker.test.ts": 1130, - "src/main/ipc/repos-remote-base-ref-queries.test.ts": 1008, - "src/main/ipc/repos-remote-client-events.test.ts": 1549, - "src/main/ipc/repos-remote-git-username.test.ts": 940, - "src/main/ipc/repos-remote.test.ts": 958, - "src/main/ipc/repos-sparse-presets.test.ts": 1305, - "src/main/ipc/repos/remote-repo-registration.test.ts": 230, - "src/main/ipc/repos/repo-creation-git-availability.test.ts": 150, - "src/main/ipc/runtime-agent-launch-capability.test.ts": 2099, - "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 179, - "src/main/ipc/runtime-environment-capability-evidence.test.ts": 31, - "src/main/ipc/runtime-environment-cli-removal-routing.test.ts": 212, - "src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts": 42, - "src/main/ipc/runtime-environment-federated-read-routing.test.ts": 233, - "src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts": 163, - "src/main/ipc/runtime-environment-removal-storage.test.ts": 810, - "src/main/ipc/runtime-environment-request-connections.test.ts": 1502, - "src/main/ipc/runtime-environment-revision-guard.test.ts": 48, - "src/main/ipc/runtime-environment-status-connection.test.ts": 637, - "src/main/ipc/runtime-environment-status-recovery.test.ts": 246, - "src/main/ipc/runtime-environment-support-routing.test.ts": 361, - "src/main/ipc/runtime-environment-transport-generation.test.ts": 45, - "src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts": 1055, - "src/main/ipc/runtime-environments-call-routing.test.ts": 626, - "src/main/ipc/runtime-environments-capability-cache.test.ts": 482, - "src/main/ipc/runtime-environments-pairing.test.ts": 1362, - "src/main/ipc/runtime-environments-status-diagnostics.test.ts": 637, - "src/main/ipc/runtime-environments-subscription-lifecycle.test.ts": 989, - "src/main/ipc/runtime-environments-subscription-routing.test.ts": 524, - "src/main/ipc/runtime-environments-subscription-teardown.test.ts": 324, + "src/main/ipc/pty/register-without-renderer.test.ts": 5362, + "src/main/ipc/pty/runtime/queried-host-kinds.test.ts": 512, + "src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts": 1018, + "src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts": 833, + "src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts": 2118, + "src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts": 1046, + "src/main/ipc/qoder-desktop-execution-host.test.ts": 1898, + "src/main/ipc/qoder-independent-renderer-spawn-review.test.ts": 1589, + "src/main/ipc/rate-limits.test.ts": 52, + "src/main/ipc/readdir-error-diagnostics.test.ts": 41, + "src/main/ipc/register-core-handlers/register-core-handlers.test.ts": 2495, + "src/main/ipc/registered-worktree-root-ownership.test.ts": 438, + "src/main/ipc/registered-worktree-root-refresh.test.ts": 184, + "src/main/ipc/registered-worktree-root-wsl-runtime.test.ts": 171, + "src/main/ipc/registered-worktree-roots-scoped-invalidation.test.ts": 89, + "src/main/ipc/remote-watcher-event-batch.test.ts": 77, + "src/main/ipc/remote-workspace-cache.test.ts": 5428, + "src/main/ipc/remote-workspace-connected-clients.test.ts": 51, + "src/main/ipc/remote-workspace-patch-queue.test.ts": 595, + "src/main/ipc/remote-workspace-snapshot-normalization.test.ts": 47, + "src/main/ipc/remote-workspace-stale-resync.test.ts": 1369, + "src/main/ipc/remote-workspace-target-session-export.test.ts": 254, + "src/main/ipc/remote-workspace.test.ts": 307, + "src/main/ipc/renderer-lifetime-abort.test.ts": 58, + "src/main/ipc/renderer-shutdown-checkpoint.test.ts": 457, + "src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 56, + "src/main/ipc/repos-add-linked-worktree.test.ts": 1324, + "src/main/ipc/repos-create.test.ts": 1219, + "src/main/ipc/repos-execution-host-catalog.test.ts": 1173, + "src/main/ipc/repos-local-add-and-project-setup.test.ts": 1476, + "src/main/ipc/repos-local-clone-lifecycle.test.ts": 1644, + "src/main/ipc/repos-nested-import.test.ts": 1393, + "src/main/ipc/repos-nested-scan.test.ts": 1295, + "src/main/ipc/repos-picker.test.ts": 1109, + "src/main/ipc/repos-remote-base-ref-queries.test.ts": 1223, + "src/main/ipc/repos-remote-client-events.test.ts": 1788, + "src/main/ipc/repos-remote-git-username.test.ts": 1104, + "src/main/ipc/repos-remote.test.ts": 1533, + "src/main/ipc/repos-sparse-presets.test.ts": 1196, + "src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts": 1525, + "src/main/ipc/repos/remote-repo-registration.test.ts": 282, + "src/main/ipc/repos/repo-creation-git-availability.test.ts": 310, + "src/main/ipc/runtime-agent-launch-capability.test.ts": 3143, + "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 1143, + "src/main/ipc/runtime-environment-capability-evidence.test.ts": 46, + "src/main/ipc/runtime-environment-cli-removal-routing.test.ts": 1153, + "src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts": 43, + "src/main/ipc/runtime-environment-federated-read-routing.test.ts": 1096, + "src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts": 328, + "src/main/ipc/runtime-environment-host-key.test.ts": 181, + "src/main/ipc/runtime-environment-managed-tunnel.test.ts": 483, + "src/main/ipc/runtime-environment-preference.test.ts": 401, + "src/main/ipc/runtime-environment-removal-cleanup.test.ts": 53, + "src/main/ipc/runtime-environment-removal-storage.test.ts": 1576, + "src/main/ipc/runtime-environment-request-connections.test.ts": 2790, + "src/main/ipc/runtime-environment-revision-guard.test.ts": 43, + "src/main/ipc/runtime-environment-session-reconcile.test.ts": 977, + "src/main/ipc/runtime-environment-status-connection.test.ts": 1592, + "src/main/ipc/runtime-environment-status-recovery.test.ts": 1038, + "src/main/ipc/runtime-environment-status-retirement.test.ts": 1009, + "src/main/ipc/runtime-environment-subscription-identity.test.ts": 1066, + "src/main/ipc/runtime-environment-support-routing.test.ts": 1306, + "src/main/ipc/runtime-environment-transport-generation.test.ts": 42, + "src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts": 2342, + "src/main/ipc/runtime-environments-call-routing.test.ts": 2632, + "src/main/ipc/runtime-environments-capability-cache.test.ts": 1740, + "src/main/ipc/runtime-environments-pairing.test.ts": 2250, + "src/main/ipc/runtime-environments-status-diagnostics.test.ts": 1491, + "src/main/ipc/runtime-environments-subscription-lifecycle.test.ts": 2695, + "src/main/ipc/runtime-environments-subscription-routing.test.ts": 1823, + "src/main/ipc/runtime-environments-subscription-teardown.test.ts": 1629, "src/main/ipc/runtime-import-limits.test.ts": 46, - "src/main/ipc/runtime-subscribe-lifecycle.test.ts": 2028, - "src/main/ipc/runtime-upload-file-stream.test.ts": 167, - "src/main/ipc/runtime-upload-slice-boundaries.test.ts": 352, - "src/main/ipc/runtime-upload-temp-sweep.test.ts": 1063, - "src/main/ipc/runtime-watcher-pending-assignment.test.ts": 307, - "src/main/ipc/runtime-watcher-process-pool.test.ts": 143, - "src/main/ipc/runtime.test.ts": 2155, - "src/main/ipc/sender-scoped-request-cancellation.test.ts": 99, - "src/main/ipc/settings.test.ts": 4454, - "src/main/ipc/shallow-watch-delivery-probe-lifetime.test.ts": 58, - "src/main/ipc/shallow-watch-delivery-probe.test.ts": 161, - "src/main/ipc/shell.test.ts": 91, - "src/main/ipc/skill-cloud-install-ipc-schemas.test.ts": 91, - "src/main/ipc/skill-cloud-ipc-handlers.test.ts": 220, - "src/main/ipc/skill-install-management-ipc-handlers.test.ts": 178, - "src/main/ipc/skill-install-progress-ipc.test.ts": 35, - "src/main/ipc/skill-ipc-main-window.test.ts": 61, - "src/main/ipc/skills.test.ts": 543, - "src/main/ipc/source-control-ai-linked-issue.test.ts": 51, - "src/main/ipc/speech-worker-exit-lifetime.test.ts": 337, - "src/main/ipc/speech.test.ts": 75, - "src/main/ipc/ssh-active-relay-sessions.test.ts": 41, - "src/main/ipc/ssh-app-shutdown.test.ts": 1719, - "src/main/ipc/ssh-browse.test.ts": 737, - "src/main/ipc/ssh-disconnect-cancellation.test.ts": 3816, - "src/main/ipc/ssh-handler-reregistration.test.ts": 2634, - "src/main/ipc/ssh-host-partition-session-export.test.ts": 1162, - "src/main/ipc/ssh-passphrase.test.ts": 137, - "src/main/ipc/ssh-pty-closed-generation-ranges.test.ts": 67, - "src/main/ipc/ssh-pty-consumer-identity.test.ts": 3105, - "src/main/ipc/ssh-pty-legacy-projection.test.ts": 62, - "src/main/ipc/ssh-pty-model-admission-generation-scope.test.ts": 58, - "src/main/ipc/ssh-pty-model-admission.test.ts": 76, - "src/main/ipc/ssh-pty-output-exit-deadline.test.ts": 83, - "src/main/ipc/ssh-pty-output-generation-guard.test.ts": 48, - "src/main/ipc/ssh-pty-output-intake.test.ts": 165, - "src/main/ipc/ssh-pty-output-model-migration.test.ts": 109, - "src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts": 85, - "src/main/ipc/ssh-pty-source-ack-coalescer.test.ts": 48, - "src/main/ipc/ssh-pty-source-ack-session-contract.test.ts": 62, - "src/main/ipc/ssh-pty-source-obligation-coordinator.test.ts": 45, - "src/main/ipc/ssh-pty-source-obligation-ledger.test.ts": 170, - "src/main/ipc/ssh-relay-reset-resume.test.ts": 3804, - "src/main/ipc/ssh-state-broadcast-fanout.test.ts": 2574, - "src/main/ipc/ssh-target-registry.test.ts": 3704, - "src/main/ipc/ssh-terminate-sessions.test.ts": 3008, - "src/main/ipc/ssh.test.ts": 2028, - "src/main/ipc/telemetry.test.ts": 230, - "src/main/ipc/terminal-git-credential-guard.test.ts": 82, - "src/main/ipc/terminal-preview-output-stream.test.ts": 28, - "src/main/ipc/terminal-preview.test.ts": 242, - "src/main/ipc/terminal-render-desync-evidence.test.ts": 63, - "src/main/ipc/terminal-startup-color-query-replies.test.ts": 138, - "src/main/ipc/tui-agent-detection-commands.test.ts": 44, - "src/main/ipc/ui.test.ts": 84, - "src/main/ipc/usage-provider-handlers.test.ts": 44, - "src/main/ipc/watched-worktree-catalog-notification.test.ts": 71, - "src/main/ipc/watcher-event-root-path-rewrite.test.ts": 61, - "src/main/ipc/watcher-removal-gate.test.ts": 37, - "src/main/ipc/workspace-cleanup-activity.test.ts": 80, - "src/main/ipc/workspace-cleanup-broad-scan.test.ts": 531, - "src/main/ipc/workspace-cleanup-execution-host-routing.test.ts": 305, - "src/main/ipc/workspace-cleanup-local-git-routing.test.ts": 461, - "src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts": 114, - "src/main/ipc/workspace-cleanup.test.ts": 4644, + "src/main/ipc/runtime-ssh-access-handlers.test.ts": 191, + "src/main/ipc/runtime-subscribe-lifecycle.test.ts": 2991, + "src/main/ipc/runtime-upload-file-stream.test.ts": 140, + "src/main/ipc/runtime-upload-slice-boundaries.test.ts": 373, + "src/main/ipc/runtime-upload-temp-sweep.test.ts": 1055, + "src/main/ipc/runtime-watcher-disposal-owners.test.ts": 51, + "src/main/ipc/runtime-watcher-pending-assignment.test.ts": 294, + "src/main/ipc/runtime-watcher-pool-shutdown.test.ts": 91, + "src/main/ipc/runtime-watcher-process-pool.test.ts": 146, + "src/main/ipc/runtime.test.ts": 3357, + "src/main/ipc/sender-scoped-request-cancellation.test.ts": 96, + "src/main/ipc/session-close-terminal-surface.test.ts": 346, + "src/main/ipc/session.test.ts": 431, + "src/main/ipc/settings.test.ts": 1109, + "src/main/ipc/shallow-watch-delivery-probe-lifetime.test.ts": 92, + "src/main/ipc/shallow-watch-delivery-probe.test.ts": 104, + "src/main/ipc/shell.test.ts": 168, + "src/main/ipc/skill-cloud-install-ipc-schemas.test.ts": 269, + "src/main/ipc/skill-cloud-ipc-handlers.test.ts": 280, + "src/main/ipc/skill-install-management-ipc-handlers.test.ts": 274, + "src/main/ipc/skill-install-progress-ipc.test.ts": 50, + "src/main/ipc/skill-ipc-main-window.test.ts": 56, + "src/main/ipc/skills.test.ts": 1180, + "src/main/ipc/source-control-ai-linked-issue.test.ts": 89, + "src/main/ipc/sparse-preset-attribution.test.ts": 43, + "src/main/ipc/speech-worker-exit-lifetime.test.ts": 529, + "src/main/ipc/speech.test.ts": 80, + "src/main/ipc/ssh-active-relay-sessions.test.ts": 405, + "src/main/ipc/ssh-app-shutdown.test.ts": 4272, + "src/main/ipc/ssh-browse.test.ts": 706, + "src/main/ipc/ssh-connect-recheck-cancellation.test.ts": 4164, + "src/main/ipc/ssh-disconnect-cancellation.test.ts": 5166, + "src/main/ipc/ssh-handler-reregistration.test.ts": 4043, + "src/main/ipc/ssh-host-node-runtime-connect.test.ts": 4239, + "src/main/ipc/ssh-host-partition-session-export.test.ts": 1644, + "src/main/ipc/ssh-host-server-decision-failure.test.ts": 385, + "src/main/ipc/ssh-host-server-on-connect-wiring.test.ts": 2374, + "src/main/ipc/ssh-host-server-relay-decision.test.ts": 377, + "src/main/ipc/ssh-host-sleep-reconnect.test.ts": 424, + "src/main/ipc/ssh-managed-server-move-conversion.test.ts": 2156, + "src/main/ipc/ssh-managed-server-move-stop-retention.test.ts": 6067, + "src/main/ipc/ssh-managed-server-move.test.ts": 325, + "src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts": 487, + "src/main/ipc/ssh-passphrase.test.ts": 104, + "src/main/ipc/ssh-plain-ssh-connect.test.ts": 4001, + "src/main/ipc/ssh-pty-closed-generation-ranges.test.ts": 71, + "src/main/ipc/ssh-pty-consumer-identity.test.ts": 4353, + "src/main/ipc/ssh-pty-legacy-projection.test.ts": 67, + "src/main/ipc/ssh-pty-model-admission-generation-scope.test.ts": 69, + "src/main/ipc/ssh-pty-model-admission.test.ts": 63, + "src/main/ipc/ssh-pty-output-exit-deadline.test.ts": 95, + "src/main/ipc/ssh-pty-output-generation-guard.test.ts": 56, + "src/main/ipc/ssh-pty-output-intake-recovery-cancellation.test.ts": 104, + "src/main/ipc/ssh-pty-output-intake.test.ts": 137, + "src/main/ipc/ssh-pty-output-model-migration.test.ts": 117, + "src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts": 80, + "src/main/ipc/ssh-pty-source-ack-coalescer.test.ts": 62, + "src/main/ipc/ssh-pty-source-ack-session-contract.test.ts": 88, + "src/main/ipc/ssh-pty-source-obligation-coordinator.test.ts": 63, + "src/main/ipc/ssh-pty-source-obligation-ledger.test.ts": 189, + "src/main/ipc/ssh-relay-reset-resume.test.ts": 6183, + "src/main/ipc/ssh-state-broadcast-fanout.test.ts": 4164, + "src/main/ipc/ssh-target-crud-handlers-ownership.test.ts": 560, + "src/main/ipc/ssh-target-registry.test.ts": 6430, + "src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts": 4034, + "src/main/ipc/ssh-terminate-sessions.test.ts": 5128, + "src/main/ipc/ssh-test-connection-probe-registry.test.ts": 46, + "src/main/ipc/ssh-test-probe-presence.test.ts": 63, + "src/main/ipc/ssh.test.ts": 4177, + "src/main/ipc/telemetry.test.ts": 297, + "src/main/ipc/terminal-git-credential-guard.test.ts": 103, + "src/main/ipc/terminal-preview-output-stream.test.ts": 57, + "src/main/ipc/terminal-preview.test.ts": 237, + "src/main/ipc/terminal-render-desync-evidence.test.ts": 88, + "src/main/ipc/terminal-startup-color-query-replies.test.ts": 322, + "src/main/ipc/tui-agent-detection-commands.test.ts": 60, + "src/main/ipc/ui.test.ts": 86, + "src/main/ipc/usage-provider-handlers-readiness.test.ts": 597, + "src/main/ipc/usage-provider-handlers.test.ts": 59, + "src/main/ipc/watched-worktree-catalog-notification.test.ts": 74, + "src/main/ipc/watcher-event-root-path-rewrite.test.ts": 56, + "src/main/ipc/watcher-removal-gate.test.ts": 52, + "src/main/ipc/workspace-cleanup-activity.test.ts": 89, + "src/main/ipc/workspace-cleanup-broad-scan.test.ts": 686, + "src/main/ipc/workspace-cleanup-execution-host-routing.test.ts": 595, + "src/main/ipc/workspace-cleanup-local-git-routing.test.ts": 1167, + "src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts": 105, + "src/main/ipc/workspace-cleanup.test.ts": 2928, "src/main/ipc/workspace-create-error-classifier.test.ts": 52, - "src/main/ipc/workspace-ports.test.ts": 103, - "src/main/ipc/workspace-space.test.ts": 63, - "src/main/ipc/worktree-base-directory-change-collector.test.ts": 42, - "src/main/ipc/worktree-base-directory-event-filter.test.ts": 61, - "src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts": 219, - "src/main/ipc/worktree-base-directory-poller.test.ts": 2702, - "src/main/ipc/worktree-base-directory-watch-targets.test.ts": 388, - "src/main/ipc/worktree-base-directory-watcher.test.ts": 454, - "src/main/ipc/worktree-create-lineage.test.ts": 570, - "src/main/ipc/worktree-folder-rename-target.test.ts": 65, - "src/main/ipc/worktree-git-common-polling.test.ts": 1240, - "src/main/ipc/worktree-git-common-watch.test.ts": 2604, - "src/main/ipc/worktree-git-status-ref-watch.test.ts": 40, - "src/main/ipc/worktree-head-identity-reader-concurrency.test.ts": 72, - "src/main/ipc/worktree-head-identity-reader-incremental.test.ts": 255, - "src/main/ipc/worktree-head-identity-reader.test.ts": 99, - "src/main/ipc/worktree-head-identity-refresh.test.ts": 84, - "src/main/ipc/worktree-head-identity-retirement.test.ts": 80, - "src/main/ipc/worktree-include-copy-budget.test.ts": 119, - "src/main/ipc/worktree-logic-created-agent.test.ts": 82, - "src/main/ipc/worktree-logic-wsl.test.ts": 102, - "src/main/ipc/worktree-logic.test.ts": 185, - "src/main/ipc/worktree-metadata-merge.test.ts": 60, - "src/main/ipc/worktree-path-deduplication.test.ts": 69, - "src/main/ipc/worktree-push-target-cleanup.test.ts": 182, - "src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts": 862, - "src/main/ipc/worktree-push-target-reconciliation.test.ts": 305, - "src/main/ipc/worktree-push-target-refspec-migration.test.ts": 178, - "src/main/ipc/worktree-push-target-refspec-real-git.test.ts": 2471, - "src/main/ipc/worktree-push-target-remote-scan.test.ts": 135, - "src/main/ipc/worktree-push-target-setup.test.ts": 223, - "src/main/ipc/worktree-remote-push-target-materialization.test.ts": 717, - "src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts": 500, - "src/main/ipc/worktree-symlink-reconciliation.real.test.ts": 239, - "src/main/ipc/worktree-symlinks.test.ts": 136, - "src/main/ipc/worktree-watcher-removal-binding.test.ts": 150, - "src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts": 1279, - "src/main/ipc/worktrees-canonical-ssh-listing.test.ts": 577, - "src/main/ipc/worktrees-create-execution-host-routing.test.ts": 588, - "src/main/ipc/worktrees-create-metadata-persistence.test.ts": 1369, - "src/main/ipc/worktrees-delete-pty-teardown.test.ts": 536, - "src/main/ipc/worktrees-detected-scan-cache.test.ts": 1356, - "src/main/ipc/worktrees-discovery-metadata-backfill.test.ts": 452, - "src/main/ipc/worktrees-existing-branch-checkout.test.ts": 1249, - "src/main/ipc/worktrees-forget-local.test.ts": 638, - "src/main/ipc/worktrees-issue-command-overrides.test.ts": 430, - "src/main/ipc/worktrees-lineage-hydration.test.ts": 886, - "src/main/ipc/worktrees-listing-fallback-rows.test.ts": 778, - "src/main/ipc/worktrees-local-base-ref-resolution.test.ts": 872, - "src/main/ipc/worktrees-local-create-flow.test.ts": 922, - "src/main/ipc/worktrees-mutation-scan-generation-order.test.ts": 971, - "src/main/ipc/worktrees-orphan-directory-cleanup.test.ts": 933, - "src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts": 816, - "src/main/ipc/worktrees-removal-recovery.test.ts": 1234, - "src/main/ipc/worktrees-remove-archive-hooks.test.ts": 1347, - "src/main/ipc/worktrees-remove-host-disambiguation.test.ts": 868, - "src/main/ipc/worktrees-remove-preflight.test.ts": 840, - "src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts": 664, - "src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts": 917, - "src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts": 925, - "src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts": 717, - "src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts": 914, - "src/main/ipc/worktrees-ssh-local-base-refresh.test.ts": 906, - "src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts": 768, - "src/main/ipc/worktrees-ssh-provider-authority.test.ts": 895, - "src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts": 1245, - "src/main/ipc/worktrees-ssh-setup-launch.test.ts": 927, - "src/main/ipc/worktrees-windows.test.ts": 1052, - "src/main/ipc/worktrees-wsl-runtime-routing.test.ts": 812, - "src/main/ipc/worktrees/listing/detected-provider-listing-catalog-version.test.ts": 107, - "src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts": 358, - "src/main/ipc/worktrees/listing/detected-provider-listing-overtaken-scan.test.ts": 127, - "src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts": 431, - "src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts": 148, - "src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts": 55, - "src/main/ipc/worktrees/listing/detected-worktree-scan-superseded.test.ts": 89, - "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 168, - "src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts": 54, - "src/main/ipc/worktrees/removal/remove-unregistered-worktree-host-home.test.ts": 99, - "src/main/ipc/worktrees/removal/worktree-archive-hook.test.ts": 211, - "src/main/issue-command-ignore.test.ts": 395, - "src/main/jira/adf-markdown.test.ts": 58, - "src/main/jira/attachment-image-cache-generation.test.ts": 46, - "src/main/jira/attachment-image-cache.test.ts": 47, - "src/main/jira/attachment-images.test.ts": 82, - "src/main/jira/client.test.ts": 304, - "src/main/jira/issues.test.ts": 172, - "src/main/jira/jira-issue-mutations.test.ts": 56, - "src/main/jira/jira-issue-summary-timeout.test.ts": 78, - "src/main/jira/jira-search-abort.test.ts": 42, - "src/main/keybindings/keybinding-file.test.ts": 119, - "src/main/keybindings/keybinding-service.test.ts": 140, - "src/main/kimi/hook-service-config-delete-race.test.ts": 124, + "src/main/ipc/workspace-ports.test.ts": 92, + "src/main/ipc/workspace-space.test.ts": 66, + "src/main/ipc/worktree-base-directory-change-collector.test.ts": 54, + "src/main/ipc/worktree-base-directory-event-filter.test.ts": 72, + "src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts": 204, + "src/main/ipc/worktree-base-directory-poller.test.ts": 2668, + "src/main/ipc/worktree-base-directory-watch-targets.test.ts": 437, + "src/main/ipc/worktree-base-directory-watcher.test.ts": 293, + "src/main/ipc/worktree-create-lineage.test.ts": 958, + "src/main/ipc/worktree-folder-rename-target.test.ts": 96, + "src/main/ipc/worktree-git-common-polling.test.ts": 1046, + "src/main/ipc/worktree-git-common-watch-reconciliation.test.ts": 112, + "src/main/ipc/worktree-git-common-watch.test.ts": 2683, + "src/main/ipc/worktree-git-status-ref-watch.test.ts": 76, + "src/main/ipc/worktree-head-identity-reader-concurrency.test.ts": 77, + "src/main/ipc/worktree-head-identity-reader-incremental.test.ts": 113, + "src/main/ipc/worktree-head-identity-reader.test.ts": 85, + "src/main/ipc/worktree-head-identity-refresh.test.ts": 87, + "src/main/ipc/worktree-head-identity-retirement.test.ts": 79, + "src/main/ipc/worktree-include-copy-budget.test.ts": 129, + "src/main/ipc/worktree-logic-created-agent.test.ts": 97, + "src/main/ipc/worktree-logic-wsl.test.ts": 104, + "src/main/ipc/worktree-logic.test.ts": 143, + "src/main/ipc/worktree-metadata-merge.test.ts": 52, + "src/main/ipc/worktree-path-deduplication.test.ts": 77, + "src/main/ipc/worktree-push-target-cleanup.test.ts": 454, + "src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts": 789, + "src/main/ipc/worktree-push-target-reconciliation.test.ts": 466, + "src/main/ipc/worktree-push-target-refspec-migration.test.ts": 188, + "src/main/ipc/worktree-push-target-refspec-real-git.test.ts": 1650, + "src/main/ipc/worktree-push-target-remote-scan.test.ts": 440, + "src/main/ipc/worktree-push-target-setup.test.ts": 464, + "src/main/ipc/worktree-remote-push-target-materialization.test.ts": 772, + "src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts": 860, + "src/main/ipc/worktree-symlink-reconciliation.real.test.ts": 465, + "src/main/ipc/worktree-symlinks.test.ts": 155, + "src/main/ipc/worktree-watcher-removal-binding.test.ts": 51, + "src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts": 1670, + "src/main/ipc/worktrees-background-removal.test.ts": 1866, + "src/main/ipc/worktrees-canonical-ssh-listing.test.ts": 1212, + "src/main/ipc/worktrees-create-event-timing.test.ts": 1165, + "src/main/ipc/worktrees-create-metadata-persistence.test.ts": 1531, + "src/main/ipc/worktrees-delete-pty-teardown.test.ts": 1507, + "src/main/ipc/worktrees-detected-scan-cache.test.ts": 2006, + "src/main/ipc/worktrees-discovery-metadata-backfill.test.ts": 1324, + "src/main/ipc/worktrees-existing-branch-checkout.test.ts": 1158, + "src/main/ipc/worktrees-failed-removal.test.ts": 1242, + "src/main/ipc/worktrees-forget-local.test.ts": 1316, + "src/main/ipc/worktrees-issue-command-overrides.test.ts": 1354, + "src/main/ipc/worktrees-lineage-hydration.test.ts": 1297, + "src/main/ipc/worktrees-listing-fallback-rows.test.ts": 1487, + "src/main/ipc/worktrees-local-base-ref-resolution.test.ts": 1454, + "src/main/ipc/worktrees-local-create-flow.test.ts": 1392, + "src/main/ipc/worktrees-mutation-scan-generation-order.test.ts": 1353, + "src/main/ipc/worktrees-orphan-directory-cleanup.test.ts": 1334, + "src/main/ipc/worktrees-removal-recovery.test.ts": 1321, + "src/main/ipc/worktrees-remove-archive-hooks.test.ts": 1873, + "src/main/ipc/worktrees-remove-preflight.test.ts": 1662, + "src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts": 1390, + "src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts": 1298, + "src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts": 1305, + "src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts": 1327, + "src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts": 1478, + "src/main/ipc/worktrees-ssh-local-base-refresh.test.ts": 1443, + "src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts": 1459, + "src/main/ipc/worktrees-ssh-provider-authority.test.ts": 1177, + "src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts": 1671, + "src/main/ipc/worktrees-windows.test.ts": 1489, + "src/main/ipc/worktrees-wsl-runtime-routing.test.ts": 1166, + "src/main/ipc/worktrees/listing/detected-provider-listing-catalog-version.test.ts": 337, + "src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts": 1057, + "src/main/ipc/worktrees/listing/detected-provider-listing-overtaken-scan.test.ts": 390, + "src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts": 1030, + "src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts": 161, + "src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts": 93, + "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/worktrees/removal/remove-unregistered-worktree-host-home.test.ts": 402, + "src/main/ipc/worktrees/removal/worktree-archive-hook.test.ts": 442, + "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, + "src/main/jcode/hook-command-quoting.test.ts": 54, + "src/main/jcode/hook-config.test.ts": 80, + "src/main/jcode/hook-gate-script.test.ts": 118, + "src/main/jcode/hook-service.test.ts": 114, + "src/main/jira/adf-markdown.test.ts": 57, + "src/main/jira/attachment-image-cache-generation.test.ts": 63, + "src/main/jira/attachment-image-cache.test.ts": 76, + "src/main/jira/attachment-images.test.ts": 80, + "src/main/jira/client.test.ts": 285, + "src/main/jira/issues.test.ts": 170, + "src/main/jira/jira-issue-mutations.test.ts": 100, + "src/main/jira/jira-issue-summary-timeout.test.ts": 80, + "src/main/jira/jira-search-abort.test.ts": 94, + "src/main/jsonl-rpc/agent-connection.test.ts": 224, + "src/main/jsonl-rpc/peer.test.ts": 223, + "src/main/jsonl-rpc/timeline-final-tail.test.ts": 523, + "src/main/jsonl-rpc/timeline-lane.test.ts": 471, + "src/main/keybindings/keybinding-file.test.ts": 185, + "src/main/keybindings/keybinding-service.test.ts": 138, + "src/main/kimi/hook-service-config-delete-race.test.ts": 90, "src/main/kimi/hook-service-mode-read-failure.test.ts": 86, - "src/main/kimi/hook-service.test.ts": 161, - "src/main/kimi/kimi-hook-config-toml.test.ts": 94, - "src/main/kimi/kimi-runtime-home.test.ts": 62, - "src/main/lib/unread-response-body.test.ts": 92, - "src/main/line-editor-ready-output-scanner.test.ts": 55, - "src/main/linear/client.test.ts": 107, - "src/main/linear/issue-context-client.test.ts": 61, - "src/main/linear/issue-context-current.test.ts": 63, - "src/main/linear/issue-context-errors.test.ts": 52, - "src/main/linear/issue-context-includes.test.ts": 66, - "src/main/linear/issue-context-inline-media.test.ts": 76, - "src/main/linear/issue-context-relations.test.ts": 37, - "src/main/linear/issue-context.test.ts": 66, - "src/main/linear/issue-list-filter.test.ts": 44, - "src/main/linear/issue-relation-write.test.ts": 79, - "src/main/linear/issue-search-summary.test.ts": 50, - "src/main/linear/issues.test.ts": 114, - "src/main/linear/linear-workspace-registry.test.ts": 39, - "src/main/linear/mappers.test.ts": 53, - "src/main/linear/mcp-issue-list-pagination.test.ts": 93, - "src/main/linear/mcp-issue-list.test.ts": 105, - "src/main/linear/projects.test.ts": 369, - "src/main/linear/teams.test.ts": 84, - "src/main/linux-lid-sleep-assertion.test.ts": 93, - "src/main/linux-package-install-command.test.ts": 46, - "src/main/linux-package-install-diagnostic.test.ts": 79, - "src/main/linux-package-update-recovery.test.ts": 361, - "src/main/linux-update-package-type.test.ts": 98, - "src/main/local-builds/local-build-candidate.test.ts": 265, - "src/main/local-builds/local-build-compatibility-contract.test.ts": 162, - "src/main/local-builds/local-build-feed-server.test.ts": 191, - "src/main/local-downloaded-folder-promotion.test.ts": 185, - "src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts": 54, - "src/main/local-worktree-filesystem.test.ts": 348, - "src/main/local-worktree-metadata-prune-gate.test.ts": 60, - "src/main/local-worktree-path-presence.test.ts": 77, - "src/main/local-worktree-removal-recovery-live.test.ts": 198, - "src/main/local-worktree-removal-recovery.test.ts": 123, - "src/main/localhost-worktree-label-proxy.test.ts": 141, - "src/main/macos-full-disk-access-status.test.ts": 54, - "src/main/macos-keychain/generic-password.test.ts": 61, - "src/main/macos-press-and-hold-default.defaults-domain.test.ts": 51, + "src/main/kimi/hook-service.test.ts": 93, + "src/main/kimi/kimi-hook-config-toml.test.ts": 109, + "src/main/kimi/kimi-runtime-home.test.ts": 66, + "src/main/kiro/hook-service.test.ts": 152, + "src/main/lib/html-to-pdf.test.ts": 79, + "src/main/lib/unread-response-body.test.ts": 60, + "src/main/line-editor-ready-output-scanner.test.ts": 49, + "src/main/linear/client.test.ts": 174, + "src/main/linear/issue-context-client.test.ts": 86, + "src/main/linear/issue-context-current.test.ts": 58, + "src/main/linear/issue-context-errors.test.ts": 45, + "src/main/linear/issue-context-includes.test.ts": 92, + "src/main/linear/issue-context-inline-media.test.ts": 85, + "src/main/linear/issue-context-relations.test.ts": 58, + "src/main/linear/issue-context.test.ts": 77, + "src/main/linear/issue-list-filter.test.ts": 55, + "src/main/linear/issue-relation-write.test.ts": 74, + "src/main/linear/issue-search-summary.test.ts": 45, + "src/main/linear/issues.test.ts": 133, + "src/main/linear/linear-description-images.test.ts": 163, + "src/main/linear/linear-issue-detail-deadline.test.ts": 225, + "src/main/linear/linear-workspace-registry.test.ts": 52, + "src/main/linear/mappers.test.ts": 43, + "src/main/linear/mcp-issue-list-pagination.test.ts": 100, + "src/main/linear/mcp-issue-list.test.ts": 142, + "src/main/linear/projects.test.ts": 394, + "src/main/linear/teams.test.ts": 85, + "src/main/linux-lid-sleep-assertion.test.ts": 95, + "src/main/linux-package-install-command.test.ts": 104, + "src/main/linux-package-install-diagnostic.test.ts": 71, + "src/main/linux-package-update-recovery.test.ts": 226, + "src/main/linux-update-package-type.test.ts": 140, + "src/main/local-builds/local-build-candidate.test.ts": 178, + "src/main/local-builds/local-build-compatibility-contract.test.ts": 295, + "src/main/local-builds/local-build-feed-server.test.ts": 88, + "src/main/local-downloaded-folder-promotion.test.ts": 163, + "src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts": 81, + "src/main/local-worktree-filesystem.test.ts": 368, + "src/main/local-worktree-metadata-prune-gate.test.ts": 56, + "src/main/local-worktree-path-presence.test.ts": 70, + "src/main/local-worktree-removal-recovery-live.test.ts": 385, + "src/main/local-worktree-removal-recovery.test.ts": 131, + "src/main/localhost-worktree-label-proxy.test.ts": 126, + "src/main/macos-full-disk-access-status.test.ts": 49, + "src/main/macos-keychain/generic-password.test.ts": 57, + "src/main/macos-press-and-hold-default.defaults-domain.test.ts": 68, "src/main/macos-press-and-hold-default.test.ts": 95, - "src/main/macos-system-sleep-assertion.test.ts": 73, - "src/main/macos-tcc-prompt-notice.test.ts": 61, - "src/main/macos-tcc-prompt-watch.test.ts": 66, - "src/main/macos-tcc-reset.test.ts": 41, - "src/main/main-process-tree-kill-gate.test.ts": 378, - "src/main/memory/collector-windows-sweep.test.ts": 455, - "src/main/memory/collector.test.ts": 333, + "src/main/macos-system-sleep-assertion.test.ts": 71, + "src/main/macos-tcc-prompt-notice.test.ts": 96, + "src/main/macos-tcc-prompt-watch.test.ts": 94, + "src/main/macos-tcc-reset.test.ts": 67, + "src/main/macos-update-running-instances.integration.test.ts": 47, + "src/main/macos-update-running-instances.test.ts": 69, + "src/main/managed-data-accounts/launch-environment.test.ts": 342, + "src/main/managed-data-accounts/original-environment.test.ts": 316, + "src/main/managed-data-accounts/profile-removal-recovery.test.ts": 629, + "src/main/managed-data-accounts/service-enrollment-persistence.test.ts": 397, + "src/main/managed-data-accounts/service.test.ts": 1418, + "src/main/media/media-preview-protocol.test.ts": 82, + "src/main/media/media-range-response.test.ts": 78, + "src/main/memory/collector-windows-sweep.test.ts": 699, + "src/main/memory/collector.test.ts": 815, "src/main/memory/host-memory.test.ts": 78, - "src/main/memory/hydrate-local-pty-registry.test.ts": 774, - "src/main/memory/memory-snapshot-buckets.test.ts": 56, - "src/main/memory/process-memory-metric.test.ts": 49, - "src/main/memory/windows-process-sample-parsing.test.ts": 95, - "src/main/menu/gpu-acceleration-about-panel.test.ts": 30, - "src/main/menu/register-app-menu.test.ts": 65, - "src/main/mimo/hook-service.test.ts": 66, - "src/main/minimax/minimax-api-key-store.test.ts": 88, - "src/main/minimax/minimax-cookie-store.test.ts": 71, - "src/main/muse-usage/scanner.test.ts": 162, - "src/main/muse-usage/snapshot-rollups.test.ts": 49, - "src/main/muse-usage/store-snapshot-filtering.test.ts": 475, - "src/main/muse/hook-config-json.test.ts": 85, - "src/main/muse/hook-service.test.ts": 134, - "src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts": 59, - "src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts": 550, - "src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts": 52, - "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts": 355, - "src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts": 346, - "src/main/native-chat/agent-session-journal/journal-cursor.test.ts": 54, - "src/main/native-chat/agent-session-journal/journal-database.test.ts": 177, - "src/main/native-chat/agent-session-journal/journal-dispatch-observation.test.ts": 45, - "src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts": 246, - "src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts": 234, - "src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts": 269, - "src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts": 5325, - "src/main/native-chat/agent-session-journal/journal-item-identity.test.ts": 64, - "src/main/native-chat/agent-session-journal/journal-legacy-import-source-bound.test.ts": 236, - "src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts": 451, - "src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts": 238, - "src/main/native-chat/agent-session-journal/journal-reducer.test.ts": 155, - "src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts": 324, - "src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts": 232, - "src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts": 248, - "src/main/native-chat/agent-session-journal/journal-row-schema.test.ts": 127, - "src/main/native-chat/agent-session-journal/journal-row-writer.test.ts": 187, - "src/main/native-chat/agent-session-journal/journal-store-close.test.ts": 249, - "src/main/native-chat/agent-session-journal/journal-store-schema.test.ts": 222, - "src/main/native-chat/agent-session-journal/journal-store.test.ts": 401, - "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts": 1019, - "src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts": 243, - "src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts": 200, - "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts": 40, - "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts": 41, - "src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts": 173, - "src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts": 497, - "src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts": 1663, - "src/main/native-chat/agent-session-wire/agent-session-history-page-grouping-parity.test.ts": 309, - "src/main/native-chat/agent-session-wire/agent-session-history-page-scaling.test.ts": 75, - "src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts": 2331, - "src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts": 427, - "src/main/native-chat/agent-session-wire/agent-session-retired-provider-exit-copy-ratchet.test.ts": 1719, - "src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts": 52, - "src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts": 97, - "src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts": 210, - "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts": 1113, - "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts": 83, - "src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts": 47, - "src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts": 386, - "src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts": 626, - "src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts": 393, - "src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts": 687, - "src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts": 674, - "src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts": 404, - "src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts": 556, - "src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts": 297, - "src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts": 1198, - "src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts": 409, - "src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts": 259, - "src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts": 255, - "src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts": 514, - "src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts": 766, - "src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts": 780, - "src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts": 448, - "src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts": 72, - "src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts": 300, - "src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts": 170, - "src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts": 369, - "src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts": 1870, - "src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts": 442, - "src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts": 736, - "src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts": 301, - "src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts": 423, - "src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts": 389, - "src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts": 809, - "src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts": 600, - "src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts": 718, - "src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts": 1713, - "src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts": 477, - "src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts": 255, - "src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts": 558, - "src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts": 972, - "src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts": 370, - "src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts": 51, - "src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts": 816, - "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts": 739, - "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts": 297, - "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts": 3078, - "src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts": 48, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts": 785, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts": 330, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts": 1273, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts": 829, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts": 711, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts": 3393, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts": 320, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts": 48, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts": 131, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts": 354, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts": 613, - "src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts": 900, - "src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts": 351, - "src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts": 1675, - "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts": 225, - "src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts": 2117, - "src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts": 871, - "src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts": 42, - "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts": 1405, - "src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts": 812, - "src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts": 358, - "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts": 271, - "src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts": 168, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts": 288, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts": 311, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts": 699, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts": 99, - "src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts": 791, - "src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts": 1004, - "src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts": 551, - "src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts": 2023, - "src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts": 89, - "src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts": 284, - "src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts": 67, - "src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts": 295, - "src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts": 405, - "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts": 127, - "src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts": 2947, - "src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts": 643, - "src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts": 57, - "src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts": 1455, - "src/main/native-chat/agent-session-wire/structured-conversation-replacements.test.ts": 330, - "src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts": 123, - "src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts": 36, - "src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts": 64, - "src/main/native-chat/claude-structured-managed-account-support.test.ts": 37, - "src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 257, - "src/main/native-chat/host-readable-transcript-path.test.ts": 89, - "src/main/native-chat/native-chat-file-provenance.test.ts": 134, - "src/main/native-chat/session-file-resolver-claude-roots.test.ts": 131, - "src/main/native-chat/session-file-resolver-codex-roots.test.ts": 66, - "src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts": 95, - "src/main/native-chat/session-file-resolver-wsl.test.ts": 92, - "src/main/native-chat/session-file-resolver.test.ts": 108, - "src/main/native-chat/structured-agent-session-create-support.test.ts": 39, - "src/main/native-chat/structured-agent-session-history-adoption.test.ts": 141, - "src/main/native-chat/subagent-entry-id-bounds.test.ts": 37, - "src/main/native-chat/transcript-fallback-id.test.ts": 47, - "src/main/native-chat/transcript-interruption-message.test.ts": 50, - "src/main/native-chat/transcript-line-decoders-claude-control-bytes.test.ts": 51, - "src/main/native-chat/transcript-line-decoders-claude-image-companion.test.ts": 81, - "src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts": 48, - "src/main/native-chat/transcript-line-decoders.grok.test.ts": 64, - "src/main/native-chat/transcript-line-decoders.omp.test.ts": 95, - "src/main/native-chat/transcript-read-cache-inflight.test.ts": 512, - "src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts": 135, - "src/main/native-chat/transcript-read-cache-wsl-stall.test.ts": 58, - "src/main/native-chat/transcript-read-cache.test.ts": 193, - "src/main/native-chat/transcript-reader-codex-history-mode.test.ts": 132, - "src/main/native-chat/transcript-reader-wsl-stall.test.ts": 114, - "src/main/native-chat/transcript-reader.test.ts": 176, - "src/main/native-chat/transcript-stream-lines.test.ts": 83, - "src/main/native-chat/transcript-tail-reader-cancellation.test.ts": 210, - "src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts": 91, - "src/main/native-chat/transcript-turn-lifecycle.test.ts": 95, - "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts": 217, - "src/main/native-chat/transcript-watch-error.test.ts": 2170, - "src/main/native-chat/transcript-watch-liveness.test.ts": 762, - "src/main/native-chat/transcript-watch-resolve-poll.test.ts": 203, - "src/main/native-chat/transcript-watch-unflushed-settle.test.ts": 201, - "src/main/native-chat/transcript-watch-unsubscribe-race.test.ts": 133, - "src/main/native-chat/transcript-watch-wsl-exact-path.test.ts": 105, - "src/main/native-chat/transcript-watch-wsl-stall.test.ts": 170, - "src/main/native-chat/transcript-watch.test.ts": 3388, - "src/main/native-chat/transcript-window-tool-attribution.test.ts": 146, - "src/main/native-chat/wsl-session-path-scan.test.ts": 100, - "src/main/native-chat/wsl-transcript-fs-access.test.ts": 147, - "src/main/native-chat/wsl-transcript-fs-gate.test.ts": 538, - "src/main/native-chat/wsl-transcript-fs-process-client.test.ts": 79, - "src/main/native-chat/wsl-transcript-fs-process-operations.test.ts": 38, - "src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts": 76, + "src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts": 129, + "src/main/memory/hydrate-local-pty-registry.test.ts": 757, + "src/main/memory/memory-snapshot-buckets.test.ts": 104, + "src/main/memory/process-memory-metric.test.ts": 47, + "src/main/memory/verified-local-folder-workspaces.test.ts": 63, + "src/main/memory/windows-process-sample-parsing.test.ts": 105, + "src/main/menu/gpu-acceleration-about-panel.test.ts": 53, + "src/main/menu/register-app-menu.test.ts": 176, + "src/main/mimo/hook-service.test.ts": 120, + "src/main/minimax/minimax-api-key-store.test.ts": 107, + "src/main/minimax/minimax-cookie-store.test.ts": 113, + "src/main/muse-usage/scanner.test.ts": 218, + "src/main/muse-usage/snapshot-rollups.test.ts": 62, + "src/main/muse-usage/store-snapshot-filtering.test.ts": 705, + "src/main/muse/hook-config-json.test.ts": 81, + "src/main/muse/hook-service.test.ts": 135, + "src/main/muse/hook-settings.test.ts": 107, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts": 119, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.test.ts": 52, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts": 342, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts": 325, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts": 1592, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts": 87, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts": 299, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts": 609, + "src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts": 103, + "src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts": 203, + "src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts": 637, + "src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts": 129, + "src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts": 664, + "src/main/native-chat/agent-session-journal/command-receipt-schema.test.ts": 1186, + "src/main/native-chat/agent-session-journal/command-receipt-table.test.ts": 994, + "src/main/native-chat/agent-session-journal/command-receipt-transaction.test.ts": 595, + "src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts": 215, + "src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts": 1761, + "src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts": 242, + "src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts": 626, + "src/main/native-chat/agent-session-journal/journal-cursor.test.ts": 58, + "src/main/native-chat/agent-session-journal/journal-damage.test.ts": 696, + "src/main/native-chat/agent-session-journal/journal-database.test.ts": 465, + "src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts": 366, + "src/main/native-chat/agent-session-journal/journal-dispatch-observation.test.ts": 57, + "src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts": 626, + "src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts": 261, + "src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts": 596, + "src/main/native-chat/agent-session-journal/journal-host-database.test.ts": 889, + "src/main/native-chat/agent-session-journal/journal-item-identity.test.ts": 76, + "src/main/native-chat/agent-session-journal/journal-legacy-import-source-bound.test.ts": 281, + "src/main/native-chat/agent-session-journal/journal-legacy-import-tool-pairing.test.ts": 479, + "src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts": 1038, + "src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.test.ts": 781, + "src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts": 13771, + "src/main/native-chat/agent-session-journal/journal-open-failure.test.ts": 384, + "src/main/native-chat/agent-session-journal/journal-payload-bounds-retention.test.ts": 337, + "src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts": 561, + "src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts": 513, + "src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts": 343, + "src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts": 339, + "src/main/native-chat/agent-session-journal/journal-reducer.test.ts": 439, + "src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts": 335, + "src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts": 846, + "src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts": 690, + "src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts": 524, + "src/main/native-chat/agent-session-journal/journal-row-schema.test.ts": 461, + "src/main/native-chat/agent-session-journal/journal-row-writer.test.ts": 605, + "src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts": 548, + "src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts": 349, + "src/main/native-chat/agent-session-journal/journal-store-schema.test.ts": 667, + "src/main/native-chat/agent-session-journal/journal-store.test.ts": 753, + "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts": 1439, + "src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts": 658, + "src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts": 588, + "src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts": 547, + "src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts": 749, + "src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts": 407, + "src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts": 753, + "src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts": 652, + "src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts": 742, + "src/main/native-chat/agent-session-journal/journal-write-queue.test.ts": 425, + "src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts": 663, + "src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts": 62, + "src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts": 638, + "src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts": 303, + "src/main/native-chat/agent-session-journal/queued-message-pause.test.ts": 808, + "src/main/native-chat/agent-session-journal/queued-message-store.test.ts": 1498, + "src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts": 346, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts": 577, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts": 460, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts": 483, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts": 840, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts": 704, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts": 802, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts": 484, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts": 857, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts": 773, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts": 1106, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts": 577, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-tool-ends.test.ts": 642, + "src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts": 502, + "src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts": 64, + "src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts": 468, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-overflow-prefix.test.ts": 101, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts": 79, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts": 74, + "src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts": 440, + "src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts": 917, + "src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts": 1364, + "src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts": 1062, + "src/main/native-chat/agent-session-wire/agent-session-history-page-grouping-parity.test.ts": 229, + "src/main/native-chat/agent-session-wire/agent-session-history-page-scaling.test.ts": 78, + "src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts": 1739, + "src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts": 853, + "src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts": 51, + "src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts": 91, + "src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts": 464, + "src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts": 2781, + "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts": 1351, + "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts": 891, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts": 355, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts": 391, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts": 233, + "src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts": 1223, + "src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts": 1257, + "src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts": 1891, + "src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts": 624, + "src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts": 1373, + "src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts": 349, + "src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts": 987, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts": 1595, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts": 1289, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts": 1404, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts": 4766, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts": 1212, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts": 4570, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts": 1253, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts": 3995, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts": 3545, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts": 1652, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts": 4314, + "src/main/native-chat/agent-session-wire/structured-agent-session-clear-queued-command.test.ts": 1453, + "src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts": 2367, + "src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts": 941, + "src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts": 1320, + "src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts": 1347, + "src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts": 3905, + "src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts": 1042, + "src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts": 459, + "src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts": 1873, + "src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts": 3529, + "src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts": 746, + "src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts": 1635, + "src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts": 992, + "src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts": 1433, + "src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts": 1584, + "src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts": 843, + "src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts": 201, + "src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts": 985, + "src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts": 876, + "src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts": 434, + "src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts": 1316, + "src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts": 1186, + "src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts": 223, + "src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts": 40, + "src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts": 1449, + "src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts": 1196, + "src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts": 580, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts": 915, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts": 699, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts": 473, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts": 1168, + "src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts": 1737, + "src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts": 2591, + "src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts": 3775, + "src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts": 957, + "src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts": 486, + "src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts": 1481, + "src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts": 355, + "src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts": 1218, + "src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts": 706, + "src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts": 1500, + "src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts": 1117, + "src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts": 190, + "src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts": 1078, + "src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts": 1098, + "src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts": 1244, + "src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts": 1201, + "src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts": 519, + "src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts": 1248, + "src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts": 365, + "src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts": 198, + "src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts": 3318, + "src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts": 917, + "src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts": 1125, + "src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel-repeat.test.ts": 782, + "src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts": 914, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts": 3062, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts": 1138, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts": 758, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts": 1956, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts": 5719, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts": 1912, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts": 5019, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts": 1908, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts": 1540, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts": 5700, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts": 1096, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts": 6327, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.test.ts": 957, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts": 1221, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts": 1544, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts": 4507, + "src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts": 1569, + "src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts": 628, + "src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts": 543, + "src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts": 461, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts": 1364, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts": 1017, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts": 4093, + "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts": 360, + "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts": 1144, + "src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts": 314, + "src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts": 47, + "src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts": 999, + "src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts": 1072, + "src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts": 1207, + "src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts": 2268, + "src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts": 1204, + "src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts": 235, + "src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts": 576, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts": 1011, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts": 686, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts": 1345, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts": 399, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts": 242, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts": 518, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts": 1326, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts": 1335, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts": 1285, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts": 1212, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts": 2691, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts": 2611, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts": 2206, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts": 269, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts": 1503, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts": 67, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts": 649, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts": 1297, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts": 632, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts": 1232, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts": 1339, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts": 1379, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts": 959, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts": 434, + "src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts": 427, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts": 1079, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts": 1097, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts": 2657, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts": 280, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts": 796, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts": 1279, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts": 3251, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts": 2021, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts": 337, + "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts": 2285, + "src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts": 1282, + "src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts": 478, + "src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts": 1364, + "src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts": 1452, + "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts": 1043, + "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts": 1011, + "src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts": 1029, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts": 3748, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts": 2188, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts": 64, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts": 392, + "src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts": 1817, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts": 570, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts": 454, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts": 643, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts": 787, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts": 595, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts": 649, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts": 524, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts": 904, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts": 470, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts": 251, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts": 1002, + "src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts": 1701, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts": 890, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts": 2907, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts": 5698, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts": 1979, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts": 2801, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts": 1766, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts": 701, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts": 513, + "src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts": 1319, + "src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts": 457, + "src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts": 3973, + "src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts": 1318, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-background-tasks.test.ts": 643, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts": 5915, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts": 1011, + "src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts": 2430, + "src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts": 98, + "src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts": 847, + "src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts": 829, + "src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts": 328, + "src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts": 1030, + "src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts": 276, + "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts": 480, + "src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts": 195, + "src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts": 968, + "src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts": 1496, + "src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts": 5952, + "src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts": 1354, + "src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts": 1592, + "src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts": 922, + "src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts": 326, + "src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts": 939, + "src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts": 196, + "src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts": 1502, + "src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts": 2102, + "src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts": 273, + "src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts": 234, + "src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts": 332, + "src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 359, + "src/main/native-chat/host-readable-transcript-path.test.ts": 160, + "src/main/native-chat/native-chat-file-provenance.test.ts": 575, + "src/main/native-chat/native-chat-inline-visuals-setting.test.ts": 536, + "src/main/native-chat/native-chat-visual-file-read.test.ts": 85, + "src/main/native-chat/native-chat-visuals-delivery.test.ts": 343, + "src/main/native-chat/native-chat-visuals-sweep.test.ts": 78, + "src/main/native-chat/session-file-resolver-claude-roots.test.ts": 142, + "src/main/native-chat/session-file-resolver-codex-roots.test.ts": 136, + "src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts": 141, + "src/main/native-chat/session-file-resolver-wsl.test.ts": 139, + "src/main/native-chat/session-file-resolver.test.ts": 217, + "src/main/native-chat/ssh-transcript-path.test.ts": 40, + "src/main/native-chat/structured-agent-command-resolution.test.ts": 86, + "src/main/native-chat/structured-agent-configured-args.test.ts": 48, + "src/main/native-chat/structured-agent-session-create-support.test.ts": 54, + "src/main/native-chat/structured-agent-session-history-adoption.test.ts": 279, + "src/main/native-chat/structured-chat-naming-command-first.test.ts": 1258, + "src/main/native-chat/structured-chat-naming-status-hook.test.ts": 2387, + "src/main/native-chat/structured-chat-naming.test.ts": 225, + "src/main/native-chat/subagent-entry-id-bounds.test.ts": 51, + "src/main/native-chat/subagent-roster-retention.test.ts": 88, + "src/main/native-chat/transcript-fallback-id.test.ts": 52, + "src/main/native-chat/transcript-file-version.test.ts": 67, + "src/main/native-chat/transcript-interruption-message.test.ts": 100, + "src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts": 96, + "src/main/native-chat/transcript-line-decoders-claude-control-bytes.test.ts": 104, + "src/main/native-chat/transcript-line-decoders-claude-image-companion.test.ts": 102, + "src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts": 108, + "src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts": 97, + "src/main/native-chat/transcript-line-decoders.grok.test.ts": 132, + "src/main/native-chat/transcript-line-decoders.omp.test.ts": 142, + "src/main/native-chat/transcript-opencode-database.test.ts": 107, + "src/main/native-chat/transcript-opencode-read-bounds.test.ts": 589, + "src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts": 2910, + "src/main/native-chat/transcript-opencode-sqlite-query.test.ts": 594, + "src/main/native-chat/transcript-opencode-subscribe.test.ts": 5611, + "src/main/native-chat/transcript-opencode.test.ts": 405, + "src/main/native-chat/transcript-opencode2-sqlite.test.ts": 10052, + "src/main/native-chat/transcript-read-cache-inflight.test.ts": 1054, + "src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts": 580, + "src/main/native-chat/transcript-read-cache-wsl-stall.test.ts": 758, + "src/main/native-chat/transcript-read-cache.test.ts": 652, + "src/main/native-chat/transcript-reader-codex-history-mode.test.ts": 636, + "src/main/native-chat/transcript-reader-wsl-stall.test.ts": 637, + "src/main/native-chat/transcript-reader.test.ts": 607, + "src/main/native-chat/transcript-record-copy-counts.test.ts": 487, + "src/main/native-chat/transcript-stream-lines.test.ts": 103, + "src/main/native-chat/transcript-tail-reader-cancellation.test.ts": 701, + "src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts": 611, + "src/main/native-chat/transcript-tool-result-identity.test.ts": 537, + "src/main/native-chat/transcript-turn-lifecycle.test.ts": 105, + "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts": 730, + "src/main/native-chat/transcript-watch-error.test.ts": 2634, + "src/main/native-chat/transcript-watch-inplace-drain-race.test.ts": 960, + "src/main/native-chat/transcript-watch-liveness.test.ts": 1270, + "src/main/native-chat/transcript-watch-resolve-poll.test.ts": 784, + "src/main/native-chat/transcript-watch-unflushed-settle.test.ts": 820, + "src/main/native-chat/transcript-watch-unsubscribe-race.test.ts": 624, + "src/main/native-chat/transcript-watch-wsl-exact-path.test.ts": 594, + "src/main/native-chat/transcript-watch-wsl-stall.test.ts": 693, + "src/main/native-chat/transcript-watch.test.ts": 2600, + "src/main/native-chat/transcript-window-tool-attribution.test.ts": 616, + "src/main/native-chat/wsl-session-path-scan.test.ts": 107, + "src/main/native-chat/wsl-transcript-fs-access.test.ts": 161, + "src/main/native-chat/wsl-transcript-fs-gate.test.ts": 585, + "src/main/native-chat/wsl-transcript-fs-process-client.test.ts": 115, + "src/main/native-chat/wsl-transcript-fs-process-operations.test.ts": 55, + "src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts": 98, "src/main/native-chat/wsl-transcript-fs-route.test.ts": 44, - "src/main/native-chat/wsl-transcript-running-observer.test.ts": 119, - "src/main/network/electron-proxy-request-guard.test.ts": 351, - "src/main/network/macos-system-resolver-health.test.ts": 74, - "src/main/network/macos-tailscale-dns-diagnostic.test.ts": 47, - "src/main/network/proxy-settings-session.test.ts": 837, - "src/main/network/proxy-settings.test.ts": 90, - "src/main/notebook/notebook-kernel.test.ts": 160, - "src/main/notebook/python-environments.test.ts": 82, - "src/main/notifications/announced-notification-registry.test.ts": 38, - "src/main/notifications/desktop-away-state.test.ts": 54, - "src/main/notifications/notification-delivery-service.test.ts": 69, - "src/main/observability/architecture.test.ts": 56, - "src/main/observability/bundle.test.ts": 471, - "src/main/observability/diagnostic-upload-http.test.ts": 50, - "src/main/observability/instrumentation.test.ts": 472, - "src/main/observability/local-file-sink-memory.test.ts": 149, - "src/main/observability/local-file-sink.test.ts": 84, - "src/main/observability/redactor-environment-lines.test.ts": 546, - "src/main/observability/redactor.test.ts": 74, - "src/main/observability/tracer.test.ts": 74, - "src/main/opencode-usage/cache-read-reproduction.test.ts": 173, - "src/main/opencode-usage/opencode2-session-usage.test.ts": 528, - "src/main/opencode-usage/scanner-windows-data-directory.test.ts": 164, - "src/main/opencode-usage/scanner-wsl-gate.test.ts": 49, - "src/main/opencode-usage/scanner.test.ts": 182, - "src/main/opencode-usage/store.test.ts": 141, - "src/main/opencode/hook-plugin-background-child-completion.test.ts": 405, - "src/main/opencode/hook-plugin-child-attention.test.ts": 698, - "src/main/opencode/hook-plugin-fail-open-ownership.test.ts": 519, - "src/main/opencode/hook-plugin-lifecycle-delivery.test.ts": 1151, - "src/main/opencode/hook-plugin-message-part-throttle.test.ts": 306, - "src/main/opencode/hook-plugin-module-contract.test.ts": 601, - "src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts": 1056, - "src/main/opencode/hook-plugin-opencode2-setup.test.ts": 1712, - "src/main/opencode/hook-service.test.ts": 275, - "src/main/opencode/opencode-client-sweep.test.ts": 66, - "src/main/opencode/opencode-data-directory.test.ts": 51, + "src/main/native-chat/wsl-transcript-running-observer.test.ts": 104, + "src/main/nested-worktree-removal-plan.test.ts": 87, + "src/main/network/electron-proxy-request-guard.test.ts": 381, + "src/main/network/macos-system-resolver-health.test.ts": 71, + "src/main/network/macos-tailscale-dns-diagnostic.test.ts": 58, + "src/main/network/macos-tailscale-dns-probe-admission.test.ts": 225, + "src/main/network/proxy-settings-session.test.ts": 883, + "src/main/network/proxy-settings.test.ts": 120, + "src/main/notebook/notebook-kernel.test.ts": 148, + "src/main/notebook/python-environments.test.ts": 126, + "src/main/notifications/announced-notification-registry.test.ts": 253, + "src/main/notifications/desktop-away-state.test.ts": 62, + "src/main/notifications/notification-delivery-service.test.ts": 87, + "src/main/observability/bundle.test.ts": 487, + "src/main/observability/diagnostic-upload-http.test.ts": 48, + "src/main/observability/instrumentation.test.ts": 235, + "src/main/observability/local-file-sink-memory.test.ts": 135, + "src/main/observability/local-file-sink.test.ts": 81, + "src/main/observability/redactor-environment-lines.test.ts": 217, + "src/main/observability/redactor.test.ts": 86, + "src/main/observability/tracer.test.ts": 91, + "src/main/opencode-usage/cache-read-reproduction.test.ts": 244, + "src/main/opencode-usage/cache-write-accounting.test.ts": 197, + "src/main/opencode-usage/opencode2-session-usage.test.ts": 906, + "src/main/opencode-usage/scanner-windows-data-directory.test.ts": 207, + "src/main/opencode-usage/scanner-wsl-gate.test.ts": 98, + "src/main/opencode-usage/scanner.test.ts": 349, + "src/main/opencode-usage/store.test.ts": 424, + "src/main/opencode/hook-plugin-background-child-completion.test.ts": 375, + "src/main/opencode/hook-plugin-child-attention.test.ts": 759, + "src/main/opencode/hook-plugin-dispose-host.test.ts": 404, + "src/main/opencode/hook-plugin-fail-open-ownership.test.ts": 553, + "src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts": 2096, + "src/main/opencode/hook-plugin-lifecycle-delivery.test.ts": 1063, + "src/main/opencode/hook-plugin-message-part-throttle.test.ts": 266, + "src/main/opencode/hook-plugin-module-contract.test.ts": 682, + "src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts": 1037, + "src/main/opencode/hook-plugin-opencode2-setup.test.ts": 2250, + "src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts": 1275, + "src/main/opencode/hook-plugin-run-process-session-start.test.ts": 183, + "src/main/opencode/hook-service-invalid-tui-config.test.ts": 111, + "src/main/opencode/hook-service-legacy-acl.test.ts": 251, + "src/main/opencode/hook-service-overlay-confinement.test.ts": 113, + "src/main/opencode/hook-service-startup-refresh.test.ts": 89, + "src/main/opencode/hook-service.test.ts": 350, + "src/main/opencode/legacy-shared-config-dir.test.ts": 54, + "src/main/opencode/opencode-acp-stored-messages.test.ts": 134, + "src/main/opencode/opencode-cli-version.test.ts": 53, + "src/main/opencode/opencode-client-sweep.test.ts": 119, + "src/main/opencode/opencode-credential-backend.test.ts": 79, + "src/main/opencode/opencode-data-directory.test.ts": 50, + "src/main/opencode/opencode-go-api-key-store.test.ts": 97, + "src/main/opencode/opencode-launch-capabilities.test.ts": 67, + "src/main/opencode/opencode-launch-model-config.test.ts": 217, + "src/main/opencode/opencode-launch-model-context-cleanup.test.ts": 4256, + "src/main/opencode/opencode-launch-model-context-supervision.test.ts": 2443, + "src/main/opencode/opencode-launch-model-context.test.ts": 201, + "src/main/opencode/opencode-model-availability.test.ts": 71, + "src/main/opencode/opencode-model-catalog-listing.test.ts": 190, + "src/main/opencode/opencode-model-startup-plan.test.ts": 263, + "src/main/opencode/opencode-model-version-policy.test.ts": 41, + "src/main/opencode/opencode-overlay-manifest.test.ts": 39, + "src/main/opencode/opencode-pty-launch.test.ts": 321, "src/main/opencode/opencode-session-binder.test.ts": 118, - "src/main/opencode2/status-plugin-setup-lifetime.test.ts": 51, - "src/main/orca-profiles/profile-active-transfer-worker.test.ts": 2219, - "src/main/orca-profiles/profile-active-transfer.test.ts": 1956, - "src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts": 371, - "src/main/orca-profiles/profile-cloud-auth-config.test.ts": 57, - "src/main/orca-profiles/profile-cloud-auth-status.test.ts": 66, - "src/main/orca-profiles/profile-cloud-client.test.ts": 127, - "src/main/orca-profiles/profile-cloud-dev-service.test.ts": 328, - "src/main/orca-profiles/profile-cloud-org-members-client.test.ts": 68, - "src/main/orca-profiles/profile-cloud-org-members-service.test.ts": 197, - "src/main/orca-profiles/profile-cloud-pkce.test.ts": 620, - "src/main/orca-profiles/profile-cloud-refresh-replay-guard.test.ts": 53, - "src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts": 296, - "src/main/orca-profiles/profile-cloud-service-connect-overlap.test.ts": 311, - "src/main/orca-profiles/profile-cloud-service-refresh.test.ts": 315, - "src/main/orca-profiles/profile-cloud-service-sign-out-connect-race.test.ts": 378, - "src/main/orca-profiles/profile-cloud-service.test.ts": 445, - "src/main/orca-profiles/profile-cloud-session-mutation.test.ts": 95, - "src/main/orca-profiles/profile-cloud-session-refresh.test.ts": 97, - "src/main/orca-profiles/profile-cloud-session-store.test.ts": 130, - "src/main/orca-profiles/profile-index-store.test.ts": 700, - "src/main/orca-profiles/profile-persistence-deadline.test.ts": 39, - "src/main/orca-profiles/profile-project-domain-transfer.test.ts": 1889, - "src/main/orca-profiles/profile-project-presence.test.ts": 408, - "src/main/orca-profiles/profile-project-session-field-disposition.test.ts": 190, - "src/main/orca-profiles/profile-project-session-state.test.ts": 175, - "src/main/orca-profiles/profile-project-state-file.test.ts": 264, - "src/main/orca-profiles/profile-project-transfer-migration.test.ts": 1155, - "src/main/orca-profiles/profile-project-transfer.test.ts": 1755, - "src/main/orca-profiles/profile-telemetry-consent-seed.test.ts": 221, - "src/main/orca-profiles/profile-ui-scope.test.ts": 28, - "src/main/orcad/electron-serve-browser-process.test.ts": 2507, - "src/main/orcad/electron-serve-provider-selection.test.ts": 696, - "src/main/orcad/electron-sidecar-method-routing.test.ts": 105, - "src/main/orcad/electron-sidecar-tab-registry.test.ts": 78, - "src/main/orcad/external-chromium-browser-process.integration.test.ts": 233, - "src/main/orcad/external-chromium-browser-session.test.ts": 172, - "src/main/orcad/main-preflight-order.test.ts": 106, - "src/main/orcad/native-host-abi.test.ts": 58, - "src/main/orcad/node-pty-prebuilt-slot.test.ts": 64, - "src/main/orcad/node-pty-precondition.test.ts": 310, - "src/main/orcad/orcad-app-paths.test.ts": 73, - "src/main/orcad/orcad-artifact-preflight.test.ts": 377, - "src/main/orcad/orcad-bind-address.test.ts": 57, - "src/main/orcad/orcad-browser-provider.test.ts": 336, - "src/main/orcad/orcad-bundle-native-load-order.test.ts": 2639, - "src/main/orcad/orcad-bundled-runtime.integration.test.ts": 5450, - "src/main/orcad/orcad-bundled-runtime.test.ts": 90, - "src/main/orcad/orcad-daemon-supervision.test.ts": 35, - "src/main/orcad/orcad-entry.test.ts": 287, - "src/main/orcad/orcad-forked-child-paths.test.ts": 352, - "src/main/orcad/orcad-health.test.ts": 49, - "src/main/orcad/orcad-instance-lock.test.ts": 91, - "src/main/orcad/orcad-launch-contract.test.ts": 405, - "src/main/orcad/orcad-native-preflight.test.ts": 71, - "src/main/orcad/orcad-node-launcher.integration.test.ts": 50, - "src/main/orcad/orcad-profile-preflight.test.ts": 246, - "src/main/orcad/orcad-profile-state-startup.test.ts": 55, - "src/main/orcad/orcad-profile-state-telemetry.test.ts": 36, - "src/main/orcad/orcad-push-startup.test.ts": 1287, - "src/main/orcad/orcad-runtime-native-preflight.test.ts": 113, - "src/main/orcad/orcad-sidecar-runtime-client.test.ts": 211, - "src/main/own-chromium-tree-kill-guard.test.ts": 99, - "src/main/persistence-async-write-syscalls.test.ts": 3167, - "src/main/persistence-automations.test.ts": 2205, - "src/main/persistence-clipboard-selection-migration.test.ts": 1235, - "src/main/persistence-cohort-and-identity-migration.test.ts": 1307, - "src/main/persistence-cross-host-pane-identity.test.ts": 1023, - "src/main/persistence-deregistered-repo-residue.test.ts": 1319, - "src/main/persistence-duplicate-repo-id-host-scope.test.ts": 1500, - "src/main/persistence-feature-interaction-broadcast.benchmark.test.ts": 1424, - "src/main/persistence-floating-terminal-trust.test.ts": 1342, - "src/main/persistence-flush-and-save-scheduling.test.ts": 3251, - "src/main/persistence-folder-workspace-notes.test.ts": 1466, - "src/main/persistence-host-admitted-terminal-membership.test.ts": 1200, - "src/main/persistence-host-partitioned-sessions.test.ts": 1016, - "src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts": 1205, - "src/main/persistence-initial-load.test.ts": 1188, - "src/main/persistence-layout-binding-recovery.test.ts": 1127, - "src/main/persistence-load-repair-durability.test.ts": 693, - "src/main/persistence-loading-store-extraction.test.ts": 1319, - "src/main/persistence-loading-store-write-risks.test.ts": 1386, - "src/main/persistence-native-chat-tab-view-mode.test.ts": 1121, - "src/main/persistence-pane-identity-migration.test.ts": 28713, - "src/main/persistence-protected-secret-fail-closed.test.ts": 5578, - "src/main/persistence-protected-secret-write-race.test.ts": 1294, - "src/main/persistence-proxy-secret-recovery.test.ts": 1954, - "src/main/persistence-pty-binding-leaf-tab-resolution.test.ts": 938, - "src/main/persistence-pty-binding-reconciliation.test.ts": 1174, - "src/main/persistence-remote-session-startup.test.ts": 1128, - "src/main/persistence-repo-lifecycle.test.ts": 3659, - "src/main/persistence-right-sidebar-tab.test.ts": 1040, - "src/main/persistence-settings-ui-defaults.test.ts": 1660, - "src/main/persistence-settings-update.test.ts": 1245, - "src/main/persistence-single-serialize.test.ts": 2736, - "src/main/persistence-source-control-ai-migration.test.ts": 683, - "src/main/persistence-split-pane-incarnation.test.ts": 1213, - "src/main/persistence-ssh-lease-reattach-reclaim.test.ts": 1164, - "src/main/persistence-ssh-lease-tombstone-retention.test.ts": 1178, - "src/main/persistence-ssh-pending-pty-kill.test.ts": 1764, - "src/main/persistence-ssh-readoption-automation-migration.test.ts": 1863, - "src/main/persistence-ssh-remote-pty-binding-replay.test.ts": 799, - "src/main/persistence-ssh-remote-pty-leases.test.ts": 1111, - "src/main/persistence-ssh-targets-and-pane-keys.test.ts": 1001, - "src/main/persistence-terminal-option-key-migration.test.ts": 1316, - "src/main/persistence-ui-state.test.ts": 3909, - "src/main/persistence-update-repo.test.ts": 1077, - "src/main/persistence-workspace-pinned-automation-fence.test.ts": 1833, - "src/main/persistence-workspace-repin-automation-owner.test.ts": 1750, - "src/main/persistence-workspace-session-scrollback.test.ts": 1387, - "src/main/persistence-workspace-status-workflow.test.ts": 1055, - "src/main/persistence-worktree-card-properties.test.ts": 576, - "src/main/persistence-worktree-deletion-fencing.test.ts": 1370, - "src/main/persistence-worktree-lineage-and-backups.test.ts": 1778, - "src/main/persistence-worktree-meta-and-folder-workspaces.test.ts": 818, - "src/main/persistence-worktree-name-retirement.test.ts": 6044, - "src/main/persistence-worktree-visibility.test.ts": 1446, - "src/main/persistence/applying-settings/settings-update-machine-name.test.ts": 235, - "src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts": 177, - "src/main/persistence/applying-settings/terminal-settings-migrations.test.ts": 55, - "src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.test.ts": 221, - "src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts": 356, - "src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts": 261, - "src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts": 228, - "src/main/persistence/loading-store/persisted-state-redundancy.test.ts": 1195, - "src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts": 1418, - "src/main/persistence/loading-store/profile-state-checkpoints.test.ts": 1468, - "src/main/persistence/loading-store/profile-state-direct-flush.test.ts": 776, - "src/main/persistence/loading-store/profile-state-import-lifetime.test.ts": 882, - "src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts": 2555, - "src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts": 1334, - "src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts": 1797, - "src/main/persistence/loading-store/profile-state-maintenance.test.ts": 2795, - "src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts": 3208, - "src/main/persistence/loading-store/profile-state-selective-serialization.test.ts": 216, - "src/main/persistence/loading-store/profile-state-settings-writes.test.ts": 1551, - "src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts": 8170, - "src/main/persistence/loading-store/profile-state-store-backups.test.ts": 3107, - "src/main/persistence/loading-store/profile-state-temp-cleanup-lifetime.test.ts": 1081, - "src/main/persistence/loading-store/profile-state-update-quit.test.ts": 1347, - "src/main/persistence/loading-store/profile-state-worker-coordination.test.ts": 1583, - "src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts": 1293, - "src/main/persistence/loading-store/profile-state-write-batching.test.ts": 1364, - "src/main/persistence/loading-store/pty-binding-async-durability.test.ts": 1459, - "src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts": 806, - "src/main/persistence/loading-store/pty-binding-fast-lane.test.ts": 168, - "src/main/persistence/loading-store/pty-binding-span.test.ts": 104, - "src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts": 3450, - "src/main/persistence/loading-store/pty-retirement-async-durability.test.ts": 893, - "src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts": 2239, - "src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts": 4488, - "src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts": 3961, - "src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts": 4045, - "src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts": 4185, - "src/main/persistence/loading-store/secret-sentinel-substitution.test.ts": 92, - "src/main/persistence/loading-store/ssh-lease-async-durability.test.ts": 914, - "src/main/persistence/loading-store/state-write-round-trip.test.ts": 1233, - "src/main/persistence/loading-store/store-prune-gate-signals.test.ts": 1151, - "src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts": 1062, - "src/main/persistence/loading-store/terminal-tab-pty-ownership.test.ts": 35, - "src/main/persistence/loading-store/workspace-session-partitions.test.ts": 252, - "src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts": 68, - "src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts": 1809, - "src/main/persistence/loading-store/worktree-meta-write-normalization.test.ts": 68, - "src/main/persistence/profile-state-cutover-fixture.test.ts": 1259, - "src/main/persistence/profile-state-cutover-soak.test.ts": 1782, - "src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.test.ts": 122, - "src/main/persistence/profile-state/profile-state-access-identity.test.ts": 125, - "src/main/persistence/profile-state/profile-state-access-process.test.ts": 706, - "src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts": 50, - "src/main/persistence/profile-state/profile-state-access-windows.test.ts": 181, - "src/main/persistence/profile-state/profile-state-access.test.ts": 218, - "src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts": 2390, - "src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts": 187, - "src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts": 206, - "src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts": 225, - "src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts": 2866, - "src/main/persistence/profile-state/profile-state-backup-path.test.ts": 40, - "src/main/persistence/profile-state/profile-state-backup-rotation.test.ts": 519, - "src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts": 76, - "src/main/persistence/profile-state/profile-state-backup-worker.test.ts": 2514, - "src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts": 1143, - "src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts": 324, - "src/main/persistence/profile-state/profile-state-crash-recovery.test.ts": 442, - "src/main/persistence/profile-state/profile-state-current-json-command.test.ts": 2724, - "src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts": 1546, - "src/main/persistence/profile-state/profile-state-database-export-crash.test.ts": 221, - "src/main/persistence/profile-state/profile-state-database-recovery.test.ts": 1228, - "src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts": 1283, - "src/main/persistence/profile-state/profile-state-database-snapshot.test.ts": 1947, - "src/main/persistence/profile-state/profile-state-database.test.ts": 1004, - "src/main/persistence/profile-state/profile-state-documents.test.ts": 758, - "src/main/persistence/profile-state/profile-state-domain-equality.test.ts": 197, - "src/main/persistence/profile-state/profile-state-domain-reader.test.ts": 134, - "src/main/persistence/profile-state/profile-state-domain-writes.test.ts": 354, - "src/main/persistence/profile-state/profile-state-fragment-validation.test.ts": 978, - "src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts": 1250, - "src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts": 3070, - "src/main/persistence/profile-state/profile-state-live-store-factory.test.ts": 1747, - "src/main/persistence/profile-state/profile-state-offline-settings.test.ts": 286, - "src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts": 367, - "src/main/persistence/profile-state/profile-state-read-concurrency.test.ts": 112, - "src/main/persistence/profile-state/profile-state-recovery-batch.test.ts": 142, - "src/main/persistence/profile-state/profile-state-recovery-copy.test.ts": 92, - "src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts": 34081, - "src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts": 567, - "src/main/persistence/profile-state/profile-state-startup-authority.test.ts": 2198, - "src/main/persistence/profile-state/profile-state-startup-failure.test.ts": 1080, - "src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts": 95, - "src/main/persistence/profile-state/profile-state-startup-secrets.test.ts": 1403, - "src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts": 1390, - "src/main/persistence/profile-state/profile-state-store-factory.test.ts": 2255, - "src/main/persistence/profile-state/profile-state-streaming-validation.test.ts": 246, - "src/main/persistence/profile-state/profile-state-worker-authority.test.ts": 1304, - "src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts": 905, - "src/main/persistence/profile-state/profile-state-write-transaction.test.ts": 42, - "src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts": 2015, - "src/main/persistence/profile-state/profile-state-writer-worker.test.ts": 1074, - "src/main/persistence/restoring-sessions/pane-alias-normalization-scaling.test.ts": 70, - "src/main/persistence/restoring-sessions/pane-alias-normalization.test.ts": 399, - "src/main/persistence/restoring-sessions/pane-key-remapping.test.ts": 26, - "src/main/persistence/restoring-sessions/session-owner-fields.test.ts": 141, - "src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts": 205, - "src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts": 53, - "src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts": 69, - "src/main/persistence/runtime-authored-workspace-session-fields.test.ts": 51, - "src/main/persistence/scheduling-automations/automation-run-operations.test.ts": 62, - "src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts": 248, - "src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts": 152, - "src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts": 301, - "src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts": 218, - "src/main/persistence/tracking-repos/project-host-compatibility.test.ts": 36, - "src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts": 147, - "src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts": 182, - "src/main/pi/agent-status-completion-delivery.test.ts": 899, - "src/main/pi/agent-status-extension-async-subagents.test.ts": 1070, - "src/main/pi/agent-status-extension-nested-run-settlement.test.ts": 588, - "src/main/pi/agent-status-extension-omp-lifecycle.test.ts": 2513, - "src/main/pi/agent-status-extension-omp-model.test.ts": 1180, - "src/main/pi/agent-status-extension-omp-prefill.test.ts": 615, - "src/main/pi/agent-status-extension-source.test.ts": 2899, - "src/main/pi/agent-status-input-redaction.test.ts": 1930, - "src/main/pi/agent-status-omp-approval-forwarding.test.ts": 1049, - "src/main/pi/agent-status-owner-recovery.test.ts": 1135, - "src/main/pi/agent-status-ui-prompt.test.ts": 1344, - "src/main/pi/omp-model-command-source.test.ts": 619, - "src/main/pi/omp-session-status-owner.test.ts": 780, - "src/main/pi/omp-transcript-metadata.test.ts": 1593, - "src/main/pi/prefill-extension-source.test.ts": 36, - "src/main/pi/titlebar-extension-overlay-path.test.ts": 92, - "src/main/pi/titlebar-extension-service.test.ts": 524, - "src/main/pi/titlebar-extension-source.test.ts": 1416, - "src/main/plugins/plugin-agent-status-event.test.ts": 86, - "src/main/plugins/plugin-atomic-file-write.test.ts": 2056, - "src/main/plugins/plugin-audit-log-scaling.test.ts": 497, - "src/main/plugins/plugin-audit-log.test.ts": 73, - "src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts": 364, - "src/main/plugins/plugin-bundled-bootstrap.test.ts": 468, - "src/main/plugins/plugin-command-registry.test.ts": 271, - "src/main/plugins/plugin-content-pack-registry.test.ts": 303, - "src/main/plugins/plugin-content-safety.test.ts": 716, - "src/main/plugins/plugin-dev-watcher.test.ts": 165, - "src/main/plugins/plugin-discovery.test.ts": 193, - "src/main/plugins/plugin-enablement.test.ts": 194, - "src/main/plugins/plugin-host-conformance.test.ts": 202, - "src/main/plugins/plugin-host-methods.test.ts": 173, - "src/main/plugins/plugin-host-process.test.ts": 208, - "src/main/plugins/plugin-host-runtime.test.ts": 193, - "src/main/plugins/plugin-install-trust.test.ts": 378, - "src/main/plugins/plugin-install.test.ts": 787, - "src/main/plugins/plugin-kill-list-content-revocation.test.ts": 321, - "src/main/plugins/plugin-kill-list-service.test.ts": 239, - "src/main/plugins/plugin-language-pack-registry.test.ts": 179, - "src/main/plugins/plugin-launch-content.test.ts": 470, - "src/main/plugins/plugin-list-projection.test.ts": 145, - "src/main/plugins/plugin-marketplace-installer.test.ts": 480, - "src/main/plugins/plugin-marketplace-service.test.ts": 369, - "src/main/plugins/plugin-marketplace-store.test.ts": 135, - "src/main/plugins/plugin-panel-controller.test.ts": 210, - "src/main/plugins/plugin-panel-navigation-guard.test.ts": 140, - "src/main/plugins/plugin-panel-owner-lifecycle.test.ts": 54, - "src/main/plugins/plugin-panel-sessions.test.ts": 50, - "src/main/plugins/plugin-private-marketplace.integration.test.ts": 685, - "src/main/plugins/plugin-secrets-store.test.ts": 219, - "src/main/plugins/plugin-service-integrity.test.ts": 408, - "src/main/plugins/plugin-service-reconciliation.test.ts": 597, - "src/main/plugins/plugin-startup-budget.test.ts": 768, - "src/main/plugins/plugin-storage-store.test.ts": 198, - "src/main/plugins/plugin-uninstall-lifecycle.test.ts": 740, - "src/main/plugins/plugin-uninstall-log-retention.test.ts": 974, - "src/main/plugins/plugin-uninstall-ordering.test.ts": 967, - "src/main/plugins/plugin-vm-recipe-registry.test.ts": 236, - "src/main/plugins/plugin-worker-controller.test.ts": 393, - "src/main/plugins/plugin-worker-env.test.ts": 35, - "src/main/plugins/plugin-worker-manager.test.ts": 165, - "src/main/plugins/plugin-worker-output-buffer.test.ts": 54, - "src/main/plugins/plugin-worker-output-retention.test.ts": 229, - "src/main/plugins/plugin-worker-supervision.integration.test.ts": 8081, - "src/main/ports/advertised-url-retention.test.ts": 163, - "src/main/ports/advertised-url-watcher-pid-validation.test.ts": 53, - "src/main/ports/advertised-url-watcher.test.ts": 59, - "src/main/ports/local-workspace-port-scanner.test.ts": 119, - "src/main/ports/port-scan-command-client.test.ts": 1765, - "src/main/ports/port-scan-command-execution.test.ts": 5284, - "src/main/ports/port-scan-command-import-boundary.test.ts": 39, - "src/main/ports/ssh-advertised-url-enrichment.test.ts": 96, - "src/main/ports/workspace-port-ownership.test.ts": 36, - "src/main/powershell-osc133-bootstrap-windows-clm.test.ts": 84, - "src/main/powershell-osc133-bootstrap.test.ts": 81, - "src/main/project-groups/folder-workspace-path-status.test.ts": 72, - "src/main/project-groups/nested-repo-discovery-queue.test.ts": 201, - "src/main/project-groups/nested-repo-discovery.test.ts": 177, - "src/main/project-groups/nested-repo-glob-budget.test.ts": 841, - "src/main/project-groups/nested-repo-import-target.test.ts": 58, - "src/main/project-groups/nested-repo-import.test.ts": 58, - "src/main/project-groups/nested-repo-scan-rules.test.ts": 31, - "src/main/project-runtime-git-options.test.ts": 73, - "src/main/protected-secret-persistence-concurrency.test.ts": 68, - "src/main/protected-secret-persistence.test.ts": 50, - "src/main/providers/agent-foreground-process-batch.test.ts": 91, - "src/main/providers/agent-foreground-process-git-bash-launcher.test.ts": 87, - "src/main/providers/agent-foreground-process-git-bash.win32.test.ts": 196, - "src/main/providers/agent-foreground-process-pi.test.ts": 82, - "src/main/providers/agent-foreground-process-ps-scan-volume.test.ts": 105, - "src/main/providers/agent-foreground-process-real-rows.test.ts": 105, - "src/main/providers/agent-foreground-process-remote-evidence.test.ts": 81, - "src/main/providers/agent-foreground-process-shell-path.test.ts": 76, - "src/main/providers/agent-foreground-process-windows-job-rejection.test.ts": 48, - "src/main/providers/agent-foreground-process-wsl-shell-proof.test.ts": 108, - "src/main/providers/agent-foreground-process.test.ts": 149, - "src/main/providers/execution-host-provider-dispatch.test.ts": 33, - "src/main/providers/local-pty-child-process-verdict.test.ts": 280, - "src/main/providers/local-pty-default-shell.test.ts": 124, - "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 140, - "src/main/providers/local-pty-pending-native-spawn.test.ts": 281, - "src/main/providers/local-pty-pi-owner-env.test.ts": 108, - "src/main/providers/local-pty-provider-foreground-process.test.ts": 413, - "src/main/providers/local-pty-provider-io-events.test.ts": 295, - "src/main/providers/local-pty-provider-session-inventory.test.ts": 323, - "src/main/providers/local-pty-provider-shell-readiness.test.ts": 240, - "src/main/providers/local-pty-provider-shutdown.test.ts": 652, - "src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts": 305, - "src/main/providers/local-pty-provider-spawn-env.test.ts": 308, - "src/main/providers/local-pty-provider-spawn-session.test.ts": 410, - "src/main/providers/local-pty-provider-windows-shell-launch.test.ts": 301, - "src/main/providers/local-pty-shell-ready-marker-scan.test.ts": 61, - "src/main/providers/local-pty-shell-ready-startup-command.test.ts": 66, - "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts": 544, - "src/main/providers/local-pty-shell-startup-command.node-pty.test.ts": 253, - "src/main/providers/local-pty-utils-windows-fallback.test.ts": 81, - "src/main/providers/local-pty-utils.test.ts": 81, - "src/main/providers/macos-login-session-pty-probe.test.ts": 42, - "src/main/providers/macos-tcc-login-shell.test.ts": 146, - "src/main/providers/posix-pane-foreground-fingerprint.test.ts": 42, - "src/main/providers/process-cwd.test.ts": 141, - "src/main/providers/provider-dispatch.test.ts": 1559, - "src/main/providers/pty-default-cwd.test.ts": 58, - "src/main/providers/pty-process-inspection.test.ts": 54, - "src/main/providers/pty-process-list-admission.test.ts": 108, - "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1943, - "src/main/providers/settled-pty-writer-census.test.ts": 646, - "src/main/providers/ssh-filesystem-dispatch.test.ts": 41, - "src/main/providers/ssh-filesystem-doc-preview.test.ts": 61, - "src/main/providers/ssh-filesystem-download.test.ts": 183, - "src/main/providers/ssh-filesystem-provider-capabilities.test.ts": 74, - "src/main/providers/ssh-filesystem-provider-download-folder.test.ts": 79, - "src/main/providers/ssh-filesystem-provider-range.test.ts": 84, - "src/main/providers/ssh-filesystem-provider-stream.test.ts": 164, - "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts": 617, - "src/main/providers/ssh-filesystem-provider.test.ts": 323, - "src/main/providers/ssh-filesystem-watch-notifications.test.ts": 53, - "src/main/providers/ssh-git-dispatch.test.ts": 55, - "src/main/providers/ssh-git-provider-api.test.ts": 54, - "src/main/providers/ssh-git-provider-commit-message.test.ts": 68, - "src/main/providers/ssh-git-provider-diff.test.ts": 141, - "src/main/providers/ssh-git-provider-exec.test.ts": 104, - "src/main/providers/ssh-git-provider-merge.test.ts": 95, - "src/main/providers/ssh-git-provider-remote-sync.test.ts": 99, - "src/main/providers/ssh-git-provider-staging.test.ts": 112, - "src/main/providers/ssh-git-provider-status-lease.test.ts": 79, - "src/main/providers/ssh-git-provider-status.test.ts": 106, - "src/main/providers/ssh-git-provider-upstream-lease.test.ts": 78, - "src/main/providers/ssh-git-provider-worktree.test.ts": 140, - "src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 103, - "src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 54, - "src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 207, - "src/main/providers/ssh-pty-notification-rejection-routing.test.ts": 196, - "src/main/providers/ssh-pty-notification-routing.test.ts": 92, - "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts": 123, - "src/main/providers/ssh-pty-provider-claim-incarnation.test.ts": 109, - "src/main/providers/ssh-pty-provider-exit-race.test.ts": 200, - "src/main/providers/ssh-pty-provider-process-events.test.ts": 157, - "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts": 138, - "src/main/providers/ssh-pty-provider-spawn.test.ts": 124, - "src/main/providers/ssh-pty-provider-terminal-repair.test.ts": 213, - "src/main/providers/ssh-pty-provider.test.ts": 232, - "src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts": 69, - "src/main/providers/ssh-pty-relay-absence-verdict.test.ts": 157, - "src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 49, - "src/main/providers/ssh-pty-write.test.ts": 182, - "src/main/providers/ssh-worktree-catalog-authority.test.ts": 429, + "src/main/opencode/opencode-startup-prompt-claims.test.ts": 59, + "src/main/opencode/opencode-startup-prompt-installer.test.ts": 181, + "src/main/opencode/opencode-startup-prompt-owner.test.ts": 81, + "src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts": 4479, + "src/main/opencode/opencode-startup-prompt-source.test.ts": 4927, + "src/main/opencode/opencode-status-plugin-startup-refresh.test.ts": 52, + "src/main/opencode/opencode-structured-account-home.test.ts": 198, + "src/main/opencode/opencode-wsl-probe-environment.test.ts": 148, + "src/main/opencode/overlay-dir-gc-lifecycle.test.ts": 48, + "src/main/opencode/overlay-dir-gc.test.ts": 131, + "src/main/opencode/overlay-source-ownership.test.ts": 64, + "src/main/opencode/status-plugin-shared-server-capability.test.ts": 43, + "src/main/opencode2/status-plugin-setup-lifetime.test.ts": 98, + "src/main/orca-profiles/other-profile-workspace-catalog.test.ts": 385, + "src/main/orca-profiles/profile-active-transfer-worker.test.ts": 2466, + "src/main/orca-profiles/profile-active-transfer.test.ts": 1924, + "src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts": 640, + "src/main/orca-profiles/profile-cloud-auth-config.test.ts": 65, + "src/main/orca-profiles/profile-cloud-auth-status.test.ts": 72, + "src/main/orca-profiles/profile-cloud-client.test.ts": 148, + "src/main/orca-profiles/profile-cloud-dev-service.test.ts": 511, + "src/main/orca-profiles/profile-cloud-org-members-client.test.ts": 75, + "src/main/orca-profiles/profile-cloud-org-members-service.test.ts": 478, + "src/main/orca-profiles/profile-cloud-pkce.test.ts": 615, + "src/main/orca-profiles/profile-cloud-refresh-replay-guard.test.ts": 91, + "src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts": 535, + "src/main/orca-profiles/profile-cloud-service-connect-overlap.test.ts": 688, + "src/main/orca-profiles/profile-cloud-service-refresh.test.ts": 548, + "src/main/orca-profiles/profile-cloud-service-sign-out-connect-race.test.ts": 529, + "src/main/orca-profiles/profile-cloud-service.test.ts": 541, + "src/main/orca-profiles/profile-cloud-session-mutation.test.ts": 96, + "src/main/orca-profiles/profile-cloud-session-refresh.test.ts": 86, + "src/main/orca-profiles/profile-cloud-session-store.test.ts": 176, + "src/main/orca-profiles/profile-index-store.test.ts": 1403, + "src/main/orca-profiles/profile-persistence-deadline.test.ts": 61, + "src/main/orca-profiles/profile-project-domain-transfer.test.ts": 2843, + "src/main/orca-profiles/profile-project-presence.test.ts": 680, + "src/main/orca-profiles/profile-project-session-field-disposition.test.ts": 367, + "src/main/orca-profiles/profile-project-session-state.test.ts": 326, + "src/main/orca-profiles/profile-project-state-file.test.ts": 384, + "src/main/orca-profiles/profile-project-transfer-migration.test.ts": 1054, + "src/main/orca-profiles/profile-project-transfer.test.ts": 1132, + "src/main/orca-profiles/profile-session-owner-transfer.test.ts": 268, + "src/main/orca-profiles/profile-telemetry-consent-seed.test.ts": 537, + "src/main/orca-profiles/profile-ui-scope.test.ts": 57, + "src/main/orcad-migration-export-holds.test.ts": 286, + "src/main/orcad/electron-serve-browser-process.test.ts": 2791, + "src/main/orcad/electron-serve-provider-selection.test.ts": 808, + "src/main/orcad/electron-sidecar-method-routing.test.ts": 304, + "src/main/orcad/electron-sidecar-tab-registry.test.ts": 207, + "src/main/orcad/external-chromium-browser-process.integration.test.ts": 429, + "src/main/orcad/external-chromium-browser-process.test.ts": 235, + "src/main/orcad/external-chromium-browser-session.test.ts": 239, + "src/main/orcad/main-preflight-order.test.ts": 808, + "src/main/orcad/native-host-abi.test.ts": 50, + "src/main/orcad/node-pty-prebuilt-slot.test.ts": 302, + "src/main/orcad/node-pty-precondition.test.ts": 614, + "src/main/orcad/orcad-app-paths.test.ts": 47, + "src/main/orcad/orcad-artifact-cache-retention.test.ts": 300, + "src/main/orcad/orcad-artifact-preflight.test.ts": 458, + "src/main/orcad/orcad-automations.test.ts": 2225, + "src/main/orcad/orcad-bind-address.test.ts": 41, + "src/main/orcad/orcad-browser-provider.test.ts": 420, + "src/main/orcad/orcad-browser-startup-cancellation.test.ts": 65, + "src/main/orcad/orcad-browser-startup.test.ts": 61, + "src/main/orcad/orcad-bundle-native-load-order.test.ts": 2425, + "src/main/orcad/orcad-bundled-runtime.integration.test.ts": 4817, + "src/main/orcad/orcad-bundled-runtime.test.ts": 97, + "src/main/orcad/orcad-cli-launcher.test.ts": 116, + "src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts": 397, + "src/main/orcad/orcad-daemon-retirement.test.ts": 186, + "src/main/orcad/orcad-daemon-supervision.test.ts": 63, + "src/main/orcad/orcad-entry.test.ts": 583, + "src/main/orcad/orcad-forked-child-paths.test.ts": 1742, + "src/main/orcad/orcad-health.test.ts": 269, + "src/main/orcad/orcad-help.integration.test.ts": 4302, + "src/main/orcad/orcad-idle-exit-monitor.test.ts": 66, + "src/main/orcad/orcad-idle-stop-record.test.ts": 265, + "src/main/orcad/orcad-instance-lock.test.ts": 302, + "src/main/orcad/orcad-launch-contract.test.ts": 1502, + "src/main/orcad/orcad-launcher-isolation.integration.test.ts": 728, + "src/main/orcad/orcad-lifecycle-host.test.ts": 313, + "src/main/orcad/orcad-local-serve-selection.test.ts": 651, + "src/main/orcad/orcad-managed-idle-exit.test.ts": 230, + "src/main/orcad/orcad-managed-stop-cancel-race.test.ts": 255, + "src/main/orcad/orcad-managed-stop-cancellation.test.ts": 246, + "src/main/orcad/orcad-managed-stop.test.ts": 1323, + "src/main/orcad/orcad-native-preflight.test.ts": 259, + "src/main/orcad/orcad-node-launcher.integration.test.ts": 67, + "src/main/orcad/orcad-observability.test.ts": 280, + "src/main/orcad/orcad-packaged-node-pty.integration.test.ts": 80, + "src/main/orcad/orcad-process-start-time.test.ts": 62, + "src/main/orcad/orcad-profile-preflight.test.ts": 376, + "src/main/orcad/orcad-profile-state-startup.test.ts": 97, + "src/main/orcad/orcad-profile-state-telemetry.test.ts": 49, + "src/main/orcad/orcad-push-startup.test.ts": 2094, + "src/main/orcad/orcad-runtime-entry-artifacts.test.ts": 2239, + "src/main/orcad/orcad-runtime-lifetime.test.ts": 58, + "src/main/orcad/orcad-runtime-native-preflight.test.ts": 108, + "src/main/orcad/orcad-serve-parity.integration.test.ts": 355, + "src/main/orcad/orcad-sidecar-runtime-client.test.ts": 227, + "src/main/orcad/orcad-stop-request-listener.test.ts": 755, + "src/main/orcad/orcad-stop-request-shutdown.integration.test.ts": 482, + "src/main/orcad/orcad-terminal-census.test.ts": 2253, + "src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts": 74, + "src/main/orcad/serve-profile-ssh-targets.test.ts": 476, + "src/main/own-chromium-tree-kill-guard.test.ts": 158, + "src/main/persistence-async-write-syscalls.test.ts": 3814, + "src/main/persistence-automation-extra-agent-args.test.ts": 1200, + "src/main/persistence-automations.test.ts": 2450, + "src/main/persistence-clipboard-selection-migration.test.ts": 1767, + "src/main/persistence-cohort-and-identity-migration.test.ts": 1422, + "src/main/persistence-cross-host-pane-identity.test.ts": 1564, + "src/main/persistence-deregistered-repo-residue.test.ts": 1665, + "src/main/persistence-duplicate-repo-id-host-scope.test.ts": 1664, + "src/main/persistence-feature-interaction-broadcast.benchmark.test.ts": 1812, + "src/main/persistence-floating-terminal-trust.test.ts": 1766, + "src/main/persistence-flush-and-save-scheduling.test.ts": 2502, + "src/main/persistence-folder-scan-project-groups.test.ts": 1419, + "src/main/persistence-folder-workspace-notes.test.ts": 1820, + "src/main/persistence-host-admitted-terminal-membership.test.ts": 1706, + "src/main/persistence-host-partitioned-sessions.test.ts": 1731, + "src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts": 1409, + "src/main/persistence-initial-load.test.ts": 1921, + "src/main/persistence-layout-binding-recovery.test.ts": 1504, + "src/main/persistence-load-repair-durability.test.ts": 1479, + "src/main/persistence-loading-store-extraction.test.ts": 1733, + "src/main/persistence-loading-store-write-risks.test.ts": 1592, + "src/main/persistence-native-chat-appearance.test.ts": 1702, + "src/main/persistence-native-chat-tab-view-mode.test.ts": 1389, + "src/main/persistence-native-chat-upgrade-tip-audience.test.ts": 1987, + "src/main/persistence-orcad-migration-catalog.test.ts": 1607, + "src/main/persistence-orcad-repository-cache-reimport.test.ts": 1240, + "src/main/persistence-pane-alias-selective-saves.test.ts": 1406, + "src/main/persistence-pane-identity-migration.test.ts": 10029, + "src/main/persistence-protected-secret-fail-closed.test.ts": 1679, + "src/main/persistence-protected-secret-write-race.test.ts": 1450, + "src/main/persistence-proxy-secret-recovery.test.ts": 1456, + "src/main/persistence-pty-binding-leaf-tab-resolution.test.ts": 1655, + "src/main/persistence-pty-binding-reconciliation.test.ts": 1737, + "src/main/persistence-remote-session-startup.test.ts": 1716, + "src/main/persistence-repo-lifecycle.test.ts": 2658, + "src/main/persistence-right-sidebar-tab.test.ts": 1623, + "src/main/persistence-settings-ui-defaults.test.ts": 2488, + "src/main/persistence-settings-update.test.ts": 1778, + "src/main/persistence-single-serialize.test.ts": 1683, + "src/main/persistence-source-control-ai-migration.test.ts": 1814, + "src/main/persistence-split-pane-incarnation.test.ts": 1439, + "src/main/persistence-ssh-lease-reattach-reclaim.test.ts": 1505, + "src/main/persistence-ssh-lease-tombstone-retention.test.ts": 1687, + "src/main/persistence-ssh-pending-pty-kill.test.ts": 2151, + "src/main/persistence-ssh-readoption-automation-migration.test.ts": 1910, + "src/main/persistence-ssh-remote-pty-binding-replay.test.ts": 1287, + "src/main/persistence-ssh-remote-pty-leases.test.ts": 1649, + "src/main/persistence-ssh-targets-and-pane-keys.test.ts": 1785, + "src/main/persistence-status-bar-compact-change-notice.test.ts": 1422, + "src/main/persistence-terminal-option-key-migration.test.ts": 1484, + "src/main/persistence-ui-state.test.ts": 2968, + "src/main/persistence-update-repo.test.ts": 1699, + "src/main/persistence-workspace-attachments.test.ts": 1648, + "src/main/persistence-workspace-pinned-automation-fence.test.ts": 1841, + "src/main/persistence-workspace-repin-automation-owner.test.ts": 2087, + "src/main/persistence-workspace-session-scrollback.test.ts": 1556, + "src/main/persistence-workspace-status-workflow.test.ts": 1917, + "src/main/persistence-worktree-card-properties.test.ts": 1715, + "src/main/persistence-worktree-deletion-fencing.test.ts": 1851, + "src/main/persistence-worktree-lineage-and-backups.test.ts": 1621, + "src/main/persistence-worktree-meta-and-folder-workspaces.test.ts": 1769, + "src/main/persistence-worktree-name-retirement.test.ts": 2258, + "src/main/persistence-worktree-visibility.test.ts": 1878, + "src/main/persistence/applying-settings/notification-settings-normalization.test.ts": 48, + "src/main/persistence/applying-settings/settings-update-machine-name.test.ts": 161, + "src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts": 174, + "src/main/persistence/applying-settings/terminal-settings-migrations.test.ts": 74, + "src/main/persistence/atomic-snapshot-writer.test.ts": 76, + "src/main/persistence/closed-terminal-tab-write-fence.test.ts": 5551, + "src/main/persistence/leasing-ssh-ptys/ssh-normalization-orcad-fence.test.ts": 200, + "src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.test.ts": 373, + "src/main/persistence/loading-store/durable-settings-write.test.ts": 1968, + "src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts": 62, + "src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts": 516, + "src/main/persistence/loading-store/normalize-loaded-explorer-root-migration.test.ts": 289, + "src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts": 369, + "src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts": 370, + "src/main/persistence/loading-store/persisted-state-redundancy.test.ts": 2095, + "src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts": 1569, + "src/main/persistence/loading-store/profile-state-checkpoints.test.ts": 1426, + "src/main/persistence/loading-store/profile-state-direct-flush.test.ts": 1898, + "src/main/persistence/loading-store/profile-state-import-lifetime.test.ts": 1339, + "src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts": 1841, + "src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts": 2130, + "src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts": 2887, + "src/main/persistence/loading-store/profile-state-maintenance.test.ts": 3136, + "src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts": 3327, + "src/main/persistence/loading-store/profile-state-selective-serialization.test.ts": 361, + "src/main/persistence/loading-store/profile-state-settings-writes.test.ts": 1962, + "src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts": 3947, + "src/main/persistence/loading-store/profile-state-store-backups.test.ts": 1838, + "src/main/persistence/loading-store/profile-state-temp-cleanup-lifetime.test.ts": 1843, + "src/main/persistence/loading-store/profile-state-update-quit.test.ts": 2031, + "src/main/persistence/loading-store/profile-state-worker-coordination.test.ts": 1931, + "src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts": 1546, + "src/main/persistence/loading-store/profile-state-write-batching.test.ts": 1637, + "src/main/persistence/loading-store/pty-binding-async-durability.test.ts": 2161, + "src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts": 1817, + "src/main/persistence/loading-store/pty-binding-fast-lane.test.ts": 406, + "src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts": 1843, + "src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts": 1599, + "src/main/persistence/loading-store/pty-binding-span.test.ts": 118, + "src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts": 5933, + "src/main/persistence/loading-store/pty-retirement-async-durability.test.ts": 2298, + "src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts": 5281, + "src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts": 5496, + "src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts": 5526, + "src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts": 5757, + "src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts": 7219, + "src/main/persistence/loading-store/secret-sentinel-substitution.test.ts": 99, + "src/main/persistence/loading-store/ssh-lease-async-durability.test.ts": 1576, + "src/main/persistence/loading-store/state-write-round-trip.test.ts": 1937, + "src/main/persistence/loading-store/store-prune-gate-signals.test.ts": 1724, + "src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts": 1738, + "src/main/persistence/loading-store/store-workspace-session-host-removal.test.ts": 1405, + "src/main/persistence/loading-store/terminal-close-async-durability.test.ts": 5695, + "src/main/persistence/loading-store/terminal-tab-pty-ownership.test.ts": 68, + "src/main/persistence/loading-store/workspace-session-partitions.test.ts": 435, + "src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts": 73, + "src/main/persistence/loading-store/workspace-session-write-listeners.test.ts": 1588, + "src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts": 2168, + "src/main/persistence/loading-store/worktree-meta-write-normalization.test.ts": 98, + "src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.test.ts": 365, + "src/main/persistence/migrating-orcad-catalog/orcad-destination-client-state.test.ts": 405, + "src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.test.ts": 395, + "src/main/persistence/migrating-orcad-catalog/orcad-dormant-metadata-defaults.test.ts": 358, + "src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts": 412, + "src/main/persistence/migrating-orcad-catalog/orcad-source-client-focus-census.test.ts": 603, + "src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts": 472, + "src/main/persistence/migrating-orcad-catalog/orcad-source-client-subtraction.test.ts": 390, + "src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts": 475, + "src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts": 1403, + "src/main/persistence/migrating-orcad-catalog/orcad-source-host-qualified-scope.test.ts": 581, + "src/main/persistence/migrating-orcad-catalog/orcad-source-legacy-key-scope.test.ts": 660, + "src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.test.ts": 404, + "src/main/persistence/migrating-orcad-catalog/orcad-source-partial-partition.test.ts": 486, + "src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts": 71, + "src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts": 222, + "src/main/persistence/migrating-orcad-catalog/orcad-staged-catalog-claims.test.ts": 440, + "src/main/persistence/profile-state-cutover-fixture.test.ts": 1623, + "src/main/persistence/profile-state-cutover-soak.test.ts": 2103, + "src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.test.ts": 134, + "src/main/persistence/profile-state/profile-state-access-identity.test.ts": 166, + "src/main/persistence/profile-state/profile-state-access-process.test.ts": 592, + "src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts": 102, + "src/main/persistence/profile-state/profile-state-access-windows.test.ts": 195, + "src/main/persistence/profile-state/profile-state-access.test.ts": 277, + "src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts": 1809, + "src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts": 225, + "src/main/persistence/profile-state/profile-state-authority-marker-writes.test.ts": 541, + "src/main/persistence/profile-state/profile-state-authority-marker.test.ts": 1460, + "src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts": 203, + "src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts": 416, + "src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts": 2903, + "src/main/persistence/profile-state/profile-state-backup-path.test.ts": 68, + "src/main/persistence/profile-state/profile-state-backup-rotation.test.ts": 730, + "src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts": 94, + "src/main/persistence/profile-state/profile-state-backup-worker.test.ts": 1602, + "src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts": 1679, + "src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts": 379, + "src/main/persistence/profile-state/profile-state-crash-recovery.test.ts": 596, + "src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts": 316, + "src/main/persistence/profile-state/profile-state-current-json-command.test.ts": 2938, + "src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts": 1748, + "src/main/persistence/profile-state/profile-state-database-export-crash.test.ts": 203, + "src/main/persistence/profile-state/profile-state-database-recovery.test.ts": 1415, + "src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts": 1597, + "src/main/persistence/profile-state/profile-state-database-snapshot.test.ts": 454, + "src/main/persistence/profile-state/profile-state-database.test.ts": 243, + "src/main/persistence/profile-state/profile-state-documents.test.ts": 792, + "src/main/persistence/profile-state/profile-state-domain-equality.test.ts": 282, + "src/main/persistence/profile-state/profile-state-domain-reader.test.ts": 178, + "src/main/persistence/profile-state/profile-state-domain-writes.test.ts": 562, + "src/main/persistence/profile-state/profile-state-fragment-validation.test.ts": 1697, + "src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts": 1489, + "src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts": 3444, + "src/main/persistence/profile-state/profile-state-live-store-factory.test.ts": 2219, + "src/main/persistence/profile-state/profile-state-offline-settings.test.ts": 422, + "src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts": 229, + "src/main/persistence/profile-state/profile-state-read-concurrency.test.ts": 200, + "src/main/persistence/profile-state/profile-state-recovery-batch.test.ts": 164, + "src/main/persistence/profile-state/profile-state-recovery-copy.test.ts": 111, + "src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts": 11352, + "src/main/persistence/profile-state/profile-state-recovery-crash-process.test.ts": 228, + "src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts": 602, + "src/main/persistence/profile-state/profile-state-selected-profile-recovery.test.ts": 1692, + "src/main/persistence/profile-state/profile-state-startup-authority.test.ts": 2001, + "src/main/persistence/profile-state/profile-state-startup-failure.test.ts": 1463, + "src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts": 266, + "src/main/persistence/profile-state/profile-state-startup-secrets.test.ts": 1720, + "src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts": 1603, + "src/main/persistence/profile-state/profile-state-store-factory.test.ts": 2673, + "src/main/persistence/profile-state/profile-state-streaming-validation.test.ts": 282, + "src/main/persistence/profile-state/profile-state-worker-authority.test.ts": 1651, + "src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts": 440, + "src/main/persistence/profile-state/profile-state-write-transaction.test.ts": 74, + "src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts": 66, + "src/main/persistence/profile-state/profile-state-writer-close.test.ts": 86, + "src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts": 936, + "src/main/persistence/profile-state/profile-state-writer-sleep.test.ts": 782, + "src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts": 8846, + "src/main/persistence/profile-state/profile-state-writer-worker.test.ts": 1542, + "src/main/persistence/restoring-sessions/pane-alias-normalization-scaling.test.ts": 85, + "src/main/persistence/restoring-sessions/pane-alias-normalization.test.ts": 921, + "src/main/persistence/restoring-sessions/pane-key-remapping.test.ts": 50, + "src/main/persistence/restoring-sessions/session-owner-fields.test.ts": 333, + "src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts": 348, + "src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts": 60, + "src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts": 101, + "src/main/persistence/runtime-authored-workspace-session-fields.test.ts": 49, + "src/main/persistence/scheduling-automations/automation-run-operations.test.ts": 143, + "src/main/persistence/terminal-topology/terminal-late-bind-layout-rules.test.ts": 1562, + "src/main/persistence/terminal-topology/terminal-leaf-move-concurrent-close.test.ts": 1529, + "src/main/persistence/terminal-topology/terminal-leaf-move.test.ts": 1619, + "src/main/persistence/terminal-topology/terminal-owner-invariants.test.ts": 299, + "src/main/persistence/terminal-topology/terminal-owner-report-only.test.ts": 1399, + "src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts": 1626, + "src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts": 4000, + "src/main/persistence/terminal-topology/terminal-topology-commit.test.ts": 280, + "src/main/persistence/terminal-topology/terminal-topology-model.test.ts": 2243, + "src/main/persistence/terminal-topology/workspace-layout-rules.test.ts": 304, + "src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts": 409, + "src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts": 418, + "src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts": 503, + "src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts": 400, + "src/main/persistence/tracking-repos/project-group-session-batch.test.ts": 353, + "src/main/persistence/tracking-repos/project-host-compatibility.test.ts": 61, + "src/main/persistence/tracking-repos/repo-update-host-guard.test.ts": 424, + "src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts": 351, + "src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts": 327, + "src/main/pi/agent-status-completion-delivery.test.ts": 1338, + "src/main/pi/agent-status-extension-async-subagents.test.ts": 1604, + "src/main/pi/agent-status-extension-nested-run-settlement.test.ts": 486, + "src/main/pi/agent-status-extension-omp-lifecycle.test.ts": 2614, + "src/main/pi/agent-status-extension-omp-model.test.ts": 1167, + "src/main/pi/agent-status-extension-omp-prefill.test.ts": 698, + "src/main/pi/agent-status-extension-session-change.test.ts": 2226, + "src/main/pi/agent-status-extension-source.test.ts": 2691, + "src/main/pi/agent-status-input-redaction.test.ts": 1660, + "src/main/pi/agent-status-omp-approval-forwarding.test.ts": 914, + "src/main/pi/agent-status-owner-recovery.test.ts": 1128, + "src/main/pi/agent-status-reload-delivery.test.ts": 680, + "src/main/pi/agent-status-ui-prompt.test.ts": 1662, + "src/main/pi/omp-model-command-source.test.ts": 789, + "src/main/pi/omp-session-status-owner.test.ts": 1010, + "src/main/pi/omp-transcript-metadata.test.ts": 1375, + "src/main/pi/rpc-capture-replay.test.ts": 518, + "src/main/pi/rpc-configured-default.test.ts": 1225, + "src/main/pi/rpc-launch-resolution.test.ts": 810, + "src/main/pi/rpc-launch.test.ts": 432, + "src/main/pi/rpc-location.test.ts": 1032, + "src/main/pi/rpc-message-dialog-shapes.test.ts": 205, + "src/main/pi/rpc-model-catalog-probe.test.ts": 481, + "src/main/pi/rpc-options.test.ts": 208, + "src/main/pi/rpc-prompt-delivery.test.ts": 300, + "src/main/pi/rpc-prompt.test.ts": 253, + "src/main/pi/rpc-runtime-registration.test.ts": 452, + "src/main/pi/rpc-session-adapter.test.ts": 1081, + "src/main/pi/rpc-turn-races.test.ts": 714, + "src/main/pi/titlebar-extension-overlay-path.test.ts": 83, + "src/main/pi/titlebar-extension-service.test.ts": 230, + "src/main/pi/titlebar-extension-source.test.ts": 1009, + "src/main/plugins/plugin-agent-status-event.test.ts": 227, + "src/main/plugins/plugin-atomic-file-write.test.ts": 2063, + "src/main/plugins/plugin-audit-log-scaling.test.ts": 405, + "src/main/plugins/plugin-audit-log.test.ts": 66, + "src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts": 425, + "src/main/plugins/plugin-bundled-bootstrap.test.ts": 486, + "src/main/plugins/plugin-command-registry.test.ts": 345, + "src/main/plugins/plugin-content-pack-registry.test.ts": 302, + "src/main/plugins/plugin-content-safety.test.ts": 850, + "src/main/plugins/plugin-dev-watcher-unknown-identity.test.ts": 252, + "src/main/plugins/plugin-dev-watcher.test.ts": 509, + "src/main/plugins/plugin-discovery.test.ts": 325, + "src/main/plugins/plugin-enablement.test.ts": 366, + "src/main/plugins/plugin-host-conformance.test.ts": 363, + "src/main/plugins/plugin-host-methods.test.ts": 300, + "src/main/plugins/plugin-host-process.test.ts": 249, + "src/main/plugins/plugin-host-runtime.test.ts": 258, + "src/main/plugins/plugin-install-trust.test.ts": 464, + "src/main/plugins/plugin-install.test.ts": 819, + "src/main/plugins/plugin-kill-list-content-revocation.test.ts": 541, + "src/main/plugins/plugin-kill-list-service.test.ts": 292, + "src/main/plugins/plugin-language-pack-registry.test.ts": 352, + "src/main/plugins/plugin-launch-content.test.ts": 464, + "src/main/plugins/plugin-list-projection.test.ts": 320, + "src/main/plugins/plugin-marketplace-installer.test.ts": 425, + "src/main/plugins/plugin-marketplace-service.test.ts": 526, + "src/main/plugins/plugin-marketplace-store.test.ts": 269, + "src/main/plugins/plugin-panel-controller.test.ts": 337, + "src/main/plugins/plugin-panel-owner-lifecycle.test.ts": 51, + "src/main/plugins/plugin-panel-sessions.test.ts": 56, + "src/main/plugins/plugin-private-marketplace.integration.test.ts": 715, + "src/main/plugins/plugin-secrets-store.test.ts": 295, + "src/main/plugins/plugin-service-housekeeping.test.ts": 63, + "src/main/plugins/plugin-service-integrity.test.ts": 555, + "src/main/plugins/plugin-service-reconciliation.test.ts": 841, + "src/main/plugins/plugin-startup-budget.test.ts": 757, + "src/main/plugins/plugin-storage-store.test.ts": 286, + "src/main/plugins/plugin-uninstall-lifecycle.test.ts": 994, + "src/main/plugins/plugin-uninstall-log-retention.test.ts": 1223, + "src/main/plugins/plugin-uninstall-ordering.test.ts": 892, + "src/main/plugins/plugin-vm-recipe-registry.test.ts": 426, + "src/main/plugins/plugin-worker-controller.test.ts": 371, + "src/main/plugins/plugin-worker-env.test.ts": 48, + "src/main/plugins/plugin-worker-manager.test.ts": 311, + "src/main/plugins/plugin-worker-output-buffer.test.ts": 49, + "src/main/plugins/plugin-worker-output-retention.test.ts": 379, + "src/main/plugins/plugin-worker-supervision.integration.test.ts": 8486, + "src/main/ports/advertised-url-retention.test.ts": 145, + "src/main/ports/advertised-url-watcher-pid-validation.test.ts": 66, + "src/main/ports/advertised-url-watcher.test.ts": 91, + "src/main/ports/local-workspace-port-scanner.test.ts": 144, + "src/main/ports/port-scan-command-client.test.ts": 1724, + "src/main/ports/port-scan-command-execution.test.ts": 5262, + "src/main/ports/ssh-advertised-url-enrichment.test.ts": 83, + "src/main/ports/workspace-port-execution-host.test.ts": 59, + "src/main/ports/workspace-port-ownership.test.ts": 62, + "src/main/powershell-osc133-bootstrap-windows-clm.test.ts": 109, + "src/main/powershell-osc133-bootstrap.test.ts": 159, + "src/main/preflight/freebuff-remote-detection.test.ts": 620, + "src/main/preflight/workspace-agent-detection.test.ts": 575, + "src/main/project-groups/folder-workspace-path-status.test.ts": 73, + "src/main/project-groups/nested-repo-discovery-queue.test.ts": 208, + "src/main/project-groups/nested-repo-discovery.test.ts": 271, + "src/main/project-groups/nested-repo-glob-budget.test.ts": 391, + "src/main/project-groups/nested-repo-import-target.test.ts": 56, + "src/main/project-groups/nested-repo-import.test.ts": 63, + "src/main/project-groups/nested-repo-scan-rules.test.ts": 52, + "src/main/project-runtime-git-options.test.ts": 118, + "src/main/protected-secret-persistence-concurrency.test.ts": 61, + "src/main/protected-secret-persistence.test.ts": 77, + "src/main/provider-process/managed-provider-process-fallback-tree.test.ts": 98, + "src/main/provider-process/managed-provider-process-grace.test.ts": 108, + "src/main/provider-process/managed-provider-process-output.test.ts": 101, + "src/main/provider-process/managed-provider-process-root-only.test.ts": 105, + "src/main/provider-process/managed-provider-process.test.ts": 121, + "src/main/provider-process/provider-acquisition-starts.test.ts": 46, + "src/main/provider-process/provider-process-launch.test.ts": 46, + "src/main/provider-process/provider-process-supervisor.integration.test.ts": 14407, + "src/main/provider-process/provider-process-supervisor.test.ts": 65, + "src/main/provider-process/provider-process-teardown.test.ts": 140, + "src/main/provider-process/provider-spawn-failure-report.test.ts": 51, + "src/main/provider-process/provider-stdio-write-queue.test.ts": 48, + "src/main/providers/agent-foreground-command-line.test.ts": 157, + "src/main/providers/agent-foreground-process-batch.test.ts": 89, + "src/main/providers/agent-foreground-process-git-bash-launcher.test.ts": 137, + "src/main/providers/agent-foreground-process-git-bash.win32.test.ts": 445, + "src/main/providers/agent-foreground-process-pi.test.ts": 136, + "src/main/providers/agent-foreground-process-ps-scan-volume.test.ts": 155, + "src/main/providers/agent-foreground-process-real-rows.test.ts": 141, + "src/main/providers/agent-foreground-process-remote-evidence.test.ts": 108, + "src/main/providers/agent-foreground-process-shell-path.test.ts": 131, + "src/main/providers/agent-foreground-process-windows-job-rejection.test.ts": 107, + "src/main/providers/agent-foreground-process-wsl-shell-proof.test.ts": 125, + "src/main/providers/agent-foreground-process.test.ts": 186, + "src/main/providers/execution-host-provider-dispatch.test.ts": 404, + "src/main/providers/local-filesystem-provider.test.ts": 206, + "src/main/providers/local-git-provider.test.ts": 77, + "src/main/providers/local-pty-child-process-verdict.test.ts": 498, + "src/main/providers/local-pty-default-shell.test.ts": 168, + "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 202, + "src/main/providers/local-pty-pending-native-spawn.test.ts": 306, + "src/main/providers/local-pty-pi-owner-env.test.ts": 257, + "src/main/providers/local-pty-provider-foreground-process.test.ts": 645, + "src/main/providers/local-pty-provider-io-events.test.ts": 438, + "src/main/providers/local-pty-provider-session-inventory.test.ts": 475, + "src/main/providers/local-pty-provider-shell-readiness.test.ts": 521, + "src/main/providers/local-pty-provider-shutdown.test.ts": 714, + "src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts": 501, + "src/main/providers/local-pty-provider-spawn-env.test.ts": 493, + "src/main/providers/local-pty-provider-spawn-session.test.ts": 539, + "src/main/providers/local-pty-provider-windows-shell-launch.test.ts": 571, + "src/main/providers/local-pty-shell-ready-marker-scan.test.ts": 82, + "src/main/providers/local-pty-shell-ready-startup-command.test.ts": 74, + "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts": 491, + "src/main/providers/local-pty-shell-startup-command.node-pty.test.ts": 310, + "src/main/providers/local-pty-utils-windows-fallback.test.ts": 79, + "src/main/providers/local-pty-utils.test.ts": 91, + "src/main/providers/macos-login-session-pty-probe.test.ts": 54, + "src/main/providers/macos-tcc-login-shell.test.ts": 148, + "src/main/providers/posix-pane-foreground-fingerprint.test.ts": 62, + "src/main/providers/process-cwd.test.ts": 136, + "src/main/providers/provider-dispatch.test.ts": 2275, + "src/main/providers/pty-default-cwd.test.ts": 64, + "src/main/providers/pty-process-inspection.test.ts": 55, + "src/main/providers/pty-process-list-admission.test.ts": 150, + "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1987, + "src/main/providers/ssh-directory-legacy-compatibility.test.ts": 225, + "src/main/providers/ssh-directory-listing-options.test.ts": 106, + "src/main/providers/ssh-directory-streaming-real.test.ts": 224, + "src/main/providers/ssh-directory-streaming.test.ts": 130, + "src/main/providers/ssh-filesystem-dispatch.test.ts": 52, + "src/main/providers/ssh-filesystem-doc-preview.test.ts": 76, + "src/main/providers/ssh-filesystem-download.test.ts": 193, + "src/main/providers/ssh-filesystem-provider-capabilities.test.ts": 95, + "src/main/providers/ssh-filesystem-provider-download-folder.test.ts": 151, + "src/main/providers/ssh-filesystem-provider-range.test.ts": 130, + "src/main/providers/ssh-filesystem-provider-stream.test.ts": 167, + "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts": 286, + "src/main/providers/ssh-filesystem-provider.test.ts": 258, + "src/main/providers/ssh-filesystem-watch-notifications.test.ts": 61, + "src/main/providers/ssh-git-dispatch.test.ts": 54, + "src/main/providers/ssh-git-provider-commit-message.test.ts": 122, + "src/main/providers/ssh-git-provider-diff.test.ts": 140, + "src/main/providers/ssh-git-provider-exec.test.ts": 126, + "src/main/providers/ssh-git-provider-merge.test.ts": 103, + "src/main/providers/ssh-git-provider-remote-sync.test.ts": 115, + "src/main/providers/ssh-git-provider-staging.test.ts": 163, + "src/main/providers/ssh-git-provider-status-lease.test.ts": 102, + "src/main/providers/ssh-git-provider-status.test.ts": 112, + "src/main/providers/ssh-git-provider-upstream-lease.test.ts": 110, + "src/main/providers/ssh-git-provider-worktree.test.ts": 149, + "src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 125, + "src/main/providers/ssh-listing-compatibility.test.ts": 210, + "src/main/providers/ssh-plain-shell-pty-provider.test.ts": 80, + "src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 59, + "src/main/providers/ssh-pty-legacy-relay-delegation.test.ts": 450, + "src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 434, + "src/main/providers/ssh-pty-notification-recovery-activation.test.ts": 66, + "src/main/providers/ssh-pty-notification-rejection-routing.test.ts": 210, + "src/main/providers/ssh-pty-notification-routing.test.ts": 90, + "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts": 446, + "src/main/providers/ssh-pty-provider-claim-incarnation.test.ts": 435, + "src/main/providers/ssh-pty-provider-exit-race.test.ts": 509, + "src/main/providers/ssh-pty-provider-process-events.test.ts": 474, + "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts": 529, + "src/main/providers/ssh-pty-provider-spawn.test.ts": 499, + "src/main/providers/ssh-pty-provider-terminal-repair.test.ts": 455, + "src/main/providers/ssh-pty-provider.test.ts": 470, + "src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts": 249, + "src/main/providers/ssh-pty-reattach-previous-relay-hold.test.ts": 69, + "src/main/providers/ssh-pty-relay-absence-verdict.test.ts": 430, + "src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 58, + "src/main/providers/ssh-pty-spawn-env.test.ts": 49, + "src/main/providers/ssh-pty-write.test.ts": 473, + "src/main/providers/ssh-review-draft-context.test.ts": 936, + "src/main/providers/ssh-sftp-filesystem-provider.test.ts": 103, + "src/main/providers/ssh-worktree-catalog-authority.test.ts": 1068, "src/main/providers/stable-foreground-process.test.ts": 68, - "src/main/providers/terminal-path-existence-batch.integration.test.ts": 171, - "src/main/providers/windows-agent-foreground-process-scan-volume.test.ts": 70, - "src/main/providers/windows-cached-agent-revalidation.test.ts": 56, - "src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts": 34, - "src/main/providers/windows-console-attached-processes.test.ts": 69, - "src/main/providers/windows-foreground-process-inspection-cost.test.ts": 88, - "src/main/providers/windows-foreground-process-rows.test.ts": 40, - "src/main/providers/windows-powershell-executable.test.ts": 67, - "src/main/providers/windows-powershell.test.ts": 53, - "src/main/providers/windows-pty-job-membership.test.ts": 95, - "src/main/providers/windows-shell-args.test.ts": 131, - "src/main/providers/windows-shell-fallback-chain.test.ts": 124, + "src/main/providers/startup-line-typed-length.live-shell.test.ts": 165, + "src/main/providers/terminal-path-existence-batch.integration.test.ts": 150, + "src/main/providers/windows-agent-foreground-command-line.test.ts": 102, + "src/main/providers/windows-agent-foreground-process-scan-volume.test.ts": 107, + "src/main/providers/windows-cached-agent-revalidation.test.ts": 55, + "src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts": 59, + "src/main/providers/windows-console-attached-processes.test.ts": 56, + "src/main/providers/windows-foreground-process-inspection-cost.test.ts": 105, + "src/main/providers/windows-foreground-process-rows.test.ts": 90, + "src/main/providers/windows-powershell-executable.test.ts": 76, + "src/main/providers/windows-powershell.test.ts": 42, + "src/main/providers/windows-pty-job-membership.test.ts": 68, + "src/main/providers/windows-shell-args.test.ts": 192, + "src/main/providers/windows-shell-fallback-chain.test.ts": 131, "src/main/providers/windows-shell-preflight-runtime.windows.test.ts": 123, - "src/main/providers/working-directory-validation-retention.test.ts": 125, - "src/main/providers/working-directory-validation.test.ts": 111, - "src/main/provisioned-root-ssh-adoption.test.ts": 285, - "src/main/proxy-guarded-fetch-call-site-audit.test.ts": 266, - "src/main/pty-descendant-exit-verification.test.ts": 83, - "src/main/pty-descendant-termination-job-coverage.test.ts": 303, - "src/main/pty-descendant-termination.test.ts": 205, - "src/main/pty/appimage-terminal-env.test.ts": 42, - "src/main/pty/build-mode-env.test.ts": 42, - "src/main/pty/codex-home-wsl-env.test.ts": 33, - "src/main/pty/codex-preflight-profile-path-rewrite.test.ts": 168, - "src/main/pty/codex-shell-launch-preflight.test.ts": 2210, - "src/main/pty/conda-activation-env.test.ts": 36, - "src/main/pty/legacy-terminal-shim-dir.test.ts": 444, - "src/main/pty/legacy-terminal-windows-tombstone.test.ts": 59, - "src/main/pty/node-pty-master-fd-retirement.test.ts": 5222, - "src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts": 42, - "src/main/pty/omp-sqlite-overlay.test.ts": 43, - "src/main/pty/overlay-mirror.test.ts": 41, - "src/main/pty/pi-process-owner-env.test.ts": 929, - "src/main/pty/posix-pty-foreground-group.test.ts": 60, - "src/main/pty/posix-pty-process-groups.integration.test.ts": 123, - "src/main/pty/posix-pty-process-groups.test.ts": 78, - "src/main/pty/shell-startup-env.test.ts": 116, - "src/main/pty/terminal-color-env.test.ts": 49, - "src/main/pty/windows-environment-path-main-loop.test.ts": 414, - "src/main/pty/windows-environment-path.test.ts": 72, - "src/main/pty/windows-path-registry-change.test.ts": 37, - "src/main/pty/windows-path-registry-fallback.test.ts": 66, - "src/main/pty/windows-path-registry-reader.test.ts": 61, - "src/main/pty/wsl-orca-env.test.ts": 85, - "src/main/pwsh.test.ts": 74, - "src/main/quit-path-durable-write-blocking.test.ts": 2121, - "src/main/quit-teardown-agent-browser-daemons.test.ts": 27, - "src/main/quit-teardown-deadline.test.ts": 52, - "src/main/quit-teardown-start-gate.test.ts": 45, - "src/main/rate-limits/account-runtime-target-sync.test.ts": 205, - "src/main/rate-limits/antigravity-usage-mirror.test.ts": 38, - "src/main/rate-limits/auth-filesystem-operation-retention.test.ts": 132, - "src/main/rate-limits/auth-filesystem-operation.test.ts": 56, - "src/main/rate-limits/claude-fetcher-cli-fallback.test.ts": 183, - "src/main/rate-limits/claude-fetcher-fable-usage.test.ts": 251, - "src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts": 273, - "src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts": 167, - "src/main/rate-limits/claude-oauth-usage-error.test.ts": 102, - "src/main/rate-limits/claude-pty.test.ts": 178, - "src/main/rate-limits/claude-usage-error-classification.test.ts": 70, - "src/main/rate-limits/claude-usage-refresh-plan.test.ts": 33, - "src/main/rate-limits/codex-auth-presence.test.ts": 45, - "src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 152, - "src/main/rate-limits/codex-fetcher-backend.test.ts": 251, - "src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 223, - "src/main/rate-limits/codex-fetcher-process-contract.test.ts": 190, - "src/main/rate-limits/codex-fetcher-pty-settle.test.ts": 271, - "src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 465, - "src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts": 108, - "src/main/rate-limits/codex-fetcher-session-supplement.test.ts": 186, - "src/main/rate-limits/codex-fetcher.test.ts": 339, - "src/main/rate-limits/codex-probe-termination.test.ts": 63, - "src/main/rate-limits/codex-pty-rate-limit-probe.test.ts": 70, - "src/main/rate-limits/codex-pty-status-parser.test.ts": 47, - "src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 26, - "src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 109, - "src/main/rate-limits/cursor-auth.test.ts": 133, - "src/main/rate-limits/cursor-fetcher.test.ts": 173, - "src/main/rate-limits/cursor-session-token.test.ts": 71, - "src/main/rate-limits/cursor-usage-mapping.test.ts": 97, - "src/main/rate-limits/gemini-bucket-formatting.test.ts": 37, - "src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts": 113, - "src/main/rate-limits/gemini-usage-fetcher.test.ts": 100, - "src/main/rate-limits/grok-auth.test.ts": 101, - "src/main/rate-limits/grok-fetcher.test.ts": 152, - "src/main/rate-limits/hidden-pty-cleanup.test.ts": 53, - "src/main/rate-limits/initial-account-rate-limit-target.test.ts": 233, - "src/main/rate-limits/kimi-fetcher-wsl-home.test.ts": 92, - "src/main/rate-limits/kimi-fetcher.test.ts": 129, - "src/main/rate-limits/minimax/minimax-fetcher.test.ts": 109, - "src/main/rate-limits/minimax/minimax-request-context.test.ts": 89, - "src/main/rate-limits/opencode-go-api-key-source.test.ts": 73, - "src/main/rate-limits/opencode-go-status-parsing.test.ts": 63, - "src/main/rate-limits/opencode-go-usage-api.test.ts": 109, - "src/main/rate-limits/opencode-go-usage-fetcher.test.ts": 79, - "src/main/rate-limits/opencode-go-usage-source-selection.test.ts": 104, - "src/main/rate-limits/service-account-target-selection.test.ts": 780, - "src/main/rate-limits/service-antigravity-usage.test.ts": 123, - "src/main/rate-limits/service-cursor-usage.test.ts": 229, - "src/main/rate-limits/service-inactive-account-previews.test.ts": 187, - "src/main/rate-limits/service-live-claude-usage.test.ts": 173, - "src/main/rate-limits/service-minimax-usage.test.ts": 91, - "src/main/rate-limits/service-refresh-orchestration.test.ts": 243, - "src/main/rate-limits/service-window-activation.test.ts": 321, - "src/main/refused-tree-kill-root-termination.test.ts": 2628, - "src/main/remote-agent-trust-presets.test.ts": 166, - "src/main/remote-worktree-history-cleanup.test.ts": 59, - "src/main/repo-git-remote-avatar-refresh.test.ts": 108, - "src/main/repo-git-remote-identity-enrichment.test.ts": 91, - "src/main/repo-git-remote-identity.test.ts": 71, - "src/main/repo-git-username-enrichment.test.ts": 43, - "src/main/repo-icon-autodetect.test.ts": 606, - "src/main/repo-icon-file-detection.test.ts": 92, - "src/main/repo-icon-source-href.test.ts": 109, - "src/main/repo-maintenance-idle-gate.test.ts": 53, - "src/main/repo-worktrees.test.ts": 104, - "src/main/ripgrep/bare-ripgrep-spawn-boundary.test.ts": 1021, - "src/main/ripgrep/bundled-ripgrep-path.test.ts": 104, - "src/main/ripgrep/bundled-ripgrep-real.test.ts": 150, - "src/main/ripgrep/bundled-ripgrep-spawn.test.ts": 67, - "src/main/ripgrep/fd-exhausted-spawn.test.ts": 68, - "src/main/ripgrep/synchronous-spawn-failure.test.ts": 63, - "src/main/ripgrep/wsl-missing-cwd-exit.test.ts": 92, - "src/main/runtime/acknowledged-terminal-tab-retirement.test.ts": 3317, - "src/main/runtime/agent-prompt-line-settle.test.ts": 2927, - "src/main/runtime/agent-prompt-receipt-correlation.test.ts": 3413, + "src/main/providers/working-directory-validation-retention.test.ts": 132, + "src/main/providers/working-directory-validation.test.ts": 118, + "src/main/provisioned-root-ssh-adoption.test.ts": 332, + "src/main/pty-descendant-exit-verification.test.ts": 139, + "src/main/pty-descendant-termination.test.ts": 173, + "src/main/pty/appimage-terminal-env.test.ts": 68, + "src/main/pty/build-mode-env.test.ts": 49, + "src/main/pty/chromium-session-bus-env.test.ts": 202, + "src/main/pty/codex-home-wsl-env.test.ts": 44, + "src/main/pty/codex-launch-shell-wrapping.test.ts": 180, + "src/main/pty/codex-no-daemon-binary-contract.test.ts": 44, + "src/main/pty/codex-no-daemon-launch-command.test.ts": 96, + "src/main/pty/codex-preflight-profile-path-rewrite.test.ts": 137, + "src/main/pty/codex-shell-launch-preflight.test.ts": 2497, + "src/main/pty/codex-shell-no-daemon.test.ts": 7782, + "src/main/pty/conda-activation-env.test.ts": 64, + "src/main/pty/legacy-terminal-shim-dir.test.ts": 287, + "src/main/pty/legacy-terminal-windows-tombstone.test.ts": 85, + "src/main/pty/managed-ssh-host-login-shell.test.ts": 179, + "src/main/pty/node-pty-master-fd-retirement.test.ts": 2373, + "src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts": 55, + "src/main/pty/omp-sqlite-overlay.test.ts": 51, + "src/main/pty/overlay-mirror.test.ts": 69, + "src/main/pty/pi-process-owner-env.test.ts": 909, + "src/main/pty/posix-pty-foreground-group.test.ts": 65, + "src/main/pty/posix-pty-process-groups.integration.test.ts": 167, + "src/main/pty/posix-pty-process-groups.test.ts": 105, + "src/main/pty/powershell-profile-env.test.ts": 98, + "src/main/pty/shell-startup-env.test.ts": 131, + "src/main/pty/terminal-color-env.test.ts": 46, + "src/main/pty/windows-environment-path-main-loop.test.ts": 430, + "src/main/pty/windows-environment-path.test.ts": 118, + "src/main/pty/windows-path-registry-change.test.ts": 49, + "src/main/pty/windows-path-registry-fallback.test.ts": 69, + "src/main/pty/windows-path-registry-reader.test.ts": 55, + "src/main/pty/wsl-orca-env.test.ts": 107, + "src/main/pwsh.test.ts": 78, + "src/main/qoder/hook-service.test.ts": 146, + "src/main/qoder/real-cli.test.ts": 709, + "src/main/quit-path-durable-write-blocking.test.ts": 3220, + "src/main/quit-teardown-deadline.test.ts": 54, + "src/main/quit-teardown-start-gate.test.ts": 38, + "src/main/rate-limits/account-runtime-target-sync.test.ts": 277, + "src/main/rate-limits/antigravity-usage-error.test.ts": 52, + "src/main/rate-limits/antigravity-usage-fetcher.test.ts": 103, + "src/main/rate-limits/antigravity-usage-real-cli.test.ts": 124, + "src/main/rate-limits/antigravity-usage-response.test.ts": 66, + "src/main/rate-limits/auth-filesystem-operation-retention.test.ts": 140, + "src/main/rate-limits/auth-filesystem-operation.test.ts": 95, + "src/main/rate-limits/claude-oauth-usage-error.test.ts": 44, + "src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts": 84, + "src/main/rate-limits/claude-profile-usage.test.ts": 275, + "src/main/rate-limits/claude-usage-error-classification.test.ts": 58, + "src/main/rate-limits/codex-auth-presence.test.ts": 63, + "src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 120, + "src/main/rate-limits/codex-fetcher-backend.test.ts": 267, + "src/main/rate-limits/codex-fetcher-credit-balance.test.ts": 111, + "src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 166, + "src/main/rate-limits/codex-fetcher-process-contract.test.ts": 237, + "src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 358, + "src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts": 136, + "src/main/rate-limits/codex-fetcher-session-supplement.test.ts": 152, + "src/main/rate-limits/codex-fetcher.test.ts": 265, + "src/main/rate-limits/codex-probe-termination.test.ts": 81, + "src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 41, + "src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 156, + "src/main/rate-limits/cursor-auth.test.ts": 220, + "src/main/rate-limits/cursor-fetcher.test.ts": 370, + "src/main/rate-limits/cursor-session-token.test.ts": 214, + "src/main/rate-limits/cursor-usage-mapping.test.ts": 247, + "src/main/rate-limits/gemini-bucket-formatting.test.ts": 60, + "src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts": 88, + "src/main/rate-limits/gemini-usage-fetcher.test.ts": 112, + "src/main/rate-limits/grok-auth.test.ts": 103, + "src/main/rate-limits/grok-fetcher.test.ts": 97, + "src/main/rate-limits/grok-weekly-zero.test.ts": 180, + "src/main/rate-limits/initial-account-rate-limit-target.test.ts": 295, + "src/main/rate-limits/kimi-fetcher-wsl-home.test.ts": 107, + "src/main/rate-limits/kimi-fetcher.test.ts": 175, + "src/main/rate-limits/minimax/minimax-fetcher.test.ts": 122, + "src/main/rate-limits/minimax/minimax-request-context.test.ts": 97, + "src/main/rate-limits/opencode-go-api-key-source.test.ts": 452, + "src/main/rate-limits/opencode-go-managed-account-source.test.ts": 514, + "src/main/rate-limits/opencode-go-status-parsing.test.ts": 56, + "src/main/rate-limits/opencode-go-usage-api.test.ts": 77, + "src/main/rate-limits/opencode-go-usage-fetcher.test.ts": 136, + "src/main/rate-limits/opencode-go-usage-source-selection.test.ts": 107, + "src/main/rate-limits/service-account-target-selection.test.ts": 1032, + "src/main/rate-limits/service-antigravity-usage.test.ts": 139, + "src/main/rate-limits/service-cursor-usage.test.ts": 430, + "src/main/rate-limits/service-inactive-account-previews.test.ts": 158, + "src/main/rate-limits/service-live-claude-usage.test.ts": 188, + "src/main/rate-limits/service-minimax-usage.test.ts": 159, + "src/main/rate-limits/service-opencode-go-credentials.test.ts": 136, + "src/main/rate-limits/service-refresh-orchestration.test.ts": 177, + "src/main/rate-limits/service-window-activation.test.ts": 363, + "src/main/rate-limits/service-zcode-usage.test.ts": 322, + "src/main/rate-limits/zcode-usage-fetcher.test.ts": 77, + "src/main/refused-tree-kill-root-termination.test.ts": 293, + "src/main/remote-worktree-history-cleanup.test.ts": 44, + "src/main/repo-git-remote-avatar-refresh.test.ts": 135, + "src/main/repo-git-remote-identity-enrichment.test.ts": 119, + "src/main/repo-git-remote-identity.test.ts": 402, + "src/main/repo-git-username-enrichment.test.ts": 65, + "src/main/repo-icon-autodetect.test.ts": 826, + "src/main/repo-icon-file-detection.test.ts": 455, + "src/main/repo-icon-source-href.test.ts": 81, + "src/main/repo-maintenance-idle-gate.test.ts": 219, + "src/main/repo-worktrees.test.ts": 372, + "src/main/ripgrep/bundled-ripgrep-path.test.ts": 107, + "src/main/ripgrep/bundled-ripgrep-real.test.ts": 166, + "src/main/ripgrep/bundled-ripgrep-spawn.test.ts": 80, + "src/main/ripgrep/bundled-ripgrep-stop.test.ts": 84, + "src/main/ripgrep/bundled-ripgrep-text-search.test.ts": 207, + "src/main/ripgrep/fd-exhausted-spawn.test.ts": 75, + "src/main/ripgrep/ripgrep-filename-identity-real.test.ts": 209, + "src/main/ripgrep/synchronous-spawn-failure.test.ts": 69, + "src/main/ripgrep/text-search-unicode-real.test.ts": 228, + "src/main/ripgrep/wsl-missing-cwd-exit.test.ts": 124, + "src/main/runtime/acknowledged-terminal-tab-retirement.test.ts": 5402, + "src/main/runtime/agent-detection-host-resolution.test.ts": 3351, + "src/main/runtime/agent-launch-record-warmup-gate.test.ts": 42, + "src/main/runtime/agent-launch-typed-line-shell.test.ts": 50, + "src/main/runtime/agent-prompt-line-settle.test.ts": 4010, + "src/main/runtime/agent-prompt-receipt-correlation.test.ts": 4528, "src/main/runtime/agent-prompt-request-correlation.test.ts": 46, - "src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts": 3872, - "src/main/runtime/agent-prompt-submission-runtime.test.ts": 4515, - "src/main/runtime/agent-prompt-submission-verification.test.ts": 193, - "src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts": 2931, - "src/main/runtime/agent-session-backup-recovery.test.ts": 422, - "src/main/runtime/agent-session-claim-identity.test.ts": 71, - "src/main/runtime/agent-session-conversation-name-store.test.ts": 302, - "src/main/runtime/agent-session-launch-env-admission.test.ts": 253, - "src/main/runtime/agent-session-launch-env-backfill.test.ts": 264, - "src/main/runtime/agent-session-lease-renewal.test.ts": 242, - "src/main/runtime/agent-session-operation-admission.test.ts": 165, - "src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts": 47, - "src/main/runtime/agent-session-process-identity-probe.test.ts": 78, - "src/main/runtime/agent-session-provider-handle-transition.test.ts": 104, - "src/main/runtime/agent-session-record-conversation-name.test.ts": 164, - "src/main/runtime/agent-session-record-options.test.ts": 203, - "src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts": 303, - "src/main/runtime/agent-session-record-store-security.test.ts": 156, - "src/main/runtime/agent-session-record-store-tab-table.test.ts": 420, - "src/main/runtime/agent-session-record-store.test.ts": 4639, - "src/main/runtime/agent-session-record-unsupported-schema.test.ts": 255, - "src/main/runtime/agent-session-recovery-capsule.test.ts": 642, - "src/main/runtime/agent-session-recovery-publish-fault.test.ts": 252, - "src/main/runtime/agent-session-released-reservation-replay.test.ts": 351, - "src/main/runtime/agent-session-reservation-admission.test.ts": 150, - "src/main/runtime/agent-session-spawn-token-process-scan.test.ts": 54, - "src/main/runtime/agent-session-spawn-token-readback.test.ts": 52, - "src/main/runtime/agent-session-surface-release-transition.test.ts": 167, - "src/main/runtime/agent-session-unreadable-record-salvage.test.ts": 310, - "src/main/runtime/agent-session-unsaved-creation-supersession.test.ts": 363, - "src/main/runtime/agent-startup-input-assembly-census.test.ts": 41, - "src/main/runtime/agent-terminal-launch-trust-host.test.ts": 3780, - "src/main/runtime/agent-terminal-startup-prompt.test.ts": 2169, - "src/main/runtime/antigravity-readiness-transcripts.test.ts": 6110, - "src/main/runtime/antigravity-terminal-readiness.test.ts": 78, - "src/main/runtime/automation-change-publication.test.ts": 8897, - "src/main/runtime/browser-client-download-transfers.test.ts": 201, - "src/main/runtime/browser-dialog-settlement.test.ts": 359, - "src/main/runtime/browser-execution-host-key-resolution.test.ts": 1993, - "src/main/runtime/browser-host-capability-selection.test.ts": 172, - "src/main/runtime/browser-host-client-page-adoption-grants.test.ts": 46, - "src/main/runtime/browser-host-client-page-adoption.test.ts": 36, - "src/main/runtime/browser-host-client-page-creation.test.ts": 133, - "src/main/runtime/browser-host-command-ledger-capacity.test.ts": 157, - "src/main/runtime/browser-host-command-ledger.test.ts": 138, - "src/main/runtime/browser-host-file-channel-admission.test.ts": 154, - "src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts": 319, - "src/main/runtime/browser-host-lease-fenced-page-release.test.ts": 206, - "src/main/runtime/browser-host-lease-placement-retirement.test.ts": 189, - "src/main/runtime/browser-host-lease-registry.test.ts": 243, - "src/main/runtime/browser-host-page-placement-replacement.test.ts": 169, - "src/main/runtime/browser-host-page-placement.test.ts": 156, - "src/main/runtime/browser-host-page-reconciliation-executor.test.ts": 64, - "src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts": 369, - "src/main/runtime/browser-host-page-reconciliation-plan.test.ts": 74, - "src/main/runtime/browser-host-page-reconciliation-preconditions.test.ts": 166, - "src/main/runtime/browser-host-page-retirement.test.ts": 32, - "src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts": 2930, - "src/main/runtime/browser-screencast-driver-attribution.test.ts": 4314, - "src/main/runtime/browser-screencast-driver-scope.test.ts": 38, - "src/main/runtime/browser-screencast-ghost-subscriber-eviction.test.ts": 59, - "src/main/runtime/browser-screencast-remote-viewer-retention.test.ts": 3782, - "src/main/runtime/browser-session-tab-selection-snapshot.test.ts": 57, - "src/main/runtime/browser-tab-create-caller-navigation.test.ts": 3696, - "src/main/runtime/browser-tab-create-publication.test.ts": 775, - "src/main/runtime/claude-agent-teams-pty-exit-leak.test.ts": 3509, - "src/main/runtime/claude-agent-teams-service.test.ts": 86, - "src/main/runtime/claude-agent-teams-shim-env.test.ts": 102, - "src/main/runtime/claude-structured-failed-start-resume.test.ts": 1167, - "src/main/runtime/claude-structured-resumed-start-failure.test.ts": 1250, - "src/main/runtime/claude-structured-send-held-for-startup.test.ts": 1153, - "src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts": 1691, - "src/main/runtime/claude-structured-session-integration.test.ts": 4718, - "src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts": 1435, - "src/main/runtime/cli-terminal-create-host-session-binding.test.ts": 1988, - "src/main/runtime/client-hosted-browser-page-persistence.integration.test.ts": 3289, - "src/main/runtime/client-hosted-browser-page-persistence.test.ts": 233, - "src/main/runtime/client-hosted-browser-row-hydration-census.test.ts": 485, - "src/main/runtime/client-hosted-browser-row-projection.test.ts": 57, - "src/main/runtime/client-hosted-browser-row-publication.test.ts": 59, - "src/main/runtime/client-hosted-browser-row-push.integration.test.ts": 2544, - "src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts": 3363, - "src/main/runtime/client-hosted-page-reconciliation-window.test.ts": 57, - "src/main/runtime/client-session-tab-selection.test.ts": 57, - "src/main/runtime/decorative-title-fact-emission.test.ts": 33, - "src/main/runtime/exit-provenance-audit.test.ts": 3844, - "src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts": 2958, - "src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts": 4448, - "src/main/runtime/fetch-remote-cache.test.ts": 3566, - "src/main/runtime/file-watcher-host.test.ts": 816, - "src/main/runtime/fit-override-integration.test.ts": 3341, - "src/main/runtime/folder-workspace-pty-identity.test.ts": 2999, - "src/main/runtime/graph-sync-deletion-fence.test.ts": 3314, - "src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts": 3308, - "src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts": 9743, - "src/main/runtime/graph-sync-payload-partition.test.ts": 2285, - "src/main/runtime/headless-close-keeps-publication-epoch.test.ts": 2149, - "src/main/runtime/headless-hydration-ownership.test.ts": 4098, - "src/main/runtime/headless-placeholder-graph-surface-standing.test.ts": 2838, - "src/main/runtime/headless-seed-ownership.test.ts": 4790, - "src/main/runtime/headless-tab-group-split-layout.test.ts": 55, - "src/main/runtime/headless-tab-order-stability.test.ts": 2511, - "src/main/runtime/headless-terminal-dispose-write-ordering.test.ts": 3202, - "src/main/runtime/headless-terminal-query-reply-policy.test.ts": 49, - "src/main/runtime/headless-terminal-split-layout.test.ts": 34, - "src/main/runtime/hidden-output-restored-provider-snapshot.test.ts": 3364, - "src/main/runtime/host-terminal-close-persistence-durability.test.ts": 838, - "src/main/runtime/leaf-pty-verdict-expiry.test.ts": 3418, - "src/main/runtime/linear-save-issue.test.ts": 3157, - "src/main/runtime/managed-worktree-create-execution-host.test.ts": 2508, - "src/main/runtime/missing-worktree-terminal-reconciliation.test.ts": 197, - "src/main/runtime/mobile-agent-status-permission-renewal.test.ts": 3290, - "src/main/runtime/mobile-notification-dismissal-read-failure.test.ts": 78, - "src/main/runtime/mobile-notification-dismissal-store.test.ts": 73, - "src/main/runtime/mobile-notification-replay.test.ts": 77, - "src/main/runtime/mobile-pairing-qr.test.ts": 973, - "src/main/runtime/mobile-pairing-userdata-path.test.ts": 2117, - "src/main/runtime/mobile-presence-lock.test.ts": 15604, - "src/main/runtime/mobile-rpc-allowlist.test.ts": 1726, - "src/main/runtime/mobile-session-tab-close-refusal-message.test.ts": 54, - "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts": 44, - "src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts": 2760, - "src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts": 35, - "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts": 179, - "src/main/runtime/mobile-session-terminal-retirement-proof.test.ts": 37, - "src/main/runtime/mobile-session-terminal-retirement.test.ts": 153, - "src/main/runtime/mobile-subscribe-integration.test.ts": 18419, - "src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 4776, - "src/main/runtime/muse-readiness-transcript.test.ts": 6923, - "src/main/runtime/opencode-finished-session-authority.test.ts": 3824, - "src/main/runtime/orca-runtime-agent-session-operation.test.ts": 2429, - "src/main/runtime/orca-runtime-agent-skill-share.test.ts": 2486, - "src/main/runtime/orca-runtime-automations.test.ts": 3439, - "src/main/runtime/orca-runtime-browser-client-hosted.test.ts": 425, - "src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts": 373, - "src/main/runtime/orca-runtime-browser-headless.test.ts": 784, - "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts": 429, - "src/main/runtime/orca-runtime-browser.test.ts": 747, - "src/main/runtime/orca-runtime-create-base-prefetch.test.ts": 3300, - "src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts": 2993, - "src/main/runtime/orca-runtime-exact-worker-provider-session.test.ts": 2563, - "src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts": 439, - "src/main/runtime/orca-runtime-files-preview-budget.test.ts": 346, - "src/main/runtime/orca-runtime-files-rename-authority.test.ts": 469, - "src/main/runtime/orca-runtime-files-search.test.ts": 325, - "src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts": 436, - "src/main/runtime/orca-runtime-files-ssh-rearm.test.ts": 273, - "src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts": 400, - "src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts": 554, - "src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts": 501, - "src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts": 547, - "src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts": 362, - "src/main/runtime/orca-runtime-files-watch-host-scope.test.ts": 484, - "src/main/runtime/orca-runtime-files-watch.test.ts": 517, - "src/main/runtime/orca-runtime-git-branch-diff.test.ts": 732, - "src/main/runtime/orca-runtime-git-diff-budget.test.ts": 871, - "src/main/runtime/orca-runtime-git.test.ts": 464, - "src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts": 3261, - "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts": 2257, - "src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts": 2750, - "src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts": 3103, - "src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts": 3435, - "src/main/runtime/orca-runtime-module-size.test.ts": 31, - "src/main/runtime/orca-runtime-path-candidate-history.test.ts": 50, - "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts": 1851, - "src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts": 3259, - "src/main/runtime/orca-runtime-skill-recovery.test.ts": 2176, - "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts": 2913, - "src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts": 2897, - "src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts": 2908, - "src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts": 2429, - "src/main/runtime/orca-runtime-structured-session-restore.test.ts": 3480, - "src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts": 2007, - "src/main/runtime/orca-runtime-tab-id-collision.test.ts": 3392, - "src/main/runtime/orca-runtime-tail-wait-memo.test.ts": 2831, - "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts": 2299, - "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts": 3332, - "src/main/runtime/orca-runtime-terminal-cwd.test.ts": 3470, - "src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts": 2155, - "src/main/runtime/orca-runtime-terminal-rename-retention.test.ts": 2085, - "src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts": 3504, - "src/main/runtime/orca-runtime-terminal-retirement.test.ts": 3035, - "src/main/runtime/orca-runtime-terminal-split-authority.test.ts": 4293, - "src/main/runtime/orca-runtime-test-fragment-coverage.test.ts": 54, - "src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts": 3002, - "src/main/runtime/orca-runtime.test.ts": 20016, - "src/main/runtime/orchestration-cli-subprocess.test.ts": 4022, - "src/main/runtime/orchestration-codex-completion-title.test.ts": 4864, - "src/main/runtime/orchestration-codex-real-pty.integration.test.ts": 3073, - "src/main/runtime/orchestration-compatibility-authority.test.ts": 3514, - "src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts": 4228, - "src/main/runtime/orchestration-mailbox-cold-park-idle.test.ts": 4102, - "src/main/runtime/orchestration-mailbox-crash-recovery.test.ts": 3847, - "src/main/runtime/orchestration-mailbox-detached-routing.test.ts": 3792, - "src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts": 2765, - "src/main/runtime/orchestration-mailbox-notification-consistency.test.ts": 15860, - "src/main/runtime/orchestration-mailbox-pointer-cli-command.test.ts": 3401, - "src/main/runtime/orchestration-mailbox-routing-races.test.ts": 4303, - "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts": 3583, - "src/main/runtime/orchestration-message-delivery-identity.test.ts": 4561, - "src/main/runtime/orchestration-messages-fake-parity.test.ts": 237, - "src/main/runtime/orchestration-worker-workspace-resolution.test.ts": 3441, - "src/main/runtime/orchestration/cli-command.test.ts": 45, - "src/main/runtime/orchestration/coordinator-decision-gates.test.ts": 435, - "src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts": 419, - "src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts": 503, - "src/main/runtime/orchestration/coordinator-escalation-triage.test.ts": 388, - "src/main/runtime/orchestration/coordinator-stale-base-flag.test.ts": 31, - "src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts": 245, - "src/main/runtime/orchestration/coordinator.test.ts": 3812, - "src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts": 355, - "src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts": 433, - "src/main/runtime/orchestration/db-message-timestamp.test.ts": 322, - "src/main/runtime/orchestration/db-messages.test.ts": 277, - "src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts": 391, - "src/main/runtime/orchestration/db-task-create-readiness.test.ts": 412, - "src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts": 954, - "src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts": 948, - "src/main/runtime/orchestration/db-task-dispatch-races.test.ts": 595, - "src/main/runtime/orchestration/db-undelivered-mailboxes.test.ts": 291, - "src/main/runtime/orchestration/db.test.ts": 1046, - "src/main/runtime/orchestration/db/attempt-outcome-projection.test.ts": 489, - "src/main/runtime/orchestration/db/decision-gate-lifecycle.test.ts": 216, - "src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.test.ts": 405, - "src/main/runtime/orchestration/db/dispatch-depth.test.ts": 608, - "src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts": 367, - "src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts": 592, - "src/main/runtime/orchestration/db/federated-worker-report-outcome.test.ts": 45, - "src/main/runtime/orchestration/db/federation/federated-dispatch-observation-fence.test.ts": 275, - "src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-release.test.ts": 382, - "src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts": 425, - "src/main/runtime/orchestration/db/legacy-question-identity.test.ts": 43, - "src/main/runtime/orchestration/db/lifecycle-rejection-marker.test.ts": 27, - "src/main/runtime/orchestration/db/lifecycle-transition-boundary.test.ts": 47, - "src/main/runtime/orchestration/db/lifecycle-transition.test.ts": 355, - "src/main/runtime/orchestration/db/messages/mailbox-consumer-lifecycle-fencing.test.ts": 732, - "src/main/runtime/orchestration/db/pane-key-match.test.ts": 33, - "src/main/runtime/orchestration/db/row-column-lists.test.ts": 397, - "src/main/runtime/orchestration/db/run-list-cursor.test.ts": 30, - "src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts": 408, - "src/main/runtime/orchestration/db/schema/derived-delivery-migration.test.ts": 669, - "src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts": 348, - "src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts": 440, - "src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts": 361, - "src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-orca-session.test.ts": 261, - "src/main/runtime/orchestration/db/writer-run-required.test.ts": 365, - "src/main/runtime/orchestration/dispatch-consumer-generation-migration.test.ts": 498, - "src/main/runtime/orchestration/dispatch-creator-identity-migration.test.ts": 480, - "src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts": 434, - "src/main/runtime/orchestration/federation-ack-checkpoints.test.ts": 33, - "src/main/runtime/orchestration/federation-acknowledgment-integrity.test.ts": 277, - "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts": 373, - "src/main/runtime/orchestration/federation-lifecycle-settlement.test.ts": 23, - "src/main/runtime/orchestration/federation-sync.test.ts": 2820, - "src/main/runtime/orchestration/federation-terminal-recovery.test.ts": 3474, - "src/main/runtime/orchestration/formatter.test.ts": 61, - "src/main/runtime/orchestration/groups.test.ts": 42, - "src/main/runtime/orchestration/lifecycle-caller-edges.test.ts": 352, - "src/main/runtime/orchestration/lifecycle-reconciliation.test.ts": 786, - "src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts": 3611, - "src/main/runtime/orchestration/mailbox-pointer-eligibility.test.ts": 343, - "src/main/runtime/orchestration/mailbox-pointer-release-query.test.ts": 339, - "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts": 504, - "src/main/runtime/orchestration/mailbox-pointer-submit.test.ts": 696, - "src/main/runtime/orchestration/message-batch-atomicity.test.ts": 395, - "src/main/runtime/orchestration/mutation-receipt-capacity.test.ts": 400, - "src/main/runtime/orchestration/nested-worker-depth-migration.test.ts": 579, - "src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts": 1024, - "src/main/runtime/orchestration/orchestration-all-start-versions-migration.test.ts": 1741, - "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts": 1165, - "src/main/runtime/orchestration/orchestration-db-permissions.test.ts": 240, - "src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts": 706, - "src/main/runtime/orchestration/orchestration-delivery-consumption.test.ts": 464, - "src/main/runtime/orchestration/orchestration-derived-delivery.test.ts": 327, - "src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts": 500, - "src/main/runtime/orchestration/orchestration-legacy-coordinator-authority-db.test.ts": 712, - "src/main/runtime/orchestration/orchestration-legacy-question-migration-db.test.ts": 751, - "src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts": 1661, - "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts": 192, - "src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts": 289, - "src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts": 641, - "src/main/runtime/orchestration/orchestration-peer-capability-cache.test.ts": 74, - "src/main/runtime/orchestration/orchestration-reset-db.test.ts": 323, - "src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts": 532, - "src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts": 364, - "src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts": 1040, - "src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts": 712, - "src/main/runtime/orchestration/preamble.test.ts": 144, - "src/main/runtime/orchestration/r1-identity-migration.test.ts": 407, - "src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts": 520, - "src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts": 557, - "src/main/runtime/orchestration/settled-question-threads-migration.test.ts": 297, - "src/main/runtime/orchestration/setup-completion-signal.test.ts": 28, - "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts": 431, - "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts": 224, - "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts": 84, - "src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts": 73, - "src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts": 3260, - "src/main/runtime/orchestration/structured-worker-group-addressing.test.ts": 337, - "src/main/runtime/orchestration/structured-worker-journal-archive.test.ts": 91, - "src/main/runtime/orchestration/task-deps-flag.test.ts": 84, - "src/main/runtime/orchestration/worker-attention-context.test.ts": 58, - "src/main/runtime/orchestration/worker-output-archive-bounding.test.ts": 460, - "src/main/runtime/orchestration/worker-output-archive.test.ts": 386, - "src/main/runtime/orchestration/worker-output-cursor.test.ts": 65, - "src/main/runtime/orchestration/worker-provider-session.test.ts": 60, - "src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts": 394, - "src/main/runtime/orchestration/worker-transcript-payload.test.ts": 83, - "src/main/runtime/orchestration/worker-transcript-read.test.ts": 267, - "src/main/runtime/orchestration/worker-transcript-remote-read.test.ts": 201, - "src/main/runtime/paired-close-retirement-proof-publication-order.test.ts": 3296, - "src/main/runtime/pairing-endpoint.test.ts": 170, - "src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts": 34, - "src/main/runtime/pty-exit-agent-status-reconciliation.test.ts": 3395, - "src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts": 3330, - "src/main/runtime/pty-inventory-lifecycle-admission.test.ts": 2868, - "src/main/runtime/pty-inventory-lifecycle-scope.test.ts": 2608, - "src/main/runtime/pty-inventory-liveness-verdict.test.ts": 3469, - "src/main/runtime/pty-inventory-partial-relay-liveness.test.ts": 3435, - "src/main/runtime/pty-recorded-surface-topology.test.ts": 46, - "src/main/runtime/pty-shell-ownership-mirror.test.ts": 1555, - "src/main/runtime/pty-transcript-prune-wait-cache.test.ts": 3233, - "src/main/runtime/pty-waiver-source-invariant.test.ts": 50, - "src/main/runtime/public-ssh-state.test.ts": 27, - "src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts": 223, - "src/main/runtime/push/desktop-push-service.test.ts": 391, - "src/main/runtime/push/push-agent-state.test.ts": 54, - "src/main/runtime/push/push-cleanup-auth-expiry.test.ts": 219, - "src/main/runtime/push/push-delivery-policy.test.ts": 47, - "src/main/runtime/push/push-device-registration-persistence.test.ts": 85, - "src/main/runtime/push/push-dispatcher.test.ts": 73, - "src/main/runtime/push/push-gateway-client.test.ts": 230, - "src/main/runtime/push/push-gateway-session.test.ts": 312, - "src/main/runtime/push/push-host-proof-vector.test.ts": 84, - "src/main/runtime/push/push-host-proof.test.ts": 142, - "src/main/runtime/push/push-outcome-counters.test.ts": 34, - "src/main/runtime/push/push-policy-pipeline.integration.test.ts": 226, - "src/main/runtime/push/push-preferences.test.ts": 59, - "src/main/runtime/push/push-registration-races.test.ts": 320, - "src/main/runtime/push/push-registration-rpc.test.ts": 2997, - "src/main/runtime/push/push-unpair-persistence.test.ts": 4171, - "src/main/runtime/push/push-unregister-outbox.test.ts": 83, - "src/main/runtime/quarter-circle-title-send-authorization.test.ts": 19888, - "src/main/runtime/queued-terminal-graph-exit.test.ts": 3355, - "src/main/runtime/recent-pty-output-buffer.test.ts": 118, - "src/main/runtime/recent-pty-output-retention.test.ts": 101, - "src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts": 215, - "src/main/runtime/relay/desktop-relay-service.test.ts": 667, - "src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts": 281, - "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts": 166, - "src/main/runtime/relay/relay-auth-coordinator.test.ts": 1240, - "src/main/runtime/relay/relay-auth-host-close-reason.test.ts": 184, - "src/main/runtime/relay/relay-control-client.test.ts": 602, - "src/main/runtime/relay/relay-control-close-reason.test.ts": 293, - "src/main/runtime/relay/relay-control-origin.test.ts": 138, - "src/main/runtime/relay/relay-control-request-retirement.test.ts": 160, - "src/main/runtime/relay/relay-control-timeout-classification.test.ts": 159, - "src/main/runtime/relay/relay-demand-ledger.test.ts": 100, - "src/main/runtime/relay/relay-host-proof.test.ts": 131, - "src/main/runtime/relay/relay-http-client.test.ts": 341, - "src/main/runtime/relay/relay-region-correction.test.ts": 297, - "src/main/runtime/relay/relay-region-preference.test.ts": 180, - "src/main/runtime/relay/relay-region-probe-log.test.ts": 297, - "src/main/runtime/relay/relay-region-refresh.test.ts": 188, - "src/main/runtime/relay/relay-renewal-jitter.test.ts": 969, - "src/main/runtime/relay/relay-revoke-outbox.test.ts": 79, - "src/main/runtime/relay/relay-session-broker.test.ts": 667, - "src/main/runtime/remote-agent-session-host-authority.integration.test.ts": 3521, - "src/main/runtime/remote-browser-screencast-frame-admission.test.ts": 360, - "src/main/runtime/remote-desktop-driver.test.ts": 2835, - "src/main/runtime/remote-runtime-close-intent.integration.test.ts": 1447, - "src/main/runtime/remote-runtime-request-connection.integration.test.ts": 2224, - "src/main/runtime/remote-server-updater.test.ts": 39, - "src/main/runtime/renderer-browser-session-reconciliation.test.ts": 2331, - "src/main/runtime/repo-icon-fork-backfill.test.ts": 3128, - "src/main/runtime/repo-worktree-admin-fingerprint.test.ts": 856, + "src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts": 5685, + "src/main/runtime/agent-prompt-submission-runtime.test.ts": 6589, + "src/main/runtime/agent-prompt-submission-verification.test.ts": 140, + "src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts": 4945, + "src/main/runtime/agent-session-claim-identity.test.ts": 67, + "src/main/runtime/agent-session-conversation-clear-commit.test.ts": 705, + "src/main/runtime/agent-session-conversation-name-store.test.ts": 703, + "src/main/runtime/agent-session-death-evidence-persistence.test.ts": 730, + "src/main/runtime/agent-session-launch-directory.test.ts": 583, + "src/main/runtime/agent-session-launch-env-admission.test.ts": 812, + "src/main/runtime/agent-session-lease-renewal.test.ts": 706, + "src/main/runtime/agent-session-operation-admission.test.ts": 308, + "src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts": 88, + "src/main/runtime/agent-session-process-identity-probe.test.ts": 93, + "src/main/runtime/agent-session-provider-handle-transition.test.ts": 241, + "src/main/runtime/agent-session-record-conversation-name.test.ts": 296, + "src/main/runtime/agent-session-record-options.test.ts": 741, + "src/main/runtime/agent-session-record-store-slot.test.ts": 1888, + "src/main/runtime/agent-session-record-store-tab-table.test.ts": 847, + "src/main/runtime/agent-session-record-store.test.ts": 1364, + "src/main/runtime/agent-session-recovery-capsule.test.ts": 1090, + "src/main/runtime/agent-session-released-reservation-replay.test.ts": 851, + "src/main/runtime/agent-session-replaced-runtime.test.ts": 238, + "src/main/runtime/agent-session-reservation-admission.test.ts": 291, + "src/main/runtime/agent-session-runtime-attribution.test.ts": 799, + "src/main/runtime/agent-session-runtime-end-record.test.ts": 314, + "src/main/runtime/agent-session-spawn-token-process-scan.test.ts": 82, + "src/main/runtime/agent-session-spawn-token-readback.test.ts": 82, + "src/main/runtime/agent-session-store-transactions.test.ts": 575, + "src/main/runtime/agent-session-surface-release-transition.test.ts": 240, + "src/main/runtime/agent-session-unsaved-creation-supersession.test.ts": 730, + "src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts": 354, + "src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts": 239, + "src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts": 325, + "src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts": 470, + "src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts": 209, + "src/main/runtime/agent-terminal-launch-host.test.ts": 4780, + "src/main/runtime/agent-terminal-startup-prompt.test.ts": 4385, + "src/main/runtime/antigravity-readiness-transcripts.test.ts": 9127, + "src/main/runtime/antigravity-terminal-readiness.test.ts": 318, + "src/main/runtime/automatic-activation-host-sleep.test.ts": 5209, + "src/main/runtime/automation-change-publication.test.ts": 13953, + "src/main/runtime/browser-client-download-transfers.test.ts": 341, + "src/main/runtime/browser-dialog-settlement.test.ts": 415, + "src/main/runtime/browser-execution-host-key-resolution.test.ts": 4603, + "src/main/runtime/browser-host-capability-selection.test.ts": 270, + "src/main/runtime/browser-host-client-page-adoption-grants.test.ts": 56, + "src/main/runtime/browser-host-client-page-adoption.test.ts": 53, + "src/main/runtime/browser-host-client-page-creation.test.ts": 298, + "src/main/runtime/browser-host-command-ledger-capacity.test.ts": 233, + "src/main/runtime/browser-host-command-ledger.test.ts": 267, + "src/main/runtime/browser-host-file-channel-admission.test.ts": 228, + "src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts": 407, + "src/main/runtime/browser-host-lease-fenced-page-release.test.ts": 271, + "src/main/runtime/browser-host-lease-placement-retirement.test.ts": 299, + "src/main/runtime/browser-host-lease-registry.test.ts": 279, + "src/main/runtime/browser-host-page-placement-replacement.test.ts": 299, + "src/main/runtime/browser-host-page-placement.test.ts": 299, + "src/main/runtime/browser-host-page-reconciliation-executor.test.ts": 89, + "src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts": 512, + "src/main/runtime/browser-host-page-reconciliation-plan.test.ts": 83, + "src/main/runtime/browser-host-page-reconciliation-preconditions.test.ts": 277, + "src/main/runtime/browser-host-page-retirement.test.ts": 58, + "src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts": 5322, + "src/main/runtime/browser-screencast-driver-attribution.test.ts": 4877, + "src/main/runtime/browser-screencast-driver-scope.test.ts": 46, + "src/main/runtime/browser-screencast-ghost-subscriber-eviction.test.ts": 67, + "src/main/runtime/browser-screencast-remote-viewer-retention.test.ts": 5765, + "src/main/runtime/browser-screencast-renderer-throttle-lease.test.ts": 5601, + "src/main/runtime/browser-session-tab-selection-snapshot.test.ts": 60, + "src/main/runtime/browser-tab-create-caller-navigation.test.ts": 4661, + "src/main/runtime/browser-tab-create-folder-workspace.test.ts": 407, + "src/main/runtime/browser-tab-create-publication.test.ts": 550, + "src/main/runtime/claude-agent-teams-pty-exit-leak.test.ts": 4658, + "src/main/runtime/claude-agent-teams-service.test.ts": 92, + "src/main/runtime/claude-agent-teams-shim-env.test.ts": 79, + "src/main/runtime/claude-structured-exit-mid-response.test.ts": 1386, + "src/main/runtime/claude-structured-failed-start-resume.test.ts": 1942, + "src/main/runtime/claude-structured-idle-releases-unknown.test.ts": 2200, + "src/main/runtime/claude-structured-resumed-start-failure.test.ts": 1787, + "src/main/runtime/claude-structured-retired-saved-model.test.ts": 1984, + "src/main/runtime/claude-structured-send-during-startup.test.ts": 2343, + "src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts": 2482, + "src/main/runtime/claude-structured-session-integration.test.ts": 3307, + "src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts": 1987, + "src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts": 2142, + "src/main/runtime/claude-trust-dialog-transcript.test.ts": 3395, + "src/main/runtime/cli-terminal-create-host-session-binding.test.ts": 4725, + "src/main/runtime/client-hosted-browser-page-persistence.integration.test.ts": 4478, + "src/main/runtime/client-hosted-browser-page-persistence.test.ts": 369, + "src/main/runtime/client-hosted-browser-row-projection.test.ts": 54, + "src/main/runtime/client-hosted-browser-row-publication.test.ts": 77, + "src/main/runtime/client-hosted-browser-row-push.integration.test.ts": 4403, + "src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts": 4758, + "src/main/runtime/client-hosted-page-reconciliation-window.test.ts": 44, + "src/main/runtime/client-session-tab-selection.test.ts": 68, + "src/main/runtime/cline-screen-readiness-transcripts.test.ts": 8000, + "src/main/runtime/codex-header-readiness-transcripts.test.ts": 5220, + "src/main/runtime/codex-quiet-ready-screen.test.ts": 8412, + "src/main/runtime/codex-startup-paste-transcripts.test.ts": 503, + "src/main/runtime/created-mobile-session-tab-caller-selection.test.ts": 4175, + "src/main/runtime/decorative-title-fact-emission.test.ts": 48, + "src/main/runtime/draft-paste-ready-scanner-chunking.test.ts": 1720, + "src/main/runtime/dsb-terminal-runtime.test.ts": 4441, + "src/main/runtime/dsh-readiness-transcript.test.ts": 70, + "src/main/runtime/e2ee-keypair.test.ts": 92, + "src/main/runtime/exit-provenance-audit.test.ts": 5831, + "src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts": 6032, + "src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts": 5078, + "src/main/runtime/fetch-remote-cache.test.ts": 4636, + "src/main/runtime/file-watcher-host.test.ts": 393, + "src/main/runtime/fit-override-integration.test.ts": 4532, + "src/main/runtime/floating-terminal-inventory-ownership.test.ts": 4508, + "src/main/runtime/folder-workspace-pty-identity.test.ts": 4443, + "src/main/runtime/graph-sync-deletion-fence.test.ts": 4769, + "src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts": 4728, + "src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts": 4118, + "src/main/runtime/graph-sync-payload-partition.test.ts": 4542, + "src/main/runtime/headless-close-keeps-publication-epoch.test.ts": 4883, + "src/main/runtime/headless-hydration-ownership.test.ts": 6383, + "src/main/runtime/headless-placeholder-graph-surface-standing.test.ts": 4474, + "src/main/runtime/headless-runtime-graph.test.ts": 4867, + "src/main/runtime/headless-seed-ownership.test.ts": 6450, + "src/main/runtime/headless-tab-group-selection.test.ts": 39, + "src/main/runtime/headless-tab-order-stability.test.ts": 5018, + "src/main/runtime/headless-terminal-dispose-write-ordering.test.ts": 4223, + "src/main/runtime/headless-terminal-query-reply-policy.test.ts": 55, + "src/main/runtime/headless-terminal-split-layout.test.ts": 62, + "src/main/runtime/hidden-output-restored-provider-snapshot.test.ts": 4953, + "src/main/runtime/host-descriptor.test.ts": 299, + "src/main/runtime/host-terminal-close-persistence-durability.test.ts": 1889, + "src/main/runtime/launch-agent-terminal-followup.test.ts": 4203, + "src/main/runtime/launched-agent-composer-readiness-claude.test.ts": 4003, + "src/main/runtime/launched-agent-composer-readiness.test.ts": 581, + "src/main/runtime/launched-agent-crash-guard.live-shell.test.ts": 209, + "src/main/runtime/launched-agent-exit-delivery.test.ts": 342, + "src/main/runtime/launched-agent-foreground.test.ts": 92, + "src/main/runtime/launched-agent-shell-handoff-readiness.test.ts": 101, + "src/main/runtime/launched-agent-write-guard.test.ts": 63, + "src/main/runtime/leaf-pty-verdict-expiry.test.ts": 5707, + "src/main/runtime/linear-save-issue.test.ts": 4330, + "src/main/runtime/managed-worktree-create-execution-host.test.ts": 4606, + "src/main/runtime/managed-worktree-create-telemetry.test.ts": 4411, + "src/main/runtime/missing-worktree-terminal-reconciliation.test.ts": 347, + "src/main/runtime/mobile-agent-status-permission-renewal.test.ts": 4833, + "src/main/runtime/mobile-notification-dismissal-read-failure.test.ts": 268, + "src/main/runtime/mobile-notification-dismissal-store.test.ts": 293, + "src/main/runtime/mobile-notification-replay.test.ts": 52, + "src/main/runtime/mobile-pairing-qr.test.ts": 1059, + "src/main/runtime/mobile-pairing-userdata-path.test.ts": 2373, + "src/main/runtime/mobile-presence-lock.test.ts": 16927, + "src/main/runtime/mobile-rpc-allowlist.test.ts": 3585, + "src/main/runtime/mobile-session-tab-close-refusal-message.test.ts": 46, + "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts": 65, + "src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts": 4509, + "src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts": 67, + "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts": 331, + "src/main/runtime/mobile-session-terminal-retirement-proof.test.ts": 52, + "src/main/runtime/mobile-session-terminal-retirement.test.ts": 253, + "src/main/runtime/mobile-store-orphaned-temp-cleanup.test.ts": 381, + "src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts": 5383, + "src/main/runtime/mobile-subscribe-integration.test.ts": 18007, + "src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 7135, + "src/main/runtime/native-chat-visuals-workspace-verdict.test.ts": 323, + "src/main/runtime/omp-readiness-transcripts.test.ts": 8110, + "src/main/runtime/opencode-agent-row-enter-transcript.test.ts": 559, + "src/main/runtime/opencode-agent-row-structure-transcript.test.ts": 333, + "src/main/runtime/opencode-finished-session-authority.test.ts": 5338, + "src/main/runtime/opencode-model-worktree-create.test.ts": 964, + "src/main/runtime/opencode-run-lifetime-runtime.test.ts": 4511, + "src/main/runtime/opencode-run-lifetime-status.test.ts": 131, + "src/main/runtime/orca-runtime-agent-session-operation.test.ts": 4398, + "src/main/runtime/orca-runtime-agent-skill-share.test.ts": 5202, + "src/main/runtime/orca-runtime-automations.test.ts": 4377, + "src/main/runtime/orca-runtime-browser-client-hosted.test.ts": 573, + "src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts": 519, + "src/main/runtime/orca-runtime-browser-headless.test.ts": 448, + "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts": 543, + "src/main/runtime/orca-runtime-browser.test.ts": 656, + "src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts": 4342, + "src/main/runtime/orca-runtime-create-base-prefetch.test.ts": 4314, + "src/main/runtime/orca-runtime-default-tab-agent-status.test.ts": 4293, + "src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts": 4356, + "src/main/runtime/orca-runtime-exact-worker-provider-session.test.ts": 2713, + "src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts": 549, + "src/main/runtime/orca-runtime-files-preview-budget.test.ts": 634, + "src/main/runtime/orca-runtime-files-rename-authority.test.ts": 542, + "src/main/runtime/orca-runtime-files-search.test.ts": 622, + "src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts": 636, + "src/main/runtime/orca-runtime-files-ssh-rearm.test.ts": 1330, + "src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts": 663, + "src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts": 635, + "src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts": 617, + "src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts": 590, + "src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts": 674, + "src/main/runtime/orca-runtime-files-watch-host-scope.test.ts": 466, + "src/main/runtime/orca-runtime-files-watch.test.ts": 533, + "src/main/runtime/orca-runtime-git-branch-diff.test.ts": 1006, + "src/main/runtime/orca-runtime-git-diff-budget.test.ts": 952, + "src/main/runtime/orca-runtime-git.test.ts": 1054, + "src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts": 4653, + "src/main/runtime/orca-runtime-headless-terminal-close.test.ts": 4240, + "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts": 4572, + "src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts": 4470, + "src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts": 4519, + "src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts": 4450, + "src/main/runtime/orca-runtime-on-pty-data-redraw.test.ts": 4103, + "src/main/runtime/orca-runtime-path-candidate-history.test.ts": 64, + "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts": 5125, + "src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts": 4630, + "src/main/runtime/orca-runtime-quick-open-capabilities.test.ts": 618, + "src/main/runtime/orca-runtime-skill-recovery.test.ts": 4922, + "src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts": 5171, + "src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts": 4301, + "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts": 4720, + "src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts": 4032, + "src/main/runtime/orca-runtime-structured-session-restore.test.ts": 4748, + "src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts": 4449, + "src/main/runtime/orca-runtime-tab-id-collision.test.ts": 4395, + "src/main/runtime/orca-runtime-tail-wait-memo.test.ts": 303, + "src/main/runtime/orca-runtime-terminal-close-agent-launch.test.ts": 3986, + "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts": 4919, + "src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts": 4813, + "src/main/runtime/orca-runtime-terminal-close-records.test.ts": 6684, + "src/main/runtime/orca-runtime-terminal-close-spill-partition.test.ts": 4512, + "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts": 4787, + "src/main/runtime/orca-runtime-terminal-cwd.test.ts": 4884, + "src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts": 5490, + "src/main/runtime/orca-runtime-terminal-rename-retention.test.ts": 6075, + "src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts": 4772, + "src/main/runtime/orca-runtime-terminal-retirement.test.ts": 4562, + "src/main/runtime/orca-runtime-terminal-split-authority.test.ts": 4555, + "src/main/runtime/orca-runtime-terminal-surface-close.test.ts": 5472, + "src/main/runtime/orca-runtime-workspace-ports-host.test.ts": 3791, + "src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts": 4736, + "src/main/runtime/orca-runtime-worktree-provisioning-availability.test.ts": 5541, + "src/main/runtime/orca-runtime.test.ts": 36218, + "src/main/runtime/orcad-migration-catalog-import.test.ts": 45, + "src/main/runtime/orchestration-cli-subprocess.test.ts": 5861, + "src/main/runtime/orchestration-codex-completion-title.test.ts": 5337, + "src/main/runtime/orchestration-codex-real-pty.integration.test.ts": 6330, + "src/main/runtime/orchestration-compatibility-authority.test.ts": 4711, + "src/main/runtime/orchestration-configured-agent-target.test.ts": 4510, + "src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts": 6140, + "src/main/runtime/orchestration-live-agent-title-restore.test.ts": 5202, + "src/main/runtime/orchestration-mailbox-cold-park-idle.test.ts": 5151, + "src/main/runtime/orchestration-mailbox-crash-recovery.test.ts": 5866, + "src/main/runtime/orchestration-mailbox-detached-routing.test.ts": 5444, + "src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts": 6051, + "src/main/runtime/orchestration-mailbox-notification-consistency.test.ts": 19774, + "src/main/runtime/orchestration-mailbox-pointer-cli-command.test.ts": 5540, + "src/main/runtime/orchestration-mailbox-routing-races.test.ts": 8786, + "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts": 5363, + "src/main/runtime/orchestration-message-delivery-identity.test.ts": 6283, + "src/main/runtime/orchestration-messages-fake-parity.test.ts": 619, + "src/main/runtime/orchestration-opencode-model-host.test.ts": 4210, + "src/main/runtime/orchestration-worker-workspace-resolution.test.ts": 4914, + "src/main/runtime/orchestration/agent-facing-parity.test.ts": 866, + "src/main/runtime/orchestration/chat-assignee.test.ts": 5175, + "src/main/runtime/orchestration/cli-command.test.ts": 55, + "src/main/runtime/orchestration/configured-worker-agent-selector.test.ts": 73, + "src/main/runtime/orchestration/coordinator-decision-gates.test.ts": 589, + "src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts": 614, + "src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts": 652, + "src/main/runtime/orchestration/coordinator-escalation-triage.test.ts": 533, + "src/main/runtime/orchestration/coordinator-stale-base-flag.test.ts": 41, + "src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts": 590, + "src/main/runtime/orchestration/coordinator.test.ts": 961, + "src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts": 609, + "src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts": 599, + "src/main/runtime/orchestration/db-message-timestamp.test.ts": 562, + "src/main/runtime/orchestration/db-messages.test.ts": 551, + "src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts": 640, + "src/main/runtime/orchestration/db-task-create-readiness.test.ts": 786, + "src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts": 1161, + "src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts": 1315, + "src/main/runtime/orchestration/db-task-dispatch-races.test.ts": 762, + "src/main/runtime/orchestration/db-task-promotion-projection.test.ts": 662, + "src/main/runtime/orchestration/db-undelivered-mailboxes.test.ts": 573, + "src/main/runtime/orchestration/db.test.ts": 1313, + "src/main/runtime/orchestration/db/attempt-outcome-projection.test.ts": 673, + "src/main/runtime/orchestration/db/decision-gate-lifecycle.test.ts": 527, + "src/main/runtime/orchestration/db/dispatch-depth.test.ts": 834, + "src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts": 627, + "src/main/runtime/orchestration/db/federated-worker-report-outcome.test.ts": 48, + "src/main/runtime/orchestration/db/federation/federated-dispatch-observation-fence.test.ts": 560, + "src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-release.test.ts": 633, + "src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts": 597, + "src/main/runtime/orchestration/db/legacy-question-identity.test.ts": 52, + "src/main/runtime/orchestration/db/lifecycle-rejection-marker.test.ts": 51, + "src/main/runtime/orchestration/db/lifecycle-transition.test.ts": 572, + "src/main/runtime/orchestration/db/messages/mailbox-consumer-lifecycle-fencing.test.ts": 809, + "src/main/runtime/orchestration/db/pane-key-match.test.ts": 51, + "src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts": 1139, + "src/main/runtime/orchestration/db/row-column-lists.test.ts": 554, + "src/main/runtime/orchestration/db/run-list-cursor.test.ts": 46, + "src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts": 720, + "src/main/runtime/orchestration/db/schema/derived-delivery-migration.test.ts": 810, + "src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts": 581, + "src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts": 620, + "src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts": 630, + "src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.test.ts": 627, + "src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-orca-session.test.ts": 575, + "src/main/runtime/orchestration/db/writer-run-required.test.ts": 659, + "src/main/runtime/orchestration/dispatch-consumer-generation-migration.test.ts": 720, + "src/main/runtime/orchestration/dispatch-creator-identity-migration.test.ts": 649, + "src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts": 693, + "src/main/runtime/orchestration/federation-ack-checkpoints.test.ts": 38, + "src/main/runtime/orchestration/federation-acknowledgment-integrity.test.ts": 561, + "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts": 684, + "src/main/runtime/orchestration/federation-lifecycle-settlement.test.ts": 39, + "src/main/runtime/orchestration/federation-sync.test.ts": 5859, + "src/main/runtime/orchestration/federation-terminal-recovery.test.ts": 4138, + "src/main/runtime/orchestration/formatter.test.ts": 75, + "src/main/runtime/orchestration/groups.test.ts": 61, + "src/main/runtime/orchestration/lifecycle-caller-edges.test.ts": 653, + "src/main/runtime/orchestration/lifecycle-reconciliation.test.ts": 799, + "src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts": 5376, + "src/main/runtime/orchestration/mailbox-pointer-eligibility.test.ts": 666, + "src/main/runtime/orchestration/mailbox-pointer-release-query.test.ts": 591, + "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts": 691, + "src/main/runtime/orchestration/mailbox-pointer-submit.test.ts": 981, + "src/main/runtime/orchestration/message-batch-atomicity.test.ts": 641, + "src/main/runtime/orchestration/mutation-receipt-capacity.test.ts": 638, + "src/main/runtime/orchestration/nested-worker-depth-migration.test.ts": 676, + "src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts": 995, + "src/main/runtime/orchestration/orchestration-all-start-versions-migration.test.ts": 1990, + "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts": 944, + "src/main/runtime/orchestration/orchestration-db-permissions.test.ts": 573, + "src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts": 776, + "src/main/runtime/orchestration/orchestration-delivery-consumption.test.ts": 643, + "src/main/runtime/orchestration/orchestration-derived-delivery.test.ts": 567, + "src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts": 868, + "src/main/runtime/orchestration/orchestration-legacy-coordinator-authority-db.test.ts": 956, + "src/main/runtime/orchestration/orchestration-legacy-question-migration-db.test.ts": 1066, + "src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts": 2229, + "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts": 347, + "src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts": 660, + "src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts": 957, + "src/main/runtime/orchestration/orchestration-party-cli-address.test.ts": 281, + "src/main/runtime/orchestration/orchestration-party-location.test.ts": 616, + "src/main/runtime/orchestration/orchestration-peer-capability-cache.test.ts": 79, + "src/main/runtime/orchestration/orchestration-reset-db.test.ts": 588, + "src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts": 768, + "src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts": 593, + "src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts": 1120, + "src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts": 822, + "src/main/runtime/orchestration/preamble.test.ts": 174, + "src/main/runtime/orchestration/r1-identity-migration.test.ts": 634, + "src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts": 691, + "src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts": 693, + "src/main/runtime/orchestration/send-agent-turn-host.test.ts": 1639, + "src/main/runtime/orchestration/send-agent-turn.test.ts": 267, + "src/main/runtime/orchestration/settled-question-threads-migration.test.ts": 570, + "src/main/runtime/orchestration/setup-completion-signal.test.ts": 54, + "src/main/runtime/orchestration/structured-mail-source.test.ts": 302, + "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts": 664, + "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts": 401, + "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts": 91, + "src/main/runtime/orchestration/structured-session-mail-target.test.ts": 4683, + "src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts": 288, + "src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts": 4219, + "src/main/runtime/orchestration/structured-worker-group-addressing.test.ts": 513, + "src/main/runtime/orchestration/structured-worker-journal-archive.test.ts": 354, + "src/main/runtime/orchestration/structured-worker-journal-page.test.ts": 978, + "src/main/runtime/orchestration/task-deps-flag.test.ts": 70, + "src/main/runtime/orchestration/worker-attention-context.test.ts": 89, + "src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts": 2467, + "src/main/runtime/orchestration/worker-dispatch-settlement.test.ts": 1181, + "src/main/runtime/orchestration/worker-output-archive-bounding.test.ts": 794, + "src/main/runtime/orchestration/worker-output-archive.test.ts": 801, + "src/main/runtime/orchestration/worker-output-cursor.test.ts": 51, + "src/main/runtime/orchestration/worker-provider-session.test.ts": 59, + "src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts": 506, + "src/main/runtime/orchestration/worker-terminal-process-liveness.test.ts": 58, + "src/main/runtime/orchestration/worker-transcript-payload.test.ts": 319, + "src/main/runtime/orchestration/worker-transcript-read.test.ts": 844, + "src/main/runtime/orchestration/worker-transcript-remote-read.test.ts": 668, + "src/main/runtime/paired-close-retirement-proof-publication-order.test.ts": 4291, + "src/main/runtime/pairing-endpoint.test.ts": 264, + "src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts": 7440, + "src/main/runtime/project-host-setup-delete-guard.test.ts": 387, + "src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts": 41, + "src/main/runtime/pty-exit-agent-status-reconciliation.test.ts": 4014, + "src/main/runtime/pty-exit-record-wait.test.ts": 4307, + "src/main/runtime/pty-inventory-lifecycle-admission.test.ts": 4897, + "src/main/runtime/pty-inventory-lifecycle-scope.test.ts": 5629, + "src/main/runtime/pty-inventory-liveness-verdict.test.ts": 4512, + "src/main/runtime/pty-inventory-partial-relay-liveness.test.ts": 4555, + "src/main/runtime/pty-lifecycle-generation-retention.test.ts": 5736, + "src/main/runtime/pty-recorded-surface-topology.test.ts": 47, + "src/main/runtime/pty-shell-ownership-mirror.test.ts": 1615, + "src/main/runtime/pty-transcript-prune-wait-cache.test.ts": 4405, + "src/main/runtime/public-ssh-state.test.ts": 42, + "src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts": 369, + "src/main/runtime/push/desktop-push-service.test.ts": 455, + "src/main/runtime/push/push-agent-state.test.ts": 61, + "src/main/runtime/push/push-cleanup-auth-expiry.test.ts": 392, + "src/main/runtime/push/push-delivery-policy.test.ts": 75, + "src/main/runtime/push/push-device-registration-persistence.test.ts": 129, + "src/main/runtime/push/push-dispatcher.test.ts": 84, + "src/main/runtime/push/push-gateway-client.test.ts": 415, + "src/main/runtime/push/push-gateway-session.test.ts": 392, + "src/main/runtime/push/push-host-proof-vector.test.ts": 60, + "src/main/runtime/push/push-host-proof.test.ts": 155, + "src/main/runtime/push/push-outcome-counters.test.ts": 39, + "src/main/runtime/push/push-policy-pipeline.integration.test.ts": 358, + "src/main/runtime/push/push-preferences.test.ts": 66, + "src/main/runtime/push/push-registration-races.test.ts": 398, + "src/main/runtime/push/push-registration-rpc.test.ts": 5420, + "src/main/runtime/push/push-unpair-persistence.test.ts": 5651, + "src/main/runtime/push/push-unregister-outbox.test.ts": 91, + "src/main/runtime/qoder-independent-spawn-review.test.ts": 4429, + "src/main/runtime/qoder-mobile-resume-adoption.test.ts": 6484, + "src/main/runtime/qoder-terminal-command-resolution.test.ts": 148, + "src/main/runtime/quarter-circle-title-send-authorization.test.ts": 16020, + "src/main/runtime/queued-terminal-graph-exit.test.ts": 5194, + "src/main/runtime/recent-pty-output-buffer.test.ts": 137, + "src/main/runtime/recent-pty-output-retention.test.ts": 160, + "src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts": 306, + "src/main/runtime/relay/desktop-relay-service-installer.test.ts": 48, + "src/main/runtime/relay/desktop-relay-service.test.ts": 743, + "src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts": 526, + "src/main/runtime/relay/relay-auth-coordinator-readiness.test.ts": 526, + "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts": 326, + "src/main/runtime/relay/relay-auth-coordinator.test.ts": 1435, + "src/main/runtime/relay/relay-auth-host-close-reason.test.ts": 355, + "src/main/runtime/relay/relay-control-client.test.ts": 788, + "src/main/runtime/relay/relay-control-close-reason.test.ts": 339, + "src/main/runtime/relay/relay-control-origin.test.ts": 284, + "src/main/runtime/relay/relay-control-request-retirement.test.ts": 296, + "src/main/runtime/relay/relay-control-timeout-classification.test.ts": 244, + "src/main/runtime/relay/relay-demand-ledger.test.ts": 98, + "src/main/runtime/relay/relay-host-proof.test.ts": 166, + "src/main/runtime/relay/relay-http-client.test.ts": 279, + "src/main/runtime/relay/relay-region-correction.test.ts": 312, + "src/main/runtime/relay/relay-region-preference.test.ts": 352, + "src/main/runtime/relay/relay-region-probe-log.test.ts": 275, + "src/main/runtime/relay/relay-region-refresh.test.ts": 249, + "src/main/runtime/relay/relay-renewal-jitter.test.ts": 254, + "src/main/runtime/relay/relay-revoke-outbox.test.ts": 102, + "src/main/runtime/relay/relay-session-broker.test.ts": 859, + "src/main/runtime/remote-agent-session-host-authority.integration.test.ts": 5984, + "src/main/runtime/remote-browser-screencast-frame-admission.test.ts": 409, + "src/main/runtime/remote-desktop-driver.test.ts": 4594, + "src/main/runtime/remote-desktop-terminal-floor-reconnect.test.ts": 46, + "src/main/runtime/remote-runtime-close-intent.integration.test.ts": 4011, + "src/main/runtime/remote-runtime-request-connection.integration.test.ts": 3568, + "src/main/runtime/remote-server-updater.test.ts": 51, + "src/main/runtime/renderer-browser-session-reconciliation.test.ts": 2554, + "src/main/runtime/repo-icon-fork-backfill.test.ts": 4230, + "src/main/runtime/repo-worktree-admin-fingerprint.test.ts": 813, "src/main/runtime/repo-worktree-resolution-scan.test.ts": 54, - "src/main/runtime/repo-worktree-row-resolution.test.ts": 221, - "src/main/runtime/retained-tail-redraw-window.equivalence.test.ts": 3753, - "src/main/runtime/rpc/core-typed-method-contract.test.ts": 107, - "src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts": 1502, - "src/main/runtime/rpc/dispatcher-computer-errors.test.ts": 2001, - "src/main/runtime/rpc/dispatcher-feature-interactions.test.ts": 1855, - "src/main/runtime/rpc/dispatcher-request-parsing.test.ts": 143, - "src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts": 183, - "src/main/runtime/rpc/e2ee-channel-v2.test.ts": 226, - "src/main/runtime/rpc/e2ee-channel.test.ts": 490, - "src/main/runtime/rpc/e2ee-crypto.test.ts": 99, - "src/main/runtime/rpc/e2ee-integration.test.ts": 267, - "src/main/runtime/rpc/errors.test.ts": 190, - "src/main/runtime/rpc/methods/accounts.test.ts": 152, - "src/main/runtime/rpc/methods/agent-hooks.test.ts": 138, - "src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts": 2274, - "src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts": 1814, - "src/main/runtime/rpc/methods/agent-launch-replay.test.ts": 2245, - "src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts": 925, - "src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts": 72, - "src/main/runtime/rpc/methods/agent-launch-terminal-identity.test.ts": 349, - "src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts": 301, - "src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts": 93, - "src/main/runtime/rpc/methods/agent-launch.test.ts": 545, - "src/main/runtime/rpc/methods/agent-session.test.ts": 1701, - "src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts": 2731, - "src/main/runtime/rpc/methods/ai-vault-search.test.ts": 3943, - "src/main/runtime/rpc/methods/ai-vault.test.ts": 3245, - "src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts": 2728, - "src/main/runtime/rpc/methods/artifacts.test.ts": 419, - "src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts": 2331, - "src/main/runtime/rpc/methods/automations.test.ts": 1969, - "src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts": 1420, - "src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts": 1886, - "src/main/runtime/rpc/methods/browser-client-host.test.ts": 1978, - "src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts": 2040, - "src/main/runtime/rpc/methods/browser-identity-rpc.test.ts": 2038, - "src/main/runtime/rpc/methods/browser-network-tunnel.test.ts": 2142, - "src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts": 75, - "src/main/runtime/rpc/methods/browser.test.ts": 2100, - "src/main/runtime/rpc/methods/client-events.test.ts": 1168, - "src/main/runtime/rpc/methods/client-native-chat-settings.test.ts": 2581, - "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts": 2057, - "src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts": 1201, - "src/main/runtime/rpc/methods/client-ui.test.ts": 5509, - "src/main/runtime/rpc/methods/clipboard.test.ts": 1919, - "src/main/runtime/rpc/methods/computer-actions.test.ts": 203, - "src/main/runtime/rpc/methods/computer.test.ts": 185, - "src/main/runtime/rpc/methods/diagnostics.test.ts": 2205, - "src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts": 78, - "src/main/runtime/rpc/methods/files-base64-padding.test.ts": 1950, - "src/main/runtime/rpc/methods/files-doc-preview.test.ts": 2034, - "src/main/runtime/rpc/methods/files-list-all-page-size.test.ts": 2043, - "src/main/runtime/rpc/methods/files-path-search.test.ts": 1362, - "src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts": 1828, - "src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts": 2031, - "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts": 2085, - "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts": 2050, - "src/main/runtime/rpc/methods/files.test.ts": 2096, - "src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts": 1619, - "src/main/runtime/rpc/methods/git.test.ts": 1380, - "src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts": 1972, - "src/main/runtime/rpc/methods/github.test.ts": 1972, - "src/main/runtime/rpc/methods/gitlab.test.ts": 2078, - "src/main/runtime/rpc/methods/host-capabilities.test.ts": 2054, - "src/main/runtime/rpc/methods/hosted-review.test.ts": 1980, - "src/main/runtime/rpc/methods/jira.test.ts": 1754, - "src/main/runtime/rpc/methods/linear-agent-access.test.ts": 3679, - "src/main/runtime/rpc/methods/linear-agent-project-access.test.ts": 4428, - "src/main/runtime/rpc/methods/linear.test.ts": 2000, - "src/main/runtime/rpc/methods/mobile-web-bundle-range.test.ts": 2006, - "src/main/runtime/rpc/methods/mobile-web-bundle-read-concurrency.test.ts": 1627, - "src/main/runtime/rpc/methods/mobile-web-bundle-window-refusals.test.ts": 1956, - "src/main/runtime/rpc/methods/mobile-web-bundle.test.ts": 2278, - "src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts": 59, - "src/main/runtime/rpc/methods/native-chat.test.ts": 205, - "src/main/runtime/rpc/methods/notification-preferences.test.ts": 187, - "src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts": 125, - "src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts": 3127, - "src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts": 3454, - "src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts": 177, - "src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts": 2861, - "src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts": 202, - "src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts": 257, - "src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts": 3575, - "src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts": 3617, - "src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts": 221, - "src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts": 181, - "src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts": 146, - "src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts": 2942, - "src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.test.ts": 59, - "src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.test.ts": 306, - "src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts": 3006, - "src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts": 3590, - "src/main/runtime/rpc/methods/orchestration/federation/federated-transport-safety.test.ts": 3305, - "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts": 3044, - "src/main/runtime/rpc/methods/orchestration/federation/federation-agent-launch.test.ts": 3886, - "src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts": 2200, - "src/main/runtime/rpc/methods/orchestration/federation/federation-effects.test.ts": 48, - "src/main/runtime/rpc/methods/orchestration/federation/federation-folder-placement.test.ts": 3459, - "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts": 8330, - "src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts": 3459, - "src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts": 5069, - "src/main/runtime/rpc/methods/orchestration/federation/federation-setup.test.ts": 3127, - "src/main/runtime/rpc/methods/orchestration/federation/federation-start-prompt-budget.test.ts": 3354, - "src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts": 135, - "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts": 5899, - "src/main/runtime/rpc/methods/orchestration/gates/gate-run-authorization.test.ts": 4327, - "src/main/runtime/rpc/methods/orchestration/gates/gates.test.ts": 4254, - "src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts": 3642, - "src/main/runtime/rpc/methods/orchestration/messaging/check-delivery-history.test.ts": 3832, - "src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts": 2154, - "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts": 3774, - "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts": 2399, - "src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts": 4320, - "src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts": 3153, - "src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts": 3500, - "src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts": 1944, - "src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts": 3325, - "src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts": 3305, - "src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts": 3760, - "src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts": 3572, - "src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts": 3495, - "src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts": 3989, - "src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts": 170, - "src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts": 3653, - "src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts": 3767, - "src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts": 5044, - "src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts": 3631, - "src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts": 3335, - "src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts": 3459, - "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts": 2831, - "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts": 2477, - "src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts": 3377, - "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts": 3653, - "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts": 2997, - "src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts": 3581, - "src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts": 301, - "src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts": 2671, - "src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts": 185, - "src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts": 2921, - "src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts": 3483, - "src/main/runtime/rpc/methods/orchestration/worker/worker-observation.test.ts": 226, - "src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts": 189, - "src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts": 2982, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts": 2904, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts": 3642, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-liveness-verdict.test.ts": 386, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts": 2795, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts": 3055, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts": 3085, - "src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts": 3192, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts": 37, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-outcome-classification.test.ts": 306, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts": 103, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts": 3639, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts": 3746, - "src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts": 47, - "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-capability.test.ts": 394, - "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-exit-race.test.ts": 2902, - "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts": 2444, - "src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-custody-at-creation.test.ts": 3618, - "src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts": 4522, - "src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts": 2613, - "src/main/runtime/rpc/methods/paired-caller-host-id.test.ts": 57, - "src/main/runtime/rpc/methods/pairing.test.ts": 4021, - "src/main/runtime/rpc/methods/plugins.test.ts": 357, - "src/main/runtime/rpc/methods/preflight.test.ts": 1474, - "src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts": 147, - "src/main/runtime/rpc/methods/repo-badge-color.test.ts": 1628, - "src/main/runtime/rpc/methods/repo.test.ts": 3091, - "src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts": 2076, - "src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts": 1544, - "src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts": 72, - "src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts": 1954, - "src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts": 43, - "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts": 7364, - "src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts": 3424, - "src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts": 3387, - "src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts": 1812, - "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts": 214, - "src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts": 1499, - "src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts": 1741, - "src/main/runtime/rpc/methods/session-tabs.test.ts": 2523, - "src/main/runtime/rpc/methods/skills.test.ts": 145, - "src/main/runtime/rpc/methods/speech.test.ts": 2115, - "src/main/runtime/rpc/methods/ssh.test.ts": 2113, - "src/main/runtime/rpc/methods/status-machine-name-publish-budget.test.ts": 4507, - "src/main/runtime/rpc/methods/status.test.ts": 3576, - "src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts": 1748, - "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts": 3622, - "src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts": 1976, - "src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.test.ts": 2085, - "src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts": 5554, - "src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts": 1458, - "src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts": 56, - "src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts": 2074, - "src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts": 2079, - "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts": 1973, - "src/main/runtime/rpc/methods/structured-agent-session.test.ts": 3566, - "src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts": 4222, - "src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts": 278, - "src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts": 3571, - "src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts": 3424, - "src/main/runtime/rpc/methods/task-provider-identity.test.ts": 199, - "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts": 243, - "src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts": 1363, - "src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts": 218, - "src/main/runtime/rpc/methods/terminal-navigation.test.ts": 1906, - "src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts": 257, - "src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts": 99, - "src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts": 171, - "src/main/runtime/rpc/methods/terminal/terminal-output-batcher.test.ts": 56, - "src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts": 440, - "src/main/runtime/rpc/methods/updater.test.ts": 96, - "src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts": 174, - "src/main/runtime/rpc/methods/workspace-ports.test.ts": 1990, - "src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts": 1995, - "src/main/runtime/rpc/methods/worktree-catalog-version-stamp.test.ts": 1225, - "src/main/runtime/rpc/methods/worktree-create-args.test.ts": 229, - "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts": 2179, - "src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts": 1632, - "src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts": 2053, - "src/main/runtime/rpc/methods/worktree-retired-names.test.ts": 2044, - "src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts": 1170, - "src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts": 1653, - "src/main/runtime/rpc/methods/worktree-schemas.test.ts": 213, - "src/main/runtime/rpc/methods/worktree.test.ts": 1890, - "src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts": 738, - "src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts": 75, - "src/main/runtime/rpc/mobile-e2ee-outbound-memory-budget.test.ts": 39, - "src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts": 88, - "src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts": 58, - "src/main/runtime/rpc/mobile-socket-wiring.test.ts": 407, - "src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts": 6095, - "src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts": 4449, - "src/main/runtime/rpc/orchestration-contract-fence.test.ts": 3677, - "src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts": 4165, - "src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts": 4292, - "src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts": 5243, - "src/main/runtime/rpc/orchestration-legacy-fence-jurisdiction.test.ts": 4116, - "src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts": 4209, - "src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts": 4083, - "src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts": 3570, - "src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts": 3472, - "src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts": 3978, - "src/main/runtime/rpc/orchestration-mutation-executor.test.ts": 2234, - "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts": 4386, - "src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts": 3422, - "src/main/runtime/rpc/orchestration-mutation-request-show.test.ts": 3415, - "src/main/runtime/rpc/orchestration-party-addressing.test.ts": 2984, - "src/main/runtime/rpc/orchestration-party-agreement.test.ts": 4332, - "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts": 3707, - "src/main/runtime/rpc/orchestration-session-caller.test.ts": 3095, - "src/main/runtime/rpc/orchestration-session-coordinator.test.ts": 4617, - "src/main/runtime/rpc/orchestration-session-recipient.test.ts": 3497, - "src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts": 4509, - "src/main/runtime/rpc/relay-transport.test.ts": 232, - "src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts": 66, - "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts": 66, - "src/main/runtime/rpc/runtime-client-capabilities.test.ts": 51, - "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts": 1449, - "src/main/runtime/rpc/runtime-close-attribution.test.ts": 1499, - "src/main/runtime/rpc/schemas.test.ts": 342, - "src/main/runtime/rpc/static-web-client-handler-stream.test.ts": 4560, - "src/main/runtime/rpc/streaming.test.ts": 2073, - "src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 1690, - "src/main/runtime/rpc/terminal-agent-send-guard.test.ts": 2687, - "src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts": 1909, - "src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts": 1995, - "src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts": 1877, - "src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts": 2532, - "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts": 3965, - "src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts": 1743, - "src/main/runtime/rpc/terminal-multiplex-end-verdict.test.ts": 2073, - "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts": 1942, - "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts": 42, - "src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts": 2257, - "src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts": 2087, - "src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts": 2541, - "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts": 2115, - "src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts": 2070, - "src/main/runtime/rpc/terminal-multiplex-round-robin.test.ts": 53, - "src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts": 2326, - "src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts": 2706, - "src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts": 1321, - "src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts": 4114, - "src/main/runtime/rpc/terminal-output-batching.test.ts": 1858, - "src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts": 24855, - "src/main/runtime/rpc/terminal-output-frame-source-ranges.test.ts": 48, - "src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts": 2576, - "src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts": 2196, - "src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts": 2186, - "src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts": 1892, - "src/main/runtime/rpc/terminal-send.test.ts": 2332, - "src/main/runtime/rpc/terminal-source-range-ledger.test.ts": 56, - "src/main/runtime/rpc/terminal-stream-byte-length.test.ts": 6429, - "src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts": 1622, - "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts": 8969, - "src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts": 2041, - "src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts": 1193, - "src/main/runtime/rpc/terminal-subscribe-ownership.test.ts": 2029, - "src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts": 1967, - "src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts": 3468, - "src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts": 1930, - "src/main/runtime/rpc/unix-socket-transport.test.ts": 88, - "src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts": 37, - "src/main/runtime/rpc/worktree-catalog-snapshot.test.ts": 65, - "src/main/runtime/rpc/ws-fallback-port-store.test.ts": 43, - "src/main/runtime/rpc/ws-transport-accept-order.test.ts": 157, - "src/main/runtime/rpc/ws-transport-static-web.test.ts": 218, - "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts": 721, - "src/main/runtime/rpc/ws-transport.test.ts": 1076, - "src/main/runtime/runtime-agent-row-main-agent-projection.test.ts": 51, - "src/main/runtime/runtime-binary-message-router.test.ts": 36, - "src/main/runtime/runtime-browser-client-automation.test.ts": 181, - "src/main/runtime/runtime-browser-client-page-adoption.test.ts": 210, - "src/main/runtime/runtime-browser-client-page-creation.test.ts": 126, - "src/main/runtime/runtime-browser-client-page-recovery.test.ts": 250, - "src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts": 116, - "src/main/runtime/runtime-browser-network-execution-host.test.ts": 43, - "src/main/runtime/runtime-browser-page-registry.test.ts": 57, - "src/main/runtime/runtime-client-settings-linear-team-projection.test.ts": 453, - "src/main/runtime/runtime-client-settings-minimax-projection.test.ts": 289, - "src/main/runtime/runtime-client-settings-terminal-copy-projection.test.ts": 429, - "src/main/runtime/runtime-extraction-regressions.test.ts": 3371, - "src/main/runtime/runtime-file-path-existence.test.ts": 1374, - "src/main/runtime/runtime-file-target-connection-field-ratchet.test.ts": 37, - "src/main/runtime/runtime-file-target-execution-host.test.ts": 3281, - "src/main/runtime/runtime-file-upload-decode.test.ts": 3770, - "src/main/runtime/runtime-folder-workspace.test.ts": 50, - "src/main/runtime/runtime-folder-worktree-create.test.ts": 71, - "src/main/runtime/runtime-git-api-contract.test.ts": 722, - "src/main/runtime/runtime-git-branch-compare-admission.test.ts": 66, - "src/main/runtime/runtime-git-command-target.test.ts": 34, - "src/main/runtime/runtime-git-conflict-operation-routing.test.ts": 501, - "src/main/runtime/runtime-git-execution-host-ownership.test.ts": 819, - "src/main/runtime/runtime-git-generation-admission.test.ts": 132, - "src/main/runtime/runtime-git-status-admission.test.ts": 2074, - "src/main/runtime/runtime-git-sync-commands.test.ts": 795, - "src/main/runtime/runtime-git-target-execution-host.test.ts": 2543, - "src/main/runtime/runtime-graph-reload-lifecycle.test.ts": 39, - "src/main/runtime/runtime-hook-agent-row-selection.test.ts": 91, - "src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts": 40, - "src/main/runtime/runtime-linear-read-commands.test.ts": 118, - "src/main/runtime/runtime-local-create-rearm-ordering.test.ts": 3195, - "src/main/runtime/runtime-local-worktree-create.test.ts": 258, - "src/main/runtime/runtime-local-worktree-materialization.test.ts": 71, - "src/main/runtime/runtime-local-worktree-terminal-startup.test.ts": 46, - "src/main/runtime/runtime-machine-name.test.ts": 76, - "src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts": 1074, - "src/main/runtime/runtime-managed-worktree-metadata.test.ts": 75, - "src/main/runtime/runtime-managed-worktree-queries.test.ts": 275, - "src/main/runtime/runtime-metadata-ownership-watch.test.ts": 119, - "src/main/runtime/runtime-metadata.test.ts": 168, - "src/main/runtime/runtime-mobile-agent-status-builder.test.ts": 62, - "src/main/runtime/runtime-mobile-file-path-search.test.ts": 32, - "src/main/runtime/runtime-owned-terminal-publication-lineage.test.ts": 3962, - "src/main/runtime/runtime-project-group-controller-folder-delete.test.ts": 53, - "src/main/runtime/runtime-project-host-setup-controller.test.ts": 328, - "src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts": 292, - "src/main/runtime/runtime-remote-managed-worktree-create.test.ts": 42, - "src/main/runtime/runtime-remove-project-host-scope.test.ts": 2613, - "src/main/runtime/runtime-repository-issue-command.test.ts": 280, - "src/main/runtime/runtime-resolved-worktree-cache.test.ts": 54, - "src/main/runtime/runtime-rpc-browser-host-admission.test.ts": 4251, - "src/main/runtime/runtime-rpc-device-revocation.test.ts": 4197, - "src/main/runtime/runtime-rpc-long-poll-transport.test.ts": 6862, - "src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts": 3056, - "src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts": 1796, - "src/main/runtime/runtime-rpc-mobile-native-chat-settings.test.ts": 29, - "src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts": 4788, - "src/main/runtime/runtime-rpc-mobile-unknown-method-scope-refusal.test.ts": 1513, - "src/main/runtime/runtime-rpc-orchestration-db-migration.test.ts": 424, - "src/main/runtime/runtime-rpc-pairing-mode-persistence.test.ts": 3549, - "src/main/runtime/runtime-rpc-pairing-offer.test.ts": 4433, - "src/main/runtime/runtime-rpc-relay-pairing.test.ts": 4084, - "src/main/runtime/runtime-rpc-request-authorization.test.ts": 4122, - "src/main/runtime/runtime-rpc-startup-failure.test.ts": 66, - "src/main/runtime/runtime-rpc-terminal-list.test.ts": 4158, - "src/main/runtime/runtime-rpc-websocket-bind-host.test.ts": 4774, - "src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts": 4036, - "src/main/runtime/runtime-rpc-worktree-queries.test.ts": 4031, - "src/main/runtime/runtime-search-line-fragments.test.ts": 264, - "src/main/runtime/runtime-server-git-availability.test.ts": 60, - "src/main/runtime/runtime-session-search-settings.test.ts": 66, - "src/main/runtime/runtime-skill-install-authority.test.ts": 64, - "src/main/runtime/runtime-skill-install-commands.test.ts": 428, - "src/main/runtime/runtime-skill-install-queries.test.ts": 263, - "src/main/runtime/runtime-socket-sweep.test.ts": 2436, - "src/main/runtime/runtime-subscription-registry.test.ts": 30, - "src/main/runtime/runtime-terminal-idle-polls.test.ts": 62, - "src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts": 44, - "src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts": 93, - "src/main/runtime/runtime-unregistered-worktree-removal-host-home.test.ts": 617, - "src/main/runtime/runtime-worktree-agent-rows-structured.test.ts": 473, - "src/main/runtime/runtime-worktree-agent-sources.test.ts": 49, - "src/main/runtime/runtime-worktree-agent-startup.test.ts": 166, - "src/main/runtime/runtime-worktree-ps-summaries.test.ts": 40, - "src/main/runtime/runtime-worktree-selection.test.ts": 53, - "src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts": 663, - "src/main/runtime/saved-structured-agent-session-restoration.test.ts": 24, - "src/main/runtime/selected-review-branch.test.ts": 44, - "src/main/runtime/session-tabs-inventory-publication.test.ts": 3283, - "src/main/runtime/settled-worker-process-replacement.test.ts": 2400, - "src/main/runtime/structured-agent-session-close.test.ts": 115, - "src/main/runtime/structured-agent-session-integration-replay.test.ts": 2654, - "src/main/runtime/structured-agent-session-integration.test.ts": 11330, - "src/main/runtime/structured-agent-session-rollback-compatibility.test.ts": 352, - "src/main/runtime/structured-agent-session-runtime-exit.test.ts": 1281, - "src/main/runtime/structured-agent-session-runtime-provider-started.test.ts": 1074, - "src/main/runtime/structured-agent-session-runtime.test.ts": 861, - "src/main/runtime/structured-agent-session-support-probe.test.ts": 3514, - "src/main/runtime/structured-agent-shell-environment.test.ts": 57, - "src/main/runtime/structured-claude-auth-policy-wiring.test.ts": 777, - "src/main/runtime/structured-claude-pending-rewind.test.ts": 1026, - "src/main/runtime/structured-conversation-tab-replacement.test.ts": 30, - "src/main/runtime/structured-session-worktree-teardown.test.ts": 3171, - "src/main/runtime/structured-worker-agent-presence.test.ts": 89, - "src/main/runtime/structured-worker-authority.test.ts": 207, - "src/main/runtime/structured-worker-child-identity-env.test.ts": 213, - "src/main/runtime/structured-worker-hook-attestation.test.ts": 1582, - "src/main/runtime/structured-worker-identity.test.ts": 263, - "src/main/runtime/structured-worker-mail-routing.test.ts": 1584, - "src/main/runtime/structured-worker-takeover-pane-key.test.ts": 3936, - "src/main/runtime/structured-worker-terminal-read.test.ts": 1912, - "src/main/runtime/structured-worker-terminal-refusal.test.ts": 170, - "src/main/runtime/terminal-ansi-pending-retention.test.ts": 524, - "src/main/runtime/terminal-chat-owner-persistence.test.ts": 4208, - "src/main/runtime/terminal-close-observed-exit.test.ts": 3596, - "src/main/runtime/terminal-close-slow-agent-exit.test.ts": 5694, - "src/main/runtime/terminal-focus-navigation-coalescer.test.ts": 64, - "src/main/runtime/terminal-identity-probe.test.ts": 31, - "src/main/runtime/terminal-interactive-wait-visibility.test.ts": 9863, - "src/main/runtime/terminal-leaf-tab-resolution.test.ts": 70, - "src/main/runtime/terminal-list-execution-host-scope.test.ts": 6496, - "src/main/runtime/terminal-list-payload-size.test.ts": 3401, - "src/main/runtime/terminal-list-pending-pty-registration.test.ts": 3228, - "src/main/runtime/terminal-list-stale-leaf-liveness.test.ts": 3332, - "src/main/runtime/terminal-list-surface-lost-orphan.test.ts": 3567, - "src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts": 3510, - "src/main/runtime/terminal-model-query-authority.test.ts": 64, - "src/main/runtime/terminal-orphan-owner.test.ts": 28, + "src/main/runtime/repo-worktree-row-resolution.test.ts": 231, + "src/main/runtime/retained-tail-redraw-window.equivalence.test.ts": 724, + "src/main/runtime/ripgrep-filename-identity.test.ts": 68, + "src/main/runtime/rpc/core-typed-method-contract.test.ts": 257, + "src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts": 482, + "src/main/runtime/rpc/dispatcher-computer-errors.test.ts": 559, + "src/main/runtime/rpc/dispatcher-feature-interactions.test.ts": 601, + "src/main/runtime/rpc/dispatcher-request-parsing.test.ts": 296, + "src/main/runtime/rpc/dispatcher-transport-parity.test.ts": 5920, + "src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts": 300, + "src/main/runtime/rpc/e2ee-channel-v2.test.ts": 412, + "src/main/runtime/rpc/e2ee-channel.test.ts": 659, + "src/main/runtime/rpc/e2ee-crypto.test.ts": 139, + "src/main/runtime/rpc/e2ee-integration.test.ts": 357, + "src/main/runtime/rpc/errors.test.ts": 357, + "src/main/runtime/rpc/methods/accounts.test.ts": 248, + "src/main/runtime/rpc/methods/agent-hooks.test.ts": 233, + "src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts": 1188, + "src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts": 1753, + "src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts": 4476, + "src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts": 1776, + "src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts": 2435, + "src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts": 2058, + "src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts": 2073, + "src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts": 1280, + "src/main/runtime/rpc/methods/agent-launch-replay.test.ts": 1990, + "src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts": 5221, + "src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts": 2077, + "src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts": 245, + "src/main/runtime/rpc/methods/agent-launch-terminal-identity.test.ts": 1355, + "src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts": 1325, + "src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts": 385, + "src/main/runtime/rpc/methods/agent-launch.test.ts": 1715, + "src/main/runtime/rpc/methods/agent-session.test.ts": 699, + "src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts": 4568, + "src/main/runtime/rpc/methods/ai-vault-search.test.ts": 4950, + "src/main/runtime/rpc/methods/ai-vault-structured-ownership.test.ts": 568, + "src/main/runtime/rpc/methods/ai-vault.test.ts": 5161, + "src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts": 4562, + "src/main/runtime/rpc/methods/artifacts.test.ts": 371, + "src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts": 533, + "src/main/runtime/rpc/methods/automations.test.ts": 607, + "src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts": 690, + "src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts": 684, + "src/main/runtime/rpc/methods/browser-client-host.test.ts": 609, + "src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts": 637, + "src/main/runtime/rpc/methods/browser-identity-rpc.test.ts": 562, + "src/main/runtime/rpc/methods/browser-network-tunnel.test.ts": 798, + "src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts": 235, + "src/main/runtime/rpc/methods/browser.test.ts": 704, + "src/main/runtime/rpc/methods/client-events.test.ts": 3207, + "src/main/runtime/rpc/methods/client-native-chat-settings.test.ts": 654, + "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts": 675, + "src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts": 659, + "src/main/runtime/rpc/methods/client-ui.test.ts": 661, + "src/main/runtime/rpc/methods/clipboard-image-upload-decoding.test.ts": 680, + "src/main/runtime/rpc/methods/clipboard.test.ts": 627, + "src/main/runtime/rpc/methods/computer-actions.test.ts": 326, + "src/main/runtime/rpc/methods/computer.test.ts": 296, + "src/main/runtime/rpc/methods/diagnostics.test.ts": 471, + "src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts": 57, + "src/main/runtime/rpc/methods/files-base64-padding.test.ts": 626, + "src/main/runtime/rpc/methods/files-doc-preview.test.ts": 568, + "src/main/runtime/rpc/methods/files-list-all-page-size.test.ts": 647, + "src/main/runtime/rpc/methods/files-open-navigation.test.ts": 597, + "src/main/runtime/rpc/methods/files-path-search.test.ts": 624, + "src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts": 547, + "src/main/runtime/rpc/methods/files-read-dir-remote-budget.test.ts": 4567, + "src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts": 568, + "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts": 672, + "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts": 565, + "src/main/runtime/rpc/methods/files.test.ts": 815, + "src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts": 1484, + "src/main/runtime/rpc/methods/git.test.ts": 596, + "src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts": 538, + "src/main/runtime/rpc/methods/github.test.ts": 619, + "src/main/runtime/rpc/methods/gitlab.test.ts": 600, + "src/main/runtime/rpc/methods/host-capabilities.test.ts": 508, + "src/main/runtime/rpc/methods/hosted-review.test.ts": 1004, + "src/main/runtime/rpc/methods/jira.test.ts": 584, + "src/main/runtime/rpc/methods/linear-agent-access.test.ts": 4650, + "src/main/runtime/rpc/methods/linear-agent-project-access.test.ts": 5785, + "src/main/runtime/rpc/methods/linear.test.ts": 555, + "src/main/runtime/rpc/methods/managed-server.test.ts": 3128, + "src/main/runtime/rpc/methods/mobile-web-bundle-range.test.ts": 3197, + "src/main/runtime/rpc/methods/mobile-web-bundle-read-concurrency.test.ts": 3452, + "src/main/runtime/rpc/methods/mobile-web-bundle-window-refusals.test.ts": 3539, + "src/main/runtime/rpc/methods/mobile-web-bundle.test.ts": 2747, + "src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts": 699, + "src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts": 2650, + "src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts": 69, + "src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts": 1634, + "src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts": 212, + "src/main/runtime/rpc/methods/native-chat.test.ts": 293, + "src/main/runtime/rpc/methods/notification-preferences.test.ts": 330, + "src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts": 297, + "src/main/runtime/rpc/methods/orcad-migration.test.ts": 3207, + "src/main/runtime/rpc/methods/orcad-terminal-census.test.ts": 214, + "src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts": 6710, + "src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts": 3849, + "src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts": 395, + "src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts": 5166, + "src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts": 462, + "src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts": 1247, + "src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts": 5434, + "src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts": 5547, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts": 5253, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts": 1089, + "src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts": 1242, + "src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts": 920, + "src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts": 1109, + "src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts": 5257, + "src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.test.ts": 54, + "src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.test.ts": 580, + "src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts": 5648, + "src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts": 5387, + "src/main/runtime/rpc/methods/orchestration/federation/federated-transport-safety.test.ts": 4379, + "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-agent-launch.test.ts": 1057, + "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts": 5244, + "src/main/runtime/rpc/methods/orchestration/federation/federation-agent-launch.test.ts": 5782, + "src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts": 6612, + "src/main/runtime/rpc/methods/orchestration/federation/federation-effects.test.ts": 49, + "src/main/runtime/rpc/methods/orchestration/federation/federation-folder-placement.test.ts": 5952, + "src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts": 5779, + "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts": 10158, + "src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts": 5824, + "src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts": 6850, + "src/main/runtime/rpc/methods/orchestration/federation/federation-setup.test.ts": 5357, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-prompt-budget.test.ts": 5348, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts": 230, + "src/main/runtime/rpc/methods/orchestration/federation/federation-worker-brief-agent-in-front.test.ts": 5354, + "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts": 6454, + "src/main/runtime/rpc/methods/orchestration/gates/gate-run-authorization.test.ts": 6061, + "src/main/runtime/rpc/methods/orchestration/gates/gates.test.ts": 4295, + "src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts": 4846, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts": 5619, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts": 5245, + "src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts": 4797, + "src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts": 4757, + "src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-history.test.ts": 4560, + "src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-interleavings.test.ts": 4411, + "src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts": 4829, + "src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts": 4949, + "src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts": 4642, + "src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts": 4314, + "src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts": 4897, + "src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts": 5370, + "src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts": 223, + "src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts": 4878, + "src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts": 4979, + "src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts": 6689, + "src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts": 5051, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts": 3341, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts": 3237, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts": 5925, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts": 5791, + "src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts": 332, + "src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts": 5697, + "src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts": 289, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts": 5550, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts": 5064, + "src/main/runtime/rpc/methods/orchestration/worker/worker-observation.test.ts": 492, + "src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts": 732, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts": 4946, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts": 4973, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-liveness-verdict.test.ts": 829, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts": 4843, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts": 4849, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts": 5546, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts": 4885, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts": 5097, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement-teardown.test.ts": 1020, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts": 48, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-launch-executor-parity.test.ts": 1191, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-outcome-classification.test.ts": 418, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts": 99, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts": 6316, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts": 350, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts": 6240, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts": 60, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-capability.test.ts": 482, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-exit-race.test.ts": 4286, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts": 5777, + "src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-custody-at-creation.test.ts": 5458, + "src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts": 5092, + "src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts": 5732, + "src/main/runtime/rpc/methods/paired-caller-host-id.test.ts": 65, + "src/main/runtime/rpc/methods/pairing.test.ts": 4533, + "src/main/runtime/rpc/methods/plugins.test.ts": 411, + "src/main/runtime/rpc/methods/preflight.test.ts": 520, + "src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts": 256, + "src/main/runtime/rpc/methods/repo-badge-color.test.ts": 605, + "src/main/runtime/rpc/methods/repo.test.ts": 4674, + "src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts": 565, + "src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts": 615, + "src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts": 88, + "src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts": 643, + "src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts": 66, + "src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts": 69, + "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts": 3462, + "src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts": 4327, + "src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts": 4892, + "src/main/runtime/rpc/methods/session-tabs-pane-layout-intent.test.ts": 604, + "src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts": 578, + "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts": 255, + "src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts": 540, + "src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts": 559, + "src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts": 639, + "src/main/runtime/rpc/methods/session-tabs.test.ts": 676, + "src/main/runtime/rpc/methods/skills.test.ts": 341, + "src/main/runtime/rpc/methods/speech.test.ts": 550, + "src/main/runtime/rpc/methods/ssh.test.ts": 558, + "src/main/runtime/rpc/methods/status-machine-name-publish-budget.test.ts": 4652, + "src/main/runtime/rpc/methods/status.test.ts": 4704, + "src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts": 3034, + "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts": 5689, + "src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts": 1000, + "src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts": 6678, + "src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts": 2917, + "src/main/runtime/rpc/methods/structured-agent-session-attention-ack.test.ts": 3201, + "src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts": 3064, + "src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.test.ts": 3264, + "src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts": 1688, + "src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts": 290, + "src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts": 5963, + "src/main/runtime/rpc/methods/structured-agent-session-optional-model-capability.test.ts": 2875, + "src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts": 3814, + "src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts": 2971, + "src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts": 89, + "src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts": 862, + "src/main/runtime/rpc/methods/structured-agent-session-queued-edit.test.ts": 3153, + "src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts": 2947, + "src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts": 3237, + "src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts": 3111, + "src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts": 3764, + "src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts": 2815, + "src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.test.ts": 2944, + "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts": 3068, + "src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts": 5112, + "src/main/runtime/rpc/methods/structured-agent-session-visual.test.ts": 3030, + "src/main/runtime/rpc/methods/structured-agent-session.test.ts": 3424, + "src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts": 6245, + "src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts": 447, + "src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts": 5290, + "src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts": 4358, + "src/main/runtime/rpc/methods/task-provider-identity.test.ts": 247, + "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts": 312, + "src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts": 853, + "src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts": 378, + "src/main/runtime/rpc/methods/terminal-navigation.test.ts": 830, + "src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts": 340, + "src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts": 83, + "src/main/runtime/rpc/methods/terminal/terminal-input-sequence-ledger.test.ts": 82, + "src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts": 266, + "src/main/runtime/rpc/methods/terminal/terminal-output-batcher.test.ts": 58, + "src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts": 190, + "src/main/runtime/rpc/methods/terminal/terminal-snapshot-scrollback-rows.test.ts": 51, + "src/main/runtime/rpc/methods/terminal/terminal-subscription-registration.test.ts": 122, + "src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts": 583, + "src/main/runtime/rpc/methods/updater.test.ts": 275, + "src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts": 304, + "src/main/runtime/rpc/methods/workspace-layout.test.ts": 3003, + "src/main/runtime/rpc/methods/workspace-ports.test.ts": 559, + "src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts": 1589, + "src/main/runtime/rpc/methods/worktree-catalog-version-stamp.test.ts": 1496, + "src/main/runtime/rpc/methods/worktree-create-args.test.ts": 351, + "src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts": 1520, + "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts": 1623, + "src/main/runtime/rpc/methods/worktree-create-startup-agent.test.ts": 1540, + "src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts": 1481, + "src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts": 1296, + "src/main/runtime/rpc/methods/worktree-retired-names.test.ts": 1517, + "src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts": 1604, + "src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts": 1546, + "src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts": 1498, + "src/main/runtime/rpc/methods/worktree-schemas.test.ts": 356, + "src/main/runtime/rpc/methods/worktree.test.ts": 1713, + "src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts": 835, + "src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts": 72, + "src/main/runtime/rpc/mobile-e2ee-outbound-memory-budget.test.ts": 52, + "src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts": 102, + "src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts": 103, + "src/main/runtime/rpc/mobile-socket-wiring.test.ts": 402, + "src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts": 11255, + "src/main/runtime/rpc/orchestration-caller-show.test.ts": 5152, + "src/main/runtime/rpc/orchestration-check-wait-session.test.ts": 5368, + "src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts": 6014, + "src/main/runtime/rpc/orchestration-contract-fence.test.ts": 5967, + "src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts": 5673, + "src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts": 5854, + "src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts": 6448, + "src/main/runtime/rpc/orchestration-legacy-fence-jurisdiction.test.ts": 5939, + "src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts": 5215, + "src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts": 5130, + "src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts": 5254, + "src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts": 4232, + "src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts": 5733, + "src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts": 5702, + "src/main/runtime/rpc/orchestration-mutation-executor.test.ts": 5127, + "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts": 5979, + "src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts": 5437, + "src/main/runtime/rpc/orchestration-mutation-request-show.test.ts": 5333, + "src/main/runtime/rpc/orchestration-party-addressing.test.ts": 5971, + "src/main/runtime/rpc/orchestration-party-agreement.test.ts": 5225, + "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts": 5613, + "src/main/runtime/rpc/orchestration-session-caller.test.ts": 5634, + "src/main/runtime/rpc/orchestration-session-coordinator.test.ts": 6501, + "src/main/runtime/rpc/orchestration-session-recipient.test.ts": 5755, + "src/main/runtime/rpc/orchestration-session-run-binding.test.ts": 5278, + "src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts": 5900, + "src/main/runtime/rpc/relay-transport.test.ts": 193, + "src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts": 67, + "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts": 82, + "src/main/runtime/rpc/rpc-caller-scope.test.ts": 5768, + "src/main/runtime/rpc/runtime-client-capabilities.test.ts": 40, + "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts": 565, + "src/main/runtime/rpc/runtime-close-attribution.test.ts": 937, + "src/main/runtime/rpc/runtime-source-admission.test.ts": 4337, + "src/main/runtime/rpc/schemas.test.ts": 1500, + "src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts": 5627, + "src/main/runtime/rpc/static-web-client-handler-stream.test.ts": 1767, + "src/main/runtime/rpc/streaming.test.ts": 690, + "src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 611, + "src/main/runtime/rpc/terminal-agent-send-guard.test.ts": 612, + "src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts": 706, + "src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts": 861, + "src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts": 531, + "src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts": 1151, + "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery-history.test.ts": 703, + "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts": 2045, + "src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts": 1317, + "src/main/runtime/rpc/terminal-multiplex-end-verdict.test.ts": 3540, + "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts": 633, + "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts": 90, + "src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts": 945, + "src/main/runtime/rpc/terminal-multiplex-input-sequence-replay.test.ts": 1902, + "src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts": 1429, + "src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts": 1047, + "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts": 1137, + "src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts": 641, + "src/main/runtime/rpc/terminal-multiplex-round-robin.test.ts": 49, + "src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts": 1278, + "src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts": 4066, + "src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts": 790, + "src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts": 4307, + "src/main/runtime/rpc/terminal-output-batching.test.ts": 965, + "src/main/runtime/rpc/terminal-output-frame-chunks.test.ts": 66, + "src/main/runtime/rpc/terminal-output-frame-source-ranges.test.ts": 54, + "src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts": 6663, + "src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts": 807, + "src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts": 1018, + "src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts": 543, + "src/main/runtime/rpc/terminal-send.test.ts": 832, + "src/main/runtime/rpc/terminal-source-range-ledger.test.ts": 69, + "src/main/runtime/rpc/terminal-stream-byte-length.test.ts": 450, + "src/main/runtime/rpc/terminal-subscribe-admission.test.ts": 5270, + "src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts": 685, + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts": 1142, + "src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts": 734, + "src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts": 844, + "src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts": 631, + "src/main/runtime/rpc/terminal-subscribe-ownership.test.ts": 1030, + "src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts": 792, + "src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts": 4313, + "src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts": 674, + "src/main/runtime/rpc/unix-socket-transport.test.ts": 86, + "src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts": 45, + "src/main/runtime/rpc/worktree-catalog-snapshot.test.ts": 64, + "src/main/runtime/rpc/worktree-removal-marker-projection.test.ts": 289, + "src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts": 118, + "src/main/runtime/rpc/ws-fallback-port-store.test.ts": 51, + "src/main/runtime/rpc/ws-transport-accept-order.test.ts": 78, + "src/main/runtime/rpc/ws-transport-static-web.test.ts": 99, + "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts": 691, + "src/main/runtime/rpc/ws-transport.test.ts": 1073, + "src/main/runtime/run-terminal-shell-alone.node-pty.test.ts": 2371, + "src/main/runtime/run-terminal-shell-alone.test.ts": 114, + "src/main/runtime/runtime-agent-row-main-agent-projection.test.ts": 94, + "src/main/runtime/runtime-binary-message-router.test.ts": 49, + "src/main/runtime/runtime-browser-client-automation.test.ts": 301, + "src/main/runtime/runtime-browser-client-page-adoption.test.ts": 275, + "src/main/runtime/runtime-browser-client-page-creation.test.ts": 254, + "src/main/runtime/runtime-browser-client-page-recovery.test.ts": 302, + "src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts": 237, + "src/main/runtime/runtime-browser-network-execution-host.test.ts": 45, + "src/main/runtime/runtime-browser-page-registry.test.ts": 55, + "src/main/runtime/runtime-client-settings-linear-team-projection.test.ts": 776, + "src/main/runtime/runtime-client-settings-minimax-projection.test.ts": 568, + "src/main/runtime/runtime-client-settings-source-control-projection.test.ts": 645, + "src/main/runtime/runtime-client-settings-terminal-copy-projection.test.ts": 605, + "src/main/runtime/runtime-created-worktree-terminal-target.test.ts": 4338, + "src/main/runtime/runtime-extraction-regressions.test.ts": 4478, + "src/main/runtime/runtime-failed-local-worktree-removal.test.ts": 723, + "src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts": 1789, + "src/main/runtime/runtime-file-listing-producer-budgets.test.ts": 528, + "src/main/runtime/runtime-file-path-existence.test.ts": 911, + "src/main/runtime/runtime-file-target-execution-host.test.ts": 4300, + "src/main/runtime/runtime-file-upload-decode.test.ts": 421, + "src/main/runtime/runtime-folder-workspace.test.ts": 65, + "src/main/runtime/runtime-folder-worktree-create.test.ts": 132, + "src/main/runtime/runtime-git-api-contract.test.ts": 982, + "src/main/runtime/runtime-git-branch-compare-admission.test.ts": 525, + "src/main/runtime/runtime-git-command-target.test.ts": 630, + "src/main/runtime/runtime-git-conflict-operation-routing.test.ts": 561, + "src/main/runtime/runtime-git-execution-host-ownership.test.ts": 959, + "src/main/runtime/runtime-git-generation-admission.test.ts": 551, + "src/main/runtime/runtime-git-stage-worktree-scope.test.ts": 959, + "src/main/runtime/runtime-git-status-admission.test.ts": 1309, + "src/main/runtime/runtime-git-sync-commands.test.ts": 995, + "src/main/runtime/runtime-git-target-execution-host.test.ts": 5048, + "src/main/runtime/runtime-graph-reload-lifecycle.test.ts": 51, + "src/main/runtime/runtime-hook-agent-row-selection.test.ts": 99, + "src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts": 1473, + "src/main/runtime/runtime-legacy-worker-missing-workspace-recovery.test.ts": 81, + "src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts": 150, + "src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts": 399, + "src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts": 96, + "src/main/runtime/runtime-linear-read-commands.test.ts": 161, + "src/main/runtime/runtime-local-create-rearm-ordering.test.ts": 4256, + "src/main/runtime/runtime-local-worktree-create.test.ts": 588, + "src/main/runtime/runtime-local-worktree-materialization.test.ts": 130, + "src/main/runtime/runtime-local-worktree-setup.test.ts": 373, + "src/main/runtime/runtime-local-worktree-terminal-startup.test.ts": 81, + "src/main/runtime/runtime-machine-name.test.ts": 78, + "src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts": 1804, + "src/main/runtime/runtime-managed-worktree-metadata.test.ts": 94, + "src/main/runtime/runtime-managed-worktree-queries.test.ts": 543, + "src/main/runtime/runtime-metadata-ownership-watch.test.ts": 116, + "src/main/runtime/runtime-metadata.test.ts": 380, + "src/main/runtime/runtime-mobile-agent-status-builder.test.ts": 91, + "src/main/runtime/runtime-mobile-file-path-search-expiry.test.ts": 223, + "src/main/runtime/runtime-mobile-file-path-search.test.ts": 75, + "src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts": 80, + "src/main/runtime/runtime-orchestration-sender-names.test.ts": 948, + "src/main/runtime/runtime-owned-terminal-publication-lineage.test.ts": 5659, + "src/main/runtime/runtime-pairing-desktop-control-grant.test.ts": 5025, + "src/main/runtime/runtime-project-group-controller-folder-create.test.ts": 57, + "src/main/runtime/runtime-project-group-controller-folder-delete.test.ts": 56, + "src/main/runtime/runtime-project-host-setup-controller.test.ts": 410, + "src/main/runtime/runtime-reference-agents.test.ts": 274, + "src/main/runtime/runtime-reference-catalog.test.ts": 97, + "src/main/runtime/runtime-reference-find.test.ts": 264, + "src/main/runtime/runtime-reference-integration.test.ts": 4869, + "src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts": 506, + "src/main/runtime/runtime-remote-managed-worktree-create.test.ts": 84, + "src/main/runtime/runtime-remove-project-host-scope.test.ts": 5466, + "src/main/runtime/runtime-repository-issue-command.test.ts": 769, + "src/main/runtime/runtime-repository-ref-queries.test.ts": 196, + "src/main/runtime/runtime-resolved-worktree-cache.test.ts": 53, + "src/main/runtime/runtime-rpc-browser-host-admission.test.ts": 5730, + "src/main/runtime/runtime-rpc-client-activity.test.ts": 5083, + "src/main/runtime/runtime-rpc-device-revocation.test.ts": 5464, + "src/main/runtime/runtime-rpc-dispatch-inject-long-poll.test.ts": 5309, + "src/main/runtime/runtime-rpc-long-poll-transport.test.ts": 10349, + "src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts": 5255, + "src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts": 3159, + "src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts": 5792, + "src/main/runtime/runtime-rpc-mobile-unknown-method-scope-refusal.test.ts": 3140, + "src/main/runtime/runtime-rpc-orchestration-db-migration.test.ts": 625, + "src/main/runtime/runtime-rpc-pairing-mode-persistence.test.ts": 5329, + "src/main/runtime/runtime-rpc-pairing-offer.test.ts": 4839, + "src/main/runtime/runtime-rpc-relay-pairing.test.ts": 6365, + "src/main/runtime/runtime-rpc-relay-provider-install.test.ts": 5568, + "src/main/runtime/runtime-rpc-request-authorization.test.ts": 6177, + "src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts": 5067, + "src/main/runtime/runtime-rpc-startup-failure.test.ts": 93, + "src/main/runtime/runtime-rpc-terminal-list.test.ts": 5576, + "src/main/runtime/runtime-rpc-websocket-bind-host.test.ts": 5192, + "src/main/runtime/runtime-rpc-websocket-fallback-port.test.ts": 5232, + "src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts": 7485, + "src/main/runtime/runtime-rpc-websocket-reply-size-limit.test.ts": 5190, + "src/main/runtime/runtime-rpc-worktree-queries.test.ts": 5245, + "src/main/runtime/runtime-search-line-fragments.test.ts": 572, + "src/main/runtime/runtime-server-git-availability.test.ts": 44, + "src/main/runtime/runtime-session-search-settings.test.ts": 236, + "src/main/runtime/runtime-skill-install-authority.test.ts": 102, + "src/main/runtime/runtime-skill-install-commands.test.ts": 673, + "src/main/runtime/runtime-skill-install-queries.test.ts": 752, + "src/main/runtime/runtime-socket-sweep.test.ts": 50, + "src/main/runtime/runtime-subscription-registry.test.ts": 138, + "src/main/runtime/runtime-terminal-client-use.test.ts": 4273, + "src/main/runtime/runtime-terminal-idle-polls.test.ts": 404, + "src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts": 58, + "src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts": 77, + "src/main/runtime/runtime-terminal-writer.test.ts": 85, + "src/main/runtime/runtime-unregistered-worktree-removal-host-home.test.ts": 1143, + "src/main/runtime/runtime-worktree-agent-rows-structured.test.ts": 650, + "src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts": 1049, + "src/main/runtime/runtime-worktree-agent-sources.test.ts": 62, + "src/main/runtime/runtime-worktree-agent-startup.test.ts": 451, + "src/main/runtime/runtime-worktree-ps-summaries.test.ts": 93, + "src/main/runtime/runtime-worktree-ps-unverifiable-terminals.test.ts": 45, + "src/main/runtime/runtime-worktree-selection.test.ts": 62, + "src/main/runtime/runtime-worktree-startup-readiness.test.ts": 140, + "src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts": 1126, + "src/main/runtime/runtime-worktree-terminal-provisioning.test.ts": 67, + "src/main/runtime/saved-structured-agent-session-restoration.test.ts": 40, + "src/main/runtime/selected-review-branch.test.ts": 61, + "src/main/runtime/session-tabs-empty-worktree-publication.test.ts": 4763, + "src/main/runtime/session-tabs-inventory-publication.test.ts": 4384, + "src/main/runtime/settled-worker-process-replacement.test.ts": 5611, + "src/main/runtime/stale-title-completion-recovery.test.ts": 5144, + "src/main/runtime/stale-title-display-surfaces.test.ts": 5236, + "src/main/runtime/structured-agent-account-kind.test.ts": 58, + "src/main/runtime/structured-agent-create-support-opencode-rig.test.ts": 981, + "src/main/runtime/structured-agent-launch-args-wiring.test.ts": 1637, + "src/main/runtime/structured-agent-launch-support-probe-omp.test.ts": 978, + "src/main/runtime/structured-agent-launch-support-probe.test.ts": 1015, + "src/main/runtime/structured-agent-launch-support.test.ts": 958, + "src/main/runtime/structured-agent-model-catalog-discovery.test.ts": 987, + "src/main/runtime/structured-agent-model-catalog-first-frame.test.ts": 1196, + "src/main/runtime/structured-agent-runtime-registrations-acp.test.ts": 996, + "src/main/runtime/structured-agent-session-attachment-wiring.test.ts": 257, + "src/main/runtime/structured-agent-session-close.test.ts": 357, + "src/main/runtime/structured-agent-session-codex-compact-exit.test.ts": 3778, + "src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts": 2081, + "src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts": 9062, + "src/main/runtime/structured-agent-session-codex-settlement-order.test.ts": 1766, + "src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts": 12290, + "src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts": 5239, + "src/main/runtime/structured-agent-session-failure-logging.test.ts": 1791, + "src/main/runtime/structured-agent-session-held-signal.test.ts": 1930, + "src/main/runtime/structured-agent-session-host-refusal.test.ts": 5070, + "src/main/runtime/structured-agent-session-install-failure.test.ts": 1569, + "src/main/runtime/structured-agent-session-integration-replay.test.ts": 4482, + "src/main/runtime/structured-agent-session-integration.test.ts": 6902, + "src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts": 3540, + "src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts": 205, + "src/main/runtime/structured-agent-session-mobile-attention.test.ts": 212, + "src/main/runtime/structured-agent-session-mobile-restore.test.ts": 1047, + "src/main/runtime/structured-agent-session-mobile-retirement.test.ts": 315, + "src/main/runtime/structured-agent-session-paired-caller.test.ts": 3386, + "src/main/runtime/structured-agent-session-rollback-compatibility.test.ts": 740, + "src/main/runtime/structured-agent-session-runtime-exit.test.ts": 1929, + "src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts": 1605, + "src/main/runtime/structured-agent-session-runtime-provider-started.test.ts": 2396, + "src/main/runtime/structured-agent-session-runtime-teardown.test.ts": 253, + "src/main/runtime/structured-agent-session-runtime.test.ts": 2060, + "src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts": 1655, + "src/main/runtime/structured-agent-session-startup-reconcile.test.ts": 4757, + "src/main/runtime/structured-agent-session-startup-tab-restore.test.ts": 5325, + "src/main/runtime/structured-agent-session-support-probe.test.ts": 4686, + "src/main/runtime/structured-agent-session-tab-close-cause.test.ts": 51, + "src/main/runtime/structured-agent-shell-environment.test.ts": 91, + "src/main/runtime/structured-agent-stored-definitions.test.ts": 334, + "src/main/runtime/structured-chat-coordinator-mail-queue.test.ts": 7105, + "src/main/runtime/structured-chat-coordinator-mail.test.ts": 13367, + "src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts": 5228, + "src/main/runtime/structured-chat-dispatch-worker-mail.test.ts": 6477, + "src/main/runtime/structured-chat-dispatch-worker.test.ts": 5004, + "src/main/runtime/structured-chat-naming-runtime.test.ts": 440, + "src/main/runtime/structured-chat-worker-start.test.ts": 4438, + "src/main/runtime/structured-child-records-switch.test.ts": 2097, + "src/main/runtime/structured-claude-attachment-grant.test.ts": 1678, + "src/main/runtime/structured-claude-auth-policy-wiring.test.ts": 1703, + "src/main/runtime/structured-claude-pending-rewind.test.ts": 1486, + "src/main/runtime/structured-claude-thinking-display-refusal.test.ts": 865, + "src/main/runtime/structured-codex-child-work-runtime.test.ts": 1600, + "src/main/runtime/structured-conversation-tab-title.test.ts": 38, + "src/main/runtime/structured-foreground-command-strip.test.ts": 2430, + "src/main/runtime/structured-session-child-identity-env.test.ts": 312, + "src/main/runtime/structured-session-cli-login-shell.test.ts": 334, + "src/main/runtime/structured-session-mail-redrive-wiring.test.ts": 4818, + "src/main/runtime/structured-session-worktree-teardown.test.ts": 452, + "src/main/runtime/structured-worker-agent-presence.test.ts": 345, + "src/main/runtime/structured-worker-at-rest.test.ts": 1193, + "src/main/runtime/structured-worker-authority.test.ts": 332, + "src/main/runtime/structured-worker-cleared-session.test.ts": 5252, + "src/main/runtime/structured-worker-hook-attestation.test.ts": 3190, + "src/main/runtime/structured-worker-identity.test.ts": 433, + "src/main/runtime/structured-worker-mail-routing.test.ts": 3023, + "src/main/runtime/structured-worker-takeover-pane-key.test.ts": 4488, + "src/main/runtime/structured-worker-terminal-read.test.ts": 2993, + "src/main/runtime/structured-worker-terminal-refusal.test.ts": 357, + "src/main/runtime/terminal-ansi-pending-retention.test.ts": 405, + "src/main/runtime/terminal-chat-owner-persistence.test.ts": 6950, + "src/main/runtime/terminal-close-observed-exit.test.ts": 4756, + "src/main/runtime/terminal-close-slow-agent-exit.test.ts": 6616, + "src/main/runtime/terminal-command-paint.test.ts": 56, + "src/main/runtime/terminal-focus-navigation-coalescer.test.ts": 66, + "src/main/runtime/terminal-foreground-group.test.ts": 78, + "src/main/runtime/terminal-identity-probe.test.ts": 50, + "src/main/runtime/terminal-intentional-stop-exit.test.ts": 4300, + "src/main/runtime/terminal-intentional-stops.test.ts": 42, + "src/main/runtime/terminal-interactive-wait-visibility.test.ts": 5553, + "src/main/runtime/terminal-leaf-tab-resolution.test.ts": 64, + "src/main/runtime/terminal-list-execution-host-scope.test.ts": 6664, + "src/main/runtime/terminal-list-payload-size.test.ts": 4739, + "src/main/runtime/terminal-list-pending-pty-registration.test.ts": 4304, + "src/main/runtime/terminal-list-stale-leaf-liveness.test.ts": 4787, + "src/main/runtime/terminal-list-surface-lost-orphan.test.ts": 4463, + "src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts": 3776, + "src/main/runtime/terminal-model-query-authority.test.ts": 60, + "src/main/runtime/terminal-orphan-owner.test.ts": 41, "src/main/runtime/terminal-orphan-topology.test.ts": 52, - "src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts": 2841, - "src/main/runtime/terminal-projection.test.ts": 3216, - "src/main/runtime/terminal-pty-exit-waiter.test.ts": 2056, - "src/main/runtime/terminal-query-responder.test.ts": 13500, - "src/main/runtime/terminal-restore-record-seed.test.ts": 3311, - "src/main/runtime/terminal-retirement-async-durability.test.ts": 3860, - "src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts": 3415, - "src/main/runtime/terminal-retirement-proof-publication.test.ts": 3354, - "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts": 3416, - "src/main/runtime/terminal-shell-override-host-support.test.ts": 42, - "src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts": 1849, - "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts": 4855, - "src/main/runtime/terminal-tail-buffer.test.ts": 5683, - "src/main/runtime/terminal-tail-row-retention.test.ts": 314, - "src/main/runtime/terminal-tail-sentinel-index.test.ts": 2880, - "src/main/runtime/terminal-tail-whitespace.test.ts": 80, - "src/main/runtime/terminal-vertical-control-scan.test.ts": 74, - "src/main/runtime/terminal-wait-detection.test.ts": 84, - "src/main/runtime/terminal-wait-name-only-idle.test.ts": 7790, - "src/main/runtime/terminal-wait-results.test.ts": 36, - "src/main/runtime/terminal-wait-tail-window.test.ts": 173, - "src/main/runtime/tui-idle-delivery-and-quiescence.test.ts": 5355, - "src/main/runtime/tui-idle-evidence.test.ts": 75, - "src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts": 20972, - "src/main/runtime/unreadable-secret-store-preservation.win32.test.ts": 38, - "src/main/runtime/wait-blocked-check-state.test.ts": 93, - "src/main/runtime/wait-blocked-keyword-carry-retention.test.ts": 2640, - "src/main/runtime/windows-default-route-interfaces.test.ts": 43, - "src/main/runtime/windows-drive-listing.test.ts": 65, - "src/main/runtime/windows-firewall-remote-scope.test.ts": 53, - "src/main/runtime/windows-mobile-firewall.test.ts": 101, - "src/main/runtime/workspace-session-membership-scaling.test.ts": 47, - "src/main/runtime/workspace-session-worktree-id.test.ts": 124, - "src/main/runtime/worktree-launch-host-repo.test.ts": 42, - "src/main/runtime/worktree-list-host-scope.test.ts": 479, - "src/main/runtime/worktree-path-selector-wsl-posix.test.ts": 2032, - "src/main/runtime/worktree-ps-degraded-repo-scan.test.ts": 2660, - "src/main/runtime/worktree-ps-host-scope.test.ts": 3244, - "src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts": 3632, - "src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts": 2610, - "src/main/runtime/worktree-scan-execution-host-routing.test.ts": 2961, - "src/main/runtime/worktree-teardown-unstopped-pty.test.ts": 436, - "src/main/runtime/worktree-teardown.test.ts": 1806, - "src/main/runtime/worktree-terminal-mutation-lock.test.ts": 104, - "src/main/runtime/zcode-readiness-transcript.test.ts": 3116, - "src/main/serve-update-handoff.app-environment.test.ts": 860, - "src/main/serve-update-handoff.test.ts": 42, - "src/main/server/serve-readiness.test.ts": 67, - "src/main/server/serve-stdout-boundary.test.ts": 39, - "src/main/shell-prompt-readiness-probe.test.ts": 55, - "src/main/shell-startup-identity-scanner.test.ts": 39, - "src/main/shell-startup-output-scanner.test.ts": 67, - "src/main/shell-wrapper-content-address.test.ts": 67, - "src/main/shell-wrapper-generated-file-snapshot.test.ts": 111, - "src/main/sidecar-snapshot-file.test.ts": 52, - "src/main/skills/agent-skill-selection.test.ts": 142, - "src/main/skills/claude-plugin-skill-sources-wsl.test.ts": 85, - "src/main/skills/claude-plugin-skill-sources.test.ts": 42, - "src/main/skills/discovery-filter-sharing.test.ts": 275, - "src/main/skills/discovery-source-filter-order.test.ts": 127, - "src/main/skills/discovery.test.ts": 199, - "src/main/skills/skill-bundle-artifacts.test.ts": 114, - "src/main/skills/skill-bundle-creation.test.ts": 456, - "src/main/skills/skill-bundle-install-service.test.ts": 533, - "src/main/skills/skill-bundle-observability-summary.test.ts": 47, - "src/main/skills/skill-bundle-process-termination-child.test.ts": 248, - "src/main/skills/skill-bundle-process-termination.integration.test.ts": 205, - "src/main/skills/skill-bundle-ssh-relay-service.test.ts": 339, - "src/main/skills/skill-candidate-concurrency.test.ts": 84, - "src/main/skills/skill-client-mediated-transfer-cancellation.test.ts": 105, - "src/main/skills/skill-client-mediated-transfer.test.ts": 197, - "src/main/skills/skill-cloud-direct-upload.test.ts": 182, - "src/main/skills/skill-cloud-grant-installation.test.ts": 172, - "src/main/skills/skill-cloud-install-target.test.ts": 28, - "src/main/skills/skill-cloud-request.test.ts": 152, - "src/main/skills/skill-cloud-service.test.ts": 377, - "src/main/skills/skill-delete/plan.test.ts": 272, - "src/main/skills/skill-delete/recovery.test.ts": 253, - "src/main/skills/skill-delete/roots.test.ts": 71, - "src/main/skills/skill-delete/service.test.ts": 549, - "src/main/skills/skill-delete/staging-visibility.test.ts": 96, - "src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts": 44, - "src/main/skills/skill-discovery-concurrency.test.ts": 194, - "src/main/skills/skill-discovery-order.test.ts": 565, - "src/main/skills/skill-discovery-source-filter.test.ts": 43, - "src/main/skills/skill-discovery-target.test.ts": 116, - "src/main/skills/skill-discovery-wsl-alias-sharing.test.ts": 108, - "src/main/skills/skill-discovery-wsl-bash-filter.test.ts": 3445, - "src/main/skills/skill-discovery-wsl-plugins.test.ts": 87, - "src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 115, - "src/main/skills/skill-discovery-wsl.test.ts": 106, - "src/main/skills/skill-freshness-eligibility.test.ts": 92, - "src/main/skills/skill-freshness-inventory-limits.test.ts": 174, - "src/main/skills/skill-freshness-inventory.test.ts": 1854, - "src/main/skills/skill-git-tree-identity.test.ts": 122, - "src/main/skills/skill-install-destinations.test.ts": 75, - "src/main/skills/skill-install-discovery-verification.test.ts": 87, - "src/main/skills/skill-install-lock-release.test.ts": 70, - "src/main/skills/skill-install-lock.test.ts": 398, - "src/main/skills/skill-install-management-service.test.ts": 327, - "src/main/skills/skill-install-provenance.test.ts": 61, - "src/main/skills/skill-install-recovery.test.ts": 479, - "src/main/skills/skill-install-request-service.test.ts": 421, - "src/main/skills/skill-install-service.test.ts": 1067, - "src/main/skills/skill-install-transaction.test.ts": 2836, - "src/main/skills/skill-operation-observability.test.ts": 316, - "src/main/skills/skill-package-creation.test.ts": 427, - "src/main/skills/skill-package-deterministic-gzip.test.ts": 1123, - "src/main/skills/skill-package-download.test.ts": 270, - "src/main/skills/skill-package-identity.test.ts": 196, - "src/main/skills/skill-package-tar.test.ts": 429, - "src/main/skills/skill-placement-alias-repair.test.ts": 192, - "src/main/skills/skill-placement-copy-drift.test.ts": 110, - "src/main/skills/skill-placement-reconciliation.test.ts": 233, - "src/main/skills/skill-placement-transaction.test.ts": 233, - "src/main/skills/skill-plugin-cache-scan.test.ts": 766, - "src/main/skills/skill-process-contention-child.test.ts": 236, - "src/main/skills/skill-process-contention.integration.test.ts": 125, - "src/main/skills/skill-process-termination-child.test.ts": 228, - "src/main/skills/skill-process-termination.integration.test.ts": 169, - "src/main/skills/skill-provider-destinations.test.ts": 39, - "src/main/skills/skill-provider-runtime-roots.test.ts": 38, - "src/main/skills/skill-remote-error-category-parity.test.ts": 228, - "src/main/skills/skill-remote-install-cancellation.test.ts": 28, - "src/main/skills/skill-remote-install-service.test.ts": 220, - "src/main/skills/skill-remove-transaction.test.ts": 494, - "src/main/skills/skill-root-file-walk.test.ts": 87, - "src/main/skills/skill-runtime-capability.test.ts": 59, - "src/main/skills/skill-scan-coalescer.test.ts": 34, - "src/main/skills/skill-share-preparation-service.test.ts": 305, - "src/main/skills/skill-ssh-relay-service.test.ts": 312, - "src/main/skills/skill-transaction-startup-recovery.test.ts": 332, - "src/main/skills/skill-update-convergence.test.ts": 76, - "src/main/skills/skill-update-outcome.test.ts": 306, - "src/main/skills/skill-update-registration.test.ts": 30, - "src/main/skills/skill-update-run.test.ts": 157, - "src/main/skills/skill-upload-process-restart-child.test.ts": 158, - "src/main/skills/skill-upload-process-restart.integration.test.ts": 155, - "src/main/skills/skill-upload-session-admission-regression.test.ts": 199, - "src/main/skills/skill-upload-session-service.test.ts": 240, + "src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts": 5332, + "src/main/runtime/terminal-projection.test.ts": 54, + "src/main/runtime/terminal-pty-exit-waiter.test.ts": 4535, + "src/main/runtime/terminal-query-responder.test.ts": 15573, + "src/main/runtime/terminal-restore-record-seed.test.ts": 4386, + "src/main/runtime/terminal-retirement-async-durability.test.ts": 5424, + "src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts": 5172, + "src/main/runtime/terminal-retirement-proof-publication.test.ts": 4203, + "src/main/runtime/terminal-run-facts-input.test.ts": 4384, + "src/main/runtime/terminal-run-facts.test.ts": 55, + "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts": 4372, + "src/main/runtime/terminal-shell-override-host-support.test.ts": 55, + "src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts": 4589, + "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts": 4236, + "src/main/runtime/terminal-surface-close.test.ts": 203, + "src/main/runtime/terminal-tail-buffer.test.ts": 6881, + "src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts": 1562, + "src/main/runtime/terminal-tail-redraw-scaling.test.ts": 344, + "src/main/runtime/terminal-tail-redraw-spans.test.ts": 251, + "src/main/runtime/terminal-tail-redraw-wall-time.test.ts": 21650, + "src/main/runtime/terminal-tail-row-retention.test.ts": 1528, + "src/main/runtime/terminal-tail-sentinel-index.test.ts": 3542, + "src/main/runtime/terminal-tail-whitespace.test.ts": 276, + "src/main/runtime/terminal-vertical-control-scan.test.ts": 307, + "src/main/runtime/terminal-wait-detection.test.ts": 350, + "src/main/runtime/terminal-wait-name-only-idle.test.ts": 4243, + "src/main/runtime/terminal-wait-results.test.ts": 38, + "src/main/runtime/terminal-wait-tail-window.test.ts": 263, + "src/main/runtime/tui-idle-claude-task-wakeup.test.ts": 282, + "src/main/runtime/tui-idle-command-paint.test.ts": 4068, + "src/main/runtime/tui-idle-delivery-and-quiescence.test.ts": 5085, + "src/main/runtime/tui-idle-evidence-source.test.ts": 3277, + "src/main/runtime/tui-idle-evidence.test.ts": 374, + "src/main/runtime/tui-idle-hook-lane-runtime.test.ts": 7403, + "src/main/runtime/tui-idle-hook-lane.test.ts": 301, + "src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts": 35459, + "src/main/runtime/tui-idle-weak-evidence-seam.test.ts": 3901, + "src/main/runtime/unreadable-secret-store-preservation.win32.test.ts": 54, + "src/main/runtime/wait-blocked-check-state.test.ts": 90, + "src/main/runtime/wait-blocked-keyword-carry-retention.test.ts": 3758, + "src/main/runtime/windows-default-route-interfaces.test.ts": 62, + "src/main/runtime/windows-drive-listing.test.ts": 54, + "src/main/runtime/windows-firewall-remote-scope.test.ts": 81, + "src/main/runtime/windows-mobile-firewall.test.ts": 99, + "src/main/runtime/workspace-layout-round-trip-watch.test.ts": 254, + "src/main/runtime/workspace-layout-stream.test.ts": 257, + "src/main/runtime/workspace-session-membership-scaling.test.ts": 99, + "src/main/runtime/workspace-session-worktree-id.test.ts": 254, + "src/main/runtime/worktree-launch-host-repo.test.ts": 52, + "src/main/runtime/worktree-list-host-scope.test.ts": 1039, + "src/main/runtime/worktree-path-selector-wsl-posix.test.ts": 4715, + "src/main/runtime/worktree-ps-degraded-repo-scan.test.ts": 5316, + "src/main/runtime/worktree-ps-host-scope.test.ts": 4255, + "src/main/runtime/worktree-ps-unverifiable-terminals.test.ts": 4242, + "src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts": 4532, + "src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts": 4765, + "src/main/runtime/worktree-scan-execution-host-routing.test.ts": 4500, + "src/main/runtime/worktree-scan-repeated-path.test.ts": 3815, + "src/main/runtime/worktree-teardown-unstopped-pty.test.ts": 554, + "src/main/runtime/worktree-teardown.test.ts": 863, + "src/main/runtime/worktree-terminal-mutation-lock.test.ts": 57, + "src/main/serve-update-handoff.app-environment.test.ts": 1475, + "src/main/serve-update-handoff.test.ts": 58, + "src/main/server/serve-readiness.test.ts": 69, + "src/main/server/serve-stdout-boundary.test.ts": 47, + "src/main/shell-prompt-readiness-probe.test.ts": 94, + "src/main/shell-startup-identity-scanner.test.ts": 43, + "src/main/shell-startup-output-scanner.test.ts": 84, + "src/main/shell-wrapper-content-address.test.ts": 56, + "src/main/shell-wrapper-generated-file-snapshot.test.ts": 118, + "src/main/sidecar-snapshot-file.test.ts": 59, + "src/main/skills/agent-skill-selection.test.ts": 238, + "src/main/skills/antigravity-skill-discovery.test.ts": 95, + "src/main/skills/claude-plugin-skill-sources-wsl.test.ts": 106, + "src/main/skills/claude-plugin-skill-sources.test.ts": 95, + "src/main/skills/discovery-filter-sharing.test.ts": 247, + "src/main/skills/discovery-source-filter-order.test.ts": 103, + "src/main/skills/discovery.test.ts": 187, + "src/main/skills/skill-bundle-artifacts.test.ts": 285, + "src/main/skills/skill-bundle-creation.test.ts": 636, + "src/main/skills/skill-bundle-install-service.test.ts": 447, + "src/main/skills/skill-bundle-observability-summary.test.ts": 39, + "src/main/skills/skill-bundle-process-termination-child.test.ts": 276, + "src/main/skills/skill-bundle-process-termination.integration.test.ts": 294, + "src/main/skills/skill-bundle-ssh-relay-service.test.ts": 215, + "src/main/skills/skill-candidate-concurrency.test.ts": 67, + "src/main/skills/skill-client-mediated-transfer-cancellation.test.ts": 278, + "src/main/skills/skill-client-mediated-transfer.test.ts": 295, + "src/main/skills/skill-cloud-direct-upload.test.ts": 110, + "src/main/skills/skill-cloud-grant-installation.test.ts": 253, + "src/main/skills/skill-cloud-install-target.test.ts": 54, + "src/main/skills/skill-cloud-request.test.ts": 62, + "src/main/skills/skill-cloud-service.test.ts": 512, + "src/main/skills/skill-delete/plan.test.ts": 217, + "src/main/skills/skill-delete/recovery.test.ts": 266, + "src/main/skills/skill-delete/roots.test.ts": 98, + "src/main/skills/skill-delete/service.test.ts": 497, + "src/main/skills/skill-delete/staging-visibility.test.ts": 124, + "src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts": 98, + "src/main/skills/skill-discovery-concurrency.test.ts": 152, + "src/main/skills/skill-discovery-order.test.ts": 267, + "src/main/skills/skill-discovery-source-filter.test.ts": 41, + "src/main/skills/skill-discovery-target.test.ts": 121, + "src/main/skills/skill-discovery-wsl-alias-sharing.test.ts": 124, + "src/main/skills/skill-discovery-wsl-bash-filter.test.ts": 2071, + "src/main/skills/skill-discovery-wsl-plugins.test.ts": 93, + "src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 112, + "src/main/skills/skill-discovery-wsl.test.ts": 111, + "src/main/skills/skill-freshness-eligibility.test.ts": 84, + "src/main/skills/skill-freshness-inventory-limits.test.ts": 254, + "src/main/skills/skill-freshness-inventory.test.ts": 902, + "src/main/skills/skill-git-tree-identity.test.ts": 79, + "src/main/skills/skill-install-destinations.test.ts": 68, + "src/main/skills/skill-install-discovery-verification.test.ts": 129, + "src/main/skills/skill-install-lock-release.test.ts": 48, + "src/main/skills/skill-install-lock.test.ts": 438, + "src/main/skills/skill-install-management-service.test.ts": 319, + "src/main/skills/skill-install-provenance.test.ts": 85, + "src/main/skills/skill-install-recovery.test.ts": 513, + "src/main/skills/skill-install-request-service.test.ts": 714, + "src/main/skills/skill-install-service.test.ts": 1015, + "src/main/skills/skill-install-transaction.test.ts": 1418, + "src/main/skills/skill-operation-observability.test.ts": 330, + "src/main/skills/skill-package-creation.test.ts": 502, + "src/main/skills/skill-package-deterministic-gzip.test.ts": 1040, + "src/main/skills/skill-package-download-response-lifetime.test.ts": 904, + "src/main/skills/skill-package-download.test.ts": 318, + "src/main/skills/skill-package-identity.test.ts": 117, + "src/main/skills/skill-package-tar.test.ts": 422, + "src/main/skills/skill-placement-alias-repair.test.ts": 238, + "src/main/skills/skill-placement-copy-drift.test.ts": 211, + "src/main/skills/skill-placement-reconciliation.test.ts": 280, + "src/main/skills/skill-placement-transaction.test.ts": 322, + "src/main/skills/skill-plugin-cache-scan.test.ts": 325, + "src/main/skills/skill-process-contention-child.test.ts": 259, + "src/main/skills/skill-process-contention.integration.test.ts": 245, + "src/main/skills/skill-process-termination-child.test.ts": 270, + "src/main/skills/skill-process-termination.integration.test.ts": 310, + "src/main/skills/skill-provider-destinations.test.ts": 64, + "src/main/skills/skill-provider-runtime-roots.test.ts": 79, + "src/main/skills/skill-remote-error-category-parity.test.ts": 264, + "src/main/skills/skill-remote-install-cancellation.test.ts": 46, + "src/main/skills/skill-remote-install-service.test.ts": 273, + "src/main/skills/skill-remove-transaction.test.ts": 852, + "src/main/skills/skill-root-file-walk.test.ts": 67, + "src/main/skills/skill-runtime-capability.test.ts": 77, + "src/main/skills/skill-scan-coalescer.test.ts": 62, + "src/main/skills/skill-share-preparation-service.test.ts": 426, + "src/main/skills/skill-ssh-relay-service.test.ts": 349, + "src/main/skills/skill-transaction-startup-recovery.test.ts": 471, + "src/main/skills/skill-update-convergence.test.ts": 73, + "src/main/skills/skill-update-outcome.test.ts": 313, + "src/main/skills/skill-update-registration.test.ts": 51, + "src/main/skills/skill-update-run.test.ts": 154, + "src/main/skills/skill-upload-process-restart-child.test.ts": 222, + "src/main/skills/skill-upload-process-restart.integration.test.ts": 244, + "src/main/skills/skill-upload-session-admission-regression.test.ts": 301, + "src/main/skills/skill-upload-session-service.test.ts": 390, "src/main/skills/skill-windows-rename-contention.integration.test.ts": 43, - "src/main/skills/skill-windows-workspace.integration.test.ts": 180, - "src/main/skills/skill-wsl-delete.integration.test.ts": 55, - "src/main/skills/skill-wsl-install-filesystem.test.ts": 63, - "src/main/skills/skill-wsl-install-transaction.integration.test.ts": 256, - "src/main/skills/skill-wsl-posix-semantics.integration.test.ts": 225, - "src/main/skills/skill-wsl-provider-detection.test.ts": 46, - "src/main/source-control/forge-provider.test.ts": 259, - "src/main/source-control/hosted-review-azure-devops.integration.test.ts": 548, - "src/main/source-control/hosted-review-base-ref-suffix.test.ts": 210, - "src/main/source-control/hosted-review-bitbucket.integration.test.ts": 1294, - "src/main/source-control/hosted-review-branch-cache.test.ts": 669, - "src/main/source-control/hosted-review-creation-eligibility.test.ts": 325, - "src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts": 194, - "src/main/source-control/hosted-review-creation-shared-symlinks.test.ts": 192, - "src/main/source-control/hosted-review-creation.test.ts": 230, - "src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts": 222, - "src/main/source-control/hosted-review-execution-host-routing.test.ts": 101, - "src/main/source-control/hosted-review-gitea.integration.test.ts": 850, - "src/main/source-control/hosted-review.test.ts": 112, - "src/main/source-control/pull-request-linked-issue.test.ts": 54, - "src/main/source-control/repo-default-branch.test.ts": 88, - "src/main/source-control/stacked-hosted-review-creation.test.ts": 44, - "src/main/speech/model-catalog.test.ts": 41, - "src/main/speech/model-manager-download-error.test.ts": 83, - "src/main/speech/model-manager-download-resume.test.ts": 3099, - "src/main/speech/model-manager-progress-callback.test.ts": 64, - "src/main/speech/model-manager-stream-cleanup.test.ts": 77, - "src/main/speech/model-manager-windows-path.test.ts": 242, - "src/main/speech/model-manager.test.ts": 140, - "src/main/speech/openai-api-key-store.test.ts": 53, - "src/main/speech/openai-transcription-client.test.ts": 51, - "src/main/speech/speech-model-deletion.test.ts": 158, - "src/main/speech/speech-model-download-response.test.ts": 62, - "src/main/speech/stt-offline-audio-chunker.test.ts": 155, - "src/main/speech/stt-service.test.ts": 86, - "src/main/speech/stt-worker-model-config.test.ts": 48, - "src/main/speech/stt-worker.test.ts": 38, - "src/main/sqlite/sqlite-read-failure.test.ts": 60, - "src/main/sqlite/sync-database-concurrent-backup.test.ts": 815, - "src/main/sqlite/sync-database-portability.test.ts": 108, - "src/main/sqlite/sync-database.test.ts": 806, - "src/main/ssh-expired-lease-pane-readoption.test.ts": 1542, - "src/main/ssh-reattach-pane-cardinality.test.ts": 1109, - "src/main/ssh/orcad-activation-gate.test.ts": 56, - "src/main/ssh/orcad-activation-record.test.ts": 44, - "src/main/ssh/orcad-artifact-materializer.test.ts": 681, - "src/main/ssh/orcad-deployment-target.test.ts": 195, - "src/main/ssh/orcad-remote-deploy.test.ts": 479, - "src/main/ssh/orcad-remote-gc.test.ts": 161, - "src/main/ssh/orcad-remote-install-termination.test.ts": 201, - "src/main/ssh/orcad-remote-launch.test.ts": 169, - "src/main/ssh/orcad-remote-rollback.test.ts": 254, - "src/main/ssh/orcad-remote-runtime.test.ts": 228, - "src/main/ssh/orcad-remote-shell-commands.integration.test.ts": 4806, - "src/main/ssh/orcad-state-snapshot.test.ts": 135, - "src/main/ssh/orcad-update-plan.test.ts": 62, - "src/main/ssh/pinned-runtime-materializer.test.ts": 357, - "src/main/ssh/relay-native-dependency-coverage.test.ts": 504, - "src/main/ssh/relay-protocol-backpressure.test.ts": 103, - "src/main/ssh/relay-protocol.test.ts": 70, - "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 95, - "src/main/ssh/relay-socket-path-limit.test.ts": 194, - "src/main/ssh/remote-install-coexistence.test.ts": 50, - "src/main/ssh/remote-install-gc-termination.test.ts": 195, - "src/main/ssh/remote-install-model.test.ts": 224, - "src/main/ssh/removed-ssh-target-tombstone-retention.test.ts": 59, - "src/main/ssh/sftp-namespace-resolution.test.ts": 94, - "src/main/ssh/sftp-stream-late-error.test.ts": 177, - "src/main/ssh/sftp-upload.test.ts": 175, - "src/main/ssh/ssh-agent-identity-filter.test.ts": 47, - "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts": 98, - "src/main/ssh/ssh-channel-multiplexer-saturation-wedge.test.ts": 73, - "src/main/ssh/ssh-channel-multiplexer-settlement.test.ts": 77, - "src/main/ssh/ssh-channel-multiplexer.test.ts": 63, - "src/main/ssh/ssh-config-alias-claim.test.ts": 74, - "src/main/ssh/ssh-config-host-picker.test.ts": 318, - "src/main/ssh/ssh-config-loader-regression.test.ts": 156, - "src/main/ssh/ssh-config-loader.test.ts": 95, - "src/main/ssh/ssh-config-parser-host-patterns.test.ts": 51, - "src/main/ssh/ssh-config-parser.test.ts": 392, - "src/main/ssh/ssh-config-resolver.test.ts": 55, - "src/main/ssh/ssh-connect-attempt-cancellation.test.ts": 784, - "src/main/ssh/ssh-connection-auth-fallback.test.ts": 232, - "src/main/ssh/ssh-connection-channel-open.test.ts": 877, - "src/main/ssh/ssh-connection-direct-startup.test.ts": 192, - "src/main/ssh/ssh-connection-file-transfer.test.ts": 130, - "src/main/ssh/ssh-connection-generation.test.ts": 62, - "src/main/ssh/ssh-connection-github-probe.test.ts": 123, - "src/main/ssh/ssh-connection-gssapi-fallback.test.ts": 259, - "src/main/ssh/ssh-connection-host-key-store-wiring.test.ts": 462, - "src/main/ssh/ssh-connection-host-key-verification.test.ts": 157, - "src/main/ssh/ssh-connection-manager-registry.test.ts": 163, - "src/main/ssh/ssh-connection-manager.test.ts": 60, - "src/main/ssh/ssh-connection-reconnect-ladder.test.ts": 420, - "src/main/ssh/ssh-connection-sftp-namespace.test.ts": 901, - "src/main/ssh/ssh-connection-sftp-wire.test.ts": 2538, - "src/main/ssh/ssh-connection-store.test.ts": 100, - "src/main/ssh/ssh-connection-system-transport.test.ts": 252, - "src/main/ssh/ssh-connection-utils.test.ts": 213, - "src/main/ssh/ssh-connection.test.ts": 395, - "src/main/ssh/ssh-control-socket.test.ts": 47, - "src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts": 68, - "src/main/ssh/ssh-file-transfer-abort.test.ts": 109, - "src/main/ssh/ssh-filesystem-stream-retention.test.ts": 122, - "src/main/ssh/ssh-g-config-resolution.test.ts": 82, - "src/main/ssh/ssh-git-response-stream-reader.test.ts": 61, - "src/main/ssh/ssh-git-stream-idle-timer.test.ts": 63, - "src/main/ssh/ssh-host-key-decision.test.ts": 60, - "src/main/ssh/ssh-host-key-store.test.ts": 238, - "src/main/ssh/ssh-host-key-verifier.test.ts": 70, - "src/main/ssh/ssh-known-hosts-source.test.ts": 104, - "src/main/ssh/ssh-known-hosts.test.ts": 106, - "src/main/ssh/ssh-multi-factor-authentication.test.ts": 1337, - "src/main/ssh/ssh-multi-key-authentication.test.ts": 161, - "src/main/ssh/ssh-multiplexer-transport-writer.test.ts": 77, - "src/main/ssh/ssh-multiplexer-writer-retention.test.ts": 538, - "src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts": 72, - "src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts": 95, - "src/main/ssh/ssh-owner-recovery-retry.test.ts": 69, - "src/main/ssh/ssh-pending-pty-kill-replay.test.ts": 78, - "src/main/ssh/ssh-port-forward.test.ts": 221, - "src/main/ssh/ssh-port-scanner.test.ts": 80, - "src/main/ssh/ssh-posix-command-wrapper.test.ts": 124, - "src/main/ssh/ssh-provider-authority.test.ts": 44, - "src/main/ssh/ssh-proxy-command.test.ts": 148, - "src/main/ssh/ssh-pty-consumer-recovery.test.ts": 61, - "src/main/ssh/ssh-pty-consumer-session.test.ts": 70, - "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts": 34, - "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts": 46, - "src/main/ssh/ssh-reconnect-error-classification.test.ts": 86, - "src/main/ssh/ssh-reconnect-ladder.test.ts": 153, - "src/main/ssh/ssh-relay-build-toolchain.test.ts": 154, - "src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts": 318, - "src/main/ssh/ssh-relay-cross-version-isolation.test.ts": 265, - "src/main/ssh/ssh-relay-deploy-helpers.test.ts": 313, - "src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts": 260, - "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts": 502, - "src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts": 207, - "src/main/ssh/ssh-relay-deploy.test.ts": 690, - "src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 2168, - "src/main/ssh/ssh-relay-endpoint-incumbent.test.ts": 99, - "src/main/ssh/ssh-relay-endpoint-takeover.test.ts": 116, - "src/main/ssh/ssh-relay-gc-claim-termination.test.ts": 174, - "src/main/ssh/ssh-relay-gc-retry.test.ts": 332, - "src/main/ssh/ssh-relay-incumbent-process.test.ts": 15877, - "src/main/ssh/ssh-relay-install-lock-termination.test.ts": 229, - "src/main/ssh/ssh-relay-install-lock.test.ts": 203, - "src/main/ssh/ssh-relay-install-namespace.test.ts": 186, - "src/main/ssh/ssh-relay-live-connect.test.ts": 214, - "src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts": 1792, - "src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts": 1158, - "src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts": 525, - "src/main/ssh/ssh-relay-native-deps-cache.test.ts": 236, - "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts": 255, - "src/main/ssh/ssh-relay-native-deps-install.test.ts": 14222, - "src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts": 418, - "src/main/ssh/ssh-relay-node-headers.test.ts": 1899, - "src/main/ssh/ssh-relay-node-pty-repair.test.ts": 124, - "src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts": 215, - "src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts": 302, - "src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts": 588, - "src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts": 77, - "src/main/ssh/ssh-relay-opencode-runtime.test.ts": 392, - "src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts": 2554, - "src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts": 2127, - "src/main/ssh/ssh-relay-repair-lock-termination.test.ts": 237, - "src/main/ssh/ssh-relay-reset.test.ts": 133, - "src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts": 521, - "src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts": 131, - "src/main/ssh/ssh-relay-ripgrep-install.test.ts": 389, - "src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts": 115, - "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts": 4019, - "src/main/ssh/ssh-relay-session-data-delivery.test.ts": 2680, - "src/main/ssh/ssh-relay-session-incarnation.test.ts": 2776, - "src/main/ssh/ssh-relay-session-managed-hooks.test.ts": 2737, - "src/main/ssh/ssh-relay-session-model-migration.test.ts": 3018, - "src/main/ssh/ssh-relay-session-orphan-sweep.test.ts": 2684, - "src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts": 2344, - "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts": 3189, - "src/main/ssh/ssh-relay-session-recovery-durability.test.ts": 4088, - "src/main/ssh/ssh-relay-session-recovery-races.test.ts": 3683, - "src/main/ssh/ssh-relay-session-rejected-delivery.test.ts": 3066, - "src/main/ssh/ssh-relay-session-relay-loss.test.ts": 2301, - "src/main/ssh/ssh-relay-session-terminal-error.test.ts": 2096, - "src/main/ssh/ssh-relay-session.test.ts": 10932, - "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts": 475, - "src/main/ssh/ssh-relay-superseded-endpoints.test.ts": 197, - "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts": 357, - "src/main/ssh/ssh-relay-upload-stage-commands.test.ts": 27518, - "src/main/ssh/ssh-relay-versioned-install-termination.test.ts": 155, - "src/main/ssh/ssh-relay-versioned-install.test.ts": 549, - "src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts": 4113, - "src/main/ssh/ssh-remote-cli-format.test.ts": 54, - "src/main/ssh/ssh-remote-cli-host-passthrough.test.ts": 200, - "src/main/ssh/ssh-remote-cli-launcher.test.ts": 80, - "src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts": 3331, - "src/main/ssh/ssh-remote-commands.test.ts": 15974, - "src/main/ssh/ssh-remote-linear-activity-output.test.ts": 36, - "src/main/ssh/ssh-remote-linear-cli.test.ts": 4061, - "src/main/ssh/ssh-remote-linear-list-issues.test.ts": 61, - "src/main/ssh/ssh-remote-linear-relation-write.test.ts": 1192, - "src/main/ssh/ssh-remote-linear-save-issue.test.ts": 2067, - "src/main/ssh/ssh-remote-linear-truncation-output.test.ts": 39, - "src/main/ssh/ssh-remote-node-resolution.test.ts": 273, - "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 241, - "src/main/ssh/ssh-remote-orca-cli.test.ts": 3176, - "src/main/ssh/ssh-remote-orchestration-compatibility.test.ts": 3146, - "src/main/ssh/ssh-remote-orchestration-send.test.ts": 29, - "src/main/ssh/ssh-remote-platform-detection.test.ts": 130, - "src/main/ssh/ssh-remote-platform.test.ts": 190, - "src/main/ssh/ssh-remote-powershell.test.ts": 238, - "src/main/ssh/ssh-remote-windows-command-line-limit.test.ts": 222, - "src/main/ssh/ssh-request-outcome-verdict.test.ts": 40, - "src/main/ssh/ssh-security-key-identity.test.ts": 179, - "src/main/ssh/ssh-session-limit-error.test.ts": 27, - "src/main/ssh/ssh-system-fallback.test.ts": 556, - "src/main/ssh/ssh-system-transport.integration.test.ts": 878, - "src/main/ssh/ssh-target-id-migration.test.ts": 41, - "src/main/ssh/ssh-target-readoption.test.ts": 64, - "src/main/ssh/system-ssh-binary.test.ts": 59, - "src/main/ssh/system-ssh-dynamic-forward-process.test.ts": 237, - "src/main/ssh/system-ssh-forward-process.test.ts": 233, - "src/main/ssh/system-ssh-sftp-args.test.ts": 38, + "src/main/skills/skill-windows-workspace.integration.test.ts": 345, + "src/main/skills/skill-wsl-delete.integration.test.ts": 70, + "src/main/skills/skill-wsl-install-filesystem.test.ts": 82, + "src/main/skills/skill-wsl-install-transaction.integration.test.ts": 254, + "src/main/skills/skill-wsl-posix-semantics.integration.test.ts": 326, + "src/main/skills/skill-wsl-provider-detection.test.ts": 84, + "src/main/source-control/forge-provider.test.ts": 418, + "src/main/source-control/hosted-review-azure-devops.integration.test.ts": 842, + "src/main/source-control/hosted-review-base-ref-suffix.test.ts": 428, + "src/main/source-control/hosted-review-bitbucket.integration.test.ts": 2640, + "src/main/source-control/hosted-review-branch-cache.test.ts": 216, + "src/main/source-control/hosted-review-creation-eligibility.test.ts": 488, + "src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts": 507, + "src/main/source-control/hosted-review-creation-shared-symlinks.test.ts": 488, + "src/main/source-control/hosted-review-creation.test.ts": 487, + "src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts": 440, + "src/main/source-control/hosted-review-execution-host-routing.test.ts": 406, + "src/main/source-control/hosted-review-gitea.integration.test.ts": 771, + "src/main/source-control/hosted-review-inflight-invalidation.test.ts": 131, + "src/main/source-control/hosted-review-inflight-lookups.test.ts": 49, + "src/main/source-control/hosted-review.test.ts": 465, + "src/main/source-control/pull-request-linked-issue.test.ts": 63, + "src/main/source-control/repo-default-branch.test.ts": 137, + "src/main/source-control/stacked-hosted-review-creation.test.ts": 51, + "src/main/speech/model-catalog.test.ts": 48, + "src/main/speech/model-manager-download-error.test.ts": 152, + "src/main/speech/model-manager-download-resume.test.ts": 3126, + "src/main/speech/model-manager-progress-callback.test.ts": 65, + "src/main/speech/model-manager-stream-cleanup.test.ts": 124, + "src/main/speech/model-manager-windows-path.test.ts": 170, + "src/main/speech/model-manager.test.ts": 90, + "src/main/speech/openai-api-key-store.test.ts": 82, + "src/main/speech/openai-transcription-client.test.ts": 49, + "src/main/speech/speech-model-deletion.test.ts": 292, + "src/main/speech/speech-model-download-response.test.ts": 49, + "src/main/speech/stt-offline-audio-chunker.test.ts": 222, + "src/main/speech/stt-service.test.ts": 109, + "src/main/speech/stt-worker-model-config.test.ts": 76, + "src/main/speech/stt-worker.test.ts": 49, + "src/main/sqlite/node-sqlite-reader-api.test.ts": 54, + "src/main/sqlite/sqlite-read-failure.test.ts": 67, + "src/main/sqlite/sync-database-concurrent-backup.test.ts": 1201, + "src/main/sqlite/sync-database-portability.test.ts": 114, + "src/main/sqlite/sync-database.test.ts": 895, + "src/main/ssh-expired-lease-pane-readoption.test.ts": 1865, + "src/main/ssh-reattach-pane-cardinality.test.ts": 1839, + "src/main/ssh/managed-server-fence-recheck.test.ts": 459, + "src/main/ssh/orcad-activation-crash-recovery.test.ts": 754, + "src/main/ssh/orcad-activation-fence-generation.test.ts": 645, + "src/main/ssh/orcad-activation-fence-hold.test.ts": 397, + "src/main/ssh/orcad-activation-gate.test.ts": 62, + "src/main/ssh/orcad-activation-outcome-log.test.ts": 55, + "src/main/ssh/orcad-activation-record.test.ts": 254, + "src/main/ssh/orcad-activation-transaction.test.ts": 398, + "src/main/ssh/orcad-artifact-materializer.test.ts": 722, + "src/main/ssh/orcad-decommission-released-peer-recovery.test.ts": 295, + "src/main/ssh/orcad-decommission-transaction.test.ts": 232, + "src/main/ssh/orcad-deployment-target.test.ts": 234, + "src/main/ssh/orcad-exited-own-fence-mutation-race.test.ts": 775, + "src/main/ssh/orcad-exited-own-fence.test.ts": 5738, + "src/main/ssh/orcad-exited-own-install-lock.test.ts": 4492, + "src/main/ssh/orcad-exited-own-lock-windows.test.ts": 350, + "src/main/ssh/orcad-fence-lost-classification.test.ts": 287, + "src/main/ssh/orcad-host-unavailable.test.ts": 325, + "src/main/ssh/orcad-managed-auto-update.test.ts": 319, + "src/main/ssh/orcad-managed-bound-port.test.ts": 386, + "src/main/ssh/orcad-managed-serving.test.ts": 480, + "src/main/ssh/orcad-managed-tunnel-identity-restart.test.ts": 4422, + "src/main/ssh/orcad-managed-tunnel-identity.test.ts": 403, + "src/main/ssh/orcad-managed-tunnel-target.test.ts": 401, + "src/main/ssh/orcad-managed-tunnel-transport.test.ts": 367, + "src/main/ssh/orcad-managed-tunnel.test.ts": 901, + "src/main/ssh/orcad-managed-update-deferrals.test.ts": 37, + "src/main/ssh/orcad-managed-update-on-restore.test.ts": 334, + "src/main/ssh/orcad-managed-version-gc.test.ts": 375, + "src/main/ssh/orcad-managed-wake.test.ts": 467, + "src/main/ssh/orcad-migration-catalog-client-extra-agent-args.test.ts": 339, + "src/main/ssh/orcad-migration-catalog-client.test.ts": 294, + "src/main/ssh/orcad-migration-cutover-coordinator.test.ts": 1549, + "src/main/ssh/orcad-migration-cutover-journal.test.ts": 342, + "src/main/ssh/orcad-migration-delta-move.test.ts": 3105, + "src/main/ssh/orcad-migration-delta-snapshot.test.ts": 1922, + "src/main/ssh/orcad-migration-manifest-export.test.ts": 384, + "src/main/ssh/orcad-migration-refusal-reason.test.ts": 40, + "src/main/ssh/orcad-migration-relay-pty-lister.test.ts": 329, + "src/main/ssh/orcad-migration-scrollback-retention-wiring.test.ts": 59, + "src/main/ssh/orcad-migration-snapshot-coordinator.test.ts": 309, + "src/main/ssh/orcad-migration-snapshot-resume.test.ts": 1220, + "src/main/ssh/orcad-migration-source-fence.test.ts": 1306, + "src/main/ssh/orcad-migration-source-retention.test.ts": 385, + "src/main/ssh/orcad-migration-terminal-gate-host-wide.test.ts": 238, + "src/main/ssh/orcad-migration-terminal-gate.test.ts": 60, + "src/main/ssh/orcad-remote-build-hash.integration.test.ts": 404, + "src/main/ssh/orcad-remote-decommission.test.ts": 387, + "src/main/ssh/orcad-remote-deploy.test.ts": 603, + "src/main/ssh/orcad-remote-gc.test.ts": 468, + "src/main/ssh/orcad-remote-install-termination.test.ts": 495, + "src/main/ssh/orcad-remote-launch-file-modes.test.ts": 421, + "src/main/ssh/orcad-remote-launch.test.ts": 348, + "src/main/ssh/orcad-remote-lifecycle-windows.test.ts": 454, + "src/main/ssh/orcad-remote-log-tail.test.ts": 409, + "src/main/ssh/orcad-remote-node-runtime-promotion-lock.test.ts": 185, + "src/main/ssh/orcad-remote-node-runtime-slow-hash.test.ts": 1212, + "src/main/ssh/orcad-remote-node-runtime-windows.test.ts": 221, + "src/main/ssh/orcad-remote-node-runtime.test.ts": 220, + "src/main/ssh/orcad-remote-primitives.test.ts": 429, + "src/main/ssh/orcad-remote-readiness-wait.test.ts": 1837, + "src/main/ssh/orcad-remote-rollback.test.ts": 570, + "src/main/ssh/orcad-remote-runtime-control.test.ts": 281, + "src/main/ssh/orcad-remote-runtime.test.ts": 680, + "src/main/ssh/orcad-remote-shell-commands.integration.test.ts": 10068, + "src/main/ssh/orcad-remote-windows-commands.test.ts": 374, + "src/main/ssh/orcad-remote-windows-decommission.test.ts": 448, + "src/main/ssh/orcad-remote-windows-scripts.test.ts": 3448, + "src/main/ssh/orcad-retained-fence-takeover.test.ts": 194, + "src/main/ssh/orcad-retained-source-lists.test.ts": 325, + "src/main/ssh/orcad-runtime-conversion.test.ts": 2065, + "src/main/ssh/orcad-runtime-decommission.test.ts": 886, + "src/main/ssh/orcad-runtime-deployment.test.ts": 767, + "src/main/ssh/orcad-runtime-forget.test.ts": 558, + "src/main/ssh/orcad-runtime-maintenance.test.ts": 744, + "src/main/ssh/orcad-runtime-target.test.ts": 393, + "src/main/ssh/orcad-ssh-provisioning.test.ts": 279, + "src/main/ssh/orcad-state-mutation-exec.test.ts": 343, + "src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts": 17438, + "src/main/ssh/orcad-state-mutation-timeout.test.ts": 460, + "src/main/ssh/orcad-state-snapshot-shell.test.ts": 726, + "src/main/ssh/orcad-state-snapshot.test.ts": 382, + "src/main/ssh/orcad-stdio-bridge-provider.test.ts": 686, + "src/main/ssh/orcad-stdio-bridge.test.ts": 502, + "src/main/ssh/orcad-terminal-census-client.test.ts": 188, + "src/main/ssh/orcad-tunneled-pairing.test.ts": 209, + "src/main/ssh/orcad-unreachable-setup-release.test.ts": 1751, + "src/main/ssh/orcad-unreachable-setup-stop.test.ts": 424, + "src/main/ssh/orcad-update-plan.test.ts": 2663, + "src/main/ssh/orcad-windows-host-fence-ops.test.ts": 763, + "src/main/ssh/orcad-windows-host-lane.test.ts": 745, + "src/main/ssh/orcad-windows-host-state-ops.test.ts": 2035, + "src/main/ssh/pinned-runtime-materializer-node.test.ts": 114, + "src/main/ssh/pinned-runtime-materializer.test.ts": 3192, + "src/main/ssh/relay-bundle-paths.test.ts": 62, + "src/main/ssh/relay-native-dependency-coverage.test.ts": 1185, + "src/main/ssh/relay-protocol.test.ts": 120, + "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 107, + "src/main/ssh/relay-socket-path-limit.test.ts": 559, + "src/main/ssh/relay-version-dir-liveness.test.ts": 577, + "src/main/ssh/remote-install-coexistence.test.ts": 51, + "src/main/ssh/remote-install-gc-termination.test.ts": 267, + "src/main/ssh/remote-install-model.test.ts": 199, + "src/main/ssh/remote-install-previous-version.test.ts": 187, + "src/main/ssh/remote-node-runtime-store-gc.test.ts": 2001, + "src/main/ssh/remote-node-runtime-store-lock-host-release.test.ts": 2390, + "src/main/ssh/remote-node-runtime-store-windows.test.ts": 12643, + "src/main/ssh/removed-ssh-target-tombstone-retention.test.ts": 54, + "src/main/ssh/runtime-archive-download.test.ts": 99, + "src/main/ssh/runtime-ssh-access-verification.test.ts": 228, + "src/main/ssh/runtime-ssh-access.test.ts": 548, + "src/main/ssh/sftp-namespace-resolution.test.ts": 91, + "src/main/ssh/sftp-stream-late-error.test.ts": 232, + "src/main/ssh/sftp-upload.test.ts": 298, + "src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts": 530, + "src/main/ssh/ssh-agent-identity-filter.test.ts": 67, + "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts": 103, + "src/main/ssh/ssh-channel-multiplexer-saturation-wedge.test.ts": 72, + "src/main/ssh/ssh-channel-multiplexer-settlement.test.ts": 61, + "src/main/ssh/ssh-channel-multiplexer.test.ts": 133, + "src/main/ssh/ssh-config-alias-claim.test.ts": 87, + "src/main/ssh/ssh-config-host-picker.test.ts": 200, + "src/main/ssh/ssh-config-loader-regression.test.ts": 134, + "src/main/ssh/ssh-config-loader.test.ts": 117, + "src/main/ssh/ssh-config-parser-host-patterns.test.ts": 66, + "src/main/ssh/ssh-config-parser.test.ts": 327, + "src/main/ssh/ssh-config-resolver.test.ts": 72, + "src/main/ssh/ssh-connect-attempt-cancellation.test.ts": 94, + "src/main/ssh/ssh-connection-attribution.test.ts": 53, + "src/main/ssh/ssh-connection-auth-fallback.test.ts": 266, + "src/main/ssh/ssh-connection-channel-error.test.ts": 144, + "src/main/ssh/ssh-connection-channel-lifetime.test.ts": 41, + "src/main/ssh/ssh-connection-channel-open.test.ts": 932, + "src/main/ssh/ssh-connection-direct-startup.test.ts": 141, + "src/main/ssh/ssh-connection-file-transfer.test.ts": 178, + "src/main/ssh/ssh-connection-generation.test.ts": 52, + "src/main/ssh/ssh-connection-github-probe.test.ts": 196, + "src/main/ssh/ssh-connection-gssapi-fallback.test.ts": 254, + "src/main/ssh/ssh-connection-host-key-store-wiring.test.ts": 579, + "src/main/ssh/ssh-connection-host-key-verification.test.ts": 201, + "src/main/ssh/ssh-connection-manager-registry.test.ts": 258, + "src/main/ssh/ssh-connection-manager.test.ts": 73, + "src/main/ssh/ssh-connection-reconnect-handshake-loss.test.ts": 225, + "src/main/ssh/ssh-connection-reconnect-ladder.test.ts": 382, + "src/main/ssh/ssh-connection-sftp-namespace.test.ts": 991, + "src/main/ssh/ssh-connection-sftp-wire.test.ts": 368, + "src/main/ssh/ssh-connection-store-orcad-ownership.test.ts": 234, + "src/main/ssh/ssh-connection-store.test.ts": 320, + "src/main/ssh/ssh-connection-system-transport.test.ts": 241, + "src/main/ssh/ssh-connection-utils.test.ts": 217, + "src/main/ssh/ssh-connection.test.ts": 1261, + "src/main/ssh/ssh-control-socket.test.ts": 62, + "src/main/ssh/ssh-exec-rc-stdout-noise.test.ts": 255, + "src/main/ssh/ssh-exec-stdin-file-transfer.test.ts": 246, + "src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts": 58, + "src/main/ssh/ssh-file-transfer-abort.test.ts": 105, + "src/main/ssh/ssh-filesystem-stream-retention.test.ts": 252, + "src/main/ssh/ssh-g-config-resolution.test.ts": 112, + "src/main/ssh/ssh-git-response-pending-retention.test.ts": 238, + "src/main/ssh/ssh-git-response-stream-reader.test.ts": 59, + "src/main/ssh/ssh-git-stream-idle-timer.test.ts": 109, + "src/main/ssh/ssh-host-key-decision.test.ts": 74, + "src/main/ssh/ssh-host-key-store.test.ts": 193, + "src/main/ssh/ssh-host-key-verifier.test.ts": 92, + "src/main/ssh/ssh-host-platform-memo.test.ts": 40, + "src/main/ssh/ssh-host-provider-set.test.ts": 2175, + "src/main/ssh/ssh-host-relay-census-for-target.test.ts": 53, + "src/main/ssh/ssh-host-relay-endpoint-census.test.ts": 112, + "src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts": 576, + "src/main/ssh/ssh-host-relay-terminals-once-connected.test.ts": 616, + "src/main/ssh/ssh-host-relay-windows-census.test.ts": 209, + "src/main/ssh/ssh-host-relay-windows-inventory.test.ts": 179, + "src/main/ssh/ssh-host-server-move-offer.test.ts": 39, + "src/main/ssh/ssh-host-server-on-connect-stopped-elsewhere.test.ts": 674, + "src/main/ssh/ssh-host-server-on-connect-telemetry.test.ts": 547, + "src/main/ssh/ssh-host-server-on-connect.test.ts": 539, + "src/main/ssh/ssh-host-server-telemetry.test.ts": 228, + "src/main/ssh/ssh-hostile-host-cells.test.ts": 137, + "src/main/ssh/ssh-hostile-host-local-sshd.test.ts": 69, + "src/main/ssh/ssh-hostile-host-observer.test.ts": 79, + "src/main/ssh/ssh-keyboard-interactive-wire.test.ts": 290, + "src/main/ssh/ssh-keyboard-interactive.test.ts": 72, + "src/main/ssh/ssh-known-hosts-source.test.ts": 128, + "src/main/ssh/ssh-known-hosts.test.ts": 96, + "src/main/ssh/ssh-legacy-relay-route.test.ts": 107, + "src/main/ssh/ssh-legacy-relay-router.test.ts": 55, + "src/main/ssh/ssh-legacy-relay-routing.test.ts": 111, + "src/main/ssh/ssh-listing-response-retention.test.ts": 77, + "src/main/ssh/ssh-multi-factor-authentication.test.ts": 168, + "src/main/ssh/ssh-multi-key-authentication.test.ts": 126, + "src/main/ssh/ssh-multiplexer-transport-writer.test.ts": 89, + "src/main/ssh/ssh-multiplexer-writer-retention.test.ts": 480, + "src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts": 81, + "src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts": 115, + "src/main/ssh/ssh-owner-recovery-retry.test.ts": 78, + "src/main/ssh/ssh-pending-pty-kill-replay.test.ts": 97, + "src/main/ssh/ssh-port-forward.test.ts": 253, + "src/main/ssh/ssh-port-scanner.test.ts": 69, + "src/main/ssh/ssh-posix-command-wrapper.test.ts": 140, + "src/main/ssh/ssh-previous-relay-terminals.test.ts": 209, + "src/main/ssh/ssh-provider-authority.test.ts": 67, + "src/main/ssh/ssh-proxy-command.test.ts": 120, + "src/main/ssh/ssh-pty-consumer-recovery.test.ts": 57, + "src/main/ssh/ssh-pty-consumer-session.test.ts": 63, + "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts": 50, + "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts": 47, + "src/main/ssh/ssh-quit-abort-upload.test.ts": 256, + "src/main/ssh/ssh-reconnect-error-classification.test.ts": 123, + "src/main/ssh/ssh-reconnect-ladder.test.ts": 96, + "src/main/ssh/ssh-relay-answered-failure-producers.test.ts": 301, + "src/main/ssh/ssh-relay-build-toolchain.test.ts": 133, + "src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts": 613, + "src/main/ssh/ssh-relay-cross-version-isolation.test.ts": 503, + "src/main/ssh/ssh-relay-deploy-helpers.test.ts": 571, + "src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts": 669, + "src/main/ssh/ssh-relay-deploy-pinned-node.test.ts": 645, + "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts": 674, + "src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts": 565, + "src/main/ssh/ssh-relay-deploy.test.ts": 1049, + "src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 2228, + "src/main/ssh/ssh-relay-endpoint-incumbent.test.ts": 115, + "src/main/ssh/ssh-relay-endpoint-pty-count.test.ts": 695, + "src/main/ssh/ssh-relay-endpoint-runtime.test.ts": 102, + "src/main/ssh/ssh-relay-endpoint-takeover.test.ts": 141, + "src/main/ssh/ssh-relay-exec-input.test.ts": 91, + "src/main/ssh/ssh-relay-gc-claim-termination.test.ts": 261, + "src/main/ssh/ssh-relay-gc-retry.test.ts": 644, + "src/main/ssh/ssh-relay-host-answered-failure.test.ts": 100, + "src/main/ssh/ssh-relay-host-node-addons.test.ts": 393, + "src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts": 752, + "src/main/ssh/ssh-relay-incumbent-process.test.ts": 5678, + "src/main/ssh/ssh-relay-install-lock-exited-owner.test.ts": 324, + "src/main/ssh/ssh-relay-install-lock-termination.test.ts": 245, + "src/main/ssh/ssh-relay-install-lock.test.ts": 178, + "src/main/ssh/ssh-relay-install-namespace.test.ts": 227, + "src/main/ssh/ssh-relay-install-noisy-sftp-wire.test.ts": 292, + "src/main/ssh/ssh-relay-install-transfers-exec-fallback.test.ts": 113, + "src/main/ssh/ssh-relay-live-connect.test.ts": 559, + "src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts": 1752, + "src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts": 706, + "src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts": 421, + "src/main/ssh/ssh-relay-native-deps-cache.test.ts": 220, + "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts": 563, + "src/main/ssh/ssh-relay-native-deps-install.test.ts": 5044, + "src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts": 829, + "src/main/ssh/ssh-relay-node-headers.test.ts": 435, + "src/main/ssh/ssh-relay-node-pty-repair.test.ts": 224, + "src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts": 532, + "src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts": 665, + "src/main/ssh/ssh-relay-opencode-pinned-node.test.ts": 379, + "src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts": 586, + "src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts": 59, + "src/main/ssh/ssh-relay-opencode-runtime.test.ts": 585, + "src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts": 3598, + "src/main/ssh/ssh-relay-pinned-node-install.test.ts": 577, + "src/main/ssh/ssh-relay-pinned-node.integration.test.ts": 352, + "src/main/ssh/ssh-relay-pinned-node.test.ts": 421, + "src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts": 2665, + "src/main/ssh/ssh-relay-repair-lock-termination.test.ts": 248, + "src/main/ssh/ssh-relay-reset.test.ts": 136, + "src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts": 360, + "src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts": 264, + "src/main/ssh/ssh-relay-ripgrep-install.test.ts": 407, + "src/main/ssh/ssh-relay-runtime-ladder.test.ts": 346, + "src/main/ssh/ssh-relay-runtime-never-worse.test.ts": 422, + "src/main/ssh/ssh-relay-runtime-resolution.test.ts": 438, + "src/main/ssh/ssh-relay-runtime-self-test.test.ts": 655, + "src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts": 165, + "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts": 6042, + "src/main/ssh/ssh-relay-session-data-delivery.test.ts": 4119, + "src/main/ssh/ssh-relay-session-incarnation.test.ts": 3421, + "src/main/ssh/ssh-relay-session-managed-hooks.test.ts": 3951, + "src/main/ssh/ssh-relay-session-model-migration.test.ts": 3953, + "src/main/ssh/ssh-relay-session-orphan-sweep.test.ts": 4431, + "src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts": 4487, + "src/main/ssh/ssh-relay-session-plain-ssh.test.ts": 3774, + "src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts": 3334, + "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts": 3624, + "src/main/ssh/ssh-relay-session-recovery-durability.test.ts": 5835, + "src/main/ssh/ssh-relay-session-recovery-races.test.ts": 5278, + "src/main/ssh/ssh-relay-session-rejected-delivery.test.ts": 5370, + "src/main/ssh/ssh-relay-session-relay-loss.test.ts": 3963, + "src/main/ssh/ssh-relay-session-terminal-error.test.ts": 3680, + "src/main/ssh/ssh-relay-session.test.ts": 5221, + "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts": 651, + "src/main/ssh/ssh-relay-superseded-endpoints.test.ts": 146, + "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts": 690, + "src/main/ssh/ssh-relay-upload-stage-commands.test.ts": 23320, + "src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts": 4823, + "src/main/ssh/ssh-relay-versioned-install-termination.test.ts": 239, + "src/main/ssh/ssh-relay-versioned-install.test.ts": 327, + "src/main/ssh/ssh-relay-windows-host-lane.test.ts": 671, + "src/main/ssh/ssh-relay-windows-launch-command.test.ts": 151, + "src/main/ssh/ssh-remote-cli-bridge-credential.test.ts": 4939, + "src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts": 5750, + "src/main/ssh/ssh-remote-cli-format.test.ts": 55, + "src/main/ssh/ssh-remote-cli-host-passthrough.test.ts": 3482, + "src/main/ssh/ssh-remote-cli-launcher.test.ts": 145, + "src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts": 5194, + "src/main/ssh/ssh-remote-commands.test.ts": 7310, + "src/main/ssh/ssh-remote-linear-activity-output.test.ts": 58, + "src/main/ssh/ssh-remote-linear-cli.test.ts": 4075, + "src/main/ssh/ssh-remote-linear-list-issues.test.ts": 60, + "src/main/ssh/ssh-remote-linear-relation-write.test.ts": 566, + "src/main/ssh/ssh-remote-linear-save-issue.test.ts": 3545, + "src/main/ssh/ssh-remote-linear-truncation-output.test.ts": 48, + "src/main/ssh/ssh-remote-node-addon-resolution.test.ts": 166, + "src/main/ssh/ssh-remote-node-resolution.test.ts": 318, + "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 173, + "src/main/ssh/ssh-remote-orca-cli.test.ts": 6368, + "src/main/ssh/ssh-remote-orchestration-compatibility.test.ts": 6047, + "src/main/ssh/ssh-remote-orchestration-send.test.ts": 58, + "src/main/ssh/ssh-remote-platform-detection.test.ts": 218, + "src/main/ssh/ssh-remote-platform.test.ts": 58, + "src/main/ssh/ssh-remote-powershell.test.ts": 328, + "src/main/ssh/ssh-remote-runtime-telemetry.test.ts": 286, + "src/main/ssh/ssh-remote-windows-command-line-limit.test.ts": 208, + "src/main/ssh/ssh-request-outcome-verdict.test.ts": 52, + "src/main/ssh/ssh-response-pending-frames.test.ts": 96, + "src/main/ssh/ssh-security-key-identity.test.ts": 156, + "src/main/ssh/ssh-session-command-audit.test.ts": 126, + "src/main/ssh/ssh-session-limit-error.test.ts": 49, + "src/main/ssh/ssh-system-fallback.test.ts": 307, + "src/main/ssh/ssh-system-transport.integration.test.ts": 1301, + "src/main/ssh/ssh-target-id-migration.test.ts": 62, + "src/main/ssh/ssh-target-orcad-claims.test.ts": 221, + "src/main/ssh/ssh-target-orcad-dependents.test.ts": 247, + "src/main/ssh/ssh-target-orcad-preflight.test.ts": 1492, + "src/main/ssh/ssh-target-readoption.test.ts": 56, + "src/main/ssh/ssh-tcp-forwarding-probe.test.ts": 109, + "src/main/ssh/ssh-windows-host-cells.test.ts": 67, + "src/main/ssh/ssh2-port-forward-listen.test.ts": 49, + "src/main/ssh/system-ssh-binary.test.ts": 62, + "src/main/ssh/system-ssh-dynamic-forward-process.test.ts": 236, + "src/main/ssh/system-ssh-forward-process.test.ts": 228, + "src/main/ssh/system-ssh-sftp-args.test.ts": 57, "src/main/ssh/system-ssh-sftp-path.test.ts": 54, - "src/main/ssh/system-ssh-windows-upload.test.ts": 415, - "src/main/ssh/system-ssh-windows-write-capabilities.test.ts": 56, - "src/main/ssh/vscode-ssh-authority.test.ts": 73, - "src/main/star-nag/service-direct-star.test.ts": 171, - "src/main/star-nag/service-force-show-races.test.ts": 260, - "src/main/star-nag/service-outcome-telemetry.test.ts": 189, - "src/main/star-nag/service-prompt-moments.test.ts": 229, - "src/main/star-nag/service-threshold-prompt.test.ts": 214, - "src/main/startup/bootstrap-fatal-exit-guard.test.ts": 26, - "src/main/startup/branch-rename-hook-structured-session.test.ts": 91, - "src/main/startup/browser-process-user-agent-ordering.test.ts": 3534, - "src/main/startup/cli-launch-redirect.test.ts": 77, - "src/main/startup/configure-process-dev-parent-shutdown.test.ts": 138, - "src/main/startup/configure-process-profile-state.test.ts": 86, - "src/main/startup/configure-process.test.ts": 457, - "src/main/startup/desktop-startup-ordering.test.ts": 54, - "src/main/startup/dev-education-suppression.test.ts": 195, - "src/main/startup/dev-instance-identity.test.ts": 49, - "src/main/startup/ensure-virtual-display.test.ts": 5118, - "src/main/startup/first-window-startup-services.test.ts": 59, - "src/main/startup/gpu-fallback-marker.test.ts": 51, - "src/main/startup/gpu-fallback-switches.test.ts": 61, - "src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 549, - "src/main/startup/headless-pty-hydration-ordering.test.ts": 42, - "src/main/startup/host-port-bootstrap-wiring.test.ts": 37, - "src/main/startup/http1-compatibility-profile-state.test.ts": 65, - "src/main/startup/hydrate-shell-path.test.ts": 258, - "src/main/startup/hydrate-shell-path.windows.test.ts": 319, - "src/main/startup/legacy-worker-renderer-recovery.test.ts": 59, - "src/main/startup/login-shell-environment-cache.test.ts": 87, - "src/main/startup/login-shell-environment.test.ts": 112, - "src/main/startup/main-process-error-guards.test.ts": 73, - "src/main/startup/main-process-ready-activation-failure.test.ts": 351, - "src/main/startup/main-process-ready-identity-write.test.ts": 356, - "src/main/startup/main-process-ready-persistence-cleanup.test.ts": 216, - "src/main/startup/main-process-ready-phase-ordering.test.ts": 230, - "src/main/startup/main-process-runtime-launch-activation.test.ts": 356, - "src/main/startup/main-window-core-services.test.ts": 39, - "src/main/startup/main-window-structured-status-filter.test.ts": 347, - "src/main/startup/os-opened-markdown-delivery.test.ts": 218, - "src/main/startup/os-opened-markdown-files.test.ts": 130, - "src/main/startup/os-opened-markdown-wiring.test.ts": 48, - "src/main/startup/pre-gone-crash-sampling-wiring.test.ts": 36, - "src/main/startup/profile-state-recovery-preflight.test.ts": 556, - "src/main/startup/profile-state-write-failure.test.ts": 112, - "src/main/startup/renderer-heap-headroom.test.ts": 55, - "src/main/startup/run-electron-vite-dev-web.test.ts": 588, - "src/main/startup/run-electron-vite-dev.test.ts": 968, - "src/main/startup/secret-protection-report-deferral-wiring.test.ts": 58, - "src/main/startup/secure-dns-census.test.ts": 789, - "src/main/startup/serve-desktop-activation-wiring.test.ts": 30, - "src/main/startup/serve-desktop-activation.test.ts": 40, - "src/main/startup/serve-mode-argv-cli-redirect-order.test.ts": 53, - "src/main/startup/serve-mode-argv.test.ts": 85, - "src/main/startup/serve-options.test.ts": 67, - "src/main/startup/serve-signal-handlers.test.ts": 52, - "src/main/startup/single-instance-lock-exit.electron.test.ts": 622, - "src/main/startup/single-instance-lock-headless-exit.test.ts": 44, - "src/main/startup/single-instance-lock.test.ts": 59, - "src/main/startup/skill-share-deep-link-state.test.ts": 44, - "src/main/startup/startup-diagnostics.test.ts": 61, - "src/main/startup/window-all-closed-quit-policy.test.ts": 32, - "src/main/startup/windows-desktop-shell-path-startup.test.ts": 44, - "src/main/startup/windows-install-dir-acl-probe.test.ts": 147, - "src/main/startup/windows-install-dir-acl-recovery.test.ts": 887, - "src/main/startup/windows-install-dir-acl-repair.win32.test.ts": 78, - "src/main/startup/windows-install-dir-acl-startup-wiring.test.ts": 42, - "src/main/startup/windows-install-dir-package-acl-repair.test.ts": 112, - "src/main/startup/windows-shell-path-hydration.test.ts": 383, - "src/main/startup/windows-shell-path-ownership.test.ts": 62, - "src/main/startup/windows-shell-path-restoration.windows.test.ts": 51, - "src/main/startup/windows-user-data-acl.test.ts": 69, - "src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts": 34, - "src/main/stats/agent-session-transition-recorder.test.ts": 665, - "src/main/stats/collector-async-save.test.ts": 1081, - "src/main/stats/stats-title-detection-independence.test.ts": 45, - "src/main/synthetic-title-frame-routing.test.ts": 27, - "src/main/synthetic-title-spinner.test.ts": 52, - "src/main/synthetic-title-visibility.test.ts": 24, + "src/main/ssh/system-ssh-upload-plan-budget.test.ts": 344, + "src/main/ssh/system-ssh-windows-upload.test.ts": 379, + "src/main/ssh/system-ssh-windows-write-capabilities.test.ts": 87, + "src/main/ssh/vscode-ssh-authority.test.ts": 71, + "src/main/star-nag/service-direct-star.test.ts": 381, + "src/main/star-nag/service-force-show-races.test.ts": 346, + "src/main/star-nag/service-outcome-telemetry.test.ts": 353, + "src/main/star-nag/service-prompt-moments.test.ts": 340, + "src/main/star-nag/service-threshold-prompt.test.ts": 410, + "src/main/startup/bootstrap-fatal-exit-guard.test.ts": 55, + "src/main/startup/branch-rename-hook-structured-session.test.ts": 197, + "src/main/startup/browser-process-user-agent-ordering.test.ts": 5756, + "src/main/startup/cli-launch-redirect.test.ts": 114, + "src/main/startup/codex-account-restart-resume.test.ts": 292, + "src/main/startup/codex-hook-startup-wiring.test.ts": 6575, + "src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts": 187, + "src/main/startup/codex-session-resume-daemon-guard.test.ts": 143, + "src/main/startup/configure-process-dev-parent-shutdown.test.ts": 113, + "src/main/startup/configure-process-profile-state.test.ts": 143, + "src/main/startup/configure-process.test.ts": 235, + "src/main/startup/desktop-profile-instance-lock.test.ts": 219, + "src/main/startup/dev-education-suppression.test.ts": 306, + "src/main/startup/dev-instance-identity.test.ts": 57, + "src/main/startup/ensure-virtual-display.test.ts": 77, + "src/main/startup/first-window-startup-services.test.ts": 83, + "src/main/startup/gpu-fallback-marker.test.ts": 75, + "src/main/startup/gpu-fallback-switches.test.ts": 52, + "src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 546, + "src/main/startup/headless-serve-ssh-registration.test.ts": 4078, + "src/main/startup/http1-compatibility-profile-state.test.ts": 90, + "src/main/startup/hydrate-shell-path.test.ts": 451, + "src/main/startup/hydrate-shell-path.windows.test.ts": 322, + "src/main/startup/legacy-worker-renderer-recovery.test.ts": 100, + "src/main/startup/linux-dev-shm-policy.test.ts": 74, + "src/main/startup/linux-keyring-backend-selection.test.ts": 73, + "src/main/startup/linux-secret-service-probe.test.ts": 51, + "src/main/startup/login-shell-environment-cache.test.ts": 90, + "src/main/startup/login-shell-environment.test.ts": 104, + "src/main/startup/main-process-error-guards.test.ts": 108, + "src/main/startup/main-process-observers-hang-breadcrumb.test.ts": 160, + "src/main/startup/main-process-quit-pending-writer.test.ts": 118, + "src/main/startup/main-process-quit-update-veto.test.ts": 91, + "src/main/startup/main-process-ready-activation-failure.test.ts": 261, + "src/main/startup/main-process-ready-identity-write.test.ts": 1168, + "src/main/startup/main-process-ready-persistence-cleanup.test.ts": 213, + "src/main/startup/main-process-ready-phase-ordering.test.ts": 235, + "src/main/startup/main-process-runtime-launch-activation.test.ts": 5532, + "src/main/startup/main-window-actions-user-quit.test.ts": 193, + "src/main/startup/main-window-core-services.test.ts": 87, + "src/main/startup/main-window-structured-status-filter.test.ts": 195, + "src/main/startup/os-opened-document-arguments.test.ts": 167, + "src/main/startup/os-opened-document-delivery.test.ts": 168, + "src/main/startup/os-opened-document-resolution.test.ts": 254, + "src/main/startup/os-opened-documents.test.ts": 180, + "src/main/startup/profile-state-recovery-preflight.test.ts": 690, + "src/main/startup/profile-state-save-delay.test.ts": 53, + "src/main/startup/profile-state-write-failure.test.ts": 446, + "src/main/startup/renderer-heap-headroom.test.ts": 54, + "src/main/startup/run-electron-vite-dev-web.test.ts": 541, + "src/main/startup/run-electron-vite-dev.test.ts": 914, + "src/main/startup/select-linux-keyring-backend.test.ts": 50, + "src/main/startup/serve-desktop-activation.test.ts": 48, + "src/main/startup/serve-mode-argv-cli-redirect-order.test.ts": 85, + "src/main/startup/serve-mode-argv.test.ts": 88, + "src/main/startup/serve-native-quit-guard.test.ts": 55, + "src/main/startup/serve-options.test.ts": 104, + "src/main/startup/serve-signal-handlers.test.ts": 53, + "src/main/startup/serve-user-data-path-guard.test.ts": 165, + "src/main/startup/single-instance-lock-exit.electron.test.ts": 402, + "src/main/startup/single-instance-lock.test.ts": 80, + "src/main/startup/skill-share-deep-link-state.test.ts": 52, + "src/main/startup/startup-diagnostics.test.ts": 51, + "src/main/startup/window-all-closed-quit-policy.test.ts": 48, + "src/main/startup/windows-app-data-path.test.ts": 53, + "src/main/startup/windows-desktop-shell-path-startup.test.ts": 99, + "src/main/startup/windows-install-dir-acl-probe.test.ts": 144, + "src/main/startup/windows-install-dir-acl-recovery.test.ts": 578, + "src/main/startup/windows-install-dir-acl-repair.win32.test.ts": 124, + "src/main/startup/windows-install-dir-package-acl-repair.test.ts": 153, + "src/main/startup/windows-shell-path-hydration.test.ts": 388, + "src/main/startup/windows-shell-path-ownership.test.ts": 56, + "src/main/startup/windows-shell-path-restoration.windows.test.ts": 48, + "src/main/startup/windows-user-data-acl.test.ts": 73, + "src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts": 58, + "src/main/stats/agent-session-transition-recorder.test.ts": 604, + "src/main/stats/collector-async-save.test.ts": 513, + "src/main/synthetic-title-frame-routing.test.ts": 42, + "src/main/synthetic-title-spinner.test.ts": 51, + "src/main/synthetic-title-visibility.test.ts": 37, "src/main/system-fonts.test.ts": 80, - "src/main/system-power-lifecycle.test.ts": 54, - "src/main/system-resume-broadcast.test.ts": 84, - "src/main/telemetry/burst-cap.test.ts": 206, - "src/main/telemetry/classify-error.test.ts": 43, - "src/main/telemetry/client-lifecycle.test.ts": 155, - "src/main/telemetry/client.test.ts": 258, - "src/main/telemetry/cohort-classifier.test.ts": 59, - "src/main/telemetry/consent.test.ts": 31, - "src/main/telemetry/install-id.test.ts": 65, - "src/main/telemetry/onboarding-cohort-classifier.test.ts": 171, - "src/main/telemetry/onboarding-feature-setup-validator.test.ts": 172, - "src/main/telemetry/validator-warn-cache.test.ts": 168, - "src/main/telemetry/validator.test.ts": 128, - "src/main/terminal-history-async-delete.test.ts": 569, - "src/main/terminal-history-gc-fs-call-count.test.ts": 181, - "src/main/terminal-history-gc.test.ts": 951, - "src/main/terminal-history-tombstone-retry.test.ts": 982, - "src/main/terminal-history-tombstone-scan-cost.test.ts": 428, - "src/main/terminal-history.test.ts": 92, - "src/main/text-generation/agent-failure-output.test.ts": 31, - "src/main/text-generation/command-environment-process.test.ts": 562, - "src/main/text-generation/command-environment-wsl.test.ts": 56, - "src/main/text-generation/commit-message-agent-environment.test.ts": 50, - "src/main/text-generation/commit-message-command-backslash-mode.test.ts": 182, - "src/main/text-generation/commit-message-text-generation-branch-name.test.ts": 172, - "src/main/text-generation/commit-message-text-generation-cancellation.test.ts": 672, - "src/main/text-generation/commit-message-text-generation-failure-sanitization.test.ts": 127, - "src/main/text-generation/commit-message-text-generation-generated-output.test.ts": 149, - "src/main/text-generation/commit-message-text-generation-linked-issue.test.ts": 216, - "src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts": 228, - "src/main/text-generation/commit-message-text-generation-model-discovery.test.ts": 214, - "src/main/text-generation/commit-message-text-generation-regression.test.ts": 223, - "src/main/text-generation/commit-message-text-generation-remote-execution.test.ts": 215, - "src/main/text-generation/commit-message-text-generation-settings.test.ts": 299, - "src/main/text-generation/omp-model-discovery-policy.test.ts": 112, - "src/main/text-generation/pull-request-context-errors.test.ts": 61, - "src/main/text-generation/pull-request-context.test.ts": 58, - "src/main/tray/system-tray.test.ts": 134, - "src/main/tray/tray-attention-icon.test.ts": 58, - "src/main/tray/tray-dev-badge.test.ts": 60, - "src/main/update-install-exit-watchdog.test.ts": 64, - "src/main/updater-changelog.test.ts": 79, - "src/main/updater-events.test.ts": 205, - "src/main/updater-lifecycle-diagnostics.test.ts": 56, - "src/main/updater-linux-package-recovery-actions.test.ts": 2071, - "src/main/updater-nudge.test.ts": 74, - "src/main/updater-prerelease-feed-readiness.test.ts": 204, - "src/main/updater-prerelease-feed.test.ts": 210, - "src/main/updater-release-api-token.test.ts": 62, - "src/main/updater-release-build-cache.test.ts": 38, - "src/main/updater-release-builds.test.ts": 164, - "src/main/updater-test-harness.leaked-timers.test.ts": 2646, - "src/main/updater-test-module-loader.test.ts": 540, - "src/main/updater.build-channel-selection.test.ts": 2969, - "src/main/updater.check-failure.test.ts": 2612, - "src/main/updater.check-preflight.test.ts": 2566, - "src/main/updater.check-settlement.test.ts": 2937, - "src/main/updater.fallback.test.ts": 51, - "src/main/updater.feed-attempt-lifetime.test.ts": 1577, - "src/main/updater.headless-serve-install.test.ts": 315, - "src/main/updater.install-failure-cause.test.ts": 3143, - "src/main/updater.linux-externally-managed.test.ts": 2828, - "src/main/updater.linux-root-package-install.test.ts": 2144, - "src/main/updater.mac-install.test.ts": 54, - "src/main/updater.nudge-campaign.test.ts": 1952, - "src/main/updater.prerelease-fallback.test.ts": 4132, - "src/main/updater.publishing-window-feed.test.ts": 2272, - "src/main/updater.quit-and-install.test.ts": 2406, - "src/main/updater.startup-scheduling.test.ts": 2192, - "src/main/usage-worktree-canonicalizer.test.ts": 38, - "src/main/usage-worktree-metadata.test.ts": 57, + "src/main/system-power-lifecycle.test.ts": 43, + "src/main/system-resume-broadcast.test.ts": 68, + "src/main/telemetry/burst-cap.test.ts": 343, + "src/main/telemetry/classify-error.test.ts": 48, + "src/main/telemetry/client-lifecycle.test.ts": 359, + "src/main/telemetry/client.test.ts": 344, + "src/main/telemetry/cohort-classifier.test.ts": 42, + "src/main/telemetry/consent.test.ts": 71, + "src/main/telemetry/install-id.test.ts": 79, + "src/main/telemetry/onboarding-cohort-classifier.test.ts": 70, + "src/main/telemetry/onboarding-feature-setup-validator.test.ts": 277, + "src/main/telemetry/validator-warn-cache.test.ts": 290, + "src/main/telemetry/validator.test.ts": 364, + "src/main/terminal-history-async-delete.test.ts": 402, + "src/main/terminal-history-gc-fs-call-count.test.ts": 176, + "src/main/terminal-history-gc.test.ts": 821, + "src/main/terminal-history-tombstone-retry.test.ts": 764, + "src/main/terminal-history-tombstone-scan-cost.test.ts": 319, + "src/main/terminal-history.test.ts": 153, + "src/main/text-generation/agent-failure-output.test.ts": 57, + "src/main/text-generation/command-environment-process.test.ts": 915, + "src/main/text-generation/command-environment-wsl.test.ts": 83, + "src/main/text-generation/commit-message-agent-environment.test.ts": 73, + "src/main/text-generation/commit-message-command-backslash-mode.test.ts": 499, + "src/main/text-generation/commit-message-text-generation-branch-name.test.ts": 498, + "src/main/text-generation/commit-message-text-generation-cancellation.test.ts": 1070, + "src/main/text-generation/commit-message-text-generation-failure-sanitization.test.ts": 406, + "src/main/text-generation/commit-message-text-generation-generated-output.test.ts": 426, + "src/main/text-generation/commit-message-text-generation-linked-issue.test.ts": 401, + "src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts": 463, + "src/main/text-generation/commit-message-text-generation-model-discovery.test.ts": 504, + "src/main/text-generation/commit-message-text-generation-regression.test.ts": 463, + "src/main/text-generation/commit-message-text-generation-remote-execution.test.ts": 418, + "src/main/text-generation/commit-message-text-generation-settings.test.ts": 451, + "src/main/text-generation/conversation-name-generation-request.test.ts": 355, + "src/main/text-generation/omp-model-discovery-policy.test.ts": 251, + "src/main/text-generation/opencode-generation-events.test.ts": 351, + "src/main/text-generation/pull-request-context-errors.test.ts": 71, + "src/main/text-generation/pull-request-context.test.ts": 89, + "src/main/text-generation/source-control-agent-launch.test.ts": 1915, + "src/main/text-generation/source-control-local-process.test.ts": 1522, + "src/main/tray/system-tray.test.ts": 178, + "src/main/tray/tray-attention-icon.test.ts": 63, + "src/main/tray/tray-dev-badge.test.ts": 56, + "src/main/update-install-exit-watchdog.test.ts": 77, + "src/main/updater-agent-state-rules-release.test.ts": 103, + "src/main/updater-changelog.test.ts": 63, + "src/main/updater-events.test.ts": 227, + "src/main/updater-lifecycle-diagnostics.test.ts": 57, + "src/main/updater-linux-package-recovery-actions.test.ts": 4060, + "src/main/updater-mac-quit-guard.test.ts": 40, + "src/main/updater-nudge.test.ts": 247, + "src/main/updater-prerelease-feed-readiness.test.ts": 156, + "src/main/updater-prerelease-feed.test.ts": 237, + "src/main/updater-release-api-token.test.ts": 63, + "src/main/updater-release-build-cache.test.ts": 58, + "src/main/updater-release-builds.test.ts": 95, + "src/main/updater-sqlite-profile-downgrade.test.ts": 1672, + "src/main/updater.build-channel-selection.test.ts": 3459, + "src/main/updater.check-failure.test.ts": 3131, + "src/main/updater.check-preflight.test.ts": 4281, + "src/main/updater.check-settlement.test.ts": 3437, + "src/main/updater.fallback.test.ts": 54, + "src/main/updater.feed-attempt-lifetime.test.ts": 2886, + "src/main/updater.headless-serve-install.test.ts": 431, + "src/main/updater.install-failure-cause.test.ts": 3737, + "src/main/updater.linux-externally-managed.test.ts": 2482, + "src/main/updater.linux-root-package-install.test.ts": 2779, + "src/main/updater.mac-install.test.ts": 55, + "src/main/updater.nudge-campaign.test.ts": 2950, + "src/main/updater.prerelease-fallback.test.ts": 4430, + "src/main/updater.publishing-window-feed.test.ts": 4333, + "src/main/updater.quit-and-install.test.ts": 4367, + "src/main/updater.startup-scheduling.test.ts": 3238, + "src/main/updater/rollout-flags.test.ts": 338, + "src/main/usage-worktree-canonicalizer.test.ts": 47, + "src/main/usage-worktree-metadata.test.ts": 60, + "src/main/usage/agent-token-usage-reporter.test.ts": 802, + "src/main/usage/agent-token-usage.test.ts": 46, + "src/main/usage/analytics-session-id-store.test.ts": 247, "src/main/usage/highest-usage-key.test.ts": 68, - "src/main/usage/jsonl-line-offsets.test.ts": 64, - "src/main/usage/usage-breakdown-scaling.test.ts": 109, - "src/main/usage/usage-calendar-range.test.ts": 62, - "src/main/usage/usage-provider-store-lifecycle.test.ts": 245, - "src/main/usage/usage-scan-worker-client.test.ts": 70, - "src/main/usage/usage-scan-worker-event-loop.test.ts": 3290, - "src/main/usage/usage-worktree-refs.test.ts": 45, - "src/main/usage/usage-worktree-resolver.test.ts": 114, - "src/main/warp-themes/discovery.test.ts": 98, - "src/main/warp-themes/index.test.ts": 679, - "src/main/warp-themes/manual-warp-theme-files.test.ts": 106, - "src/main/warp-themes/parser-runner.test.ts": 65, - "src/main/warp-themes/parser.test.ts": 55, - "src/main/warp-themes/theme-file-scanner.test.ts": 66, - "src/main/win32-utils-async-acl.test.ts": 49, - "src/main/win32-utils.test.ts": 73, - "src/main/window/attach-main-window-services.test.ts": 13208, - "src/main/window/clipboard-dashboard-popout-access.test.ts": 330, - "src/main/window/clipboard-file-copy.test.ts": 38, - "src/main/window/clipboard-image-temp-file.test.ts": 46, - "src/main/window/clipboard-image-thumbnail.test.ts": 35, - "src/main/window/clipboard-ipc-handlers.test.ts": 188, - "src/main/window/clipboard-remote-file-cleanup.bench.test.ts": 30, - "src/main/window/clipboard-remote-file-copy.test.ts": 70, - "src/main/window/clipboard-remote-file-staging-fs.test.ts": 56, - "src/main/window/clipboard-remote-file-staging.test.ts": 141, - "src/main/window/clipboard-runtime-owned-ssh-paste.test.ts": 99, - "src/main/window/clipboard-text-write-verify.test.ts": 37, - "src/main/window/clipboard-windows-image-file.test.ts": 57, - "src/main/window/createMainWindow-close-confirmation.test.ts": 1201, - "src/main/window/createMainWindow-markdown-editor-focus.test.ts": 1169, - "src/main/window/createMainWindow-recovery-reload-watchdog.test.ts": 1850, - "src/main/window/createMainWindow-renderer-crash-recovery.test.ts": 1634, - "src/main/window/createMainWindow-startup-reveal.test.ts": 1360, - "src/main/window/createMainWindow-system-resume-relay.test.ts": 1385, - "src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts": 1341, - "src/main/window/createMainWindow-tray-minimize-close.test.ts": 876, - "src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts": 1287, - "src/main/window/createMainWindow.test.ts": 2476, - "src/main/window/dashboard-popout-window.test.ts": 162, - "src/main/window/editable-context-menu.test.ts": 89, - "src/main/window/focus-existing-window.test.ts": 75, - "src/main/window/foreground-activation-policy.test.ts": 36, - "src/main/window/history-gc-profile-worktree-ids.test.ts": 246, - "src/main/window/history-gc-worktree-ids.test.ts": 319, - "src/main/window/macos-app-activation.test.ts": 40, - "src/main/window/macos-tahoe-release.test.ts": 39, - "src/main/window/main-window-mouse-shortcuts.test.ts": 1533, - "src/main/window/main-window-visibility.test.ts": 38, - "src/main/window/main-window-webview-security.test.ts": 165, - "src/main/window/mobile-markdown-request-relay.test.ts": 57, - "src/main/window/privileged-window-navigation.test.ts": 178, - "src/main/window/renderer-document-navigation.test.ts": 40, - "src/main/window/renderer-publication-throttle.test.ts": 40, - "src/main/window/renderer-recovery-prompt.test.ts": 100, - "src/main/window/renderer-recovery-reload-watchdog.test.ts": 86, - "src/main/window/runtime-renderer-notification-sender.test.ts": 44, - "src/main/window/session-tab-close-request-relay.test.ts": 82, - "src/main/window/terminal-tab-close-request-relay.test.ts": 55, - "src/main/window/updater-package-recovery-ipc.test.ts": 4430, - "src/main/window/window-close-decision.test.ts": 41, - "src/main/windows-live-tree-kill.win32.test.ts": 46, - "src/main/windows-process-tree-kill.test.ts": 61, - "src/main/windows-pty-root-identity.test.ts": 94, - "src/main/windows-registry-addon.test.ts": 33, - "src/main/windows/windows-command-line-recovery-health.test.ts": 49, - "src/main/windows/windows-host-job.win32.test.ts": 31, - "src/main/windows/windows-msys-job.win32.test.ts": 65, - "src/main/windows/windows-process-creation-time.test.ts": 51, - "src/main/windows/windows-process-table-cim-scan.test.ts": 51, - "src/main/windows/windows-process-table-native-addon.win32.test.ts": 37, - "src/main/windows/windows-process-table.test.ts": 261, - "src/main/windows/windows-process-tree-command-line-patch.test.ts": 37, - "src/main/windows/windows-pty-job.test.ts": 46, - "src/main/windows/windows-pty-job.win32.test.ts": 57, - "src/main/worker-thread-request-queue.test.ts": 75, - "src/main/workspace-cleanup-removal-snapshot-prune.test.ts": 70, - "src/main/workspace-cleanup-scan-snapshot.test.ts": 174, - "src/main/workspace-snapshot-pruning.bench.test.ts": 38, - "src/main/workspace-space-analysis-capacity.test.ts": 176, - "src/main/workspace-space-analysis-du-timeout.test.ts": 358, - "src/main/workspace-space-analysis-snapshot.test.ts": 134, - "src/main/workspace-space-analysis.test.ts": 359, - "src/main/workspace-space-repo-scan.test.ts": 330, - "src/main/workspace-space-scan-control.test.ts": 52, - "src/main/worktree-archive-hook-cannot-run.test.ts": 55, - "src/main/worktree-create-base-prefetch.test.ts": 287, - "src/main/worktree-create-base.test.ts": 37, - "src/main/worktree-create-candidates.test.ts": 38, - "src/main/worktree-create-execution-host-route.test.ts": 35, - "src/main/worktree-create-preparation-cancellation.test.ts": 148, - "src/main/worktree-create-preparation-claim.test.ts": 58, - "src/main/worktree-create-preparation-contention.test.ts": 95, - "src/main/worktree-create-preparation-timing.test.ts": 72, - "src/main/worktree-create-preparation-wsl-root.test.ts": 149, - "src/main/worktree-create-preparation.test.ts": 545, - "src/main/worktree-create-timing.test.ts": 39, - "src/main/worktree-identity-persistence.test.ts": 1325, - "src/main/worktree-lineage-pruning.test.ts": 81, - "src/main/worktree-metadata-ownership.test.ts": 39, - "src/main/worktree-name-retirement.test.ts": 115, - "src/main/worktree-prunable-git-file.test.ts": 58, - "src/main/worktree-removal-authority.test.ts": 64, - "src/main/worktree-removal-execution-host-route.test.ts": 52, - "src/main/worktree-removal-home-guard.test.ts": 88, - "src/main/worktree-removal-repo-owner.test.ts": 64, - "src/main/worktree-removal-safety.test.ts": 115, - "src/main/worktree-removal-session-partition-fencing.test.ts": 1523, - "src/main/worktree-retirement-backfill-scan.test.ts": 44, + "src/main/usage/jsonl-file-reader.test.ts": 87, + "src/main/usage/jsonl-file-snapshot.test.ts": 73, + "src/main/usage/jsonl-line-offsets.test.ts": 67, + "src/main/usage/usage-breakdown-scaling.test.ts": 93, + "src/main/usage/usage-calendar-range.test.ts": 59, + "src/main/usage/usage-provider-store-initialization.test.ts": 734, + "src/main/usage/usage-provider-store-lifecycle.test.ts": 751, + "src/main/usage/usage-scan-worker-cache-cleanup.test.ts": 130, + "src/main/usage/usage-scan-worker-client.test.ts": 98, + "src/main/usage/usage-scan-worker-event-loop.test.ts": 2406, + "src/main/usage/usage-source-cache-file.test.ts": 77, + "src/main/usage/usage-worktree-refs.test.ts": 43, + "src/main/usage/usage-worktree-resolver.test.ts": 125, + "src/main/warp-themes/discovery.test.ts": 76, + "src/main/warp-themes/index.test.ts": 685, + "src/main/warp-themes/manual-warp-theme-files.test.ts": 61, + "src/main/warp-themes/parser-runner.test.ts": 55, + "src/main/warp-themes/parser.test.ts": 108, + "src/main/warp-themes/theme-file-scanner.test.ts": 70, + "src/main/win32-utils-async-acl.test.ts": 56, + "src/main/win32-utils.test.ts": 70, + "src/main/window/attach-main-window-services.test.ts": 1420, + "src/main/window/clipboard-agent-session-attachment-paste.test.ts": 438, + "src/main/window/clipboard-copied-file-paths.test.ts": 75, + "src/main/window/clipboard-dashboard-popout-access.test.ts": 1333, + "src/main/window/clipboard-file-copy.test.ts": 68, + "src/main/window/clipboard-image-source.test.ts": 60, + "src/main/window/clipboard-image-temp-file.test.ts": 61, + "src/main/window/clipboard-image-thumbnail.test.ts": 44, + "src/main/window/clipboard-ipc-handlers.test.ts": 477, + "src/main/window/clipboard-remote-file-cleanup.bench.test.ts": 46, + "src/main/window/clipboard-remote-file-copy.test.ts": 82, + "src/main/window/clipboard-remote-file-staging-fs.test.ts": 59, + "src/main/window/clipboard-remote-file-staging.test.ts": 107, + "src/main/window/clipboard-runtime-owned-ssh-paste.test.ts": 341, + "src/main/window/clipboard-text-write-verify.test.ts": 62, + "src/main/window/clipboard-windows-image-file.test.ts": 91, + "src/main/window/createMainWindow-close-confirmation.test.ts": 2132, + "src/main/window/createMainWindow-low-commit-oom-recovery.test.ts": 1748, + "src/main/window/createMainWindow-markdown-editor-focus.test.ts": 1897, + "src/main/window/createMainWindow-recovery-reload-watchdog.test.ts": 2346, + "src/main/window/createMainWindow-renderer-crash-recovery.test.ts": 2303, + "src/main/window/createMainWindow-renderer-launch-failure.test.ts": 1598, + "src/main/window/createMainWindow-startup-reveal.test.ts": 2157, + "src/main/window/createMainWindow-system-resume-relay.test.ts": 2342, + "src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts": 1846, + "src/main/window/createMainWindow-tray-minimize-close.test.ts": 1969, + "src/main/window/createMainWindow-user-quit-close.test.ts": 2017, + "src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts": 1751, + "src/main/window/createMainWindow.test.ts": 2184, + "src/main/window/darwin-user-temp-dir.test.ts": 56, + "src/main/window/dashboard-popout-window.test.ts": 122, + "src/main/window/dragged-temp-file-copy.test.ts": 189, + "src/main/window/dropped-path-preparation-ipc.test.ts": 103, + "src/main/window/dropped-path-preparation.test.ts": 89, + "src/main/window/editable-context-menu.test.ts": 80, + "src/main/window/focus-existing-window.test.ts": 70, + "src/main/window/foreground-activation-policy.test.ts": 59, + "src/main/window/history-gc-worktree-ids.test.ts": 311, + "src/main/window/host-frame-navigation-guard.test.ts": 237, + "src/main/window/macos-app-activation.test.ts": 67, + "src/main/window/macos-tahoe-release.test.ts": 55, + "src/main/window/main-window-state-lifecycle.test.ts": 54, + "src/main/window/main-window-updater-deferred-setup.test.ts": 146, + "src/main/window/main-window-visibility.test.ts": 56, + "src/main/window/main-window-webview-security.test.ts": 344, + "src/main/window/mobile-markdown-request-relay.test.ts": 61, + "src/main/window/native-chat-paste-files.test.ts": 497, + "src/main/window/privileged-window-navigation.test.ts": 266, + "src/main/window/renderer-document-navigation.test.ts": 60, + "src/main/window/renderer-launch-failure-backoff.test.ts": 37, + "src/main/window/renderer-launch-failure-probe.spawn.test.ts": 71, + "src/main/window/renderer-launch-failure-probe.test.ts": 69, + "src/main/window/renderer-publication-throttle.test.ts": 55, + "src/main/window/renderer-recovery-prompt.test.ts": 109, + "src/main/window/renderer-recovery-reload-watchdog.test.ts": 76, + "src/main/window/runtime-renderer-notification-sender.test.ts": 47, + "src/main/window/runtime-window-lifecycle-file-open.test.ts": 57, + "src/main/window/session-tab-close-request-relay.test.ts": 74, + "src/main/window/terminal-tab-close-request-relay.test.ts": 63, + "src/main/window/updater-package-recovery-ipc.test.ts": 7052, + "src/main/window/window-close-decision.test.ts": 52, + "src/main/windows-live-tree-kill.win32.test.ts": 60, + "src/main/windows-process-tree-kill.test.ts": 81, + "src/main/windows-pty-root-identity.test.ts": 129, + "src/main/windows-registry-addon.test.ts": 41, + "src/main/windows/windows-command-line-recovery-health.test.ts": 50, + "src/main/windows/windows-host-job.win32.test.ts": 44, + "src/main/windows/windows-msys-job.win32.test.ts": 60, + "src/main/windows/windows-process-creation-time.test.ts": 80, + "src/main/windows/windows-process-table-cim-scan.test.ts": 63, + "src/main/windows/windows-process-table-native-addon.win32.test.ts": 60, + "src/main/windows/windows-process-table.test.ts": 177, + "src/main/windows/windows-process-tree-command-line-patch.test.ts": 61, + "src/main/windows/windows-pty-job.test.ts": 74, + "src/main/windows/windows-pty-job.win32.test.ts": 71, + "src/main/worker-thread-request-queue.test.ts": 94, + "src/main/workspace-cleanup-removal-snapshot-prune.test.ts": 78, + "src/main/workspace-cleanup-scan-snapshot.test.ts": 166, + "src/main/workspace-create-event-fields.test.ts": 300, + "src/main/workspace-snapshot-pruning.bench.test.ts": 53, + "src/main/workspace-space-analysis-capacity.test.ts": 475, + "src/main/workspace-space-analysis-du-timeout.test.ts": 587, + "src/main/workspace-space-analysis-snapshot.test.ts": 130, + "src/main/workspace-space-analysis.test.ts": 652, + "src/main/workspace-space-repo-scan.test.ts": 388, + "src/main/workspace-space-scan-control.test.ts": 55, + "src/main/worktree-archive-hook-cannot-run.test.ts": 53, + "src/main/worktree-background-removal-records.test.ts": 417, + "src/main/worktree-background-removal.test.ts": 346, + "src/main/worktree-create-base-prefetch.test.ts": 308, + "src/main/worktree-create-base.test.ts": 50, + "src/main/worktree-create-candidates.test.ts": 49, + "src/main/worktree-create-concurrency.test.ts": 67, + "src/main/worktree-create-execution-host-route.test.ts": 385, + "src/main/worktree-create-preparation-activity.test.ts": 43, + "src/main/worktree-create-preparation-cancellation.test.ts": 353, + "src/main/worktree-create-preparation-claim.test.ts": 55, + "src/main/worktree-create-preparation-contention.test.ts": 363, + "src/main/worktree-create-preparation-timing.test.ts": 316, + "src/main/worktree-create-preparation-wsl-root.test.ts": 348, + "src/main/worktree-create-preparation.test.ts": 299, + "src/main/worktree-create-timing.test.ts": 119, + "src/main/worktree-failed-removal.test.ts": 400, + "src/main/worktree-identity-persistence.test.ts": 1751, + "src/main/worktree-lineage-pruning.test.ts": 76, + "src/main/worktree-metadata-ownership.test.ts": 46, + "src/main/worktree-name-retirement.test.ts": 175, + "src/main/worktree-preparation-discard-retry.test.ts": 245, + "src/main/worktree-preparation-fetch-barrier.test.ts": 343, + "src/main/worktree-preparation-tip-refresh.test.ts": 500, + "src/main/worktree-prunable-git-file.test.ts": 88, + "src/main/worktree-removal-authority.test.ts": 104, + "src/main/worktree-removal-close-records.test.ts": 1393, + "src/main/worktree-removal-execution-host-route.test.ts": 397, + "src/main/worktree-removal-home-guard.test.ts": 94, + "src/main/worktree-removal-repo-owner.test.ts": 49, + "src/main/worktree-removal-safety.test.ts": 177, + "src/main/worktree-removal-session-partition-fencing.test.ts": 1465, + "src/main/worktree-retirement-backfill-scan.test.ts": 76, "src/main/worktree-retirement-backfill-stall.test.ts": 141, - "src/main/worktree-retirement-discovery-wsl.test.ts": 148, - "src/main/worktree-retirement-discovery.test.ts": 121, - "src/main/worktree-retirement-namespace.test.ts": 80, - "src/main/worktree-root-preparation.test.ts": 106, - "src/main/worktree-trash.test.ts": 203, - "src/main/wsl-approved-root-race.wsl.test.ts": 68, - "src/main/wsl-availability-missing-kernel.test.ts": 74, - "src/main/wsl-bash-command.test.ts": 25, - "src/main/wsl-distro-list-output.test.ts": 35, - "src/main/wsl-distro-list-single-flight.test.ts": 81, - "src/main/wsl-fish-history-cleanup.test.ts": 55, - "src/main/wsl-interop-spawn-directory.test.ts": 56, - "src/main/wsl-managed-cli-path-restore.test.ts": 57, - "src/main/wsl-running-distros.test.ts": 242, - "src/main/wsl-running-path-filter.test.ts": 47, - "src/main/wsl-unc-delete-symlink-repro.test.ts": 61, - "src/main/wsl-unc-delete.test.ts": 111, - "src/main/wsl-unc-delete.wsl.test.ts": 88, - "src/main/wsl.test.ts": 128, - "src/main/wsl/wsl-executable-path.win32.test.ts": 34, - "src/main/wsl/wsl-guest-environment.test.ts": 110, - "src/main/wsl/wsl-invocation-boundary.test.ts": 10984, - "src/main/wsl/wsl-probe-failure-semantics.test.ts": 357, - "src/main/wsl/wsl-runner.test.ts": 58, - "src/main/wsl/wsl-runner.wsl.test.ts": 64, - "src/main/wsl/wsl-w1-w3-contract.test.ts": 42, - "src/main/zcode/hook-service.test.ts": 89, - "src/main/zcode/interactive-capability.live.test.ts": 45, - "src/preload/api/platform-bridge.test.ts": 46, - "src/preload/app-restart-checkpoint-routing.test.ts": 934, - "src/preload/browser-client-page-renderer-requests.test.ts": 158, - "src/preload/browser-find-subscriptions.test.ts": 45, - "src/preload/browser-window-close.test.ts": 39, - "src/preload/close-active-tab-payload-admission.test.ts": 51, - "src/preload/doc-preview-link-interception.test.ts": 446, - "src/preload/pty-snapshot-capability-ipc.test.ts": 678, - "src/preload/renderer-heap-statistics-reader.test.ts": 30, - "src/preload/renderer-process-memory-reader.test.ts": 53, - "src/preload/renderer-restart-wiring.test.ts": 44, - "src/preload/runtime-environment-subscriptions.test.ts": 66, - "src/preload/ssh-authority-forwarding.test.ts": 604, - "src/preload/updater-package-recovery.test.ts": 534, - "src/preload/usage-provider-api.test.ts": 47, - "src/relay/agent-exec-handler-windows.test.ts": 71, - "src/relay/agent-exec-handler.test.ts": 157, - "src/wsl-guest/agent-hook-envelope-publication.test.ts": 351, - "src/wsl-guest/agent-hook-integration.test.ts": 848, - "src/relay/agent-hook-retired-pane-suppression.test.ts": 451, - "src/wsl-guest/agent-hook-server-codex-subagent-transcript.test.ts": 1327, - "src/wsl-guest/agent-hook-server.test.ts": 1396, - "src/relay/agent-status-store-relay-context.test.ts": 144, - "src/relay/ai-vault-handler.test.ts": 689, - "src/relay/ai-vault-opencode-reader.test.ts": 405, - "src/relay/ai-vault-service-client.test.ts": 89, - "src/relay/ai-vault-service-ready-retention.test.ts": 195, - "src/relay/ai-vault-service-restart-policy.test.ts": 69, - "src/relay/ai-vault-service-spawn.test.ts": 54, - "src/relay/context.test.ts": 39, - "src/wsl-guest/dispatcher-capacity-degradation.test.ts": 194, - "src/wsl-guest/dispatcher-client-close-cause.test.ts": 194, - "src/wsl-guest/dispatcher-client-writer.test.ts": 92, - "src/wsl-guest/dispatcher-frame-guard-regressions.test.ts": 169, - "src/wsl-guest/dispatcher-json-payload.test.ts": 122, - "src/wsl-guest/dispatcher-notification-ownership.test.ts": 140, - "src/wsl-guest/dispatcher-per-connection-state-baseline.test.ts": 146, - "src/wsl-guest/dispatcher-rpc-cancel-id-coercion.test.ts": 134, - "src/wsl-guest/dispatcher-silent-client-reaper.test.ts": 213, - "src/wsl-guest/dispatcher-structured-error.test.ts": 136, - "src/wsl-guest/dispatcher-timeout.test.ts": 210, - "src/wsl-guest/dispatcher-writer-admission.test.ts": 161, - "src/wsl-guest/dispatcher.test.ts": 306, - "src/relay/external-automation-provider-catalog.test.ts": 59, - "src/relay/external-automations-handler-log-path.test.ts": 46, - "src/relay/external-automations-handler.test.ts": 77, - "src/relay/fs-handler-doc-preview.test.ts": 302, - "src/relay/fs-handler-file-range-dispatch.test.ts": 303, - "src/relay/fs-handler-file-range.test.ts": 201, - "src/relay/fs-handler-git-search.test.ts": 102, - "src/relay/fs-handler-list-files-cancel.test.ts": 83, - "src/relay/fs-handler-list-files-ignored.test.ts": 816, - "src/relay/fs-handler-list-files-result-limit.test.ts": 334, - "src/relay/fs-handler-readdir-fallback.test.ts": 88, - "src/relay/fs-handler-ripgrep-fallback.test.ts": 260, - "src/relay/fs-handler-stream.test.ts": 660, - "src/relay/fs-handler.test.ts": 805, - "src/relay/fs-list-files-cancel.integration.test.ts": 320, - "src/relay/fs-list-files-large-response.integration.test.ts": 596, - "src/relay/fs-list-files-scan-coordinator.test.ts": 42, - "src/relay/fs-path-metadata-requests.test.ts": 93, - "src/relay/fs-path-metadata-symlink-concurrency.test.ts": 79, - "src/relay/fs-search-cancellation.test.ts": 399, - "src/relay/fs-search-diagnosis-cancellation.test.ts": 165, - "src/relay/fs-search-line-fragments.test.ts": 204, - "src/relay/fs-stream-pty-echo-backpressure.integration.test.ts": 547, - "src/relay/git-branch-delete-refusal-parity.test.ts": 177, - "src/relay/git-exec-validator.test.ts": 41, - "src/relay/git-handler-blob-readers.test.ts": 45, - "src/relay/git-handler-branch-cleanup.test.ts": 95, - "src/relay/git-handler-branch-compare.test.ts": 98, - "src/relay/git-handler-branch-diff-equivalence.test.ts": 1189, - "src/relay/git-handler-branch-diff.test.ts": 348, - "src/relay/git-handler-check-ignore.test.ts": 39, - "src/relay/git-handler-diff-read-coalescing.test.ts": 448, - "src/relay/git-handler-file-diff.test.ts": 1263, - "src/relay/git-handler-fork-remote-exec.test.ts": 222, - "src/relay/git-handler-pull-reconciliation.test.ts": 1652, - "src/relay/git-handler-push-target.test.ts": 70, - "src/relay/git-handler-remote-sync.test.ts": 1894, - "src/relay/git-handler-staging.test.ts": 381, - "src/relay/git-handler-status-ops.test.ts": 101, - "src/relay/git-handler-submodule-cache-invalidation.test.ts": 181, - "src/relay/git-handler-submodule-ops.test.ts": 126, - "src/relay/git-handler-submodule-status-cancellation.test.ts": 141, - "src/relay/git-handler-termination.test.ts": 144, - "src/relay/git-handler-utils.test.ts": 25, - "src/relay/git-handler-working-tree-changes.test.ts": 1110, - "src/relay/git-handler-worktree-clean.test.ts": 41, - "src/relay/git-handler-worktree-git-capabilities.test.ts": 118, - "src/relay/git-handler-worktree-inspection.test.ts": 429, - "src/relay/git-handler-worktree-list-authority.test.ts": 110, - "src/relay/git-handler-worktree-list.test.ts": 41, - "src/relay/git-handler-worktree-ops.test.ts": 145, - "src/relay/git-handler-worktree-paths.test.ts": 63, - "src/relay/git-handler-worktree-provisioning.test.ts": 886, - "src/relay/git-handler.test.ts": 797, - "src/relay/git-porcelain-local-parity.test.ts": 314, - "src/relay/git-push-target-local-parity.test.ts": 192, - "src/relay/git-response-pty-echo-backpressure.integration.test.ts": 1021, - "src/relay/git-response-stream-ownership.test.ts": 61, - "src/relay/git-status-branch-line-total.test.ts": 362, - "src/relay/git-status-upstream-negative-cache.test.ts": 344, - "src/relay/git-stdout-stream.test.ts": 46, - "src/relay/git-working-file-read.test.ts": 58, - "src/relay/hermes-run-correlation.test.ts": 44, - "src/relay/hermes-run-history.test.ts": 85, - "src/relay/integration.test.ts": 1877, - "src/wsl-guest/legacy-relay-publication-ledger.test.ts": 36, - "src/relay/managed-hook-installer.test.ts": 117, - "src/relay/node-pty-binding-survey.test.ts": 246, - "src/relay/node-pty-unavailable-diagnosis.test.ts": 185, - "src/wsl-guest/omp-config-root.test.ts": 152, - "src/relay/omp-fresh-launch-environment.test.ts": 600, - "src/wsl-guest/plugin-overlay-env.test.ts": 60, - "src/wsl-guest/plugin-overlay.test.ts": 164, - "src/wsl-guest/plugin-source-limit.test.ts": 41, - "src/relay/port-scan-handler.test.ts": 190, - "src/wsl-guest/preflight-handler.test.ts": 97, - "src/wsl-guest/protocol-backpressure.test.ts": 87, - "src/wsl-guest/protocol-handshake.test.ts": 63, - "src/wsl-guest/protocol-json-payload.test.ts": 188, - "src/relay/pty-handler-attach-replay.test.ts": 414, - "src/relay/pty-handler-dispose-lifecycle.test.ts": 481, - "src/relay/pty-handler-grace-timer.test.ts": 346, - "src/relay/pty-handler-immediate-descendants.test.ts": 353, - "src/relay/pty-handler-inventory-process-evidence.test.ts": 324, - "src/relay/pty-handler-output-drain-differential.test.ts": 359, - "src/relay/pty-handler-output-streaming.test.ts": 1038, - "src/relay/pty-handler-ownership-attestation.test.ts": 450, - "src/relay/pty-handler-pi-owner-env.test.ts": 243, - "src/relay/pty-handler-resize-stale-pty.test.ts": 213, - "src/relay/pty-handler-retired-pane-surface.test.ts": 599, - "src/relay/pty-handler-revive.test.ts": 526, - "src/relay/pty-handler-shell-resolution.test.ts": 378, - "src/relay/pty-handler-shutdown-signals.test.ts": 373, - "src/relay/pty-handler-source-publication.test.ts": 1048, - "src/relay/pty-handler-spawn-admission.test.ts": 560, - "src/relay/pty-handler-spawn-cwd.test.ts": 328, - "src/relay/pty-handler-spawn-environment.test.ts": 555, - "src/relay/pty-handler-startup-command-delivery.test.ts": 381, - "src/relay/pty-handler-windows-child-process-evidence.test.ts": 208, - "src/relay/pty-replay-buffer-equivalence.test.ts": 356, - "src/relay/pty-shell-launch.test.ts": 229, - "src/relay/pty-shell-utils-process-cycles.test.ts": 122, - "src/relay/pty-shell-utils.test.ts": 195, - "src/relay/pty-source-credit-ledger.test.ts": 265, - "src/relay/pty-source-credit-scheduler.test.ts": 106, - "src/relay/pty-source-sent-boundaries.test.ts": 181, - "src/relay/relay-bundled-ripgrep.test.ts": 151, - "src/wsl-guest/relay-command-env.test.ts": 96, - "src/relay/relay-daemon-fatal-reap.test.ts": 1107, - "src/relay/relay-diagnostic-log.test.ts": 62, - "src/relay/relay-endpoint-credential-publication.test.ts": 2995, - "src/relay/relay-filesystem-watch-registry.test.ts": 344, - "src/relay/relay-grace-branch.test.ts": 52, - "src/relay/relay-handshake-roundtrip.test.ts": 495, - "src/wsl-guest/relay-hot-path-operation-counts.test.ts": 211, - "src/relay/relay-launch-options.test.ts": 48, - "src/relay/relay-oversized-notification-survival.test.ts": 335, - "src/relay/relay-primary-channel.test.ts": 104, - "src/relay/relay-pty-consumer-owner-displacement.test.ts": 199, - "src/relay/relay-pty-publication-admission.test.ts": 373, - "src/relay/relay-pty-source-cancellation-exit.test.ts": 238, - "src/relay/relay-pty-source-exit-publication.test.ts": 70, - "src/relay/relay-pty-source-publication.test.ts": 395, - "src/relay/relay-pty-source-recovery-completion.test.ts": 30, - "src/relay/relay-pty-source-recovery-interleavings.test.ts": 192, - "src/relay/relay-pty-source-recovery-window.test.ts": 220, - "src/relay/relay-pty-source-restore-retry.test.ts": 226, - "src/relay/relay-pty-source-send-scheduler.test.ts": 44, - "src/relay/relay-pty-source-superseded-activation.test.ts": 193, - "src/relay/relay-reconnect-listener-credential-gate.test.ts": 95, - "src/relay/relay-ripgrep-cwd-env.test.ts": 99, - "src/relay/relay-watch-root-capacity.test.ts": 208, - "src/relay/relay-watcher-event-emitter.test.ts": 673, - "src/relay/relay-watcher-frame-chunking.test.ts": 708, - "src/relay/relay-watcher-parent-removal.test.ts": 110, - "src/relay/relay-watcher-pending-setup-waiters.test.ts": 467, - "src/relay/relay-watcher-setup-wait.test.ts": 394, + "src/main/worktree-retirement-discovery-wsl.test.ts": 184, + "src/main/worktree-retirement-discovery.test.ts": 174, + "src/main/worktree-retirement-namespace.test.ts": 84, + "src/main/worktree-root-preparation.test.ts": 116, + "src/main/worktree-trash.test.ts": 138, + "src/main/wsl-approved-root-race.wsl.test.ts": 65, + "src/main/wsl-availability-missing-kernel.test.ts": 68, + "src/main/wsl-bash-command.test.ts": 47, + "src/main/wsl-distro-list-output.test.ts": 40, + "src/main/wsl-distro-list-single-flight.test.ts": 103, + "src/main/wsl-fish-history-cleanup.test.ts": 94, + "src/main/wsl-interop-spawn-directory.test.ts": 54, + "src/main/wsl-managed-cli-path-restore.test.ts": 71, + "src/main/wsl-running-distros.test.ts": 217, + "src/main/wsl-running-path-filter.test.ts": 52, + "src/main/wsl-unc-delete-symlink-repro.test.ts": 78, + "src/main/wsl-unc-delete.test.ts": 92, + "src/main/wsl-unc-delete.wsl.test.ts": 71, + "src/main/wsl.test.ts": 152, + "src/main/wsl/wsl-executable-path.win32.test.ts": 44, + "src/main/wsl/wsl-guest-environment.test.ts": 109, + "src/main/wsl/wsl-pinned-runtime.test.ts": 413, + "src/main/wsl/wsl-runner.test.ts": 86, + "src/main/wsl/wsl-runner.wsl.test.ts": 71, + "src/main/wsl/wsl-w1-w3-contract.test.ts": 79, + "src/main/zcode/hook-service.test.ts": 130, + "src/main/zcode/interactive-capability.live.test.ts": 61, + "src/main/zcode/zcode-plan-api-key-store.test.ts": 72, + "src/packages/process-host/src/bounded-output-sink.test.ts": 98, + "src/packages/process-host/src/close-process-registry.test.ts": 51, + "src/packages/process-host/src/growing-byte-buffer.test.ts": 119, + "src/packages/process-host/src/posix-lsof-probe.test.ts": 4335, + "src/packages/process-host/src/process-tree-termination.test.ts": 86, + "src/packages/process-host/src/retryable-process-exit-proof.test.ts": 53, + "src/packages/process-host/src/run-process-bytes.test.ts": 2227, + "src/packages/process-host/src/run-process-termination-failure.test.ts": 85, + "src/packages/process-host/src/run-process.test.ts": 7249, + "src/packages/process-host/src/spawn-observer.test.ts": 181, + "src/packages/process-host/src/spawn-process-stdio.test.ts": 247, + "src/packages/process-host/src/windows-command-line.test.ts": 81, + "src/preload/api/notification-sound-playback.test.ts": 52, + "src/preload/api/platform-bridge.test.ts": 71, + "src/preload/app-restart-checkpoint-routing.test.ts": 1800, + "src/preload/browser-client-page-renderer-requests.test.ts": 262, + "src/preload/browser-find-subscriptions.test.ts": 58, + "src/preload/browser-window-close.test.ts": 45, + "src/preload/close-active-tab-payload-admission.test.ts": 54, + "src/preload/doc-preview-link-interception.test.ts": 213, + "src/preload/opencode-go-credentials.test.ts": 43, + "src/preload/preload-file-drop-absence.test.ts": 293, + "src/preload/pty-snapshot-capability-ipc.test.ts": 992, + "src/preload/renderer-heap-statistics-reader.test.ts": 58, + "src/preload/renderer-process-memory-reader.test.ts": 56, + "src/preload/renderer-restart-wiring.test.ts": 84, + "src/preload/runtime-environment-subscriptions.test.ts": 73, + "src/preload/ssh-authority-forwarding.test.ts": 1025, + "src/preload/updater-package-recovery.test.ts": 867, + "src/preload/usage-provider-api.test.ts": 52, + "src/relay/agent-exec-disposal.test.ts": 174, + "src/relay/agent-exec-handler-windows.test.ts": 139, + "src/relay/agent-exec-handler.test.ts": 179, + "src/relay/agent-exec-shell-environment.test.ts": 191, + "src/relay/agent-hook-retired-pane-suppression.test.ts": 647, + "src/relay/agent-status-store-relay-context.test.ts": 314, + "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts": 292, + "src/relay/agent-workspace-trust-spawn.test.ts": 337, + "src/relay/ai-vault-handler.test.ts": 811, + "src/relay/ai-vault-opencode-reader.test.ts": 438, + "src/relay/ai-vault-service-cancellation.test.ts": 437, + "src/relay/ai-vault-service-client.test.ts": 96, + "src/relay/ai-vault-service-ready-retention.test.ts": 222, + "src/relay/ai-vault-service-restart-policy.test.ts": 51, + "src/relay/ai-vault-service-spawn.test.ts": 60, + "src/relay/context.test.ts": 46, + "src/relay/external-automation-provider-catalog.test.ts": 77, + "src/relay/external-automations-handler-log-path.test.ts": 49, + "src/relay/external-automations-handler.test.ts": 73, + "src/relay/freebuff-status-projection.test.ts": 1180, + "src/relay/fs-file-stream-shutdown.test.ts": 289, + "src/relay/fs-handler-doc-preview.test.ts": 227, + "src/relay/fs-handler-file-range-dispatch.test.ts": 705, + "src/relay/fs-handler-file-range.test.ts": 217, + "src/relay/fs-handler-git-search-capacity.test.ts": 157, + "src/relay/fs-handler-git-search-real-capacity.test.ts": 131, + "src/relay/fs-handler-git-search.test.ts": 142, + "src/relay/fs-handler-list-files-cancel.test.ts": 137, + "src/relay/fs-handler-list-files-candidates.integration.test.ts": 1201, + "src/relay/fs-handler-list-files-candidates.test.ts": 655, + "src/relay/fs-handler-list-files-diagnosis-cancel.test.ts": 101, + "src/relay/fs-handler-list-files-ignored.test.ts": 1166, + "src/relay/fs-handler-list-files-path-framing.test.ts": 152, + "src/relay/fs-handler-list-files-result-limit.test.ts": 465, + "src/relay/fs-handler-readdir-fallback.test.ts": 73, + "src/relay/fs-handler-ripgrep-fallback.test.ts": 370, + "src/relay/fs-handler-stream.test.ts": 449, + "src/relay/fs-handler.test.ts": 617, + "src/relay/fs-list-files-cancel.integration.test.ts": 352, + "src/relay/fs-list-files-large-response.integration.test.ts": 865, + "src/relay/fs-list-files-scan-coordinator.test.ts": 64, + "src/relay/fs-markdown-document-launch.test.ts": 75, + "src/relay/fs-markdown-document-listing.test.ts": 231, + "src/relay/fs-path-metadata-requests.test.ts": 64, + "src/relay/fs-path-metadata-symlink-concurrency.test.ts": 63, + "src/relay/fs-search-cancellation.test.ts": 438, + "src/relay/fs-search-diagnosis-cancellation.test.ts": 164, + "src/relay/fs-search-errors-real.test.ts": 157, + "src/relay/fs-search-line-fragments.test.ts": 197, + "src/relay/fs-stream-pty-echo-backpressure.integration.test.ts": 555, + "src/relay/fs-stream-registry-shutdown.test.ts": 66, + "src/relay/git-branch-delete-refusal-parity.test.ts": 201, + "src/relay/git-command-admission.test.ts": 141, + "src/relay/git-diff-cancellation.test.ts": 302, + "src/relay/git-exec-validator.test.ts": 93, + "src/relay/git-handler-blob-readers.test.ts": 73, + "src/relay/git-handler-branch-cleanup.test.ts": 119, + "src/relay/git-handler-branch-compare.test.ts": 135, + "src/relay/git-handler-branch-diff-equivalence.test.ts": 1113, + "src/relay/git-handler-branch-diff.test.ts": 298, + "src/relay/git-handler-check-ignore.test.ts": 50, + "src/relay/git-handler-clone-progress-capture.test.ts": 318, + "src/relay/git-handler-commit-metadata.test.ts": 488, + "src/relay/git-handler-diff-blobs.test.ts": 309, + "src/relay/git-handler-diff-read-coalescing.test.ts": 695, + "src/relay/git-handler-diff-retry.test.ts": 204, + "src/relay/git-handler-file-diff.test.ts": 921, + "src/relay/git-handler-fork-remote-exec.test.ts": 245, + "src/relay/git-handler-pull-reconciliation.test.ts": 866, + "src/relay/git-handler-push-target.test.ts": 66, + "src/relay/git-handler-remote-sync.test.ts": 1449, + "src/relay/git-handler-review-draft.test.ts": 350, + "src/relay/git-handler-stage-worktree-scope.test.ts": 905, + "src/relay/git-handler-staging.test.ts": 355, + "src/relay/git-handler-status-ops.test.ts": 140, + "src/relay/git-handler-submodule-cache-invalidation.test.ts": 248, + "src/relay/git-handler-submodule-ops.test.ts": 138, + "src/relay/git-handler-submodule-status-cancellation.test.ts": 193, + "src/relay/git-handler-termination.test.ts": 219, + "src/relay/git-handler-utils.test.ts": 46, + "src/relay/git-handler-working-tree-changes.test.ts": 879, + "src/relay/git-handler-worktree-clean.test.ts": 83, + "src/relay/git-handler-worktree-existence-cancellation.test.ts": 72, + "src/relay/git-handler-worktree-git-capabilities.test.ts": 232, + "src/relay/git-handler-worktree-inspection.test.ts": 371, + "src/relay/git-handler-worktree-list-authority.test.ts": 209, + "src/relay/git-handler-worktree-list-cancellation.test.ts": 207, + "src/relay/git-handler-worktree-list.test.ts": 72, + "src/relay/git-handler-worktree-ops.test.ts": 117, + "src/relay/git-handler-worktree-paths.test.ts": 88, + "src/relay/git-handler-worktree-provisioning.test.ts": 1195, + "src/relay/git-handler.test.ts": 543, + "src/relay/git-porcelain-local-parity.test.ts": 476, + "src/relay/git-push-target-local-parity.test.ts": 335, + "src/relay/git-response-pty-echo-backpressure.integration.test.ts": 1073, + "src/relay/git-response-stream-ownership.test.ts": 66, + "src/relay/git-response-stream-retention.test.ts": 456, + "src/relay/git-response-stream-shutdown.test.ts": 85, + "src/relay/git-review-draft-binary-compatibility.test.ts": 133, + "src/relay/git-status-branch-line-total.test.ts": 329, + "src/relay/git-status-upstream-negative-cache.test.ts": 319, + "src/relay/git-stdout-stream.test.ts": 141, + "src/relay/git-working-file-read.test.ts": 74, + "src/relay/hermes-run-correlation.test.ts": 49, + "src/relay/hermes-run-history.test.ts": 120, + "src/relay/integration.test.ts": 1263, + "src/relay/managed-hook-installer.test.ts": 78, + "src/relay/node-pty-binding-survey.test.ts": 428, + "src/relay/node-pty-unavailable-diagnosis.test.ts": 294, + "src/relay/omp-fresh-launch-environment.test.ts": 1156, + "src/relay/opencode-hook-selection.test.ts": 872, + "src/relay/port-scan-handler.test.ts": 193, + "src/relay/pty-handler-agent-launch-token.test.ts": 597, + "src/relay/pty-handler-agent-workspace-trust.test.ts": 501, + "src/relay/pty-handler-attach-replay.test.ts": 615, + "src/relay/pty-handler-dispose-lifecycle.test.ts": 685, + "src/relay/pty-handler-grace-timer.test.ts": 558, + "src/relay/pty-handler-immediate-descendants.test.ts": 685, + "src/relay/pty-handler-inventory-process-evidence.test.ts": 843, + "src/relay/pty-handler-opencode-revive.test.ts": 554, + "src/relay/pty-handler-output-drain-differential.test.ts": 728, + "src/relay/pty-handler-output-streaming.test.ts": 972, + "src/relay/pty-handler-owner-inventory-clone-budget.test.ts": 571, + "src/relay/pty-handler-ownership-attestation.test.ts": 642, + "src/relay/pty-handler-pi-owner-env.test.ts": 670, + "src/relay/pty-handler-resize-stale-pty.test.ts": 715, + "src/relay/pty-handler-retired-pane-surface.test.ts": 649, + "src/relay/pty-handler-revive.test.ts": 701, + "src/relay/pty-handler-shell-recovery.test.ts": 850, + "src/relay/pty-handler-shell-resolution.test.ts": 665, + "src/relay/pty-handler-shutdown-signals.test.ts": 646, + "src/relay/pty-handler-source-publication.test.ts": 1260, + "src/relay/pty-handler-spawn-admission.test.ts": 936, + "src/relay/pty-handler-spawn-cwd.test.ts": 692, + "src/relay/pty-handler-spawn-environment.test.ts": 700, + "src/relay/pty-handler-startup-command-delivery.test.ts": 780, + "src/relay/pty-handler-startup-command-staging.test.ts": 599, + "src/relay/pty-handler-windows-child-process-evidence.test.ts": 673, + "src/relay/pty-shell-launch.test.ts": 343, + "src/relay/pty-shell-utils-process-cycles.test.ts": 120, + "src/relay/pty-shell-utils.test.ts": 151, + "src/relay/pty-source-credit-ledger.test.ts": 238, + "src/relay/pty-source-credit-scheduler.test.ts": 68, + "src/relay/pty-source-sent-boundaries.test.ts": 106, + "src/relay/relay-agent-process-lifetime.test.ts": 135, + "src/relay/relay-bundled-ripgrep.test.ts": 128, + "src/relay/relay-daemon-fatal-reap.test.ts": 1498, + "src/relay/relay-diagnostic-log.test.ts": 51, + "src/relay/relay-endpoint-credential-publication.test.ts": 2888, + "src/relay/relay-filesystem-watch-registry.test.ts": 269, + "src/relay/relay-grace-branch.test.ts": 70, + "src/relay/relay-grace-lifecycle.test.ts": 319, + "src/relay/relay-handshake-roundtrip.test.ts": 474, + "src/relay/relay-launch-options.test.ts": 63, + "src/relay/relay-oversized-notification-survival.test.ts": 269, + "src/relay/relay-pid-publication.test.ts": 53, + "src/relay/relay-primary-channel.test.ts": 309, + "src/relay/relay-pty-consumer-owner-displacement.test.ts": 316, + "src/relay/relay-pty-publication-admission.test.ts": 405, + "src/relay/relay-pty-source-cancellation-exit.test.ts": 306, + "src/relay/relay-pty-source-exit-publication.test.ts": 78, + "src/relay/relay-pty-source-publication.test.ts": 430, + "src/relay/relay-pty-source-recovery-completion.test.ts": 46, + "src/relay/relay-pty-source-recovery-interleavings.test.ts": 334, + "src/relay/relay-pty-source-recovery-window.test.ts": 398, + "src/relay/relay-pty-source-restore-retry.test.ts": 430, + "src/relay/relay-pty-source-send-scheduler.test.ts": 86, + "src/relay/relay-pty-source-superseded-activation.test.ts": 273, + "src/relay/relay-reconnect-listener-credential-gate.test.ts": 92, + "src/relay/relay-reconnect-listener-peer-end.test.ts": 351, + "src/relay/relay-reconnect-listener-peer-write-error.test.ts": 358, + "src/relay/relay-ripgrep-cwd-env.test.ts": 138, + "src/relay/relay-runtime-owned-process-shutdown.test.ts": 1354, + "src/relay/relay-runtime-self-test.test.ts": 73, + "src/relay/relay-socket-peer-close.test.ts": 51, + "src/relay/relay-watch-root-capacity.test.ts": 256, + "src/relay/relay-watcher-event-emitter.test.ts": 686, + "src/relay/relay-watcher-frame-chunking.test.ts": 598, + "src/relay/relay-watcher-parent-removal.test.ts": 139, + "src/relay/relay-watcher-pending-setup-waiters.test.ts": 176, + "src/relay/relay-watcher-setup-wait.test.ts": 214, + "src/relay/relay-watcher-shared-byte-index.test.ts": 842, + "src/relay/relay-watcher-shutdown.test.ts": 244, + "src/relay/relay-windows-path-ripgrep-cache.test.ts": 84, "src/relay/relay-windows-path-ripgrep.test.ts": 68, - "src/relay/remote-artifact-cli-input.test.ts": 111, - "src/relay/remote-cli-env.test.ts": 31, - "src/relay/remote-cli-stdin.test.ts": 30, - "src/relay/remote-cli-timeout.test.ts": 61, - "src/relay/retired-pane-surfaces.test.ts": 29, - "src/relay/rotating-log-writer.test.ts": 68, - "src/relay/session-search-transport.test.ts": 172, - "src/relay/skill-install-handler.test.ts": 589, - "src/relay/skill-upload-multi-relay.integration.test.ts": 667, - "src/relay/ssh-pty-consumer-session-adapter.test.ts": 304, - "src/relay/ssh-pty-source-credit-adapter.test.ts": 124, - "src/relay/subprocess-tree-termination.test.ts": 41, - "src/relay/subprocess.test.ts": 26565, - "src/relay/terminal-history-wsl.test.ts": 77, - "src/relay/terminal-history.test.ts": 63, - "src/relay/windows-port-scan.test.ts": 107, - "src/relay/windows-port-scan.win32.test.ts": 71, - "src/relay/workspace-session-handler.test.ts": 87, - "src/relay/workspace-snapshot-publication.test.ts": 126, - "src/relay/workspace-space-scan-du-capacity.test.ts": 110, - "src/relay/workspace-space-scan.test.ts": 66, - "src/wsl-guest/wsl-agent-hook-relay.test.ts": 317, - "src/wsl-guest/wsl-hook-fs-bridge.test.ts": 39, - "src/wsl-guest/wsl-install-plugins-handler.test.ts": 161, - "src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 1093, - "src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts": 97, - "src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 721, - "src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 35, - "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx": 2317, - "src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 65, - "src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 401, - "src/renderer/src/app-shell/startup-actions-selector.test.ts": 127, - "src/renderer/src/app-shell/use-document-appearance.test.tsx": 2017, - "src/renderer/src/app-shell/window-visibility-actions-selector.test.ts": 697, - "src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx": 1163, - "src/renderer/src/app-startup-routing.test.ts": 76, - "src/renderer/src/assets/diff-comment-draft-card-shadow-style.test.ts": 54, - "src/renderer/src/assets/mobile-page-qr-layout.test.ts": 39, - "src/renderer/src/assets/rich-markdown-task-list-style.test.ts": 48, - "src/renderer/src/assets/terminal-container-geometry.test.ts": 28, - "src/renderer/src/assets/terminal-scrollbar-style.test.ts": 27, - "src/renderer/src/assets/theme-utility-generation.test.ts": 45, - "src/renderer/src/assets/worktree-card-active-style.test.ts": 37, - "src/renderer/src/attention/agent-attention-policy.test.ts": 60, - "src/renderer/src/components/AgentStateDot.test.ts": 245, - "src/renderer/src/components/AgentWorkingSpinner.test.tsx": 531, - "src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx": 1570, - "src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx": 2709, - "src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx": 1009, - "src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx": 1670, - "src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx": 1503, - "src/renderer/src/components/NewWorkspaceComposerCard.test.tsx": 3004, - "src/renderer/src/components/QuickOpen.mount-gating.test.tsx": 2554, - "src/renderer/src/components/SelectedTextCopyMenu.test.tsx": 725, - "src/renderer/src/components/StarNagCard.test.tsx": 617, - "src/renderer/src/components/StateIndicatorTooltip.test.tsx": 96, - "src/renderer/src/components/TerminalSearch.test.tsx": 663, - "src/renderer/src/components/TerminalTitlebarTabs.test.tsx": 583, - "src/renderer/src/components/TerminalWorkbenchContainer.test.tsx": 640, - "src/renderer/src/components/UpdateCard.error-card.test.tsx": 2112, - "src/renderer/src/components/UpdateCard.test.ts": 1299, - "src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx": 2306, - "src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx": 5178, - "src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx": 2600, - "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx": 2441, - "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx": 8351, - "src/renderer/src/components/WorktreeJumpPalette.test.tsx": 4265, - "src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts": 1835, - "src/renderer/src/components/activity/ActivityPrototypePage.test.ts": 2064, - "src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts": 2740, - "src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx": 3734, - "src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx": 1929, - "src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx": 2221, - "src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts": 2023, - "src/renderer/src/components/activity/activity-clear-completed.test.ts": 1835, - "src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts": 1527, - "src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts": 2432, - "src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts": 2802, - "src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts": 3038, - "src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts": 2280, - "src/renderer/src/components/activity/activity-portal-churn-budget.test.ts": 53, - "src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx": 2960, - "src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx": 3091, - "src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts": 47, - "src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx": 3186, - "src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts": 45, - "src/renderer/src/components/activity/activity-scope-filter.test.ts": 2628, - "src/renderer/src/components/activity/activity-terminal-portal-publication-loop.react185.test.tsx": 288, - "src/renderer/src/components/activity/activity-terminal-portal.test.tsx": 451, - "src/renderer/src/components/activity/activity-thread-actions.test.ts": 1194, - "src/renderer/src/components/activity/activity-thread-child-agent.test.ts": 1802, - "src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts": 1360, - "src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts": 3981, - "src/renderer/src/components/activity/activity-thread-hover-card.test.tsx": 2877, - "src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx": 3116, - "src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx": 2331, - "src/renderer/src/components/activity/activity-thread-presentation.test.ts": 2334, - "src/renderer/src/components/activity/activity-thread-virtual-items.test.ts": 2318, - "src/renderer/src/components/activity/dev-activity-fixture.test.ts": 45, - "src/renderer/src/components/activity/event-time-clock-refresh.test.tsx": 1277, - "src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx": 858, - "src/renderer/src/components/activity/useActivityUnreadCount.freshness.test.tsx": 1349, - "src/renderer/src/components/activity/useActivityUnreadCount.test.ts": 1568, - "src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx": 567, - "src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.test.ts": 59, - "src/renderer/src/components/agent/AgentCombobox.test.tsx": 1607, - "src/renderer/src/components/agent/AgentSettingsDialog.test.tsx": 627, - "src/renderer/src/components/agent/agent-combobox-command-state.test.ts": 38, - "src/renderer/src/components/artifacts/ArtifactCollection.test.tsx": 2261, - "src/renderer/src/components/artifacts/ArtifactPreview.test.tsx": 778, - "src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx": 842, - "src/renderer/src/components/artifacts/ArtifactsPage.test.tsx": 1366, - "src/renderer/src/components/artifacts/artifact-display-labels.test.ts": 106, - "src/renderer/src/components/artifacts/artifact-list-search.test.ts": 120, - "src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx": 6815, - "src/renderer/src/components/artifacts/artifact-publish-flow.test.ts": 77, - "src/renderer/src/components/artifacts/artifact-published-link-client.test.ts": 52, - "src/renderer/src/components/automations/AutomationDestinationField.test.tsx": 1082, - "src/renderer/src/components/automations/AutomationDetail.host.test.tsx": 647, - "src/renderer/src/components/automations/AutomationDetail.test.tsx": 780, - "src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx": 454, - "src/renderer/src/components/automations/AutomationHostBadges.test.tsx": 695, - "src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx": 1055, - "src/renderer/src/components/automations/AutomationListEmptyView.test.tsx": 798, - "src/renderer/src/components/automations/AutomationListHostGroups.test.tsx": 926, - "src/renderer/src/components/automations/AutomationListLocalRows.test.tsx": 955, - "src/renderer/src/components/automations/AutomationListSearchField.test.tsx": 511, - "src/renderer/src/components/automations/AutomationListTableHeader.test.tsx": 928, - "src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx": 892, - "src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx": 1140, - "src/renderer/src/components/automations/AutomationRunHistory.test.tsx": 1578, - "src/renderer/src/components/automations/AutomationRunsDashboardSurface.test.tsx": 623, - "src/renderer/src/components/automations/AutomationRunsTable.test.tsx": 1058, - "src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx": 1285, - "src/renderer/src/components/automations/AutomationSchedulePicker.test.ts": 1456, - "src/renderer/src/components/automations/AutomationTimeField.test.tsx": 1061, - "src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx": 1899, - "src/renderer/src/components/automations/AutomationsDetailPane.test.tsx": 2085, - "src/renderer/src/components/automations/AutomationsListPanel.test.tsx": 1538, - "src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx": 2720, - "src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx": 1339, - "src/renderer/src/components/automations/AutomationsPage.escape-precedence.test.tsx": 2204, - "src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx": 1221, - "src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx": 1235, - "src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx": 1796, - "src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx": 1594, - "src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx": 1106, - "src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx": 1789, - "src/renderer/src/components/automations/AutomationsPage.test.tsx": 3102, - "src/renderer/src/components/automations/AutomationsPageBreadcrumb.test.tsx": 708, - "src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx": 922, + "src/relay/remote-artifact-cli-input.test.ts": 122, + "src/relay/remote-cli-env.test.ts": 41, + "src/relay/remote-cli-stdin.test.ts": 46, + "src/relay/remote-cli-timeout.test.ts": 66, + "src/relay/retired-pane-surfaces.test.ts": 48, + "src/relay/rotating-log-writer.test.ts": 91, + "src/relay/session-search-transport.test.ts": 325, + "src/relay/skill-install-handler.test.ts": 746, + "src/relay/skill-upload-multi-relay.integration.test.ts": 956, + "src/relay/ssh-pty-consumer-session-adapter.test.ts": 317, + "src/relay/ssh-pty-source-credit-adapter.test.ts": 81, + "src/relay/subprocess-tree-termination.test.ts": 56, + "src/relay/subprocess.test.ts": 24561, + "src/relay/terminal-history-wsl.test.ts": 76, + "src/relay/terminal-history.test.ts": 75, + "src/relay/windows-port-scan.test.ts": 169, + "src/relay/windows-port-scan.win32.test.ts": 83, + "src/relay/workspace-session-handler.test.ts": 269, + "src/relay/workspace-snapshot-publication.test.ts": 271, + "src/relay/workspace-space-scan-du-capacity.test.ts": 83, + "src/relay/workspace-space-scan.test.ts": 72, + "src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts": 1064, + "src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 1261, + "src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts": 646, + "src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 455, + "src/renderer/src/app-shell/app-window-chrome.test.ts": 37, + "src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 40, + "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx": 2213, + "src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 62, + "src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 171, + "src/renderer/src/app-shell/startup-actions-selector.test.ts": 78, + "src/renderer/src/app-shell/use-document-appearance.test.tsx": 1472, + "src/renderer/src/app-shell/use-profile-state-save-delay-notice.sonner.test.tsx": 454, + "src/renderer/src/app-shell/use-profile-state-save-delay-notice.test.tsx": 291, + "src/renderer/src/app-shell/use-terminal-viewer-color-publication.test.tsx": 1708, + "src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx": 338, + "src/renderer/src/app-shell/visible-review-reporting.test.ts": 1357, + "src/renderer/src/app-shell/window-visibility-actions-selector.test.ts": 228, + "src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx": 927, + "src/renderer/src/attention/agent-attention-policy.test.ts": 58, + "src/renderer/src/attention/notification-sources.test.ts": 1870, + "src/renderer/src/components/AgentStateDot.test.ts": 179, + "src/renderer/src/components/AgentWorkingSpinner.test.tsx": 249, + "src/renderer/src/components/ButtonKeyHint.test.tsx": 348, + "src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx": 868, + "src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx": 2230, + "src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx": 2007, + "src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx": 2572, + "src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx": 3280, + "src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx": 884, + "src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx": 3372, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx": 1056, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx": 1064, + "src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx": 929, + "src/renderer/src/components/NewWorkspaceComposerCard.test.tsx": 1755, + "src/renderer/src/components/QuickOpen.mount-gating.test.tsx": 1998, + "src/renderer/src/components/QuickOpen.selection.test.tsx": 720, + "src/renderer/src/components/SelectedTextCopyMenu.test.tsx": 343, + "src/renderer/src/components/StarNagCard.test.tsx": 322, + "src/renderer/src/components/StateIndicatorTooltip.test.tsx": 76, + "src/renderer/src/components/TerminalSearch.test.tsx": 518, + "src/renderer/src/components/TerminalTitlebarTabs.test.tsx": 222, + "src/renderer/src/components/TerminalWorkbenchContainer.test.tsx": 293, + "src/renderer/src/components/UpdateCard.error-card.test.tsx": 1835, + "src/renderer/src/components/UpdateCard.test.ts": 1484, + "src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx": 1751, + "src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx": 3680, + "src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx": 2974, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx": 3247, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx": 5538, + "src/renderer/src/components/WorktreeJumpPalette.test.tsx": 3096, + "src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts": 54, + "src/renderer/src/components/activity/ActivityPrototypePage.test.ts": 443, + "src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts": 1500, + "src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx": 2356, + "src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx": 1673, + "src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx": 3249, + "src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts": 567, + "src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts": 2446, + "src/renderer/src/components/activity/activity-clear-completed.test.ts": 406, + "src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts": 438, + "src/renderer/src/components/activity/activity-portal-churn-budget.test.ts": 47, + "src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx": 200, + "src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx": 372, + "src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts": 62, + "src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx": 303, + "src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts": 55, + "src/renderer/src/components/activity/activity-scope-filter.test.ts": 588, + "src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx": 1691, + "src/renderer/src/components/activity/activity-terminal-portal-publication-loop.react185.test.tsx": 169, + "src/renderer/src/components/activity/activity-terminal-portal.test.tsx": 162, + "src/renderer/src/components/activity/activity-thread-actions.test.ts": 739, + "src/renderer/src/components/activity/activity-thread-context-menu.test.tsx": 2485, + "src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts": 435, + "src/renderer/src/components/activity/activity-thread-hover-card.test.tsx": 3220, + "src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx": 3450, + "src/renderer/src/components/activity/activity-thread-list-pane.multi-select.test.tsx": 3049, + "src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx": 3728, + "src/renderer/src/components/activity/dev-activity-fixture.test.ts": 62, + "src/renderer/src/components/activity/event-time-clock-refresh.test.tsx": 1553, + "src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx": 1820, + "src/renderer/src/components/activity/useActivityUnreadCount.freshness.test.tsx": 1645, + "src/renderer/src/components/activity/useActivityUnreadCount.test.ts": 1635, + "src/renderer/src/components/agent-child-row-parity.test.tsx": 641, + "src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx": 376, + "src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.test.ts": 55, + "src/renderer/src/components/agent/AgentCombobox.test.tsx": 1407, + "src/renderer/src/components/agent/AgentSettingsDialog.test.tsx": 555, + "src/renderer/src/components/agent/agent-combobox-command-state.test.ts": 45, + "src/renderer/src/components/artifacts/ArtifactCollection.test.tsx": 1101, + "src/renderer/src/components/artifacts/ArtifactPreview.test.tsx": 381, + "src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx": 616, + "src/renderer/src/components/artifacts/ArtifactsPage.test.tsx": 1568, + "src/renderer/src/components/artifacts/artifact-display-labels.test.ts": 88, + "src/renderer/src/components/artifacts/artifact-list-search.test.ts": 104, + "src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx": 3096, + "src/renderer/src/components/artifacts/artifact-publish-flow.test.ts": 125, + "src/renderer/src/components/artifacts/artifact-published-link-client.test.ts": 51, + "src/renderer/src/components/automations/AutomationDestinationField.test.tsx": 622, + "src/renderer/src/components/automations/AutomationDetail.host.test.tsx": 751, + "src/renderer/src/components/automations/AutomationDetail.test.tsx": 681, + "src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx": 318, + "src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx": 655, + "src/renderer/src/components/automations/AutomationHostBadges.test.tsx": 332, + "src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx": 379, + "src/renderer/src/components/automations/AutomationListEmptyView.test.tsx": 244, + "src/renderer/src/components/automations/AutomationListHostGroups.test.tsx": 579, + "src/renderer/src/components/automations/AutomationListLocalRows.test.tsx": 1000, + "src/renderer/src/components/automations/AutomationListSearchField.test.tsx": 345, + "src/renderer/src/components/automations/AutomationListTableHeader.test.tsx": 448, + "src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx": 476, + "src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx": 1051, + "src/renderer/src/components/automations/AutomationRunHistory.test.tsx": 1017, + "src/renderer/src/components/automations/AutomationRunsDashboardSurface.test.tsx": 351, + "src/renderer/src/components/automations/AutomationRunsTable.test.tsx": 540, + "src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx": 962, + "src/renderer/src/components/automations/AutomationSchedulePicker.test.ts": 875, + "src/renderer/src/components/automations/AutomationTimeField.test.tsx": 593, + "src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx": 2326, + "src/renderer/src/components/automations/AutomationsDetailPane.test.tsx": 2585, + "src/renderer/src/components/automations/AutomationsListPanel.test.tsx": 1015, + "src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx": 2412, + "src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx": 2046, + "src/renderer/src/components/automations/AutomationsPage.escape-precedence.test.tsx": 1344, + "src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx": 1416, + "src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx": 2091, + "src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx": 1405, + "src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx": 1871, + "src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx": 1326, + "src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx": 2040, + "src/renderer/src/components/automations/AutomationsPage.test.tsx": 2751, + "src/renderer/src/components/automations/AutomationsPageBreadcrumb.test.tsx": 304, + "src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx": 897, "src/renderer/src/components/automations/automation-authority-identity.test.ts": 54, - "src/renderer/src/components/automations/automation-captured-owner.test.ts": 107, - "src/renderer/src/components/automations/automation-create-destination.test.ts": 85, - "src/renderer/src/components/automations/automation-create-projects.test.ts": 35, - "src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts": 300, - "src/renderer/src/components/automations/automation-editor-prompt-options.test.ts": 52, - "src/renderer/src/components/automations/automation-external-target-match.test.ts": 58, - "src/renderer/src/components/automations/automation-host-cache-controller.test.ts": 139, - "src/renderer/src/components/automations/automation-host-cache-health.test.ts": 56, - "src/renderer/src/components/automations/automation-host-cache.test.ts": 83, - "src/renderer/src/components/automations/automation-host-catalog-generation.test.ts": 84, - "src/renderer/src/components/automations/automation-host-catalog-order.test.ts": 51, - "src/renderer/src/components/automations/automation-host-catalog-source.test.ts": 64, - "src/renderer/src/components/automations/automation-host-catalog.test.ts": 140, - "src/renderer/src/components/automations/automation-host-client.test.ts": 70, - "src/renderer/src/components/automations/automation-host-detail-display.test.ts": 47, - "src/renderer/src/components/automations/automation-host-diagnostics.test.ts": 141, - "src/renderer/src/components/automations/automation-host-filter-resolution.test.ts": 84, - "src/renderer/src/components/automations/automation-host-health.test.ts": 26, - "src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts": 69, - "src/renderer/src/components/automations/automation-host-invalidation.test.ts": 50, - "src/renderer/src/components/automations/automation-host-list-rows.test.ts": 140, - "src/renderer/src/components/automations/automation-host-orphan-entry.test.ts": 160, - "src/renderer/src/components/automations/automation-host-picker-groups.test.ts": 58, - "src/renderer/src/components/automations/automation-host-recovery.test.ts": 56, - "src/renderer/src/components/automations/automation-host-scale-gates.test.ts": 400, - "src/renderer/src/components/automations/automation-host-scheduler.test.ts": 147, - "src/renderer/src/components/automations/automation-host-status-descriptors.test.ts": 90, - "src/renderer/src/components/automations/automation-legacy-schedule-editing.test.ts": 1751, - "src/renderer/src/components/automations/automation-list-empty-state.test.ts": 144, - "src/renderer/src/components/automations/automation-list-focus-recovery.test.ts": 43, - "src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts": 38, - "src/renderer/src/components/automations/automation-list-last-run.test.ts": 146, - "src/renderer/src/components/automations/automation-list-row-identity.test.ts": 377, - "src/renderer/src/components/automations/automation-list-search-rows.test.ts": 318, - "src/renderer/src/components/automations/automation-list-search.test.ts": 75, - "src/renderer/src/components/automations/automation-list-view-sort.test.ts": 458, - "src/renderer/src/components/automations/automation-list-view.test.ts": 99, - "src/renderer/src/components/automations/automation-project-groups.test.ts": 62, - "src/renderer/src/components/automations/automation-run-completion-evidence.test.ts": 58, - "src/renderer/src/components/automations/automation-run-context.test.ts": 36, - "src/renderer/src/components/automations/automation-run-history-keyboard-navigation.test.ts": 400, - "src/renderer/src/components/automations/automation-run-open-target.test.ts": 54, - "src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts": 135, - "src/renderer/src/components/automations/automation-run-output-snapshot.test.ts": 78, - "src/renderer/src/components/automations/automation-run-view-state.test.ts": 40, - "src/renderer/src/components/automations/automation-runs-dashboard-model.test.ts": 38, - "src/renderer/src/components/automations/automation-scoped-list-client.test.ts": 127, - "src/renderer/src/components/automations/automation-setup-decision.test.ts": 1515, - "src/renderer/src/components/automations/automation-source-display.test.ts": 41, - "src/renderer/src/components/automations/automation-target-availability.test.ts": 60, - "src/renderer/src/components/automations/automation-usage-model.test.ts": 65, - "src/renderer/src/components/automations/automation-write-invalidation.test.ts": 53, - "src/renderer/src/components/automations/external-automation-display.test.ts": 66, - "src/renderer/src/components/automations/external-automation-list-entries.test.ts": 43, - "src/renderer/src/components/automations/external-automation-run-table-state.test.ts": 43, - "src/renderer/src/components/automations/external-automation-scope-gating.test.ts": 66, - "src/renderer/src/components/automations/external-automation-scope-keys.test.ts": 58, - "src/renderer/src/components/automations/external-automation-source-availability.test.ts": 55, - "src/renderer/src/components/automations/hermes-cron-output-parse.test.ts": 65, - "src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx": 810, - "src/renderer/src/components/automations/use-automation-host-catalog.test.tsx": 602, - "src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx": 556, - "src/renderer/src/components/automations/use-automation-list-search.test.tsx": 673, - "src/renderer/src/components/automations/use-automation-runs-dashboard.test.tsx": 805, - "src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx": 329, - "src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx": 747, - "src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx": 1424, - "src/renderer/src/components/browser-favicon.test.tsx": 631, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx": 2176, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx": 2978, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx": 2775, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.drag-focus.test.tsx": 2007, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.failure-remount.test.tsx": 2651, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.markup.test.tsx": 2864, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx": 1804, - "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx": 1822, - "src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx": 986, - "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx": 69, - "src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.test.ts": 400, - "src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts": 95, - "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx": 2934, - "src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts": 55, - "src/renderer/src/components/browser-pane/annotate/markup-screenshot-compose.test.ts": 70, - "src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts": 69, - "src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx": 1194, - "src/renderer/src/components/browser-pane/annotate/use-markup-draw-hint.test.ts": 587, - "src/renderer/src/components/browser-pane/annotate/useGrabMode.test.ts": 823, - "src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts": 590, - "src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts": 455, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx": 651, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserFind.test.tsx": 996, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts": 70, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts": 35, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts": 607, - "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx": 830, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion.test.ts": 39, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts": 155, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx": 529, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx": 1087, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx": 2498, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts": 30, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.test.tsx": 1863, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-import-hint-visibility.test.ts": 51, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx": 1001, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-page-zoom-indicator.test.tsx": 652, + "src/renderer/src/components/automations/automation-captured-owner.test.ts": 177, + "src/renderer/src/components/automations/automation-create-destination.test.ts": 115, + "src/renderer/src/components/automations/automation-create-projects.test.ts": 45, + "src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts": 153, + "src/renderer/src/components/automations/automation-editor-prompt-options.test.ts": 44, + "src/renderer/src/components/automations/automation-external-target-match.test.ts": 56, + "src/renderer/src/components/automations/automation-extra-agent-args-capability.test.ts": 115, + "src/renderer/src/components/automations/automation-host-cache-controller.test.ts": 185, + "src/renderer/src/components/automations/automation-host-cache-health.test.ts": 63, + "src/renderer/src/components/automations/automation-host-cache.test.ts": 71, + "src/renderer/src/components/automations/automation-host-catalog-generation.test.ts": 113, + "src/renderer/src/components/automations/automation-host-catalog-order.test.ts": 57, + "src/renderer/src/components/automations/automation-host-catalog-source.test.ts": 87, + "src/renderer/src/components/automations/automation-host-catalog.test.ts": 97, + "src/renderer/src/components/automations/automation-host-client.test.ts": 97, + "src/renderer/src/components/automations/automation-host-detail-display.test.ts": 54, + "src/renderer/src/components/automations/automation-host-diagnostics.test.ts": 206, + "src/renderer/src/components/automations/automation-host-filter-resolution.test.ts": 107, + "src/renderer/src/components/automations/automation-host-health.test.ts": 55, + "src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts": 78, + "src/renderer/src/components/automations/automation-host-invalidation.test.ts": 63, + "src/renderer/src/components/automations/automation-host-list-rows.test.ts": 181, + "src/renderer/src/components/automations/automation-host-orphan-entry.test.ts": 212, + "src/renderer/src/components/automations/automation-host-picker-groups.test.ts": 50, + "src/renderer/src/components/automations/automation-host-recovery.test.ts": 65, + "src/renderer/src/components/automations/automation-host-scale-gates.test.ts": 329, + "src/renderer/src/components/automations/automation-host-scheduler.test.ts": 271, + "src/renderer/src/components/automations/automation-host-status-descriptors.test.ts": 125, + "src/renderer/src/components/automations/automation-legacy-schedule-editing.test.ts": 1590, + "src/renderer/src/components/automations/automation-list-empty-state.test.ts": 120, + "src/renderer/src/components/automations/automation-list-focus-recovery.test.ts": 52, + "src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts": 80, + "src/renderer/src/components/automations/automation-list-last-run.test.ts": 111, + "src/renderer/src/components/automations/automation-list-row-identity.test.ts": 513, + "src/renderer/src/components/automations/automation-list-search-rows.test.ts": 483, + "src/renderer/src/components/automations/automation-list-search.test.ts": 78, + "src/renderer/src/components/automations/automation-list-view-sort.test.ts": 447, + "src/renderer/src/components/automations/automation-list-view.test.ts": 127, + "src/renderer/src/components/automations/automation-project-groups.test.ts": 57, + "src/renderer/src/components/automations/automation-run-completion-evidence.test.ts": 425, + "src/renderer/src/components/automations/automation-run-context.test.ts": 55, + "src/renderer/src/components/automations/automation-run-history-keyboard-navigation.test.ts": 383, + "src/renderer/src/components/automations/automation-run-open-target.test.ts": 319, + "src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts": 121, + "src/renderer/src/components/automations/automation-run-output-snapshot.test.ts": 101, + "src/renderer/src/components/automations/automation-run-view-state.test.ts": 55, + "src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts": 425, + "src/renderer/src/components/automations/automation-runs-dashboard-model.test.ts": 52, + "src/renderer/src/components/automations/automation-scoped-list-client.test.ts": 188, + "src/renderer/src/components/automations/automation-setup-decision.test.ts": 1440, + "src/renderer/src/components/automations/automation-source-display.test.ts": 52, + "src/renderer/src/components/automations/automation-target-availability.test.ts": 77, + "src/renderer/src/components/automations/automation-usage-model.test.ts": 53, + "src/renderer/src/components/automations/automation-write-invalidation.test.ts": 52, + "src/renderer/src/components/automations/external-automation-display.test.ts": 67, + "src/renderer/src/components/automations/external-automation-list-entries.test.ts": 53, + "src/renderer/src/components/automations/external-automation-run-table-state.test.ts": 50, + "src/renderer/src/components/automations/external-automation-scope-gating.test.ts": 68, + "src/renderer/src/components/automations/external-automation-scope-keys.test.ts": 76, + "src/renderer/src/components/automations/external-automation-source-availability.test.ts": 56, + "src/renderer/src/components/automations/hermes-cron-output-parse.test.ts": 75, + "src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx": 305, + "src/renderer/src/components/automations/use-automation-host-catalog.test.tsx": 341, + "src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx": 294, + "src/renderer/src/components/automations/use-automation-list-search.test.tsx": 598, + "src/renderer/src/components/automations/use-automation-runs-dashboard-payload.test.tsx": 1210, + "src/renderer/src/components/automations/use-automation-runs-dashboard.test.tsx": 631, + "src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx": 227, + "src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx": 434, + "src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx": 886, + "src/renderer/src/components/browser-favicon.test.tsx": 383, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx": 2452, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx": 2788, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx": 2232, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.drag-focus.test.tsx": 2135, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.failure-remount.test.tsx": 2806, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.markup.test.tsx": 2693, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx": 2474, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx": 2380, + "src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx": 384, + "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx": 59, + "src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.test.ts": 150, + "src/renderer/src/components/browser-pane/annotate/MarkupOverlay.test.tsx": 606, + "src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts": 105, + "src/renderer/src/components/browser-pane/annotate/browser-grab-toast-owner-cleanup.test.tsx": 921, + "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx": 2518, + "src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.test.ts": 52, + "src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts": 69, + "src/renderer/src/components/browser-pane/annotate/markup-gesture.test.ts": 110, + "src/renderer/src/components/browser-pane/annotate/markup-screenshot-compose.test.ts": 78, + "src/renderer/src/components/browser-pane/annotate/markup-shape-hit-test.test.ts": 57, + "src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts": 55, + "src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx": 883, + "src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.test.tsx": 1134, + "src/renderer/src/components/browser-pane/annotate/use-markup-draw-hint.test.ts": 281, + "src/renderer/src/components/browser-pane/annotate/useGrabMode.test.ts": 497, + "src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts": 290, + "src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts": 246, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx": 545, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserFind.test.tsx": 413, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts": 404, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx": 1274, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion.test.ts": 50, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts": 348, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx": 350, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx": 456, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx": 1800, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts": 38, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.test.tsx": 2010, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-import-hint-visibility.test.ts": 64, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx": 421, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-page-zoom-indicator.test.tsx": 276, "src/renderer/src/components/browser-pane/assemble-chrome/browser-pane-page-selection.test.ts": 48, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx": 11725, - "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx": 2366, - "src/renderer/src/components/browser-pane/assemble-chrome/context-menu-positioning.test.ts": 29, - "src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx": 2237, - "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx": 439, - "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx": 2365, - "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.test.tsx": 1627, - "src/renderer/src/components/browser-pane/browser-client-page-metadata-publisher.test.ts": 302, - "src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts": 39, - "src/renderer/src/components/browser-pane/browser-client-page-position-driver.test.ts": 369, - "src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts": 502, - "src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts": 538, - "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts": 441, - "src/renderer/src/components/browser-pane/browser-download-destination-toast.test.ts": 47, - "src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts": 150, - "src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts": 197, - "src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts": 1993, - "src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts": 31, - "src/renderer/src/components/browser-pane/describe-page/browser-overlay-shortcut-target.test.ts": 1253, - "src/renderer/src/components/browser-pane/describe-page/browser-page-url-display.test.ts": 1564, - "src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts": 44, - "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 32, - "src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts": 304, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 1550, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts": 1555, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.test.ts": 1611, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts": 743, - "src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.test.ts": 1570, - "src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts": 483, - "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 592, - "src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts": 875, - "src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts": 829, - "src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts": 35, - "src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts": 994, - "src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts": 389, - "src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts": 554, - "src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts": 316, - "src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts": 83, - "src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts": 314, - "src/renderer/src/components/browser-pane/host-guest/browser-worktree-surface-paintability.test.ts": 45, - "src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.split.test.tsx": 2091, - "src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts": 517, - "src/renderer/src/components/browser-pane/host-guest/use-browser-page-viewport-scroll-reporting.test.tsx": 605, - "src/renderer/src/components/browser-pane/host-guest/use-webview-drag-passthrough-active.test.tsx": 365, - "src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts": 73, - "src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts": 206, - "src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts": 59, - "src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx": 670, - "src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts": 114, - "src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts": 39, - "src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts": 60, - "src/renderer/src/components/browser-pane/navigate/browser-webview-load-failure.test.ts": 143, - "src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts": 1983, - "src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts": 40, - "src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx": 2074, - "src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx": 2926, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts": 30, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts": 51, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts": 32, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx": 2538, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx": 1822, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts": 97, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts": 349, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts": 60, - "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts": 104, - "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts": 2231, - "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts": 542, - "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx": 3716, - "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx": 3550, - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts": 365, - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts": 52, - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts": 168, - "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts": 433, - "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx": 513, - "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts": 414, - "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx": 390, - "src/renderer/src/components/browser-webauthn-account-dialog.test.tsx": 813, - "src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts": 57, - "src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx": 467, - "src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts": 33, - "src/renderer/src/components/cmd-j/palette-filter-options.test.ts": 107, - "src/renderer/src/components/cmd-j/palette-filter.test.ts": 73, - "src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts": 491, - "src/renderer/src/components/cmd-j/palette-host-badge.test.ts": 126, - "src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts": 45, - "src/renderer/src/components/cmd-j/palette-live-status.test.tsx": 1777, - "src/renderer/src/components/cmd-j/palette-query-tokens.test.ts": 65, - "src/renderer/src/components/cmd-j/palette-results.test.ts": 271, - "src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts": 53, - "src/renderer/src/components/cmd-j/palette-session-age.test.ts": 69, - "src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts": 150, - "src/renderer/src/components/cmd-j/quick-action-context.test.ts": 1653, - "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 104, - "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 41, - "src/renderer/src/components/codex-restart-chip.test.tsx": 2063, - "src/renderer/src/components/comment-code-context-state.test.ts": 34, - "src/renderer/src/components/comment-reply-target-state.test.ts": 51, - "src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts": 1586, - "src/renderer/src/components/confirmation-dialog.test.tsx": 1122, - "src/renderer/src/components/confirmation-skip-preference.test.ts": 157, - "src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx": 1677, - "src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts": 1553, - "src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx": 2477, - "src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx": 2265, - "src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx": 4465, - "src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx": 1925, - "src/renderer/src/components/contextual-tours/contextual-tour-floating-position.test.ts": 2237, - "src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts": 52, - "src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts": 2147, - "src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts": 55, - "src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts": 1567, - "src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts": 1611, - "src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts": 1626, - "src/renderer/src/components/crash-report/CrashReportDialogSurface.overflow.test.tsx": 1063, - "src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 99, - "src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 469, - "src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 1770, - "src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 1455, - "src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 1261, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 2804, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx": 1099, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx": 2141, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx": 1461, - "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx": 2518, - "src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx": 824, - "src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.test.tsx": 406, - "src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts": 45, - "src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts": 42, - "src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts": 467, - "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts": 611, - "src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts": 450, - "src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts": 1921, - "src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts": 391, - "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts": 54, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx": 16057, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx": 1906, + "src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx": 1675, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx": 319, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx": 1619, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.test.tsx": 1680, + "src/renderer/src/components/browser-pane/browser-client-page-attachment-scan-budget.test.ts": 382, + "src/renderer/src/components/browser-pane/browser-client-page-metadata-publisher.test.ts": 317, + "src/renderer/src/components/browser-pane/browser-client-page-position-driver.test.ts": 398, + "src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts": 333, + "src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts": 394, + "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts": 435, + "src/renderer/src/components/browser-pane/browser-download-destination-toast.test.ts": 53, + "src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts": 287, + "src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx": 359, + "src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts": 119, + "src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts": 1571, + "src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts": 48, + "src/renderer/src/components/browser-pane/describe-page/browser-overlay-shortcut-target.test.ts": 1332, + "src/renderer/src/components/browser-pane/describe-page/browser-page-url-display.test.ts": 1382, + "src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts": 42, + "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 52, + "src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts": 149, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 1390, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts": 1745, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.test.ts": 2307, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.test.ts": 2078, + "src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts": 151, + "src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts": 1878, + "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 553, + "src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts": 1737, + "src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts": 514, + "src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts": 48, + "src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts": 684, + "src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts": 179, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts": 397, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts": 388, + "src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts": 74, + "src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts": 155, + "src/renderer/src/components/browser-pane/host-guest/browser-worktree-surface-paintability.test.ts": 55, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.split.test.tsx": 2135, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts": 316, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-viewport-scroll-reporting.test.tsx": 243, + "src/renderer/src/components/browser-pane/host-guest/use-webview-drag-passthrough-active.test.tsx": 246, + "src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts": 107, + "src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts": 319, + "src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts": 147, + "src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx": 808, + "src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts": 101, + "src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts": 64, + "src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts": 54, + "src/renderer/src/components/browser-pane/navigate/browser-webview-load-failure.test.ts": 290, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts": 1762, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts": 48, + "src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts": 510, + "src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx": 1699, + "src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx": 1593, + "src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx": 2547, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts": 49, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts": 41, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts": 49, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx": 2342, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx": 2187, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-service-failure.test.ts": 390, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts": 132, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts": 409, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts": 69, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts": 80, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts": 1962, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts": 338, + "src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.host-connection.test.tsx": 1836, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx": 3852, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx": 3045, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts": 280, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts": 62, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts": 146, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts": 154, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx": 272, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts": 191, + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx": 210, + "src/renderer/src/components/browser-webauthn-account-dialog.test.tsx": 541, + "src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx": 191, + "src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts": 49, + "src/renderer/src/components/cmd-j/palette-filter-options.test.ts": 144, + "src/renderer/src/components/cmd-j/palette-filter.test.ts": 92, + "src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts": 162, + "src/renderer/src/components/cmd-j/palette-host-badge.test.ts": 120, + "src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts": 53, + "src/renderer/src/components/cmd-j/palette-live-status.test.tsx": 1819, + "src/renderer/src/components/cmd-j/palette-query-tokens.test.ts": 66, + "src/renderer/src/components/cmd-j/palette-results.test.ts": 266, + "src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts": 79, + "src/renderer/src/components/cmd-j/palette-session-age.test.ts": 79, + "src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts": 123, + "src/renderer/src/components/cmd-j/quick-action-context.test.ts": 1708, + "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 163, + "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 58, + "src/renderer/src/components/codex-restart-chip.test.tsx": 1738, + "src/renderer/src/components/comment-code-context-state.test.ts": 56, + "src/renderer/src/components/comment-reply-target-state.test.ts": 54, + "src/renderer/src/components/confirmation-dialog.test.tsx": 833, + "src/renderer/src/components/confirmation-skip-preference.test.ts": 155, + "src/renderer/src/components/connect-dialog-outside-dismiss.test.tsx": 839, + "src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts": 1374, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx": 2051, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx": 1496, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx": 4049, + "src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx": 1962, + "src/renderer/src/components/contextual-tours/contextual-tour-floating-position.test.ts": 2054, + "src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts": 69, + "src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts": 1941, + "src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts": 68, + "src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts": 1339, + "src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts": 1428, + "src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts": 1400, + "src/renderer/src/components/crash-report/CrashReportDialogSurface.overflow.test.tsx": 544, + "src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 103, + "src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 301, + "src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 1462, + "src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 630, + "src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 732, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 2011, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx": 2133, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx": 1271, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx": 1908, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx": 2148, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx": 2784, + "src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx": 470, + "src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.test.tsx": 323, + "src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts": 56, + "src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts": 67, + "src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts": 275, + "src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts": 189, + "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts": 439, + "src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts": 155, + "src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts": 1585, + "src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts": 175, + "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts": 58, + "src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.test.ts": 107, "src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts": 77, - "src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 381, - "src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 2133, - "src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx": 1129, - "src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 1861, - "src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts": 46, - "src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts": 56, - "src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 117, - "src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts": 34, - "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.allocation.test.ts": 192, - "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.cache.test.ts": 213, - "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts": 129, - "src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts": 1728, - "src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts": 1618, - "src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts": 907, - "src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts": 1818, - "src/renderer/src/components/dashboard/dashboard-card-context.test.ts": 60, - "src/renderer/src/components/dashboard/dashboard-card-labels.test.ts": 43, - "src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts": 1383, - "src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts": 75, - "src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts": 53, - "src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts": 90, - "src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts": 218, - "src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx": 594, - "src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx": 824, - "src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts": 2326, - "src/renderer/src/components/dashboard/useRetainedAgents.test.ts": 1992, - "src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts": 971, - "src/renderer/src/components/dictation/DictationIndicator.localization.test.ts": 394, - "src/renderer/src/components/dictation/DictationIndicator.test.tsx": 761, - "src/renderer/src/components/dictation/dictation-audio-meter.test.ts": 37, - "src/renderer/src/components/dictation/dictation-final-segments.test.ts": 53, - "src/renderer/src/components/dictation/dictation-insertion-target.test.ts": 535, - "src/renderer/src/components/dictation/dictation-meter-store.test.tsx": 356, - "src/renderer/src/components/dictation/dictation-stopped-sessions.test.ts": 39, - "src/renderer/src/components/dictation/microphone-devices.test.ts": 225, - "src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx": 436, - "src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx": 833, - "src/renderer/src/components/diff-comments/DiffCommentDraftCard.test.tsx": 2221, - "src/renderer/src/components/diff-comments/diff-comment-draft-zone.test.tsx": 2636, - "src/renderer/src/components/diff-comments/diff-comment-drag-auto-scroll.test.ts": 35, - "src/renderer/src/components/diff-comments/diff-comment-line-range.test.ts": 45, - "src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx": 1653, - "src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts": 51, - "src/renderer/src/components/diff-comments/diff-comment-range-drag.test.ts": 4994, - "src/renderer/src/components/diff-comments/diff-comment-zone-mouse-events.test.ts": 75, - "src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx": 2302, - "src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx": 10560, - "src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx": 9340, - "src/renderer/src/components/editor/ChangesModeView.test.tsx": 732, - "src/renderer/src/components/editor/CheckRunCopyButton.test.tsx": 913, - "src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx": 2524, - "src/renderer/src/components/editor/ConflictComponents.test.tsx": 456, - "src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx": 2582, - "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx": 2668, - "src/renderer/src/components/editor/EditorContent.test.tsx": 2479, - "src/renderer/src/components/editor/EditorEditFileSurface.monaco-containment.test.tsx": 2094, - "src/renderer/src/components/editor/EditorFileLoadErrorView.test.tsx": 931, - "src/renderer/src/components/editor/EditorPanel.markdown-classification-memoization.test.tsx": 3127, - "src/renderer/src/components/editor/EditorPanelHeader.test.tsx": 1141, - "src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx": 3424, - "src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx": 234, - "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx": 157, - "src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx": 261, - "src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx": 671, - "src/renderer/src/components/editor/ImageViewer.test.tsx": 132, - "src/renderer/src/components/editor/IpynbKernelToolbar.test.tsx": 1349, - "src/renderer/src/components/editor/IpynbRendering.test.tsx": 2742, - "src/renderer/src/components/editor/LargeDiffLoadPrompt.test.tsx": 657, - "src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx": 1262, - "src/renderer/src/components/editor/MarkdownPreview.test.ts": 2737, - "src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx": 1364, - "src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx": 579, - "src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx": 1063, - "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx": 6383, - "src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx": 2152, - "src/renderer/src/components/editor/NotesSendMenu.test.tsx": 302, - "src/renderer/src/components/editor/PdfFind.test.tsx": 881, - "src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx": 156, - "src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 640, - "src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx": 1046, - "src/renderer/src/components/editor/RichMarkdownLinkBubble.test.ts": 437, - "src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx": 1007, - "src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx": 326, - "src/renderer/src/components/editor/RichMarkdownTableControls.test.tsx": 2235, - "src/renderer/src/components/editor/check-annotation-open.test.ts": 84, - "src/renderer/src/components/editor/check-annotation-path.test.ts": 39, - "src/renderer/src/components/editor/check-job-step-status.test.ts": 34, - "src/renderer/src/components/editor/check-run-clipboard-text.test.ts": 42, - "src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts": 542, - "src/renderer/src/components/editor/check-run-details-tab.test.ts": 33, - "src/renderer/src/components/editor/closed-editor-model-lifetime.test.ts": 2002, - "src/renderer/src/components/editor/closed-editor-model-reentrancy.test.ts": 7076, - "src/renderer/src/components/editor/closed-editor-model-registry.test.ts": 8037, - "src/renderer/src/components/editor/closed-editor-model-shell.test.tsx": 4841, - "src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts": 55, - "src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts": 219, - "src/renderer/src/components/editor/closed-editor-view-state-retention.test.ts": 4770, - "src/renderer/src/components/editor/combined-diff-on-demand-load.test.ts": 49, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-collapsed-work.test.tsx": 780, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx": 1657, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx": 501, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-windowing.test.tsx": 970, - "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts": 86, - "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx": 368, - "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.test.ts": 591, - "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts": 46, - "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts": 53, - "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts": 1433, - "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts": 38, - "src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.test.tsx": 560, - "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts": 50, + "src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 290, + "src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 2233, + "src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx": 595, + "src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx": 1822, + "src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 1741, + "src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts": 48, + "src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts": 58, + "src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 134, + "src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts": 52, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.allocation.test.ts": 381, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.cache.test.ts": 230, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts": 296, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts": 1527, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts": 1528, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts": 1542, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts": 1528, + "src/renderer/src/components/dashboard/dashboard-card-context.test.ts": 76, + "src/renderer/src/components/dashboard/dashboard-card-labels.test.ts": 77, + "src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts": 1338, + "src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts": 86, + "src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts": 60, + "src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts": 56, + "src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts": 106, + "src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts": 346, + "src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx": 273, + "src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx": 1283, + "src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts": 1818, + "src/renderer/src/components/dashboard/useRetainedAgents.test.ts": 1671, + "src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts": 1484, + "src/renderer/src/components/dictation/DictationIndicator.localization.test.ts": 257, + "src/renderer/src/components/dictation/DictationIndicator.test.tsx": 461, + "src/renderer/src/components/dictation/dictation-audio-meter.test.ts": 44, + "src/renderer/src/components/dictation/dictation-final-segments.test.ts": 48, + "src/renderer/src/components/dictation/dictation-insertion-target.test.ts": 337, + "src/renderer/src/components/dictation/dictation-meter-store.test.tsx": 305, + "src/renderer/src/components/dictation/dictation-stopped-sessions.test.ts": 54, + "src/renderer/src/components/dictation/microphone-devices.test.ts": 309, + "src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx": 261, + "src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx": 460, + "src/renderer/src/components/diff-comments/DiffCommentDraftCard.test.tsx": 2015, + "src/renderer/src/components/diff-comments/diff-comment-draft-zone.test.tsx": 2153, + "src/renderer/src/components/diff-comments/diff-comment-drag-auto-scroll.test.ts": 47, + "src/renderer/src/components/diff-comments/diff-comment-line-range.test.ts": 61, + "src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx": 1638, + "src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts": 52, + "src/renderer/src/components/diff-comments/diff-comment-range-drag.test.ts": 9586, + "src/renderer/src/components/diff-comments/diff-comment-zone-mouse-events.test.ts": 54, + "src/renderer/src/components/diff-comments/use-visible-worktree-diff-comments.test.ts": 263, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx": 2092, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx": 8367, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx": 5475, + "src/renderer/src/components/editor/ChangesModeView.test.tsx": 345, + "src/renderer/src/components/editor/CheckRunCopyButton.test.tsx": 362, + "src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx": 1906, + "src/renderer/src/components/editor/ConflictComponents.test.tsx": 213, + "src/renderer/src/components/editor/DiffSectionBody.word-wrap-lifecycle.test.tsx": 405, + "src/renderer/src/components/editor/DiffViewer.word-wrap-lifecycle.test.tsx": 376, + "src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx": 1799, + "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx": 2378, + "src/renderer/src/components/editor/EditorContent.test.tsx": 2816, + "src/renderer/src/components/editor/EditorEditFileSurface.monaco-containment.test.tsx": 1937, + "src/renderer/src/components/editor/EditorFileLoadErrorView.test.tsx": 388, + "src/renderer/src/components/editor/EditorPanel.markdown-classification-memoization.test.tsx": 3015, + "src/renderer/src/components/editor/EditorPanel.markdown-review-tools-setting.test.tsx": 2316, + "src/renderer/src/components/editor/EditorPanelHeader.test.tsx": 1367, + "src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx": 2121, + "src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx": 177, + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx": 193, + "src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx": 521, + "src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx": 719, + "src/renderer/src/components/editor/ImageViewer.test.tsx": 148, + "src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx": 1507, + "src/renderer/src/components/editor/IpynbKernelToolbar.test.tsx": 676, + "src/renderer/src/components/editor/IpynbRendering.test.tsx": 2202, + "src/renderer/src/components/editor/LargeDiffLoadPrompt.test.tsx": 338, + "src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx": 1100, + "src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx": 1077, + "src/renderer/src/components/editor/MarkdownPreview.test.ts": 2492, + "src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx": 967, + "src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx": 342, + "src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx": 1650, + "src/renderer/src/components/editor/MediaViewer.test.tsx": 221, + "src/renderer/src/components/editor/MermaidBlock.test.tsx": 522, + "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx": 5776, + "src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx": 6247, + "src/renderer/src/components/editor/MonacoEditor.owner-view-state.test.tsx": 3008, + "src/renderer/src/components/editor/MonacoEditor.system-theme.test.tsx": 2679, + "src/renderer/src/components/editor/NotesSendMenu.test.tsx": 212, + "src/renderer/src/components/editor/PdfFind.test.tsx": 582, + "src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx": 215, + "src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 344, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx": 375, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.test.ts": 151, + "src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx": 423, + "src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx": 168, + "src/renderer/src/components/editor/RichMarkdownTableControls.test.tsx": 2455, + "src/renderer/src/components/editor/check-annotation-open.test.ts": 110, + "src/renderer/src/components/editor/check-annotation-path.test.ts": 59, + "src/renderer/src/components/editor/check-job-step-status.test.ts": 49, + "src/renderer/src/components/editor/check-run-clipboard-text.test.ts": 48, + "src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts": 549, + "src/renderer/src/components/editor/check-run-details-tab.test.ts": 43, + "src/renderer/src/components/editor/closed-editor-model-lifetime.test.ts": 5487, + "src/renderer/src/components/editor/closed-editor-model-registry.test.ts": 12691, + "src/renderer/src/components/editor/closed-editor-model-shell.test.tsx": 3197, + "src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts": 51, + "src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts": 111, + "src/renderer/src/components/editor/closed-editor-view-state-retention.test.ts": 5815, + "src/renderer/src/components/editor/combined-diff-on-demand-load.test.ts": 60, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-collapsed-work.test.tsx": 693, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx": 1027, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx": 287, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-windowing.test.tsx": 775, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts": 71, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx": 273, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.test.ts": 255, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts": 49, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts": 55, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts": 1148, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts": 58, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-stale-analysis-work-budget.test.tsx": 769, + "src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.test.tsx": 731, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts": 71, "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts": 55, - "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-scaling.test.tsx": 602, - "src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx": 664, - "src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts": 48, - "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 552, - "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 59, - "src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 2238, - "src/renderer/src/components/editor/csv/csv-parse.test.ts": 122, - "src/renderer/src/components/editor/details-markdown-html.test.ts": 99, - "src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 35, - "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 48, - "src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.test.ts": 484, - "src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts": 30, - "src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts": 43, - "src/renderer/src/components/editor/diff-line-stats.test.ts": 56, - "src/renderer/src/components/editor/diff-model-swap-view-state.test.ts": 524, - "src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts": 67, - "src/renderer/src/components/editor/diff-navigation-context.test.tsx": 1956, - "src/renderer/src/components/editor/diff-section-comment-submit.test.ts": 114, - "src/renderer/src/components/editor/diff-section-layout.test.ts": 68, - "src/renderer/src/components/editor/diff-section-preview.test.ts": 1595, - "src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts": 112, - "src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts": 574, - "src/renderer/src/components/editor/editor-autosave-controller.test.ts": 500, - "src/renderer/src/components/editor/editor-autosave.test.ts": 152, - "src/renderer/src/components/editor/editor-cmd-save-target.test.ts": 225, - "src/renderer/src/components/editor/editor-content-dirty-state.test.ts": 62, - "src/renderer/src/components/editor/editor-external-watch-path-index.test.ts": 1558, - "src/renderer/src/components/editor/editor-file-save-attempt.test.ts": 70, - "src/renderer/src/components/editor/editor-header-file-rename.test.ts": 608, - "src/renderer/src/components/editor/editor-header.test.ts": 37, - "src/renderer/src/components/editor/editor-labels.test.ts": 37, - "src/renderer/src/components/editor/editor-model-uri.test.ts": 127, - "src/renderer/src/components/editor/editor-panel-diff-reload.test.ts": 26, - "src/renderer/src/components/editor/editor-panel-draft-selector.test.ts": 38, - "src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts": 63, - "src/renderer/src/components/editor/editor-panel-render-model.test.ts": 2036, - "src/renderer/src/components/editor/editor-path-display.test.ts": 38, - "src/renderer/src/components/editor/editor-path-move-inflight.test.ts": 60, - "src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts": 445, - "src/renderer/src/components/editor/editor-self-write-registry.test.ts": 69, - "src/renderer/src/components/editor/editor-shortcuts.test.ts": 526, - "src/renderer/src/components/editor/export-active-markdown.test.ts": 396, - "src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts": 46, - "src/renderer/src/components/editor/image-viewer-zoom.test.ts": 55, - "src/renderer/src/components/editor/ipynb-ansi.test.ts": 54, - "src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts": 121, - "src/renderer/src/components/editor/ipynb-kernel-outputs.test.ts": 33, - "src/renderer/src/components/editor/ipynb-kernel-session.test.ts": 183, - "src/renderer/src/components/editor/ipynb-kernel-setup-commands.test.ts": 54, - "src/renderer/src/components/editor/ipynb-parse.test.ts": 77, - "src/renderer/src/components/editor/large-diff-render-limit.test.ts": 171, - "src/renderer/src/components/editor/line-copy-path.test.ts": 34, - "src/renderer/src/components/editor/local-log-tail-decoder.test.ts": 47, - "src/renderer/src/components/editor/markdown-artifact-upload.test.ts": 64, - "src/renderer/src/components/editor/markdown-dirty-state.test.ts": 317, - "src/renderer/src/components/editor/markdown-doc-completions.test.ts": 57, - "src/renderer/src/components/editor/markdown-doc-links.test.ts": 173, - "src/renderer/src/components/editor/markdown-document-list-request.test.ts": 308, - "src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts": 1467, - "src/renderer/src/components/editor/markdown-export-extract.test.ts": 606, - "src/renderer/src/components/editor/markdown-export-html.test.ts": 47, - "src/renderer/src/components/editor/markdown-frontmatter.test.ts": 49, - "src/renderer/src/components/editor/markdown-heading-slug.test.ts": 48, - "src/renderer/src/components/editor/markdown-internal-links.test.ts": 54, - "src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts": 458, - "src/renderer/src/components/editor/markdown-preview-controls.test.ts": 45, - "src/renderer/src/components/editor/markdown-preview-links.test.ts": 74, - "src/renderer/src/components/editor/markdown-preview-local-images.test.ts": 458, - "src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx": 408, - "src/renderer/src/components/editor/markdown-preview-search.test.ts": 87, - "src/renderer/src/components/editor/markdown-preview-url-transform.test.ts": 151, - "src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts": 64, - "src/renderer/src/components/editor/markdown-render-mode.test.ts": 53, - "src/renderer/src/components/editor/markdown-rich-comment-scan.test.ts": 191, - "src/renderer/src/components/editor/markdown-rich-line-scan.test.ts": 1513, - "src/renderer/src/components/editor/markdown-rich-mode-eligibility-cache.test.ts": 1351, - "src/renderer/src/components/editor/markdown-rich-mode.test.ts": 2072, - "src/renderer/src/components/editor/markdown-rich-size-limit.test.ts": 117, - "src/renderer/src/components/editor/markdown-round-trip.test.ts": 2234, - "src/renderer/src/components/editor/markdown-scanner-boundaries.test.ts": 121, - "src/renderer/src/components/editor/markdown-table-of-contents.test.ts": 275, - "src/renderer/src/components/editor/markdown-toc-collapse-state.test.ts": 56, - "src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts": 229, - "src/renderer/src/components/editor/markdown-tokenizer-start.test.ts": 80, - "src/renderer/src/components/editor/mermaid-config.test.ts": 27, - "src/renderer/src/components/editor/monaco-auto-height.test.ts": 97, - "src/renderer/src/components/editor/monaco-codebase-search.test.ts": 71, - "src/renderer/src/components/editor/monaco-conflict-decorations.test.ts": 83, - "src/renderer/src/components/editor/monaco-content-sync.test.ts": 53, - "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts": 1055, - "src/renderer/src/components/editor/monaco-context-menu-paste.test.ts": 547, - "src/renderer/src/components/editor/monaco-cursor-line-lifetime.test.ts": 552, - "src/renderer/src/components/editor/monaco-find-options.test.ts": 36, - "src/renderer/src/components/editor/monaco-find-widget.test.ts": 492, - "src/renderer/src/components/editor/monaco-large-text-paste.test.ts": 282, - "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts": 169, - "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts": 74, - "src/renderer/src/components/editor/monaco-markdown-selection-annotation.test.ts": 41, - "src/renderer/src/components/editor/monaco-programmatic-sync.test.ts": 35, - "src/renderer/src/components/editor/monaco-reveal-range.test.ts": 53, - "src/renderer/src/components/editor/monaco-view-state-persistence.test.ts": 31, - "src/renderer/src/components/editor/pdf-js-document-options.test.ts": 34, - "src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts": 42, - "src/renderer/src/components/editor/pdf-scale-preference.test.ts": 67, - "src/renderer/src/components/editor/pdf-view-position.test.ts": 51, - "src/renderer/src/components/editor/pending-editor-focus-request.test.ts": 23, - "src/renderer/src/components/editor/position-stable-node-view-update.test.ts": 49, - "src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts": 88, - "src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts": 302, - "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts": 2399, - "src/renderer/src/components/editor/rich-markdown-auto-focus.test.ts": 425, - "src/renderer/src/components/editor/rich-markdown-block-source-work.test.ts": 2647, - "src/renderer/src/components/editor/rich-markdown-code-block-languages.test.ts": 135, - "src/renderer/src/components/editor/rich-markdown-commands.test.ts": 1938, - "src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts": 2415, - "src/renderer/src/components/editor/rich-markdown-cut.test.ts": 794, - "src/renderer/src/components/editor/rich-markdown-details-case.test.ts": 1964, - "src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts": 2040, - "src/renderer/src/components/editor/rich-markdown-doc-link.test.ts": 40, - "src/renderer/src/components/editor/rich-markdown-document-fidelity.test.ts": 2200, - "src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts": 145, - "src/renderer/src/components/editor/rich-markdown-editor-config.test.ts": 1687, - "src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts": 277, - "src/renderer/src/components/editor/rich-markdown-escape-fidelity.test.ts": 2267, - "src/renderer/src/components/editor/rich-markdown-extension.test.ts": 278, - "src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts": 2950, - "src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts": 1229, - "src/renderer/src/components/editor/rich-markdown-image-insert.test.ts": 1296, - "src/renderer/src/components/editor/rich-markdown-inline-image-paragraph.test.ts": 1666, - "src/renderer/src/components/editor/rich-markdown-inline-source-tokenization.test.ts": 1619, - "src/renderer/src/components/editor/rich-markdown-key-handler.test.ts": 2540, - "src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts": 435, - "src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts": 346, - "src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts": 2061, - "src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts": 337, - "src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts": 94, - "src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts": 189, - "src/renderer/src/components/editor/rich-markdown-local-image.test.ts": 1442, - "src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts": 173, - "src/renderer/src/components/editor/rich-markdown-lowlight.test.ts": 3282, - "src/renderer/src/components/editor/rich-markdown-normalize.test.ts": 217, - "src/renderer/src/components/editor/rich-markdown-paragraph.test.ts": 72, - "src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts": 71, - "src/renderer/src/components/editor/rich-markdown-paste-image.test.ts": 290, - "src/renderer/src/components/editor/rich-markdown-range-bounds.test.ts": 77, - "src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts": 62, - "src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts": 69, - "src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts": 1569, - "src/renderer/src/components/editor/rich-markdown-review-rail-blocks.test.ts": 2656, - "src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts": 70, - "src/renderer/src/components/editor/rich-markdown-save-benchmark.test.ts": 1966, - "src/renderer/src/components/editor/rich-markdown-search-matches-cache.test.ts": 98, - "src/renderer/src/components/editor/rich-markdown-search.test.ts": 84, - "src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts": 1584, - "src/renderer/src/components/editor/rich-markdown-slash-command-filter.test.ts": 59, - "src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts": 2386, - "src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts": 2323, - "src/renderer/src/components/editor/rich-markdown-table-actions.test.ts": 2946, - "src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts": 54, - "src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts": 2006, - "src/renderer/src/components/editor/rich-markdown-table.test.ts": 108, - "src/renderer/src/components/editor/rich-markdown-terminal-path-paste.test.ts": 333, - "src/renderer/src/components/editor/rich-markdown-toc-heading-target.test.ts": 261, - "src/renderer/src/components/editor/rich-markdown-trailing-paragraph.test.ts": 1766, - "src/renderer/src/components/editor/selection-copy.test.ts": 56, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-scaling.test.tsx": 681, + "src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx": 265, + "src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts": 59, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.test.ts": 47, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 740, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 68, + "src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 1135, + "src/renderer/src/components/editor/csv/CsvGrid.test.tsx": 765, + "src/renderer/src/components/editor/csv/CsvViewer.delimiter.test.tsx": 2535, + "src/renderer/src/components/editor/csv/CsvViewer.editing.test.tsx": 3037, + "src/renderer/src/components/editor/csv/csv-byte-index.test.ts": 1733, + "src/renderer/src/components/editor/csv/csv-cell-links.test.tsx": 294, + "src/renderer/src/components/editor/csv/csv-column-width-preferences.test.ts": 144, + "src/renderer/src/components/editor/csv/csv-paged-preview.test.ts": 94, + "src/renderer/src/components/editor/csv/csv-parse-budget.test.ts": 100, + "src/renderer/src/components/editor/csv/csv-parse.test.ts": 125, + "src/renderer/src/components/editor/csv/csv-pending-save.test.ts": 1349, + "src/renderer/src/components/editor/csv/csv-preview-save-safety.test.ts": 1483, + "src/renderer/src/components/editor/csv/csv-table-interactions.test.ts": 118, + "src/renderer/src/components/editor/csv/csv-text-document.test.ts": 139, + "src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx": 396, + "src/renderer/src/components/editor/details-markdown-html.test.ts": 78, + "src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 54, + "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 73, + "src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.test.ts": 155, + "src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts": 48, + "src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts": 180, + "src/renderer/src/components/editor/diff-line-stats.test.ts": 58, + "src/renderer/src/components/editor/diff-model-swap-view-state.test.ts": 145, + "src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts": 51, + "src/renderer/src/components/editor/diff-navigation-context.test.tsx": 1732, + "src/renderer/src/components/editor/diff-section-comment-submit.test.ts": 99, + "src/renderer/src/components/editor/diff-section-layout.test.ts": 87, + "src/renderer/src/components/editor/diff-section-preview.test.ts": 1498, + "src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts": 93, + "src/renderer/src/components/editor/drag-selection-guard-lifetime.test.ts": 563, + "src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts": 634, + "src/renderer/src/components/editor/editor-autosave-controller.test.ts": 652, + "src/renderer/src/components/editor/editor-autosave.test.ts": 279, + "src/renderer/src/components/editor/editor-cmd-save-target.test.ts": 258, + "src/renderer/src/components/editor/editor-content-dirty-state.test.ts": 83, + "src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts": 362, + "src/renderer/src/components/editor/editor-dropped-file-open.test.ts": 1242, + "src/renderer/src/components/editor/editor-external-watch-path-index.test.ts": 1264, + "src/renderer/src/components/editor/editor-file-save-attempt.test.ts": 57, + "src/renderer/src/components/editor/editor-header-file-rename.test.ts": 1748, + "src/renderer/src/components/editor/editor-header.test.ts": 59, + "src/renderer/src/components/editor/editor-labels.test.ts": 54, + "src/renderer/src/components/editor/editor-model-host-isolation.test.ts": 2625, + "src/renderer/src/components/editor/editor-model-owner.test.ts": 309, + "src/renderer/src/components/editor/editor-model-uri.test.ts": 131, + "src/renderer/src/components/editor/editor-panel-diff-reload.test.ts": 46, + "src/renderer/src/components/editor/editor-panel-draft-selector.test.ts": 64, + "src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts": 54, + "src/renderer/src/components/editor/editor-panel-render-model.test.ts": 1971, + "src/renderer/src/components/editor/editor-path-display.test.ts": 52, + "src/renderer/src/components/editor/editor-path-move-inflight.test.ts": 54, + "src/renderer/src/components/editor/editor-pending-flush.test.ts": 38, + "src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts": 624, + "src/renderer/src/components/editor/editor-save-user-named-tab.test.ts": 547, + "src/renderer/src/components/editor/editor-self-write-registry.test.ts": 93, + "src/renderer/src/components/editor/editor-shortcuts.test.ts": 225, + "src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx": 668, + "src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts": 837, + "src/renderer/src/components/editor/export-active-markdown.test.ts": 128, + "src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts": 55, + "src/renderer/src/components/editor/image-viewer-zoom.test.ts": 63, + "src/renderer/src/components/editor/ipynb-ansi.test.ts": 70, + "src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts": 142, + "src/renderer/src/components/editor/ipynb-kernel-outputs.test.ts": 318, + "src/renderer/src/components/editor/ipynb-kernel-session-start-lifetime.test.ts": 113, + "src/renderer/src/components/editor/ipynb-kernel-session.test.ts": 182, + "src/renderer/src/components/editor/ipynb-kernel-setup-commands.test.ts": 58, + "src/renderer/src/components/editor/ipynb-parse.test.ts": 80, + "src/renderer/src/components/editor/large-diff-render-limit.test.ts": 168, + "src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx": 2232, + "src/renderer/src/components/editor/line-copy-path.test.ts": 45, + "src/renderer/src/components/editor/local-log-tail-decoder.test.ts": 113, + "src/renderer/src/components/editor/markdown-artifact-upload.test.ts": 68, + "src/renderer/src/components/editor/markdown-dirty-state.test.ts": 274, + "src/renderer/src/components/editor/markdown-doc-completions.test.ts": 59, + "src/renderer/src/components/editor/markdown-doc-links.test.ts": 139, + "src/renderer/src/components/editor/markdown-document-list-request.test.ts": 379, + "src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts": 1527, + "src/renderer/src/components/editor/markdown-export-extract.test.ts": 218, + "src/renderer/src/components/editor/markdown-export-html.test.ts": 58, + "src/renderer/src/components/editor/markdown-frontmatter.test.ts": 53, + "src/renderer/src/components/editor/markdown-heading-slug.test.ts": 42, + "src/renderer/src/components/editor/markdown-internal-links.test.ts": 74, + "src/renderer/src/components/editor/markdown-preview-anchor-navigation.test.ts": 135, + "src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts": 177, + "src/renderer/src/components/editor/markdown-preview-controls.test.ts": 91, + "src/renderer/src/components/editor/markdown-preview-document-client.test.ts": 58, + "src/renderer/src/components/editor/markdown-preview-document-search-budget.test.ts": 357, + "src/renderer/src/components/editor/markdown-preview-document-search-cache.test.ts": 552, + "src/renderer/src/components/editor/markdown-preview-document-search-scope.test.ts": 385, + "src/renderer/src/components/editor/markdown-preview-document.test.ts": 1525, + "src/renderer/src/components/editor/markdown-preview-links.test.ts": 67, + "src/renderer/src/components/editor/markdown-preview-local-images.test.ts": 1715, + "src/renderer/src/components/editor/markdown-preview-review-frame-lifecycle.test.tsx": 325, + "src/renderer/src/components/editor/markdown-preview-row-measurements.test.ts": 167, + "src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx": 230, + "src/renderer/src/components/editor/markdown-preview-search-memory.test.ts": 171, + "src/renderer/src/components/editor/markdown-preview-search-offsets.test.ts": 518, + "src/renderer/src/components/editor/markdown-preview-search.test.ts": 79, + "src/renderer/src/components/editor/markdown-preview-table-chunks.test.ts": 6270, + "src/renderer/src/components/editor/markdown-preview-url-transform.test.ts": 95, + "src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts": 77, + "src/renderer/src/components/editor/markdown-render-mode.test.ts": 50, + "src/renderer/src/components/editor/markdown-rich-comment-scan.test.ts": 323, + "src/renderer/src/components/editor/markdown-rich-line-scan.test.ts": 1628, + "src/renderer/src/components/editor/markdown-rich-mode-eligibility-cache.test.ts": 3909, + "src/renderer/src/components/editor/markdown-rich-mode.test.ts": 2831, + "src/renderer/src/components/editor/markdown-rich-size-limit.test.ts": 340, + "src/renderer/src/components/editor/markdown-round-trip.test.ts": 2101, + "src/renderer/src/components/editor/markdown-scanner-boundaries.test.ts": 100, + "src/renderer/src/components/editor/markdown-table-of-contents.test.ts": 166, + "src/renderer/src/components/editor/markdown-toc-collapse-state.test.ts": 53, + "src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts": 105, + "src/renderer/src/components/editor/markdown-tokenizer-start.test.ts": 97, + "src/renderer/src/components/editor/mermaid-config.test.ts": 44, + "src/renderer/src/components/editor/monaco-auto-height.test.ts": 114, + "src/renderer/src/components/editor/monaco-codebase-search.test.ts": 52, + "src/renderer/src/components/editor/monaco-conflict-decorations.test.ts": 68, + "src/renderer/src/components/editor/monaco-content-sync.test.ts": 67, + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts": 1193, + "src/renderer/src/components/editor/monaco-context-menu-paste.test.ts": 208, + "src/renderer/src/components/editor/monaco-cursor-line-lifetime.test.ts": 1102, + "src/renderer/src/components/editor/monaco-find-options.test.ts": 44, + "src/renderer/src/components/editor/monaco-find-widget.test.ts": 167, + "src/renderer/src/components/editor/monaco-large-text-paste.test.ts": 208, + "src/renderer/src/components/editor/monaco-markdown-doc-completions-retention.test.ts": 64, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts": 267, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts": 82, + "src/renderer/src/components/editor/monaco-markdown-selection-annotation.test.ts": 49, + "src/renderer/src/components/editor/monaco-programmatic-sync.test.ts": 54, + "src/renderer/src/components/editor/monaco-reveal-range.test.ts": 45, + "src/renderer/src/components/editor/monaco-view-state-persistence.test.ts": 55, + "src/renderer/src/components/editor/pdf-js-document-options.test.ts": 46, + "src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts": 65, + "src/renderer/src/components/editor/pdf-scale-preference.test.ts": 71, + "src/renderer/src/components/editor/pdf-view-position.test.ts": 73, + "src/renderer/src/components/editor/pdf-viewer-failed-load-recovery.test.tsx": 2246, + "src/renderer/src/components/editor/pdf-viewer-zoom-ownership.test.tsx": 933, + "src/renderer/src/components/editor/pdf-zoom-request.test.ts": 137, + "src/renderer/src/components/editor/pending-editor-focus-request.test.ts": 34, + "src/renderer/src/components/editor/position-stable-node-view-update.test.ts": 46, + "src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts": 82, + "src/renderer/src/components/editor/raw-markdown-html-line-start.test.ts": 62, + "src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts": 195, + "src/renderer/src/components/editor/raw-markdown-html.details-fences.test.ts": 64, + "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts": 2235, + "src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx": 1466, + "src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts": 1484, + "src/renderer/src/components/editor/rich-markdown-async-image-table.test.ts": 1133, + "src/renderer/src/components/editor/rich-markdown-auto-focus.test.ts": 197, + "src/renderer/src/components/editor/rich-markdown-code-block-languages.test.ts": 92, + "src/renderer/src/components/editor/rich-markdown-commands.test.ts": 1846, + "src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts": 2049, + "src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts": 1887, + "src/renderer/src/components/editor/rich-markdown-cut.test.ts": 293, + "src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts": 3545, + "src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts": 306, + "src/renderer/src/components/editor/rich-markdown-doc-link-resolution.test.ts": 248, + "src/renderer/src/components/editor/rich-markdown-doc-link.test.ts": 51, + "src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts": 695, + "src/renderer/src/components/editor/rich-markdown-editor-config.test.ts": 1952, + "src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts": 208, + "src/renderer/src/components/editor/rich-markdown-escape-fidelity.test.ts": 1526, + "src/renderer/src/components/editor/rich-markdown-extension.test.ts": 260, + "src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts": 3213, + "src/renderer/src/components/editor/rich-markdown-image-context.test.ts": 675, + "src/renderer/src/components/editor/rich-markdown-image-cut.test.ts": 2226, + "src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts": 1165, + "src/renderer/src/components/editor/rich-markdown-image-insert.test.ts": 770, + "src/renderer/src/components/editor/rich-markdown-image-insertion-target.test.ts": 2190, + "src/renderer/src/components/editor/rich-markdown-inline-source-tokenization.test.ts": 2521, + "src/renderer/src/components/editor/rich-markdown-key-handler.test.ts": 2196, + "src/renderer/src/components/editor/rich-markdown-large-html.test.ts": 3625, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.fidelity.test.ts": 1997, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts": 2208, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts": 1824, + "src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts": 140, + "src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts": 1507, + "src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts": 150, + "src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts": 87, + "src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts": 160, + "src/renderer/src/components/editor/rich-markdown-local-image.test.ts": 1491, + "src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts": 149, + "src/renderer/src/components/editor/rich-markdown-lowlight.test.ts": 1932, + "src/renderer/src/components/editor/rich-markdown-normalize.test.ts": 138, + "src/renderer/src/components/editor/rich-markdown-paragraph.test.ts": 90, + "src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts": 74, + "src/renderer/src/components/editor/rich-markdown-paste-image.test.ts": 494, + "src/renderer/src/components/editor/rich-markdown-range-bounds.test.ts": 94, + "src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts": 109, + "src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts": 58, + "src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts": 1687, + "src/renderer/src/components/editor/rich-markdown-review-rail-blocks.test.ts": 2390, + "src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts": 74, + "src/renderer/src/components/editor/rich-markdown-save-benchmark.test.ts": 1604, + "src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts": 316, + "src/renderer/src/components/editor/rich-markdown-search-focus.test.ts": 226, + "src/renderer/src/components/editor/rich-markdown-search-matches-cache.test.ts": 116, + "src/renderer/src/components/editor/rich-markdown-search.test.ts": 103, + "src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts": 1408, + "src/renderer/src/components/editor/rich-markdown-signed-images.test.ts": 1568, + "src/renderer/src/components/editor/rich-markdown-slash-command-filter.test.ts": 47, + "src/renderer/src/components/editor/rich-markdown-source-eol-allocation.test.ts": 285, + "src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts": 1937, + "src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts": 2528, + "src/renderer/src/components/editor/rich-markdown-table-actions.test.ts": 2439, + "src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts": 51, + "src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts": 1783, + "src/renderer/src/components/editor/rich-markdown-table.test.ts": 84, + "src/renderer/src/components/editor/rich-markdown-terminal-path-paste.test.ts": 150, + "src/renderer/src/components/editor/rich-markdown-toc-heading-target.test.ts": 168, + "src/renderer/src/components/editor/rich-markdown-trailing-paragraph.test.ts": 2081, + "src/renderer/src/components/editor/selection-copy.test.ts": 63, "src/renderer/src/components/editor/setup-contextual-copy.test.ts": 93, - "src/renderer/src/components/editor/untitled-file-rename-path.test.ts": 49, - "src/renderer/src/components/editor/use-monaco-editor-decorations.doc-link-refresh.test.tsx": 398, - "src/renderer/src/components/editor/use-monaco-editor-mount.cursor-owner.test.tsx": 1210, - "src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx": 1455, - "src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx": 2651, - "src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx": 628, - "src/renderer/src/components/editor/useEditorCmdSaveRequest.test.tsx": 494, - "src/renderer/src/components/editor/useEditorPanelContentState.test.tsx": 2597, - "src/renderer/src/components/editor/useEditorPanelExternalContentEvents.test.tsx": 567, - "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx": 600, - "src/renderer/src/components/editor/useEditorPanelHostUnresolvedContentState.test.tsx": 550, - "src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx": 556, - "src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx": 791, - "src/renderer/src/components/editor/useIpynbCellExecution.test.tsx": 768, - "src/renderer/src/components/editor/useIpynbDocumentEditing.test.tsx": 359, - "src/renderer/src/components/editor/useLinkBubble.test.tsx": 638, - "src/renderer/src/components/editor/useLocalImageSrc.test.ts": 819, - "src/renderer/src/components/editor/useLocalLogTail.test.tsx": 506, - "src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts": 674, - "src/renderer/src/components/editor/useMarkdownDocuments.test.ts": 964, - "src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx": 384, - "src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx": 589, - "src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts": 532, - "src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts": 2348, - "src/renderer/src/components/editor/useRichMarkdownReviewController.open-guard.test.ts": 673, - "src/renderer/src/components/editor/useRichMarkdownSearch.reuse.test.tsx": 500, - "src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx": 1658, - "src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx": 939, - "src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts": 32, - "src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx": 951, - "src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx": 761, - "src/renderer/src/components/emulator-pane/emulator-device-state.test.ts": 29, - "src/renderer/src/components/emulator-pane/emulator-keyboard-paste.test.ts": 325, - "src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts": 66, - "src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx": 754, - "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.test.ts": 60, - "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-visibility.test.ts": 37, - "src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx": 525, - "src/renderer/src/components/emulator-pane/mobile-emulator-tab-intro-visibility.test.ts": 37, - "src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx": 2088, - "src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx": 1609, - "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx": 1006, - "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx": 524, - "src/renderer/src/components/error-boundaries/react-185-bystander-attribution.test.tsx": 542, - "src/renderer/src/components/feature-interaction-writer-boundaries.test.ts": 51, - "src/renderer/src/components/feature-tips/CliSkillSetupTerminal.test.tsx": 1232, - "src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx": 812, - "src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.test.tsx": 264, - "src/renderer/src/components/feature-tips/SessionSearchTipDialog.test.tsx": 601, - "src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx": 557, - "src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx": 875, - "src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts": 52, - "src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts": 179, - "src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts": 218, - "src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts": 57, - "src/renderer/src/components/feature-tips/use-session-search-tip-setup.test.tsx": 702, - "src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts": 1578, - "src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx": 658, - "src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx": 1337, - "src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts": 881, - "src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx": 1030, - "src/renderer/src/components/feature-wall/agents-orchestration/UsageAccountsCard.test.tsx": 711, + "src/renderer/src/components/editor/untitled-file-rename-path.test.ts": 48, + "src/renderer/src/components/editor/use-editor-content-change-handler.test.tsx": 1784, + "src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx": 2275, + "src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx": 1048, + "src/renderer/src/components/editor/use-markdown-preview-document-search.test.tsx": 228, + "src/renderer/src/components/editor/use-markdown-preview-document.test.tsx": 290, + "src/renderer/src/components/editor/use-markdown-preview-navigation.test.tsx": 512, + "src/renderer/src/components/editor/use-markdown-preview-search-reveal.test.tsx": 372, + "src/renderer/src/components/editor/use-monaco-editor-decorations.doc-link-refresh.test.tsx": 206, + "src/renderer/src/components/editor/use-monaco-editor-mount.cursor-owner.test.tsx": 1084, + "src/renderer/src/components/editor/use-pdf-viewer-find-shortcut.test.tsx": 740, + "src/renderer/src/components/editor/use-quick-open-history-visit.test.tsx": 225, + "src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx": 2188, + "src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx": 2157, + "src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx": 273, + "src/renderer/src/components/editor/useEditorCmdSaveRequest.test.tsx": 455, + "src/renderer/src/components/editor/useEditorPanelContentState.test.tsx": 1443, + "src/renderer/src/components/editor/useEditorPanelExternalContentEvents.test.tsx": 461, + "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.host-connection.test.tsx": 1687, + "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx": 1705, + "src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx": 615, + "src/renderer/src/components/editor/useEditorPanelHostUnresolvedContentState.test.tsx": 784, + "src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx": 579, + "src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx": 660, + "src/renderer/src/components/editor/useIpynbCellExecution.test.tsx": 431, + "src/renderer/src/components/editor/useIpynbDocumentEditing.test.tsx": 349, + "src/renderer/src/components/editor/useLinkBubble.test.tsx": 356, + "src/renderer/src/components/editor/useLocalImagePick.test.tsx": 1197, + "src/renderer/src/components/editor/useLocalImageSrc.test.ts": 587, + "src/renderer/src/components/editor/useLocalLogTail.test.tsx": 511, + "src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts": 573, + "src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx": 1885, + "src/renderer/src/components/editor/useMarkdownDocuments.test.ts": 1520, + "src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts": 438, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx": 340, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx": 265, + "src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts": 249, + "src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts": 1933, + "src/renderer/src/components/editor/useRichMarkdownReviewController.open-guard.test.ts": 418, + "src/renderer/src/components/editor/useRichMarkdownReviewData.test.ts": 302, + "src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx": 617, + "src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx": 577, + "src/renderer/src/components/editor/useRichMarkdownSearch.reuse.test.tsx": 355, + "src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx": 1817, + "src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx": 386, + "src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts": 54, + "src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx": 843, + "src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx": 577, + "src/renderer/src/components/emulator-pane/emulator-device-state.test.ts": 55, + "src/renderer/src/components/emulator-pane/emulator-keyboard-paste.test.ts": 198, + "src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts": 83, + "src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx": 371, + "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-visibility.test.ts": 46, + "src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx": 214, + "src/renderer/src/components/emulator-pane/mobile-emulator-tab-intro-visibility.test.ts": 45, + "src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx": 1737, + "src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx": 1585, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx": 289, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx": 296, + "src/renderer/src/components/error-boundaries/react-185-bystander-attribution.test.tsx": 391, + "src/renderer/src/components/feature-tips/CliSkillSetupTerminal.test.tsx": 1870, + "src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx": 325, + "src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.test.tsx": 169, + "src/renderer/src/components/feature-tips/NativeChatUpgradeTipDialog.test.tsx": 1996, + "src/renderer/src/components/feature-tips/SessionSearchTipDialog.test.tsx": 374, + "src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx": 338, + "src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx": 269, + "src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts": 56, + "src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts": 269, + "src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts": 299, + "src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts": 66, + "src/renderer/src/components/feature-tips/use-session-search-tip-setup.test.tsx": 441, + "src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts": 1436, + "src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx": 749, + "src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx": 1754, + "src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts": 330, + "src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx": 641, + "src/renderer/src/components/feature-wall/agents-orchestration/UsageAccountsCard.test.tsx": 383, "src/renderer/src/components/feature-wall/browser-animated-visual-sequence.test.ts": 62, - "src/renderer/src/components/feature-wall/feature-wall-animation-visibility.test.tsx": 729, - "src/renderer/src/components/feature-wall/feature-wall-rail-navigation.test.ts": 62, - "src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts": 233, - "src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts": 69, - "src/renderer/src/components/feature-wall/feature-wall-shortcut-labels.test.tsx": 2097, - "src/renderer/src/components/feature-wall/feature-wall-usage-tracking.test.ts": 104, - "src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts": 44, - "src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts": 69, - "src/renderer/src/components/feature-wall/use-integration-connection-status.test.ts": 1530, - "src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts": 58, - "src/renderer/src/components/file-path-cursor-tooltip.test.ts": 428, - "src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx": 379, - "src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx": 624, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx": 740, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx": 576, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx": 1034, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx": 428, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx": 904, - "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx": 1123, - "src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx": 152, - "src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx": 238, - "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts": 44, - "src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts": 102, - "src/renderer/src/components/floating-terminal/floating-terminal-panel-view-state.test.ts": 242, - "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts": 69, - "src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 1642, - "src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts": 59, - "src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts": 1592, - "src/renderer/src/components/github-body-draft-state.test.ts": 61, - "src/renderer/src/components/github-checks-tab-state.test.ts": 51, - "src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts": 50, - "src/renderer/src/components/github-item-dialog-source-boundary.test.ts": 47, - "src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx": 647, - "src/renderer/src/components/github-link-copy-state.test.ts": 56, - "src/renderer/src/components/github-pr-merge-state.test.ts": 94, - "src/renderer/src/components/github-pr-reviewer-display.test.ts": 57, - "src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts": 414, - "src/renderer/src/components/github-project/ProjectRoadmap.test.tsx": 2001, - "src/renderer/src/components/github-project/ProjectViewStates.test.tsx": 757, - "src/renderer/src/components/github-project/github-project-picker-filter.test.ts": 62, - "src/renderer/src/components/github-project/group-sort.test.ts": 40, - "src/renderer/src/components/github-project/project-dialog-state.test.ts": 55, - "src/renderer/src/components/github-project/project-picker-browse-cache.test.ts": 51, - "src/renderer/src/components/github-project/project-picker-input.test.ts": 918, - "src/renderer/src/components/github-project/project-row-filtering.test.ts": 51, - "src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts": 44, - "src/renderer/src/components/github-project/project-visible-table-cache.test.ts": 49, - "src/renderer/src/components/github/PRFilterPickers.test.ts": 310, - "src/renderer/src/components/github/github-markdown-image-url.test.ts": 33, - "src/renderer/src/components/github/github-mention-option-filter.test.ts": 34, - "src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts": 74, - "src/renderer/src/components/github/github-user-avatar.test.tsx": 560, - "src/renderer/src/components/github/github-work-item-assignee-filter.test.ts": 61, - "src/renderer/src/components/github/github-work-item-label-filter.test.ts": 42, - "src/renderer/src/components/github/pr-comment-code-context.test.ts": 73, - "src/renderer/src/components/github/pr-file-content-size.test.ts": 36, - "src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts": 63, - "src/renderer/src/components/github/use-image-input.test.ts": 2130, - "src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts": 40, - "src/renderer/src/components/jira-create-adf.test.ts": 61, - "src/renderer/src/components/jira-project-picker-filter.test.ts": 40, - "src/renderer/src/components/landing-preflight-dismissal.test.ts": 67, - "src/renderer/src/components/landing-preflight-issues.test.ts": 123, - "src/renderer/src/components/landing-preflight-runtime-boundary.test.ts": 1359, - "src/renderer/src/components/landing-preflight-unverifiable-host.test.tsx": 2022, - "src/renderer/src/components/linear-api-key-dialog-state.test.ts": 51, - "src/renderer/src/components/linear-issue-attribute-filter-coverage-agreement.test.ts": 1177, - "src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx": 1616, - "src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts": 64, - "src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts": 86, - "src/renderer/src/components/linear-issue-project-selector.test.tsx": 766, + "src/renderer/src/components/feature-wall/feature-wall-animation-visibility.test.tsx": 638, + "src/renderer/src/components/feature-wall/feature-wall-rail-navigation.test.ts": 57, + "src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts": 552, + "src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts": 61, + "src/renderer/src/components/feature-wall/feature-wall-shortcut-labels.test.tsx": 1718, + "src/renderer/src/components/feature-wall/feature-wall-usage-tracking.test.ts": 91, + "src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts": 85, + "src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts": 55, + "src/renderer/src/components/feature-wall/use-integration-connection-status.test.ts": 2054, + "src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts": 54, + "src/renderer/src/components/file-path-cursor-tooltip.test.ts": 177, + "src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx": 491, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx": 4024, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx": 7004, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx": 2710, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx": 4440, + "src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx": 126, + "src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx": 433, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts": 57, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts": 313, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-view-state.test.ts": 172, + "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts": 68, + "src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts": 228, + "src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 1744, + "src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx": 2068, + "src/renderer/src/components/github-body-draft-state.test.ts": 54, + "src/renderer/src/components/github-checks-tab-state.test.ts": 61, + "src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx": 414, + "src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx": 330, + "src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts": 69, + "src/renderer/src/components/github-link-copy-state.test.ts": 45, + "src/renderer/src/components/github-pr-merge-state.test.ts": 99, + "src/renderer/src/components/github-pr-reviewer-display.test.ts": 61, + "src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts": 180, + "src/renderer/src/components/github-project/ProjectBoard.test.tsx": 554, + "src/renderer/src/components/github-project/ProjectRoadmap.test.tsx": 2087, + "src/renderer/src/components/github-project/ProjectViewStates.test.tsx": 459, + "src/renderer/src/components/github-project/github-project-picker-filter.test.ts": 67, + "src/renderer/src/components/github-project/group-sort.test.ts": 89, + "src/renderer/src/components/github-project/project-dialog-state.test.ts": 46, + "src/renderer/src/components/github-project/project-picker-browse-cache.test.ts": 52, + "src/renderer/src/components/github-project/project-picker-input.test.ts": 1461, + "src/renderer/src/components/github-project/project-row-filtering.test.ts": 68, + "src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts": 64, + "src/renderer/src/components/github-project/project-visible-table-cache.test.ts": 57, + "src/renderer/src/components/github/PRFilterPickers.test.ts": 173, + "src/renderer/src/components/github/github-markdown-image-url.test.ts": 53, + "src/renderer/src/components/github/github-mention-option-filter.test.ts": 75, + "src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts": 66, + "src/renderer/src/components/github/github-user-avatar.test.tsx": 238, + "src/renderer/src/components/github/github-work-item-assignee-filter.test.ts": 70, + "src/renderer/src/components/github/github-work-item-issue-target.test.ts": 163, + "src/renderer/src/components/github/github-work-item-label-filter.test.ts": 75, + "src/renderer/src/components/github/github-work-item-mutation-target.test.ts": 144, + "src/renderer/src/components/github/pr-comment-code-context.test.ts": 79, + "src/renderer/src/components/github/pr-file-content-size.test.ts": 57, + "src/renderer/src/components/github/use-image-input.test.ts": 2248, + "src/renderer/src/components/jira-create-adf.test.ts": 59, + "src/renderer/src/components/jira-issue-workspace-request-lifetime.test.tsx": 444, + "src/renderer/src/components/jira-project-picker-filter.test.ts": 59, + "src/renderer/src/components/jira-self-user.test.ts": 58, + "src/renderer/src/components/landing-preflight-dismissal.test.ts": 66, + "src/renderer/src/components/landing-preflight-issues.test.ts": 107, + "src/renderer/src/components/landing-preflight-runtime-boundary.test.ts": 1928, + "src/renderer/src/components/landing-preflight-unverifiable-host.test.tsx": 1541, + "src/renderer/src/components/linear-api-key-dialog-state.test.ts": 49, + "src/renderer/src/components/linear-issue-attribute-filter-coverage-agreement.test.ts": 633, + "src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx": 1593, + "src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts": 63, + "src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts": 83, + "src/renderer/src/components/linear-issue-project-selector.test.tsx": 453, "src/renderer/src/components/linear-issue-text-draft-state.test.ts": 54, - "src/renderer/src/components/linear-issue-text-save-plan.test.ts": 32, - "src/renderer/src/components/linear-issue-view-storage.test.ts": 528, - "src/renderer/src/components/linear-issue-workspace-api-parity.test.ts": 27, - "src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx": 393, - "src/renderer/src/components/linear-issue-workspace-header.test.ts": 50, - "src/renderer/src/components/linear-issue-workspace-text.test.ts": 127, - "src/renderer/src/components/linear-project-presentation.test.ts": 44, - "src/renderer/src/components/linear-project-search-query.test.ts": 50, - "src/renderer/src/components/linear-project-view-surfaces.test.tsx": 1038, - "src/renderer/src/components/linear-scope-selector.test.ts": 450, - "src/renderer/src/components/link-actions/LinkActionPopover.test.tsx": 804, - "src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts": 116, - "src/renderer/src/components/mobile/MobileHero.test.tsx": 2957, - "src/renderer/src/components/mobile/MobilePage.test.tsx": 1990, - "src/renderer/src/components/mobile/MobilePageToolbar.test.tsx": 606, - "src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx": 1041, - "src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx": 827, - "src/renderer/src/components/mobile/mobile-page-stage.test.ts": 45, - "src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 481, - "src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 361, - "src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx": 623, - "src/renderer/src/components/native-chat-restart-action-notifications.test.ts": 113, - "src/renderer/src/components/native-chat-resume-activity-label.test.ts": 108, - "src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts": 1229, - "src/renderer/src/components/native-chat-resume-failure-guidance.test.ts": 84, - "src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts": 56, - "src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx": 1564, - "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx": 872, - "src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.test.tsx": 990, - "src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx": 1274, - "src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx": 874, - "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx": 1457, - "src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx": 565, - "src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx": 750, - "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx": 688, - "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx": 986, - "src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx": 3718, - "src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx": 4072, - "src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx": 4416, - "src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx": 2482, - "src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx": 11804, - "src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx": 3611, - "src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx": 3017, - "src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx": 3343, - "src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx": 4901, - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx": 2633, - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx": 2349, - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx": 3391, - "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx": 1597, - "src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx": 5207, - "src/renderer/src/components/native-chat/NativeChatMessageRail.test.tsx": 1294, - "src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx": 2046, - "src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx": 752, - "src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx": 2656, - "src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx": 1266, - "src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx": 947, - "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx": 1217, - "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx": 950, - "src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx": 2046, - "src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx": 2769, - "src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx": 1475, - "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx": 16705, - "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx": 17563, - "src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx": 979, - "src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx": 1078, - "src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx": 1027, - "src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx": 1215, - "src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx": 868, - "src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx": 898, - "src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx": 967, - "src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx": 1191, - "src/renderer/src/components/native-chat/NativeChatView.test.tsx": 586, - "src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx": 1682, - "src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx": 419, - "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx": 5346, - "src/renderer/src/components/native-chat/background-task-roster.test.ts": 98, - "src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts": 1924, - "src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts": 124, - "src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts": 45, - "src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts": 95, - "src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts": 1516, - "src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts": 33, - "src/renderer/src/components/native-chat/native-chat-availability.test.ts": 39, - "src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx": 2042, - "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx": 2490, - "src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts": 38, - "src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 1320, - "src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 622, - "src/shared/native-chat-scope-cache.test.ts": 47, - "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 98, - "src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx": 1742, - "src/renderer/src/components/native-chat/native-chat-context-usage-summary.test.ts": 54, - "src/renderer/src/components/native-chat/native-chat-diff.test.ts": 53, - "src/renderer/src/components/native-chat/native-chat-dismiss-key.test.ts": 56, - "src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts": 53, - "src/renderer/src/components/native-chat/native-chat-file-link.test.ts": 161, - "src/renderer/src/components/native-chat/native-chat-font-scale.test.ts": 60, - "src/renderer/src/components/native-chat/native-chat-http-link-source-owner.test.ts": 69, - "src/renderer/src/components/native-chat/native-chat-image-paste.test.ts": 40, - "src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts": 1250, - "src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts": 96, - "src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts": 148, - "src/renderer/src/components/native-chat/native-chat-launch-default-adoption.test.ts": 117, - "src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts": 52, - "src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts": 75, - "src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts": 74, - "src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts": 1739, - "src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts": 44, - "src/renderer/src/components/native-chat/native-chat-live-status.test.ts": 83, - "src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts": 73, - "src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts": 83, - "src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts": 102, - "src/renderer/src/components/native-chat/native-chat-noise.test.ts": 32, - "src/renderer/src/components/native-chat/native-chat-pagination.test.ts": 62, - "src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx": 558, - "src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts": 48, - "src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts": 66, - "src/renderer/src/components/native-chat/native-chat-pending.test.ts": 86, - "src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts": 34, - "src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts": 189, - "src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts": 157, - "src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts": 375, - "src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts": 110, - "src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.test.ts": 57, - "src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts": 455, - "src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts": 39, - "src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts": 231, - "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts": 1274, - "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts": 306, - "src/renderer/src/components/native-chat/native-chat-scrape-fallback.test.ts": 69, - "src/renderer/src/components/native-chat/native-chat-send-eligibility.test.ts": 50, - "src/renderer/src/components/native-chat/native-chat-send.test.ts": 52, - "src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts": 107, - "src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts": 1585, - "src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts": 55, - "src/renderer/src/components/native-chat/native-chat-session-transport.test.ts": 88, - "src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts": 411, - "src/renderer/src/components/native-chat/native-chat-shortcut.test.ts": 44, + "src/renderer/src/components/linear-issue-text-save-plan.test.ts": 52, + "src/renderer/src/components/linear-issue-view-storage.test.ts": 174, + "src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx": 222, + "src/renderer/src/components/linear-issue-workspace-text.test.ts": 109, + "src/renderer/src/components/linear-project-presentation.test.ts": 65, + "src/renderer/src/components/linear-project-search-query.test.ts": 49, + "src/renderer/src/components/linear-project-view-surfaces.test.tsx": 504, + "src/renderer/src/components/linear-scope-selector.test.ts": 211, + "src/renderer/src/components/link-actions/LinkActionPopover.test.tsx": 545, + "src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts": 121, + "src/renderer/src/components/mobile/MobileHero.test.tsx": 2284, + "src/renderer/src/components/mobile/MobilePage.test.tsx": 1610, + "src/renderer/src/components/mobile/MobilePageToolbar.test.tsx": 313, + "src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx": 516, + "src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx": 461, + "src/renderer/src/components/mobile/mobile-page-stage.test.ts": 39, + "src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 155, + "src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 298, + "src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx": 309, + "src/renderer/src/components/native-chat-launch-resume-decision.test.tsx": 1492, + "src/renderer/src/components/native-chat-restart-action-notifications.test.ts": 129, + "src/renderer/src/components/native-chat-resume-activity-label.test.ts": 107, + "src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx": 2316, + "src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts": 1826, + "src/renderer/src/components/native-chat-resume-failure-guidance.test.ts": 102, + "src/renderer/src/components/native-chat-resume-finished-run.test.tsx": 2925, + "src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts": 106, + "src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts": 1502, + "src/renderer/src/components/native-chat-resume-run.test.ts": 99, + "src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx": 2367, + "src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx": 1911, + "src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx": 1766, + "src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx": 2314, + "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx": 428, + "src/renderer/src/components/native-chat/NativeChatBackgroundTaskRun.test.tsx": 456, + "src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx": 682, + "src/renderer/src/components/native-chat/NativeChatCodeBlock.load-failure.test.tsx": 482, + "src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx": 4855, + "src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx": 2164, + "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx": 1040, + "src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx": 494, + "src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx": 450, + "src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx": 681, + "src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx": 399, + "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx": 1768, + "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx": 1710, + "src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx": 673, + "src/renderer/src/components/native-chat/NativeChatJumpToLatest.test.tsx": 363, + "src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx": 1267, + "src/renderer/src/components/native-chat/NativeChatMarkdown.test.tsx": 1968, + "src/renderer/src/components/native-chat/NativeChatMarkdown.visual.test.tsx": 2370, + "src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx": 5931, + "src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx": 4308, + "src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx": 4124, + "src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx": 3124, + "src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx": 1738, + "src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx": 3019, + "src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx": 16129, + "src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx": 5316, + "src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx": 2450, + "src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx": 3416, + "src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx": 4465, + "src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx": 3209, + "src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx": 6211, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx": 2860, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx": 3458, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx": 3074, + "src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx": 2773, + "src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx": 7809, + "src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx": 2520, + "src/renderer/src/components/native-chat/NativeChatMessageRail.test.tsx": 975, + "src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx": 2763, + "src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx": 2752, + "src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx": 530, + "src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx": 922, + "src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx": 2999, + "src/renderer/src/components/native-chat/NativeChatPacedMarkdown.file-links.test.tsx": 2066, + "src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx": 2028, + "src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx": 670, + "src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx": 565, + "src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx": 2144, + "src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx": 1044, + "src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx": 1795, + "src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx": 2689, + "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.empty-answer.test.tsx": 397, + "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx": 1020, + "src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx": 3415, + "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx": 3672, + "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx": 3511, + "src/renderer/src/components/native-chat/NativeChatResolvedView.reveal-latest.test.tsx": 2755, + "src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx": 3611, + "src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx": 3255, + "src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx": 2974, + "src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx": 589, + "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx": 1366, + "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx": 1137, + "src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.test.tsx": 491, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx": 2905, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.availability-notice.test.tsx": 2847, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx": 3314, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx": 2758, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx": 3114, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx": 3169, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx": 2787, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx": 2576, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx": 2687, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx": 2836, + "src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx": 459, + "src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx": 1152, + "src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx": 489, + "src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx": 594, + "src/renderer/src/components/native-chat/NativeChatToolFileTarget.test.tsx": 502, + "src/renderer/src/components/native-chat/NativeChatToolLine.test.tsx": 1353, + "src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx": 1214, + "src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx": 1029, + "src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx": 1212, + "src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.test.tsx": 841, + "src/renderer/src/components/native-chat/NativeChatToolRunMemberList.identity.test.tsx": 786, + "src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx": 1913, + "src/renderer/src/components/native-chat/NativeChatTranscriptRow.work-run.test.tsx": 2772, + "src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx": 2341, + "src/renderer/src/components/native-chat/NativeChatUserMessageFold.test.tsx": 2829, + "src/renderer/src/components/native-chat/NativeChatView.test.tsx": 481, + "src/renderer/src/components/native-chat/NativeChatVisualFrame.test.tsx": 869, + "src/renderer/src/components/native-chat/NativeChatVisualTab.test.tsx": 1828, + "src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx": 2038, + "src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx": 378, + "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx": 1686, + "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx": 1946, + "src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts": 1046, + "src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts": 684, + "src/renderer/src/components/native-chat/background-task-words-text.test.ts": 158, + "src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts": 1797, + "src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts": 84, + "src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts": 53, + "src/renderer/src/components/native-chat/native-chat-agent-message-sender-host.test.tsx": 3468, + "src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx": 1721, + "src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts": 592, + "src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts": 324, + "src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts": 346, + "src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts": 1810, + "src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts": 66, + "src/renderer/src/components/native-chat/native-chat-availability.test.ts": 62, + "src/renderer/src/components/native-chat/native-chat-claude-sign-in.test.tsx": 2665, + "src/renderer/src/components/native-chat/native-chat-command-language.test.ts": 58, + "src/renderer/src/components/native-chat/native-chat-compaction-visibility.test.ts": 450, + "src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx": 1883, + "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx": 2116, + "src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts": 223, + "src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts": 48, + "src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts": 118, + "src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx": 1926, + "src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts": 1553, + "src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 1112, + "src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx": 1838, + "src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx": 1111, + "src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts": 40, + "src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx": 1254, + "src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 256, + "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 123, + "src/renderer/src/components/native-chat/native-chat-composer-upload-completion.test.tsx": 1909, + "src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx": 954, + "src/renderer/src/components/native-chat/native-chat-context-command.test.tsx": 501, + "src/renderer/src/components/native-chat/native-chat-context-usage-summary.test.ts": 52, + "src/renderer/src/components/native-chat/native-chat-diff.test.ts": 69, + "src/renderer/src/components/native-chat/native-chat-dismiss-key.test.ts": 50, + "src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts": 92, + "src/renderer/src/components/native-chat/native-chat-element-file-drop.test.tsx": 1865, + "src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx": 594, + "src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts": 1427, + "src/renderer/src/components/native-chat/native-chat-file-link.test.ts": 366, + "src/renderer/src/components/native-chat/native-chat-find-visibility.test.ts": 135, + "src/renderer/src/components/native-chat/native-chat-floating-file-drop.test.tsx": 773, + "src/renderer/src/components/native-chat/native-chat-font-size.test.ts": 82, + "src/renderer/src/components/native-chat/native-chat-http-link-source-owner.test.ts": 56, + "src/renderer/src/components/native-chat/native-chat-image-paste.test.ts": 69, + "src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts": 1523, + "src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts": 301, + "src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts": 115, + "src/renderer/src/components/native-chat/native-chat-launch-default-adoption.test.ts": 129, + "src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts": 51, + "src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts": 63, + "src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts": 1604, + "src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts": 60, + "src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts": 314, + "src/renderer/src/components/native-chat/native-chat-live-status.test.ts": 79, + "src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts": 280, + "src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts": 298, + "src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts": 388, + "src/renderer/src/components/native-chat/native-chat-observed-send.test.ts": 82, + "src/renderer/src/components/native-chat/native-chat-owner-selectors.test.tsx": 505, + "src/renderer/src/components/native-chat/native-chat-pagination.test.ts": 43, + "src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx": 435, + "src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts": 65, + "src/renderer/src/components/native-chat/native-chat-paste-routing.test.tsx": 1916, + "src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts": 83, + "src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts": 63, + "src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts": 74, + "src/renderer/src/components/native-chat/native-chat-pending.test.ts": 133, + "src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts": 60, + "src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts": 471, + "src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts": 57, + "src/renderer/src/components/native-chat/native-chat-prompt-editor-store-value.test.tsx": 2153, + "src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts": 118, + "src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts": 435, + "src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx": 2050, + "src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts": 363, + "src/renderer/src/components/native-chat/native-chat-reader-scroll-input.test.tsx": 276, + "src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.test.ts": 51, + "src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts": 346, + "src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts": 101, + "src/renderer/src/components/native-chat/native-chat-runtime-owner-selector.test.ts": 371, + "src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts": 326, + "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts": 1348, + "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts": 222, + "src/renderer/src/components/native-chat/native-chat-scrape-fallback.test.ts": 45, + "src/renderer/src/components/native-chat/native-chat-send-eligibility.test.ts": 38, + "src/renderer/src/components/native-chat/native-chat-send.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-sent-prompt-history.test.ts": 252, + "src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts": 321, + "src/renderer/src/components/native-chat/native-chat-session-option-discovery.test.ts": 1181, + "src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts": 1681, + "src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts": 60, + "src/renderer/src/components/native-chat/native-chat-session-transport.test.ts": 123, + "src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts": 246, + "src/renderer/src/components/native-chat/native-chat-shortcut.test.ts": 60, "src/renderer/src/components/native-chat/native-chat-split-shortcut.test.ts": 68, - "src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts": 31, - "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx": 1959, - "src/renderer/src/components/native-chat/native-chat-tab-agent-entry.test.ts": 31, - "src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts": 57, - "src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts": 45, - "src/renderer/src/components/native-chat/native-chat-thread-goal-rows.test.ts": 56, - "src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts": 69, - "src/renderer/src/components/native-chat/native-chat-tool-summary.test.ts": 58, - "src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts": 76, - "src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts": 99, - "src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts": 91, - "src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts": 66, - "src/renderer/src/components/native-chat/native-chat-view-file-drop.test.tsx": 806, - "src/renderer/src/components/native-chat/native-chat-view-state.test.ts": 57, - "src/renderer/src/components/native-chat/native-chat-web-link-actions.test.ts": 135, - "src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx": 680, + "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx": 1857, + "src/renderer/src/components/native-chat/native-chat-structured-session-notices.command-failure.test.tsx": 1836, + "src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx": 1788, + "src/renderer/src/components/native-chat/native-chat-subagent-delegation.test.ts": 61, + "src/renderer/src/components/native-chat/native-chat-subagent-group-words-text.test.ts": 159, + "src/renderer/src/components/native-chat/native-chat-subagent-handoff-copy-budget.test.ts": 339, + "src/renderer/src/components/native-chat/native-chat-subagent-section-work-runs.test.ts": 377, + "src/renderer/src/components/native-chat/native-chat-subagent-sections-retention.test.ts": 774, + "src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts": 444, + "src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx": 789, + "src/renderer/src/components/native-chat/native-chat-tab-agent-entry.test.ts": 47, + "src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts": 280, + "src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts": 69, + "src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts": 68, + "src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts": 73, + "src/renderer/src/components/native-chat/native-chat-thread-goal-rows.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts": 72, + "src/renderer/src/components/native-chat/native-chat-tool-summary.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts": 355, + "src/renderer/src/components/native-chat/native-chat-transcript-work-runs.test.ts": 328, + "src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts": 328, + "src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts": 62, + "src/renderer/src/components/native-chat/native-chat-view-file-drop.test.tsx": 1879, + "src/renderer/src/components/native-chat/native-chat-view-state.test.ts": 54, + "src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx": 1741, + "src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts": 258, + "src/renderer/src/components/native-chat/native-chat-waiting-partition-budget.test.ts": 653, + "src/renderer/src/components/native-chat/native-chat-web-link-actions.test.ts": 330, + "src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts": 419, + "src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx": 300, "src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts": 53, - "src/renderer/src/components/native-chat/omp-model-switch-capability.test.ts": 84, - "src/renderer/src/components/native-chat/serialized-screen-background-rows.test.ts": 134, - "src/renderer/src/components/native-chat/structured-agent-question-projection.test.ts": 97, - "src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts": 77, - "src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts": 437, - "src/renderer/src/components/native-chat/structured-agent-session-rail-outline.test.ts": 36, - "src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts": 246, - "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-clock.test.tsx": 2216, - "src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts": 1416, - "src/renderer/src/components/native-chat/structured-attention-surface.test.ts": 994, - "src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx": 697, - "src/renderer/src/components/native-chat/use-native-chat-composer-app-menu-selection.test.tsx": 528, - "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx": 718, - "src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx": 631, - "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx": 580, - "src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx": 564, - "src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx": 708, - "src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx": 493, - "src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx": 578, - "src/renderer/src/components/native-chat/use-native-chat-file-link-click.test.tsx": 1460, - "src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts": 1638, - "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx": 620, - "src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx": 567, - "src/renderer/src/components/native-chat/use-native-chat-link-actions.test.tsx": 1582, - "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts": 1421, - "src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts": 2357, - "src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts": 5417, - "src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts": 790, - "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx": 2127, - "src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx": 1713, - "src/renderer/src/components/native-chat/use-native-chat-rail-history-jump.test.ts": 665, - "src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts": 563, - "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx": 346, - "src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx": 2103, - "src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts": 792, - "src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx": 862, - "src/renderer/src/components/native-chat/use-native-chat-skills.test.ts": 835, - "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx": 746, - "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts": 1599, - "src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx": 556, - "src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx": 660, - "src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx": 2782, - "src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx": 935, - "src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx": 448, - "src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx": 1272, - "src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx": 1393, - "src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx": 608, - "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx": 1632, - "src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx": 2498, - "src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx": 2251, - "src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx": 2510, - "src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx": 2546, - "src/renderer/src/components/native-chat/use-structured-agent-session-rail-outline.test.tsx": 707, - "src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx": 1321, - "src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx": 570, - "src/renderer/src/components/native-chat/use-structured-agent-session-transport.test.tsx": 605, - "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx": 2591, - "src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx": 481, - "src/renderer/src/components/network/CustomAddressDialog.test.tsx": 584, - "src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx": 1181, - "src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx": 936, - "src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx": 1046, - "src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx": 407, - "src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx": 1539, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-provider-boundaries.test.ts": 1258, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts": 1446, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts": 42, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx": 1142, - "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx": 1292, - "src/renderer/src/components/new-workspace/base-ref-picker-visibility.test.ts": 49, - "src/renderer/src/components/new-workspace/project-combobox-matching.test.ts": 89, - "src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts": 33, - "src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts": 247, - "src/renderer/src/components/new-workspace/smart-workspace-source-popover-focus.test.ts": 503, - "src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts": 116, - "src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx": 629, - "src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx": 454, - "src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts": 966, - "src/renderer/src/components/onboarding/AgentStep.test.tsx": 408, - "src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx": 1399, - "src/renderer/src/components/onboarding/NotificationStep.test.tsx": 1357, - "src/renderer/src/components/onboarding/OnboardingFlow.test.tsx": 5689, - "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx": 579, - "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts": 2643, - "src/renderer/src/components/onboarding/ThemeStep.test.ts": 271, - "src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx": 673, - "src/renderer/src/components/onboarding/agent-picked-payload.test.ts": 55, - "src/renderer/src/components/onboarding/onboarding-dismiss-target.test.ts": 37, - "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts": 1585, - "src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts": 281, - "src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts": 65, - "src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts": 1229, - "src/renderer/src/components/onboarding/use-onboarding-flow.test.ts": 284, - "src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.test.ts": 37, - "src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx": 515, - "src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx": 430, - "src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx": 447, - "src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx": 622, - "src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx": 654, - "src/renderer/src/components/pet/PetOverlay.test.ts": 1580, - "src/renderer/src/components/pet/pet-agent-state.test.ts": 55, - "src/renderer/src/components/pet/pet-blob-cache.test.ts": 65, - "src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx": 743, - "src/renderer/src/components/pet/pet-overlay-position.test.ts": 1053, - "src/renderer/src/components/pet/pet-overlay-visibility.test.ts": 39, - "src/renderer/src/components/pet/sprite-animation-css.test.ts": 38, - "src/renderer/src/components/pet/usePetPointerInteraction.test.ts": 328, - "src/renderer/src/components/pet/usePetUrl.test.tsx": 651, - "src/renderer/src/components/ports/WorkspacePortScanner.test.tsx": 3020, - "src/renderer/src/components/pr-check-counts.test.ts": 107, - "src/renderer/src/components/pr-checks-fix-prompt.test.ts": 56, - "src/renderer/src/components/pr-comments-resolution-prompt.test.ts": 40, - "src/renderer/src/components/provider-check-classification-parity.test.ts": 112, - "src/renderer/src/components/pull-request-page-host-boundary.test.ts": 60, - "src/renderer/src/components/pull-request-page/cache/file-content.test.ts": 61, - "src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx": 898, - "src/renderer/src/components/pull-request-page/mentions/options.test.ts": 53, - "src/renderer/src/components/pull-request-page/mentions/query.test.ts": 46, - "src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts": 241, - "src/renderer/src/components/quick-open-file-list-host-name-filter.react.test.tsx": 2090, - "src/renderer/src/components/quick-open-file-list.react.test.tsx": 1637, - "src/renderer/src/components/quick-open-file-list.test.ts": 1005, - "src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx": 799, - "src/renderer/src/components/quick-open-install-rg-guidance.test.ts": 254, - "src/renderer/src/components/quick-open-search.test.ts": 259, - "src/renderer/src/components/repo/CreateFromPicker.test.tsx": 1041, - "src/renderer/src/components/repo/NestedRepoChecklist.test.tsx": 274, - "src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts": 414, - "src/renderer/src/components/repo/repo-icon.emoji-centering.test.tsx": 469, - "src/renderer/src/components/repo/repo-icon.test.tsx": 554, - "src/renderer/src/components/right-sidebar/ActionButton.test.tsx": 3109, - "src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx": 3107, - "src/renderer/src/components/right-sidebar/AiVaultPanelHeader.focus.test.tsx": 837, - "src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx": 1050, - "src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.test.tsx": 2126, - "src/renderer/src/components/right-sidebar/AiVaultSessionListBar.test.tsx": 1195, - "src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx": 1968, - "src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx": 2633, - "src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.test.tsx": 851, - "src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx": 2132, - "src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx": 2283, - "src/renderer/src/components/right-sidebar/ChecksPanel.updated-at-metadata.test.tsx": 566, - "src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx": 2283, - "src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx": 2862, - "src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx": 3772, - "src/renderer/src/components/right-sidebar/CommitArea.test.tsx": 9563, - "src/renderer/src/components/right-sidebar/FileExplorerNameFilter.test.tsx": 442, - "src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx": 251, - "src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx": 1137, - "src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.test.tsx": 286, - "src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.row-handlers.test.tsx": 1865, - "src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx": 1859, - "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx": 756, - "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx": 540, - "src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 713, - "src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx": 1252, - "src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx": 916, - "src/renderer/src/components/right-sidebar/HostedReviewActions.draft.test.tsx": 922, - "src/renderer/src/components/right-sidebar/PluginPanel.test.tsx": 883, - "src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 2949, - "src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx": 1959, - "src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx": 526, - "src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx": 565, - "src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx": 1480, - "src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts": 3776, - "src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts": 48, - "src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts": 47, - "src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts": 3689, - "src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts": 24, - "src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx": 134, - "src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx": 4632, - "src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts": 56, - "src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx": 4125, - "src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts": 53, - "src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts": 2135, - "src/renderer/src/components/right-sidebar/SourceControl.resolve-conflicts-prompt.test.ts": 2081, - "src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx": 3225, - "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx": 2339, - "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx": 361, - "src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts": 551, - "src/renderer/src/components/right-sidebar/active-checks-status.test.ts": 1236, - "src/renderer/src/components/right-sidebar/activity-bar-buttons.test.tsx": 599, - "src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts": 49, - "src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx": 997, - "src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts": 531, - "src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts": 67, - "src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts": 49, - "src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts": 29, - "src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts": 47, - "src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts": 43, - "src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx": 731, - "src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts": 142, - "src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts": 38, - "src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts": 127, - "src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts": 58, - "src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts": 64, - "src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts": 42, - "src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts": 81, - "src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts": 52, - "src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts": 112, - "src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts": 531, - "src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts": 5506, - "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts": 575, - "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts": 66, - "src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts": 169, - "src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx": 705, - "src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts": 95, - "src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts": 45, - "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts": 49, - "src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts": 48, - "src/renderer/src/components/right-sidebar/check-details-resize.test.ts": 30, - "src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts": 58, - "src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx": 1439, - "src/renderer/src/components/right-sidebar/checks-panel-async-result-key.test.ts": 46, - "src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx": 1497, - "src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts": 113, - "src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts": 59, - "src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts": 65, - "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts": 51, - "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.test.ts": 31, - "src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts": 85, - "src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts": 52, - "src/renderer/src/components/right-sidebar/checks-panel-review.test.ts": 44, - "src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts": 79, - "src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts": 40, - "src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx": 4868, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx": 2604, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx": 597, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx": 2098, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx": 471, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx": 2233, - "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx": 678, - "src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts": 69, - "src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts": 40, - "src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts": 43, - "src/renderer/src/components/right-sidebar/create-review-draft-title.test.ts": 38, - "src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts": 37, - "src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts": 66, - "src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts": 41, - "src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts": 147, - "src/renderer/src/components/right-sidebar/file-explorer-dir-load-tracker.test.ts": 40, - "src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts": 239, - "src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts": 87, - "src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx": 2626, - "src/renderer/src/components/right-sidebar/file-explorer-entries.test.ts": 29, - "src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts": 1574, - "src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx": 842, - "src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx": 646, - "src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts": 677, - "src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts": 66, - "src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts": 51, - "src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts": 1352, - "src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts": 58, - "src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts": 42, - "src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts": 40, - "src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts": 41, - "src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts": 28, - "src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts": 51, - "src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts": 64, - "src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts": 1568, - "src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts": 2028, - "src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts": 58, - "src/renderer/src/components/right-sidebar/file-search-include-pattern.test.ts": 54, - "src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts": 67, - "src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts": 47, - "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts": 60, - "src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts": 247, - "src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts": 40, - "src/renderer/src/components/right-sidebar/git-status-refresh.test.ts": 173, - "src/renderer/src/components/right-sidebar/github-pr-link-modal.test.ts": 42, - "src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts": 58, - "src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts": 91, - "src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts": 57, - "src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts": 294, - "src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts": 176, - "src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts": 172, - "src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts": 145, - "src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts": 256, - "src/renderer/src/components/right-sidebar/plugin-panel-watchdog-visibility.test.ts": 585, - "src/renderer/src/components/right-sidebar/plugin-panel-watchdog.test.ts": 189, - "src/renderer/src/components/right-sidebar/pr-comment-ack-summary.test.ts": 40, - "src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts": 516, - "src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts": 46, - "src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts": 73, - "src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts": 70, - "src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx": 3795, - "src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts": 203, - "src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts": 52, - "src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx": 679, - "src/renderer/src/components/right-sidebar/right-panel-comment-focus-timers.test.ts": 39, - "src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts": 143, - "src/renderer/src/components/right-sidebar/right-sidebar-effective-tab.test.ts": 30, - "src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts": 42, - "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx": 1020, - "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.test.ts": 55, - "src/renderer/src/components/right-sidebar/right-sidebar-width.test.ts": 44, - "src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts": 203, - "src/renderer/src/components/right-sidebar/search-match-open.test.ts": 51, - "src/renderer/src/components/right-sidebar/search-rows.test.ts": 32, - "src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts": 154, - "src/renderer/src/components/right-sidebar/source-control-actions.test.ts": 51, - "src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts": 33, - "src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.test.ts": 143, - "src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx": 732, - "src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts": 120, - "src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx": 1334, - "src/renderer/src/components/right-sidebar/source-control-commit-eligibility.test.ts": 49, - "src/renderer/src/components/right-sidebar/source-control-commit-message-rows.test.ts": 99, - "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts": 72, - "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts": 38, - "src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts": 115, - "src/renderer/src/components/right-sidebar/source-control-created-review-link.test.ts": 50, - "src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts": 109, - "src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx": 321, - "src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts": 135, - "src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts": 222, - "src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts": 27, - "src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx": 179, - "src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts": 53, - "src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx": 602, - "src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts": 416, - "src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts": 99, - "src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts": 35, - "src/renderer/src/components/right-sidebar/source-control-huge-repo-warning-dismissals.test.ts": 153, - "src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts": 195, - "src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts": 56, - "src/renderer/src/components/right-sidebar/source-control-primary-action-tooltip.test.ts": 224, - "src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts": 97, - "src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts": 172, - "src/renderer/src/components/right-sidebar/source-control-push-recovery.test.ts": 37, - "src/renderer/src/components/right-sidebar/source-control-section-order.test.ts": 44, - "src/renderer/src/components/right-sidebar/source-control-split-open.test.ts": 50, - "src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts": 83, - "src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts": 62, - "src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts": 153, - "src/renderer/src/components/right-sidebar/source-control-too-many-changes-banner.test.tsx": 2910, - "src/renderer/src/components/right-sidebar/source-control-tree.test.ts": 73, - "src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx": 1290, - "src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts": 238, - "src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx": 513, - "src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx": 1796, - "src/renderer/src/components/right-sidebar/source-control/listing/section-header.test.tsx": 559, - "src/renderer/src/components/right-sidebar/source-control/listing/use-file-projection-work.test.tsx": 766, - "src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx": 1386, - "src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.test.tsx": 351, - "src/renderer/src/components/right-sidebar/source-control/review/suppressed-github-pr.test.ts": 35, - "src/renderer/src/components/right-sidebar/source-control/review/use-action-model.test.tsx": 369, - "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-state.test.tsx": 657, - "src/renderer/src/components/right-sidebar/status-display.test.ts": 38, - "src/renderer/src/components/right-sidebar/use-ai-vault-search-focus-request.test.tsx": 1291, - "src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx": 737, - "src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts": 1386, - "src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.runtime-listing.test.tsx": 1514, - "src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts": 1249, - "src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts": 63, - "src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx": 546, - "src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.test.tsx": 487, - "src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx": 625, - "src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.test.tsx": 526, - "src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts": 1472, - "src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx": 790, - "src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx": 608, - "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts": 2037, - "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts": 1565, - "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.test.ts": 58, - "src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts": 1455, - "src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts": 1591, - "src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts": 52, - "src/renderer/src/components/right-sidebar/useFileExplorerTree.refresh-projection-churn.test.tsx": 2884, - "src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx": 2223, - "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx": 2363, - "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts": 1640, - "src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx": 745, - "src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts": 2024, - "src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx": 594, - "src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts": 2104, - "src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts": 1852, - "src/renderer/src/components/right-sidebar/useHostedReviewStackParent.test.tsx": 650, - "src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts": 64, - "src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx": 558, - "src/renderer/src/components/settings/AccountsPane.test.tsx": 2350, - "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.interaction.test.tsx": 2044, - "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts": 1623, - "src/renderer/src/components/settings/AdvancedPane.test.tsx": 427, - "src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx": 2107, - "src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx": 2692, - "src/renderer/src/components/settings/AgentsPane.test.tsx": 2430, - "src/renderer/src/components/settings/AppearancePane.test.tsx": 2232, - "src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx": 1005, - "src/renderer/src/components/settings/AutomationsSettingsPane.test.tsx": 903, - "src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx": 155, - "src/renderer/src/components/settings/BrowserClientHostedRemoteSetting.test.tsx": 1136, - "src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx": 710, - "src/renderer/src/components/settings/BrowserPane.test.ts": 54, - "src/renderer/src/components/settings/BrowserSshWorkspaceRoutingSetting.test.tsx": 801, - "src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx": 178, - "src/renderer/src/components/settings/BrowserUserAgentSetting.test.tsx": 1325, - "src/renderer/src/components/settings/CliSection.install-failure.test.tsx": 2294, - "src/renderer/src/components/settings/CliSection.test.tsx": 2339, - "src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx": 977, - "src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx": 1314, - "src/renderer/src/components/settings/CodexLoginLinkNotice.test.tsx": 764, - "src/renderer/src/components/settings/CommitMessageAiPane.test.tsx": 1754, - "src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx": 658, - "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx": 1844, - "src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx": 1090, - "src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx": 1118, - "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx": 1144, - "src/renderer/src/components/settings/EphemeralVmsPane.test.tsx": 4524, - "src/renderer/src/components/settings/ExperimentalPane.test.tsx": 2064, - "src/renderer/src/components/settings/FloatingWorkspacePane.test.tsx": 1671, - "src/renderer/src/components/settings/GeneralPane.test.ts": 3742, - "src/renderer/src/components/settings/GeneralRemoteServerUpdates.test.tsx": 832, - "src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx": 1415, - "src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx": 1460, - "src/renderer/src/components/settings/GhosttyImportModal.test.ts": 268, - "src/renderer/src/components/settings/GitPane.test.ts": 1095, - "src/renderer/src/components/settings/GitPane.test.tsx": 1415, - "src/renderer/src/components/settings/GrokAccountsSection.test.tsx": 954, - "src/renderer/src/components/settings/HiddenExperimentalGroup.test.tsx": 513, - "src/renderer/src/components/settings/KagiSessionLinkForm.test.ts": 1135, - "src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx": 374, - "src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx": 163, - "src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx": 1432, - "src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx": 585, - "src/renderer/src/components/settings/MachineNameField.test.tsx": 561, - "src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx": 1349, - "src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx": 1228, - "src/renderer/src/components/settings/MobilePairingQrSection.test.tsx": 574, - "src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx": 1897, - "src/renderer/src/components/settings/MobilePane.test.tsx": 1448, - "src/renderer/src/components/settings/MobilePaneAddressSearch.test.tsx": 1173, - "src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx": 2849, - "src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx": 965, - "src/renderer/src/components/settings/NotificationsPane.test.tsx": 1611, - "src/renderer/src/components/settings/OpenInMenuSetting.test.ts": 483, - "src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx": 660, - "src/renderer/src/components/settings/OrchestrationPane.test.tsx": 2500, - "src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx": 142, - "src/renderer/src/components/settings/PluginConsentDialog.test.tsx": 1603, - "src/renderer/src/components/settings/PluginInstallDialog.test.tsx": 1086, - "src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts": 880, - "src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx": 857, - "src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx": 587, - "src/renderer/src/components/settings/PluginSettingsRow.test.tsx": 1082, - "src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx": 1906, - "src/renderer/src/components/settings/PrivacyPane.test.ts": 1541, - "src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx": 1249, - "src/renderer/src/components/settings/QuickCommandsList.test.tsx": 1098, - "src/renderer/src/components/settings/QuickCommandsPane.test.ts": 1711, - "src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx": 935, - "src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx": 866, - "src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx": 989, - "src/renderer/src/components/settings/RepositoryHooksSection.test.ts": 838, - "src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx": 2695, - "src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx": 2705, - "src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx": 555, - "src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx": 1099, - "src/renderer/src/components/settings/RepositoryPane.test.ts": 3138, - "src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx": 450, - "src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts": 593, - "src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx": 532, - "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts": 1724, - "src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx": 589, - "src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx": 434, - "src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx": 935, - "src/renderer/src/components/settings/SessionHistoryComputerRow.test.tsx": 1157, - "src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx": 2196, - "src/renderer/src/components/settings/Settings.load-performance.test.ts": 36, - "src/renderer/src/components/settings/SettingsConstants.test.ts": 179, - "src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx": 1401, - "src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx": 816, - "src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx": 904, - "src/renderer/src/components/settings/SettingsSidebar.test.tsx": 2031, - "src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx": 791, - "src/renderer/src/components/settings/ShortcutCommandBlock.test.tsx": 1722, - "src/renderer/src/components/settings/ShortcutFilterRail.test.ts": 463, - "src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx": 1006, - "src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 577, - "src/renderer/src/components/settings/SshTargetCard.test.tsx": 776, - "src/renderer/src/components/settings/SshTargetForm.test.tsx": 1083, - "src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx": 1195, - "src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx": 592, - "src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx": 359, - "src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx": 960, - "src/renderer/src/components/settings/TasksPane.test.tsx": 1403, - "src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx": 1646, - "src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts": 398, - "src/renderer/src/components/settings/TerminalContrastSetting.test.tsx": 1310, - "src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx": 1537, - "src/renderer/src/components/settings/TerminalPane.pwsh.test.ts": 765, - "src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx": 664, - "src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx": 885, - "src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts": 1380, - "src/renderer/src/components/settings/VoiceMicrophoneSetting.test.tsx": 1149, - "src/renderer/src/components/settings/VoicePane.test.tsx": 1609, - "src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx": 672, - "src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx": 2196, - "src/renderer/src/components/settings/accounts-search.test.ts": 45, - "src/renderer/src/components/settings/agent-availability-settings.test.ts": 209, - "src/renderer/src/components/settings/agent-default-env-draft.test.ts": 41, - "src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts": 122, - "src/renderer/src/components/settings/appearance-interface-summary.test.ts": 170, - "src/renderer/src/components/settings/appearance-search.test.ts": 835, - "src/renderer/src/components/settings/appearance-status-bar-search.test.ts": 58, - "src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts": 97, - "src/renderer/src/components/settings/bitbucket-integration-card.test.tsx": 882, - "src/renderer/src/components/settings/browser-cookie-import-label.test.ts": 115, - "src/renderer/src/components/settings/browser-link-routing-localization.test.ts": 422, - "src/renderer/src/components/settings/browser-search.test.ts": 394, - "src/renderer/src/components/settings/browser-session-host-selection.test.ts": 51, - "src/renderer/src/components/settings/cli-install-failure.test.ts": 41, - "src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx": 739, - "src/renderer/src/components/settings/codex-account-auth-warning.test.ts": 44, - "src/renderer/src/components/settings/codex-config-sync-warning.test.ts": 29, - "src/renderer/src/components/settings/codex-session-source-home-control.test.tsx": 389, - "src/renderer/src/components/settings/developer-permissions-search.test.ts": 410, - "src/renderer/src/components/settings/general-editor-search.test.ts": 109, - "src/renderer/src/components/settings/general-preview-tab-search.test.ts": 113, - "src/renderer/src/components/settings/host-scoped-setting-scope.test.ts": 40, - "src/renderer/src/components/settings/integrations-pane-status.test.ts": 56, - "src/renderer/src/components/settings/jira-integration-card.test.tsx": 546, - "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx": 1244, - "src/renderer/src/components/settings/mobile-network-interface-selection.test.ts": 87, - "src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts": 48, - "src/renderer/src/components/settings/mobile-pane-search.test.ts": 87, - "src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts": 86, - "src/renderer/src/components/settings/osc52-clipboard-copy.test.ts": 365, - "src/renderer/src/components/settings/plugin-install-source.test.ts": 167, - "src/renderer/src/components/settings/provider-account-scope.test.ts": 110, - "src/renderer/src/components/settings/provider-account-visibility.test.ts": 53, - "src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx": 486, - "src/renderer/src/components/settings/repository-hook-settings-draft.test.ts": 201, - "src/renderer/src/components/settings/repository-host-setup-options.test.ts": 71, - "src/renderer/src/components/settings/repository-icon-github.test.ts": 56, - "src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts": 689, - "src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts": 70, - "src/renderer/src/components/settings/runtime-server-row-unverifiable-host.test.tsx": 2289, - "src/renderer/src/components/settings/runtime-server-workflow-sections.test.tsx": 936, - "src/renderer/src/components/settings/session-history-status-copy.test.ts": 150, - "src/renderer/src/components/settings/session-search-computer-rollup.test.ts": 49, - "src/renderer/src/components/settings/setting-ownership.test.ts": 66, - "src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts": 479, - "src/renderer/src/components/settings/settings-form-option-filter.test.ts": 60, - "src/renderer/src/components/settings/settings-project-list.test.ts": 75, - "src/renderer/src/components/settings/settings-search-keywords.test.ts": 195, - "src/renderer/src/components/settings/settings-search.test.ts": 54, - "src/renderer/src/components/settings/settings-setup-guide-progress-hook.test.tsx": 90, - "src/renderer/src/components/settings/settings-setup-guide-progress.test.ts": 1590, - "src/renderer/src/components/settings/shortcut-binding-list-mutations.test.ts": 44, - "src/renderer/src/components/settings/shortcut-definition-catalog.test.ts": 537, - "src/renderer/src/components/settings/shortcut-groups.test.ts": 116, - "src/renderer/src/components/settings/shortcut-recording-state.test.ts": 39, - "src/renderer/src/components/settings/shortcut-row-visibility.test.ts": 485, - "src/renderer/src/components/settings/sparse-preset-date.test.ts": 64, - "src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 46, - "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 35, - "src/renderer/src/components/settings/ssh-target-draft.test.ts": 67, - "src/renderer/src/components/settings/ssh-target-remove.test.ts": 110, - "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 118, - "src/renderer/src/components/settings/task-source-setup-state.test.ts": 79, - "src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx": 892, - "src/renderer/src/components/settings/terminal-preview-content.test.ts": 56, - "src/renderer/src/components/settings/terminal-search.test.ts": 193, - "src/renderer/src/components/settings/use-debounced-settings-text-draft.test.ts": 578, - "src/renderer/src/components/settings/use-mac-captured-digit-chords.test.ts": 885, - "src/renderer/src/components/settings/use-published-machine-name.test.tsx": 593, - "src/renderer/src/components/settings/use-repository-hook-settings-draft.test.tsx": 412, - "src/renderer/src/components/settings/use-session-search-status.test.tsx": 462, - "src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx": 464, - "src/renderer/src/components/settings/useGhosttyImport.test.ts": 81, - "src/renderer/src/components/settings/useWarpThemeImport.test.ts": 96, - "src/renderer/src/components/settings/worktree-symlink-path-filter.scan-budget.test.ts": 125, - "src/renderer/src/components/settings/worktree-symlink-path-filter.test.ts": 45, - "src/renderer/src/components/setup-guide/SetupGuideModal.mount-gating.test.tsx": 1855, - "src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts": 1157, - "src/renderer/src/components/setup-guide/use-setup-guide-telemetry.test.ts": 40, - "src/renderer/src/components/shared/MacFolderAccessFixDialog.test.tsx": 1694, - "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts": 1721, - "src/renderer/src/components/shared/useDaemonActions.test.tsx": 665, - "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx": 345, - "src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx": 1413, - "src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx": 411, - "src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx": 555, - "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts": 46, - "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx": 748, - "src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx": 568, - "src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 891, - "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 1413, - "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts": 158, - "src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx": 2111, - "src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx": 534, - "src/renderer/src/components/sidebar/CacheTimer.test.tsx": 303, - "src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx": 1751, - "src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx": 2429, - "src/renderer/src/components/sidebar/CommentMarkdown.test.tsx": 2215, - "src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts": 49, - "src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx": 1072, - "src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx": 1045, - "src/renderer/src/components/sidebar/FilterToggleRow.test.tsx": 792, - "src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx": 1329, - "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx": 1307, - "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx": 7136, - "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx": 1428, - "src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.test.tsx": 2144, - "src/renderer/src/components/sidebar/MarkdownImageLightbox.test.tsx": 1145, - "src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx": 2102, - "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx": 627, - "src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx": 1725, - "src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx": 89, - "src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx": 59, - "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx": 946, - "src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx": 455, - "src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx": 1115, - "src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx": 90, - "src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx": 589, - "src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts": 43, - "src/renderer/src/components/sidebar/SetupScriptPromptCardShell.test.tsx": 538, - "src/renderer/src/components/sidebar/Sidebar.test.tsx": 747, - "src/renderer/src/components/sidebar/SidebarAgentsList.test.tsx": 2134, - "src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx": 3073, - "src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx": 522, - "src/renderer/src/components/sidebar/SidebarHeader.test.tsx": 1124, - "src/renderer/src/components/sidebar/SidebarNav.test.tsx": 1553, - "src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.test.tsx": 1398, - "src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx": 1068, - "src/renderer/src/components/sidebar/SidebarToolbar.test.tsx": 1152, - "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx": 765, - "src/renderer/src/components/sidebar/StatusIndicator.test.ts": 312, - "src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx": 1203, - "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx": 2186, - "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx": 2422, - "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx": 2043, - "src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx": 1839, - "src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx": 1498, - "src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx": 686, - "src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx": 796, - "src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx": 1013, - "src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx": 969, - "src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx": 1815, - "src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx": 3499, - "src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx": 1988, - "src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx": 1492, - "src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx": 1128, - "src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx": 976, - "src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx": 722, - "src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx": 1607, - "src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx": 1815, - "src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx": 1253, - "src/renderer/src/components/sidebar/WorktreeCard.test.ts": 2003, - "src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx": 1121, - "src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx": 715, - "src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx": 951, - "src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx": 625, - "src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx": 608, - "src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.test.tsx": 765, - "src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx": 3427, - "src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx": 2336, - "src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx": 997, - "src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx": 2296, - "src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx": 306, - "src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx": 1296, - "src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx": 1521, - "src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx": 2442, - "src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts": 1831, - "src/renderer/src/components/sidebar/WorktreeDeveloperMenu.test.tsx": 156, - "src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx": 837, - "src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx": 1550, - "src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts": 1867, - "src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts": 1777, - "src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts": 2092, - "src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 5067, - "src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts": 1705, - "src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx": 3618, - "src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx": 1918, - "src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx": 2111, - "src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx": 392, - "src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts": 1434, - "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx": 160, - "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.editor-lifecycle.test.tsx": 537, - "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx": 476, - "src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx": 1597, - "src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx": 917, - "src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx": 957, - "src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts": 109, - "src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts": 163, - "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts": 56, - "src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts": 60, - "src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts": 33, - "src/renderer/src/components/sidebar/clone-defaults.test.ts": 72, - "src/renderer/src/components/sidebar/create-project-defaults.test.ts": 33, - "src/renderer/src/components/sidebar/default-branch-folder-project-root.test.ts": 924, - "src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts": 1322, - "src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts": 46, - "src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx": 634, - "src/renderer/src/components/sidebar/delete-worktree-flow.test.ts": 799, - "src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts": 320, - "src/renderer/src/components/sidebar/delete-worktree-toast.test.ts": 89, - "src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts": 65, - "src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts": 1580, - "src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts": 159, - "src/renderer/src/components/sidebar/folder-workspace-composer-expiry.test.tsx": 2105, - "src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts": 89, - "src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx": 1905, - "src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts": 3745, - "src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts": 1959, - "src/renderer/src/components/sidebar/host-header-drag.test.tsx": 386, - "src/renderer/src/components/sidebar/host-header-menu-items.test.ts": 49, - "src/renderer/src/components/sidebar/host-rename-remove.test.ts": 49, - "src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts": 51, - "src/renderer/src/components/sidebar/host-section-order.test.ts": 33, - "src/renderer/src/components/sidebar/host-section-rows.test.ts": 223, - "src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts": 1960, - "src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts": 54, - "src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts": 60, - "src/renderer/src/components/sidebar/linear-agent-skill-runtime.shell-override.test.ts": 66, - "src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 51, - "src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx": 727, - "src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts": 447, - "src/renderer/src/components/sidebar/natural-worktree-ids.test.ts": 266, - "src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts": 80, - "src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts": 42, - "src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts": 535, - "src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx": 548, - "src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx": 937, - "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts": 134, - "src/renderer/src/components/sidebar/project-filter-reveal.test.ts": 49, - "src/renderer/src/components/sidebar/project-group-header-dom.test.ts": 33, - "src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts": 450, - "src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts": 375, - "src/renderer/src/components/sidebar/project-group-header-drag.test.ts": 565, - "src/renderer/src/components/sidebar/project-group-header-drop.test.ts": 360, - "src/renderer/src/components/sidebar/project-header-action-selector-lockstep.test.ts": 394, - "src/renderer/src/components/sidebar/project-header-color.test.ts": 200, - "src/renderer/src/components/sidebar/project-header-drag-commit.test.ts": 356, - "src/renderer/src/components/sidebar/project-header-drag-start.test.ts": 478, - "src/renderer/src/components/sidebar/project-header-drag.test.ts": 465, - "src/renderer/src/components/sidebar/project-header-drop.test.ts": 226, + "src/renderer/src/components/native-chat/omp-model-switch-capability.test.ts": 130, + "src/renderer/src/components/native-chat/serialized-screen-background-rows.test.ts": 124, + "src/renderer/src/components/native-chat/structured-agent-question-projection.test.ts": 306, + "src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts": 435, + "src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts": 369, + "src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts": 392, + "src/renderer/src/components/native-chat/structured-agent-session-legacy-outbox.test.ts": 360, + "src/renderer/src/components/native-chat/structured-agent-session-message-hand-back.test.ts": 218, + "src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts": 405, + "src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts": 325, + "src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts": 419, + "src/renderer/src/components/native-chat/structured-agent-session-queue-request.test.ts": 52, + "src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts": 219, + "src/renderer/src/components/native-chat/structured-agent-session-rail-outline.test.ts": 49, + "src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts": 294, + "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-clock.test.tsx": 1549, + "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx": 1883, + "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx": 1608, + "src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx": 1818, + "src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts": 364, + "src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts": 1277, + "src/renderer/src/components/native-chat/structured-attention-surface.test.ts": 1681, + "src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx": 1858, + "src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts": 785, + "src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx": 588, + "src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx": 1720, + "src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx": 1718, + "src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx": 564, + "src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx": 602, + "src/renderer/src/components/native-chat/use-native-chat-composer-app-menu-selection.test.tsx": 297, + "src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.test.tsx": 189, + "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx": 1797, + "src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx": 523, + "src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx": 1613, + "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx": 457, + "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx": 614, + "src/renderer/src/components/native-chat/use-native-chat-composer-notice.test.tsx": 309, + "src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx": 279, + "src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx": 446, + "src/renderer/src/components/native-chat/use-native-chat-composer-preview-lifetime.test.tsx": 1616, + "src/renderer/src/components/native-chat/use-native-chat-composer-recall.test.tsx": 466, + "src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx": 332, + "src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx": 2301, + "src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx": 702, + "src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.test.tsx": 259, + "src/renderer/src/components/native-chat/use-native-chat-file-link-click.test.tsx": 1131, + "src/renderer/src/components/native-chat/use-native-chat-file-link-existence.test.tsx": 706, + "src/renderer/src/components/native-chat/use-native-chat-find.test.tsx": 1343, + "src/renderer/src/components/native-chat/use-native-chat-font-size.test.tsx": 660, + "src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts": 1604, + "src/renderer/src/components/native-chat/use-native-chat-host-outage.test.tsx": 1609, + "src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts": 1685, + "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx": 388, + "src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx": 368, + "src/renderer/src/components/native-chat/use-native-chat-link-actions.test.tsx": 844, + "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts": 1561, + "src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts": 1650, + "src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts": 1567, + "src/renderer/src/components/native-chat/use-native-chat-mention-files.test.tsx": 1739, + "src/renderer/src/components/native-chat/use-native-chat-menu-paste-lifetime.test.tsx": 1722, + "src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts": 778, + "src/renderer/src/components/native-chat/use-native-chat-paced-text.remount.test.tsx": 288, + "src/renderer/src/components/native-chat/use-native-chat-paced-text.test.ts": 45, + "src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.test.tsx": 317, + "src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx": 350, + "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx": 1548, + "src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx": 295, + "src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts": 1743, + "src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx": 1968, + "src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx": 431, + "src/renderer/src/components/native-chat/use-native-chat-rail-history-jump.test.ts": 586, + "src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts": 217, + "src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx": 725, + "src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx": 1793, + "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx": 252, + "src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx": 1640, + "src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts": 805, + "src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx": 972, + "src/renderer/src/components/native-chat/use-native-chat-skills.test.ts": 1523, + "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx": 983, + "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts": 590, + "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts": 1442, + "src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.first-reply.test.tsx": 261, + "src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx": 355, + "src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx": 684, + "src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx": 1939, + "src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx": 2211, + "src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx": 2614, + "src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx": 575, + "src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx": 533, + "src/renderer/src/components/native-chat/use-structured-agent-session-legacy-recovery.test.tsx": 2508, + "src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx": 547, + "src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx": 876, + "src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx": 2125, + "src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx": 1942, + "src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx": 1799, + "src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx": 2745, + "src/renderer/src/components/native-chat/use-structured-agent-session-paired-runtime.test.tsx": 2294, + "src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx": 2071, + "src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx": 2502, + "src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx": 2577, + "src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx": 1996, + "src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx": 2429, + "src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx": 1709, + "src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx": 1659, + "src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx": 1691, + "src/renderer/src/components/native-chat/use-structured-agent-session-rail-outline.test.tsx": 564, + "src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx": 2334, + "src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx": 553, + "src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts": 523, + "src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx": 247, + "src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx": 2147, + "src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx": 312, + "src/renderer/src/components/native-chat/use-structured-agent-session-transport.test.tsx": 1868, + "src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx": 2124, + "src/renderer/src/components/native-chat/use-structured-agent-session.clear-send.test.tsx": 1896, + "src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx": 2024, + "src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx": 2061, + "src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx": 2287, + "src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx": 2042, + "src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx": 2472, + "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx": 3082, + "src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx": 471, + "src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx": 540, + "src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts": 295, + "src/renderer/src/components/network/CustomAddressDialog.test.tsx": 516, + "src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx": 596, + "src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx": 773, + "src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx": 926, + "src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx": 563, + "src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx": 324, + "src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx": 1306, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-provider-boundaries.test.ts": 54, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts": 97, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx": 635, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx": 1078, + "src/renderer/src/components/new-workspace/base-ref-picker-visibility.test.ts": 53, + "src/renderer/src/components/new-workspace/project-combobox-matching.test.ts": 76, + "src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts": 53, + "src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts": 185, + "src/renderer/src/components/new-workspace/smart-workspace-source-popover-focus.test.ts": 179, + "src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts": 125, + "src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx": 323, + "src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx": 293, + "src/renderer/src/components/new-workspace/use-linear-source-connection.test.tsx": 878, + "src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts": 1462, + "src/renderer/src/components/new-workspace/use-smart-workspace-field-focus-controls.test.tsx": 272, + "src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx": 663, + "src/renderer/src/components/new-workspace/work-item-source-selection.test.ts": 1480, + "src/renderer/src/components/onboarding/AgentStep.test.tsx": 289, + "src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx": 1700, + "src/renderer/src/components/onboarding/NotificationStep.test.tsx": 1612, + "src/renderer/src/components/onboarding/OnboardingFlow.test.tsx": 14464, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx": 369, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts": 9982, + "src/renderer/src/components/onboarding/ThemeStep.test.ts": 132, + "src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx": 558, + "src/renderer/src/components/onboarding/agent-picked-payload.test.ts": 68, + "src/renderer/src/components/onboarding/onboarding-dismiss-target.test.ts": 51, + "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts": 1391, + "src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts": 332, + "src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts": 60, + "src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts": 1673, + "src/renderer/src/components/onboarding/use-onboarding-flow.test.ts": 159, + "src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.test.ts": 42, + "src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx": 250, + "src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx": 269, + "src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx": 372, + "src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx": 245, + "src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx": 348, + "src/renderer/src/components/pet/PetOverlay.test.ts": 1488, + "src/renderer/src/components/pet/pet-agent-state.test.ts": 95, + "src/renderer/src/components/pet/pet-blob-cache.test.ts": 66, + "src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx": 283, + "src/renderer/src/components/pet/pet-overlay-position.test.ts": 1490, + "src/renderer/src/components/pet/pet-overlay-visibility.test.ts": 49, + "src/renderer/src/components/pet/sprite-animation-css.test.ts": 57, + "src/renderer/src/components/pet/usePetPointerInteraction.test.ts": 319, + "src/renderer/src/components/pet/usePetUrl.test.tsx": 355, + "src/renderer/src/components/ports/WorkspacePortScanner.test.tsx": 2253, + "src/renderer/src/components/pr-check-counts.test.ts": 84, + "src/renderer/src/components/pr-checks-fix-prompt.test.ts": 61, + "src/renderer/src/components/pr-comments-resolution-prompt.test.ts": 48, + "src/renderer/src/components/provider-check-classification-parity.test.ts": 129, + "src/renderer/src/components/pull-request-page/cache/file-content.test.ts": 129, + "src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx": 495, + "src/renderer/src/components/pull-request-page/mentions/options.test.ts": 59, + "src/renderer/src/components/pull-request-page/mentions/query.test.ts": 54, + "src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts": 155, + "src/renderer/src/components/quick-open-file-history.test.ts": 1540, + "src/renderer/src/components/quick-open-file-list-host-name-filter.react.test.tsx": 1753, + "src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx": 1792, + "src/renderer/src/components/quick-open-file-list.react.test.tsx": 1934, + "src/renderer/src/components/quick-open-file-list.test.ts": 1601, + "src/renderer/src/components/quick-open-file-navigation.test.ts": 335, + "src/renderer/src/components/quick-open-history-ranking.test.ts": 59, + "src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx": 314, + "src/renderer/src/components/quick-open-install-rg-guidance.test.ts": 133, + "src/renderer/src/components/quick-open-recent-lifetime.react.test.tsx": 1679, + "src/renderer/src/components/quick-open-recent-validation.test.ts": 109, + "src/renderer/src/components/quick-open-search.test.ts": 275, + "src/renderer/src/components/quick-open-selection-owner.react.test.tsx": 1849, + "src/renderer/src/components/repo/CreateFromPicker.test.tsx": 677, + "src/renderer/src/components/repo/NestedRepoChecklist.test.tsx": 186, + "src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts": 177, + "src/renderer/src/components/repo/repo-icon.emoji-centering.test.tsx": 333, + "src/renderer/src/components/repo/repo-icon.test.tsx": 399, + "src/renderer/src/components/right-sidebar/ActionButton.test.tsx": 143, + "src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx": 3070, + "src/renderer/src/components/right-sidebar/AiVaultPanelHeader.focus.test.tsx": 385, + "src/renderer/src/components/right-sidebar/AiVaultPanelSearch.test.tsx": 541, + "src/renderer/src/components/right-sidebar/AiVaultSearchEvidence.test.tsx": 2086, + "src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.test.tsx": 482, + "src/renderer/src/components/right-sidebar/AiVaultSessionListBar.test.tsx": 696, + "src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx": 2145, + "src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx": 2248, + "src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx": 2264, + "src/renderer/src/components/right-sidebar/AiVaultShowMoreSessionsRow.test.tsx": 376, + "src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx": 2999, + "src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx": 5337, + "src/renderer/src/components/right-sidebar/ChecksPanel.updated-at-metadata.test.tsx": 249, + "src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx": 2229, + "src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx": 1890, + "src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx": 3363, + "src/renderer/src/components/right-sidebar/CommitArea.test.tsx": 6748, + "src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.errors.test.tsx": 1674, + "src/renderer/src/components/right-sidebar/FileExplorerNameFilter.test.tsx": 156, + "src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx": 432, + "src/renderer/src/components/right-sidebar/FileExplorerRow.load-error.test.tsx": 1657, + "src/renderer/src/components/right-sidebar/FileExplorerScopeNotice.test.tsx": 538, + "src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx": 1512, + "src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.test.tsx": 98, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.row-handlers.test.tsx": 1604, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx": 1525, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx": 566, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx": 428, + "src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 334, + "src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx": 1611, + "src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx": 359, + "src/renderer/src/components/right-sidebar/HostedReviewActions.draft.test.tsx": 1176, + "src/renderer/src/components/right-sidebar/PluginPanel.test.tsx": 559, + "src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 2438, + "src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx": 902, + "src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx": 197, + "src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx": 280, + "src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx": 2329, + "src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts": 133, + "src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts": 70, + "src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts": 63, + "src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts": 182, + "src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx": 146, + "src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx": 3578, + "src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts": 70, + "src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx": 3147, + "src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts": 45, + "src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts": 46, + "src/renderer/src/components/right-sidebar/SourceControl.resolve-conflicts-prompt.test.ts": 98, + "src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx": 3961, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx": 2686, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx": 258, + "src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts": 376, + "src/renderer/src/components/right-sidebar/active-checks-status.test.ts": 1468, + "src/renderer/src/components/right-sidebar/activity-bar-buttons.test.tsx": 328, + "src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts": 53, + "src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx": 419, + "src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts": 201, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts": 79, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts": 66, + "src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.test.tsx": 2271, + "src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts": 69, + "src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts": 72, + "src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts": 52, + "src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx": 448, + "src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts": 268, + "src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts": 77, + "src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts": 69, + "src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts": 306, + "src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts": 59, + "src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts": 103, + "src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts": 57, + "src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts": 1623, + "src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts": 337, + "src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts": 330, + "src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts": 101, + "src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts": 156, + "src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts": 1511, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts": 1041, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts": 519, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts": 84, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts": 1709, + "src/renderer/src/components/right-sidebar/ai-vault-session-surface-switch.test.ts": 1609, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx": 1626, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts": 109, + "src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts": 70, + "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts": 68, + "src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts": 42, + "src/renderer/src/components/right-sidebar/check-details-resize.test.ts": 48, + "src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts": 72, + "src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx": 701, + "src/renderer/src/components/right-sidebar/checks-panel-async-result-key.test.ts": 55, + "src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx": 1908, + "src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts": 138, + "src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts": 66, + "src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts": 56, + "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts": 61, + "src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts": 72, + "src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts": 74, + "src/renderer/src/components/right-sidebar/checks-panel-review.test.ts": 58, + "src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts": 72, + "src/renderer/src/components/right-sidebar/checks-panel/checks-detail-polling-policy.test.ts": 49, + "src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts": 103, + "src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx": 6495, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx": 2148, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx": 1647, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.test.tsx": 228, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx": 1760, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx": 241, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx": 1661, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling-stability.test.tsx": 1756, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx": 1865, + "src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts": 65, + "src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts": 56, + "src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts": 66, + "src/renderer/src/components/right-sidebar/create-review-draft-title.test.ts": 60, + "src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts": 59, + "src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts": 76, + "src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts": 56, + "src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts": 136, + "src/renderer/src/components/right-sidebar/file-explorer-copy-file-owner.test.ts": 76, + "src/renderer/src/components/right-sidebar/file-explorer-delete-editor-owner.test.tsx": 1672, + "src/renderer/src/components/right-sidebar/file-explorer-dir-load-tracker.test.ts": 50, + "src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts": 167, + "src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts": 1416, + "src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts": 1622, + "src/renderer/src/components/right-sidebar/file-explorer-display-root.test.ts": 1628, + "src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx": 2169, + "src/renderer/src/components/right-sidebar/file-explorer-entries.test.ts": 58, + "src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts": 1581, + "src/renderer/src/components/right-sidebar/file-explorer-inline-input-ime-enter.test.tsx": 361, + "src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx": 348, + "src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx": 910, + "src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts": 389, + "src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts": 68, + "src/renderer/src/components/right-sidebar/file-explorer-linked-recovery.test.tsx": 1794, + "src/renderer/src/components/right-sidebar/file-explorer-linked-watch.test.ts": 1465, + "src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts": 70, + "src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts": 1460, + "src/renderer/src/components/right-sidebar/file-explorer-operation-owner.test.ts": 349, + "src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts": 61, + "src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts": 49, + "src/renderer/src/components/right-sidebar/file-explorer-replace-selection-order-budget.test.tsx": 338, + "src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts": 52, + "src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.test.tsx": 433, + "src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts": 53, + "src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts": 55, + "src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts": 48, + "src/renderer/src/components/right-sidebar/file-explorer-stale-retry.test.tsx": 1729, + "src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts": 1350, + "src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts": 1562, + "src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts": 1632, + "src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts": 99, + "src/renderer/src/components/right-sidebar/file-explorer-watch-selected-host.test.ts": 1457, + "src/renderer/src/components/right-sidebar/file-search-include-pattern.test.ts": 61, + "src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts": 104, + "src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts": 61, + "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts": 76, + "src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts": 318, + "src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts": 80, + "src/renderer/src/components/right-sidebar/git-status-refresh.test.ts": 430, + "src/renderer/src/components/right-sidebar/github-pr-link-modal.test.ts": 48, + "src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts": 91, + "src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts": 112, + "src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts": 55, + "src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts": 224, + "src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts": 129, + "src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts": 133, + "src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts": 111, + "src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts": 354, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog-visibility.test.ts": 404, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog.test.ts": 296, + "src/renderer/src/components/right-sidebar/ports-panel-host.test.ts": 319, + "src/renderer/src/components/right-sidebar/pr-comment-ack-summary.test.ts": 49, + "src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts": 167, + "src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts": 58, + "src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts": 51, + "src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts": 113, + "src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx": 2618, + "src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts": 390, + "src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts": 49, + "src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx": 297, + "src/renderer/src/components/right-sidebar/right-panel-comment-focus-timers.test.ts": 45, + "src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts": 106, + "src/renderer/src/components/right-sidebar/right-sidebar-effective-tab.test.ts": 50, + "src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts": 52, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx": 718, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.test.ts": 44, + "src/renderer/src/components/right-sidebar/right-sidebar-width.test.ts": 46, + "src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts": 218, + "src/renderer/src/components/right-sidebar/search-match-open.test.ts": 64, + "src/renderer/src/components/right-sidebar/search-rows.test.ts": 56, + "src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts": 270, + "src/renderer/src/components/right-sidebar/source-control-actions.test.ts": 53, + "src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts": 47, + "src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.test.ts": 134, + "src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx": 259, + "src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts": 86, + "src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx": 1703, + "src/renderer/src/components/right-sidebar/source-control-commit-eligibility.test.ts": 51, + "src/renderer/src/components/right-sidebar/source-control-commit-message-rows.test.ts": 110, + "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts": 78, + "src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts": 93, + "src/renderer/src/components/right-sidebar/source-control-created-review-link.test.ts": 49, + "src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts": 87, + "src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx": 194, + "src/renderer/src/components/right-sidebar/source-control-discard-localization.test.ts": 353, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts": 131, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts": 160, + "src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts": 48, + "src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx": 420, + "src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts": 61, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx": 372, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts": 211, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts": 71, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts": 79, + "src/renderer/src/components/right-sidebar/source-control-huge-repo-warning-dismissals.test.ts": 154, + "src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts": 434, + "src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts": 71, + "src/renderer/src/components/right-sidebar/source-control-primary-action-tooltip.test.ts": 113, + "src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts": 143, + "src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts": 148, + "src/renderer/src/components/right-sidebar/source-control-push-recovery.test.ts": 59, + "src/renderer/src/components/right-sidebar/source-control-section-order.test.ts": 67, + "src/renderer/src/components/right-sidebar/source-control-split-open.test.ts": 53, + "src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts": 56, + "src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts": 72, + "src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts": 270, + "src/renderer/src/components/right-sidebar/source-control-too-many-changes-banner.test.tsx": 421, + "src/renderer/src/components/right-sidebar/source-control-tree.test.ts": 75, + "src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx": 1485, + "src/renderer/src/components/right-sidebar/source-control/commit/rename-mutation-paths.test.ts": 55, + "src/renderer/src/components/right-sidebar/source-control/commit/source-control-discard-editor-owner.test.tsx": 315, + "src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts": 226, + "src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx": 323, + "src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions-capped-stage.test.tsx": 320, + "src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx": 2110, + "src/renderer/src/components/right-sidebar/source-control/listing/section-header.test.tsx": 417, + "src/renderer/src/components/right-sidebar/source-control/listing/use-file-projection-work.test.tsx": 331, + "src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx": 1360, + "src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.test.tsx": 300, + "src/renderer/src/components/right-sidebar/source-control/review/suppressed-github-pr.test.ts": 50, + "src/renderer/src/components/right-sidebar/source-control/review/use-action-model.test.tsx": 303, + "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.test.tsx": 1779, + "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-state.test.tsx": 306, + "src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx": 1604, + "src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.test.tsx": 251, + "src/renderer/src/components/right-sidebar/source-control/sync/git-history-format.test.ts": 69, + "src/renderer/src/components/right-sidebar/source-control/sync/git-history-graph-svg.test.tsx": 98, + "src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.local-ignore.test.tsx": 164, + "src/renderer/src/components/right-sidebar/status-display.test.ts": 51, + "src/renderer/src/components/right-sidebar/use-ai-vault-search-focus-request.test.tsx": 1381, + "src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx": 453, + "src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts": 1614, + "src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.runtime-listing.test.tsx": 1666, + "src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.test.ts": 1698, + "src/renderer/src/components/right-sidebar/use-file-explorer-root-navigation.test.tsx": 297, + "src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts": 53, + "src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx": 297, + "src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.test.tsx": 357, + "src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx": 341, + "src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.test.tsx": 480, + "src/renderer/src/components/right-sidebar/use-right-sidebar-tab-routing.effective-tab.test.tsx": 266, + "src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts": 1903, + "src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx": 421, + "src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx": 303, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts": 1571, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts": 1533, + "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.os-file-drop.test.tsx": 2146, + "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.test.ts": 52, + "src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts": 1433, + "src/renderer/src/components/right-sidebar/useFileExplorerImport.committed-scope.test.tsx": 291, + "src/renderer/src/components/right-sidebar/useFileExplorerImport.os-file-drop.test.tsx": 514, + "src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx": 1732, + "src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts": 1412, + "src/renderer/src/components/right-sidebar/useFileExplorerReveal.scoped-root.test.tsx": 316, + "src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts": 64, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.lifecycle.test.tsx": 1983, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.refresh-projection-churn.test.tsx": 1825, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.scoped-root.test.tsx": 1837, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx": 1690, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx": 1954, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts": 1433, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.broker.test.tsx": 286, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx": 569, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx": 346, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts": 1444, + "src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx": 1892, + "src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx": 582, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts": 1604, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts": 2655, + "src/renderer/src/components/right-sidebar/useHostedReviewStackParent.test.tsx": 296, + "src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts": 63, + "src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx": 254, + "src/renderer/src/components/settings/AccountsPane.owner-scope.test.tsx": 1292, + "src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx": 2440, + "src/renderer/src/components/settings/AccountsPane.test.tsx": 2349, + "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.interaction.test.tsx": 1600, + "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts": 1496, + "src/renderer/src/components/settings/AdvancedPane.test.tsx": 496, + "src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx": 1728, + "src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx": 2128, + "src/renderer/src/components/settings/AgentsPane.test.tsx": 2229, + "src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx": 743, + "src/renderer/src/components/settings/AppearanceChatContrastControls.draft.test.tsx": 697, + "src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx": 1066, + "src/renderer/src/components/settings/AppearanceChatSection.test.tsx": 1335, + "src/renderer/src/components/settings/AppearancePane.test.tsx": 1175, + "src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx": 859, + "src/renderer/src/components/settings/AutomationsSettingsPane.test.tsx": 735, + "src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx": 116, + "src/renderer/src/components/settings/BrowserClientHostedRemoteSetting.test.tsx": 710, + "src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx": 564, + "src/renderer/src/components/settings/BrowserPane.test.ts": 49, + "src/renderer/src/components/settings/BrowserSshWorkspaceRoutingSetting.test.tsx": 619, + "src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx": 162, + "src/renderer/src/components/settings/BrowserUserAgentSetting.test.tsx": 1795, + "src/renderer/src/components/settings/ChatNamingSetting.test.tsx": 1784, + "src/renderer/src/components/settings/ChatSettingsSection.inline-visuals.test.tsx": 1723, + "src/renderer/src/components/settings/ChatSettingsSection.navigation.test.tsx": 1343, + "src/renderer/src/components/settings/ChatSettingsSection.test.tsx": 1939, + "src/renderer/src/components/settings/CliSection.install-failure.test.tsx": 2113, + "src/renderer/src/components/settings/CliSection.test.tsx": 1847, + "src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx": 1626, + "src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx": 1517, + "src/renderer/src/components/settings/CodexLoginLinkNotice.test.tsx": 463, + "src/renderer/src/components/settings/CommitMessageAiPane.test.tsx": 1859, + "src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx": 459, + "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx": 1979, + "src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx": 558, + "src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx": 685, + "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx": 786, + "src/renderer/src/components/settings/EphemeralVmsPane.test.tsx": 7418, + "src/renderer/src/components/settings/ExperimentalPane.test.tsx": 1697, + "src/renderer/src/components/settings/FloatingWorkspacePane.test.tsx": 1674, + "src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx": 1367, + "src/renderer/src/components/settings/GeneralPane.test.ts": 9929, + "src/renderer/src/components/settings/GeneralRemoteServerUpdates.test.tsx": 306, + "src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx": 1953, + "src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx": 1880, + "src/renderer/src/components/settings/GhosttyImportModal.test.ts": 213, + "src/renderer/src/components/settings/GitPane.test.ts": 1505, + "src/renderer/src/components/settings/GitPane.test.tsx": 1506, + "src/renderer/src/components/settings/GrokAccountsSection.test.tsx": 384, + "src/renderer/src/components/settings/HiddenExperimentalGroup.test.tsx": 326, + "src/renderer/src/components/settings/KagiSessionLinkForm.test.ts": 1476, + "src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx": 227, + "src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx": 152, + "src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx": 672, + "src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx": 432, + "src/renderer/src/components/settings/MachineNameField.test.tsx": 531, + "src/renderer/src/components/settings/ManagedServerRow.test.tsx": 401, + "src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx": 1848, + "src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx": 1509, + "src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx": 551, + "src/renderer/src/components/settings/MobilePairingQrSection.test.tsx": 539, + "src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx": 1031, + "src/renderer/src/components/settings/MobilePane.test.tsx": 1213, + "src/renderer/src/components/settings/MobilePaneAddressSearch.test.tsx": 635, + "src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx": 2559, + "src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx": 564, + "src/renderer/src/components/settings/NotificationHostToggles.test.tsx": 610, + "src/renderer/src/components/settings/NotificationsPane.test.tsx": 2062, + "src/renderer/src/components/settings/OpenInMenuSetting.test.ts": 233, + "src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx": 516, + "src/renderer/src/components/settings/OrchestrationPane.test.tsx": 1871, + "src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx": 207, + "src/renderer/src/components/settings/PluginConsentDialog.test.tsx": 2096, + "src/renderer/src/components/settings/PluginInstallDialog.test.tsx": 803, + "src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts": 1327, + "src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx": 804, + "src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx": 533, + "src/renderer/src/components/settings/PluginSettingsRow.test.tsx": 642, + "src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx": 1128, + "src/renderer/src/components/settings/PrivacyPane.test.ts": 1570, + "src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx": 709, + "src/renderer/src/components/settings/QuickCommandsList.test.tsx": 604, + "src/renderer/src/components/settings/QuickCommandsPane.test.ts": 1851, + "src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx": 335, + "src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx": 685, + "src/renderer/src/components/settings/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx": 475, + "src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx": 479, + "src/renderer/src/components/settings/RepositoryHooksSection.test.ts": 799, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.settings-entry.test.tsx": 1925, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx": 2009, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx": 1574, + "src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx": 262, + "src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx": 542, + "src/renderer/src/components/settings/RepositoryPane.test.ts": 2984, + "src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx": 258, + "src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts": 466, + "src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx": 415, + "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts": 120, + "src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx": 405, + "src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx": 315, + "src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx": 335, + "src/renderer/src/components/settings/SessionHistoryComputerRow.test.tsx": 967, + "src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx": 1217, + "src/renderer/src/components/settings/Settings.load-performance.test.ts": 54, + "src/renderer/src/components/settings/SettingsConstants.test.ts": 285, + "src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx": 797, + "src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx": 622, + "src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx": 662, + "src/renderer/src/components/settings/SettingsSidebar.test.tsx": 2372, + "src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx": 832, + "src/renderer/src/components/settings/ShortcutCommandBlock.test.tsx": 1771, + "src/renderer/src/components/settings/ShortcutFilterRail.test.ts": 215, + "src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx": 399, + "src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 633, + "src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx": 450, + "src/renderer/src/components/settings/SshManagedServerMoveDialog.test.tsx": 1810, + "src/renderer/src/components/settings/SshTargetCard.test.tsx": 430, + "src/renderer/src/components/settings/SshTargetForm.test.tsx": 1002, + "src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx": 484, + "src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx": 376, + "src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx": 196, + "src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx": 355, + "src/renderer/src/components/settings/TasksPane.test.tsx": 926, + "src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx": 1998, + "src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts": 499, + "src/renderer/src/components/settings/TerminalContrastSetting.test.tsx": 692, + "src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx": 1643, + "src/renderer/src/components/settings/TerminalPane.pwsh.test.ts": 489, + "src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx": 407, + "src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx": 322, + "src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts": 1446, + "src/renderer/src/components/settings/UnsealedCredentialNotice.test.tsx": 313, + "src/renderer/src/components/settings/VoiceMicrophoneSetting.test.tsx": 945, + "src/renderer/src/components/settings/VoicePane.test.tsx": 902, + "src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx": 552, + "src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx": 1671, + "src/renderer/src/components/settings/ZcodePlanAccountsSection.test.tsx": 873, + "src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx": 1765, + "src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts": 435, + "src/renderer/src/components/settings/accounts-search.test.ts": 61, + "src/renderer/src/components/settings/agent-availability-settings.test.ts": 297, + "src/renderer/src/components/settings/agent-default-env-draft.test.ts": 60, + "src/renderer/src/components/settings/appearance-interface-summary.test.ts": 321, + "src/renderer/src/components/settings/appearance-search.test.ts": 556, + "src/renderer/src/components/settings/appearance-status-bar-search.test.ts": 117, + "src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts": 109, + "src/renderer/src/components/settings/bitbucket-integration-card.test.tsx": 466, + "src/renderer/src/components/settings/browser-cookie-import-label.test.ts": 150, + "src/renderer/src/components/settings/browser-link-routing-localization.test.ts": 307, + "src/renderer/src/components/settings/browser-search.test.ts": 234, + "src/renderer/src/components/settings/browser-session-host-selection.test.ts": 47, + "src/renderer/src/components/settings/chat-naming-persistence.test.tsx": 3409, + "src/renderer/src/components/settings/chat-settings-unsaved-guard.test.tsx": 1734, + "src/renderer/src/components/settings/cli-install-failure.test.ts": 60, + "src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx": 414, + "src/renderer/src/components/settings/codex-account-auth-warning.test.ts": 60, + "src/renderer/src/components/settings/codex-config-sync-warning.test.ts": 42, + "src/renderer/src/components/settings/codex-session-source-home-control.test.tsx": 164, + "src/renderer/src/components/settings/developer-permissions-search.test.ts": 318, + "src/renderer/src/components/settings/general-editor-search.test.ts": 140, + "src/renderer/src/components/settings/general-preview-tab-search.test.ts": 101, + "src/renderer/src/components/settings/host-scoped-setting-scope.test.ts": 60, + "src/renderer/src/components/settings/integrations-pane-status.test.ts": 45, + "src/renderer/src/components/settings/jira-integration-card.test.tsx": 467, + "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx": 1710, + "src/renderer/src/components/settings/managed-server-copy.test.ts": 85, + "src/renderer/src/components/settings/mobile-network-interface-selection.test.ts": 71, + "src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts": 67, + "src/renderer/src/components/settings/mobile-pane-search.test.ts": 92, + "src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts": 112, + "src/renderer/src/components/settings/notification-host-pending-save.test.tsx": 1929, + "src/renderer/src/components/settings/osc52-clipboard-copy.test.ts": 277, + "src/renderer/src/components/settings/plugin-install-source.test.ts": 297, + "src/renderer/src/components/settings/provider-account-scope.test.ts": 86, + "src/renderer/src/components/settings/provider-account-visibility.test.ts": 59, + "src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx": 248, + "src/renderer/src/components/settings/repository-hook-settings-draft.test.ts": 290, + "src/renderer/src/components/settings/repository-host-setup-options.test.ts": 290, + "src/renderer/src/components/settings/repository-icon-github.test.ts": 62, + "src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts": 429, + "src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts": 94, + "src/renderer/src/components/settings/runtime-server-row-unverifiable-host.test.tsx": 1730, + "src/renderer/src/components/settings/runtime-server-workflow-sections.test.tsx": 377, + "src/renderer/src/components/settings/session-history-status-copy.test.ts": 282, + "src/renderer/src/components/settings/session-search-computer-rollup.test.ts": 45, + "src/renderer/src/components/settings/setting-ownership.test.ts": 96, + "src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts": 187, + "src/renderer/src/components/settings/settings-form-option-filter.test.ts": 69, + "src/renderer/src/components/settings/settings-host-scope.test.ts": 94, + "src/renderer/src/components/settings/settings-project-checkout-target.test.ts": 70, + "src/renderer/src/components/settings/settings-project-list.test.ts": 94, + "src/renderer/src/components/settings/settings-project-regrouping.test.ts": 550, + "src/renderer/src/components/settings/settings-search-keywords.test.ts": 137, + "src/renderer/src/components/settings/settings-search.test.ts": 63, + "src/renderer/src/components/settings/settings-setup-guide-progress-hook.test.tsx": 95, + "src/renderer/src/components/settings/settings-setup-guide-progress.test.ts": 1583, + "src/renderer/src/components/settings/shortcut-binding-list-mutations.test.ts": 57, + "src/renderer/src/components/settings/shortcut-definition-catalog.test.ts": 316, + "src/renderer/src/components/settings/shortcut-groups.test.ts": 143, + "src/renderer/src/components/settings/shortcut-recording-state.test.ts": 51, + "src/renderer/src/components/settings/shortcut-row-visibility.test.ts": 179, + "src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx": 3192, + "src/renderer/src/components/settings/sparse-preset-date.test.ts": 66, + "src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 53, + "src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts": 107, + "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 47, + "src/renderer/src/components/settings/ssh-target-draft.test.ts": 66, + "src/renderer/src/components/settings/ssh-target-remove.test.ts": 264, + "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 114, + "src/renderer/src/components/settings/task-source-setup-state.test.ts": 62, + "src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx": 565, + "src/renderer/src/components/settings/terminal-preview-content.test.ts": 55, + "src/renderer/src/components/settings/terminal-search.test.ts": 304, + "src/renderer/src/components/settings/use-debounced-settings-text-draft.test.ts": 297, + "src/renderer/src/components/settings/use-mac-captured-digit-chords.test.ts": 706, + "src/renderer/src/components/settings/use-published-machine-name.test.tsx": 269, + "src/renderer/src/components/settings/use-repository-hook-settings-draft.test.tsx": 461, + "src/renderer/src/components/settings/use-runtime-environment-catalog.test.tsx": 1802, + "src/renderer/src/components/settings/use-session-search-status.test.tsx": 560, + "src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx": 245, + "src/renderer/src/components/settings/use-zcode-plan-credentials.test.tsx": 890, + "src/renderer/src/components/settings/useGhosttyImport.test.ts": 84, + "src/renderer/src/components/settings/useWarpThemeImport.test.ts": 111, + "src/renderer/src/components/settings/worktree-symlink-path-filter.scan-budget.test.ts": 104, + "src/renderer/src/components/settings/worktree-symlink-path-filter.test.ts": 56, + "src/renderer/src/components/setup-guide/SetupGuideModal.mount-gating.test.tsx": 1712, + "src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts": 1636, + "src/renderer/src/components/setup-guide/use-setup-guide-telemetry.test.ts": 57, + "src/renderer/src/components/shared/MacFolderAccessFixDialog.test.tsx": 1061, + "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts": 2003, + "src/renderer/src/components/shared/useDaemonActions.test.tsx": 448, + "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx": 195, + "src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx": 1551, + "src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx": 541, + "src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx": 317, + "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts": 59, + "src/renderer/src/components/sidebar/AddRepoDialogChrome.test.tsx": 632, + "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx": 558, + "src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx": 228, + "src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 492, + "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 659, + "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts": 141, + "src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx": 1722, + "src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx": 505, + "src/renderer/src/components/sidebar/CacheTimer.test.tsx": 154, + "src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx": 1722, + "src/renderer/src/components/sidebar/CommentMarkdown.github-callout.test.tsx": 2046, + "src/renderer/src/components/sidebar/CommentMarkdown.growing.test.tsx": 2136, + "src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx": 2032, + "src/renderer/src/components/sidebar/CommentMarkdown.ordered-list.test.tsx": 2756, + "src/renderer/src/components/sidebar/CommentMarkdown.test.tsx": 2088, + "src/renderer/src/components/sidebar/CommentMarkdown.word-fade.test.tsx": 1979, + "src/renderer/src/components/sidebar/CommentMermaidBlock.system-theme.test.tsx": 290, + "src/renderer/src/components/sidebar/DeleteNestedWorktreesDialog.test.tsx": 878, + "src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx": 1146, + "src/renderer/src/components/sidebar/DeleteWorktreeDialogFooter.test.tsx": 613, + "src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx": 699, + "src/renderer/src/components/sidebar/FilterToggleRow.test.tsx": 307, + "src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx": 1535, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx": 1917, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx": 2535, + "src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.test.tsx": 1490, + "src/renderer/src/components/sidebar/MarkdownImageLightbox.test.tsx": 589, + "src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx": 1727, + "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx": 1115, + "src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx": 1056, + "src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx": 94, + "src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx": 108, + "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx": 561, + "src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx": 194, + "src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx": 510, + "src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx": 425, + "src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx": 353, + "src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts": 54, + "src/renderer/src/components/sidebar/SetupScriptPromptCardShell.test.tsx": 484, + "src/renderer/src/components/sidebar/Sidebar.test.tsx": 840, + "src/renderer/src/components/sidebar/SidebarAgentsList.test.tsx": 2753, + "src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx": 3074, + "src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx": 456, + "src/renderer/src/components/sidebar/SidebarHeader.test.tsx": 407, + "src/renderer/src/components/sidebar/SidebarNav.test.tsx": 1772, + "src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.test.tsx": 598, + "src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx": 605, + "src/renderer/src/components/sidebar/SidebarToolbar.test.tsx": 392, + "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx": 459, + "src/renderer/src/components/sidebar/StatusIndicator.test.ts": 201, + "src/renderer/src/components/sidebar/WorkspaceAttachmentOrigins.test.tsx": 589, + "src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.resolution.test.tsx": 2470, + "src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx": 367, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx": 1879, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx": 2008, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx": 1680, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx": 1665, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx": 304, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx": 387, + "src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx": 379, + "src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx": 826, + "src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx": 339, + "src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx": 1949, + "src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx": 3248, + "src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx": 1908, + "src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx": 1127, + "src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx": 1664, + "src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx": 1698, + "src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx": 1106, + "src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx": 1239, + "src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx": 1332, + "src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx": 1162, + "src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx": 1696, + "src/renderer/src/components/sidebar/WorktreeCard.single-reference-details.test.tsx": 1881, + "src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx": 1238, + "src/renderer/src/components/sidebar/WorktreeCard.test.ts": 3227, + "src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx": 1133, + "src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx": 965, + "src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx": 1237, + "src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx": 1319, + "src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx": 977, + "src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx": 418, + "src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.test.tsx": 473, + "src/renderer/src/components/sidebar/WorktreeCardLinkedItemsSection.test.tsx": 2662, + "src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx": 2731, + "src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx": 2991, + "src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx": 1396, + "src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx": 1913, + "src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx": 387, + "src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx": 1913, + "src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx": 1311, + "src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx": 2062, + "src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts": 2108, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenu.test.tsx": 146, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx": 586, + "src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx": 2031, + "src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts": 2233, + "src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts": 2667, + "src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts": 3532, + "src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 4327, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts": 3044, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx": 1805, + "src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx": 2913, + "src/renderer/src/components/sidebar/WorktreeMetaDialog.host-scope.test.tsx": 2087, + "src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx": 6094, + "src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx": 195, + "src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts": 1624, + "src/renderer/src/components/sidebar/WorktreeReferenceStack.test.tsx": 414, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx": 120, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.editor-lifecycle.test.tsx": 439, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx": 265, + "src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx": 2342, + "src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx": 450, + "src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx": 460, + "src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts": 84, + "src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts": 139, + "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts": 49, + "src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts": 59, + "src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts": 81, + "src/renderer/src/components/sidebar/agent-send-picker-collapse.test.tsx": 1815, + "src/renderer/src/components/sidebar/child-workspaces-toggle-target.test.ts": 1879, + "src/renderer/src/components/sidebar/child-workspaces-toggle-visibility.test.ts": 2328, + "src/renderer/src/components/sidebar/clone-defaults.test.ts": 59, + "src/renderer/src/components/sidebar/create-project-defaults.test.ts": 53, + "src/renderer/src/components/sidebar/default-branch-folder-project-root.test.ts": 1426, + "src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts": 1666, + "src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx": 2527, + "src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts": 81, + "src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx": 644, + "src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx": 2640, + "src/renderer/src/components/sidebar/delete-worktree-flow.test.ts": 914, + "src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts": 521, + "src/renderer/src/components/sidebar/delete-worktree-toast.test.ts": 81, + "src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts": 63, + "src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts": 1445, + "src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts": 130, + "src/renderer/src/components/sidebar/folder-workspace-composer-expiry.test.tsx": 2194, + "src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts": 91, + "src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx": 1765, + "src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts": 2163, + "src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts": 2015, + "src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts": 1762, + "src/renderer/src/components/sidebar/host-header-drag.test.tsx": 275, + "src/renderer/src/components/sidebar/host-header-menu-items.test.ts": 53, + "src/renderer/src/components/sidebar/host-rename-remove.test.ts": 53, + "src/renderer/src/components/sidebar/host-section-collapse-folders.test.ts": 184, + "src/renderer/src/components/sidebar/host-section-collapse.test.ts": 195, + "src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts": 53, + "src/renderer/src/components/sidebar/host-section-order.test.ts": 47, + "src/renderer/src/components/sidebar/host-section-reveal.test.tsx": 2061, + "src/renderer/src/components/sidebar/host-section-rows.test.ts": 179, + "src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts": 2113, + "src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts": 63, + "src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts": 75, + "src/renderer/src/components/sidebar/kanban-empty-selection-prune-budget.test.tsx": 1098, + "src/renderer/src/components/sidebar/linear-agent-skill-runtime.shell-override.test.ts": 82, + "src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 44, + "src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx": 246, + "src/renderer/src/components/sidebar/manual-sort-switch-toast.test.ts": 1537, + "src/renderer/src/components/sidebar/markdown-top-level-blocks.test.ts": 466, + "src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts": 234, + "src/renderer/src/components/sidebar/natural-worktree-ids.test.ts": 136, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts": 105, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts": 58, + "src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts": 460, + "src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx": 524, + "src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx": 357, + "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts": 92, + "src/renderer/src/components/sidebar/project-filter-reveal.test.ts": 47, + "src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts": 201, + "src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts": 155, + "src/renderer/src/components/sidebar/project-group-header-drop.test.ts": 136, + "src/renderer/src/components/sidebar/project-header-action-selector-lockstep.test.ts": 172, + "src/renderer/src/components/sidebar/project-header-color.test.ts": 281, + "src/renderer/src/components/sidebar/project-header-drag-commit.test.ts": 154, + "src/renderer/src/components/sidebar/project-header-drag-start.test.ts": 159, + "src/renderer/src/components/sidebar/project-header-drag.test.ts": 201, + "src/renderer/src/components/sidebar/project-header-drop.test.ts": 169, "src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.test.ts": 53, - "src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts": 65, - "src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts": 55, - "src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts": 58, - "src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts": 155, - "src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts": 1657, - "src/renderer/src/components/sidebar/repo-header-create-state.test.ts": 110, - "src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts": 45, - "src/renderer/src/components/sidebar/sidebar-filter-state.test.ts": 1063, - "src/renderer/src/components/sidebar/sidebar-host-options.test.ts": 109, + "src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts": 51, + "src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts": 54, + "src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts": 56, + "src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts": 143, + "src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts": 1479, + "src/renderer/src/components/sidebar/repo-header-create-state.test.ts": 86, + "src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts": 53, + "src/renderer/src/components/sidebar/sidebar-filter-state.test.ts": 1362, + "src/renderer/src/components/sidebar/sidebar-host-options.test.ts": 119, + "src/renderer/src/components/sidebar/sidebar-lineage-ancestors.test.ts": 220, "src/renderer/src/components/sidebar/sidebar-project-drop.test.ts": 93, - "src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts": 4515, - "src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts": 205, - "src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts": 69, - "src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts": 101, - "src/renderer/src/components/sidebar/smart-attention.test.ts": 142, - "src/renderer/src/components/sidebar/smart-sort.test.ts": 85, - "src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts": 33, - "src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts": 39, - "src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts": 60, - "src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts": 1707, - "src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx": 396, - "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts": 266, - "src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts": 63, - "src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx": 678, - "src/renderer/src/components/sidebar/use-sidebar-feedback-images.test.tsx": 560, - "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx": 2267, - "src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts": 986, - "src/renderer/src/components/sidebar/use-workspace-kanban-drawer-lingering.test.tsx": 606, - "src/renderer/src/components/sidebar/use-workspace-kanban-outside-dismiss.test.ts": 34, - "src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx": 283, - "src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.test.tsx": 611, - "src/renderer/src/components/sidebar/use-workspace-status-drop.test.ts": 256, - "src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx": 140, - "src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts": 1246, - "src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx": 2448, - "src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx": 79, - "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts": 145, - "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts": 162, - "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts": 105, - "src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts": 95, - "src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts": 101, - "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts": 147, - "src/renderer/src/components/sidebar/useRenderedSetupScriptPromptState.test.ts": 476, - "src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx": 1585, - "src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx": 556, - "src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts": 168, - "src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts": 32, - "src/renderer/src/components/sidebar/visible-worktree-indexes.test.ts": 1479, - "src/renderer/src/components/sidebar/visible-worktrees.test.ts": 3541, - "src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 58, - "src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 29, + "src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts": 386, + "src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts": 112, + "src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts": 129, + "src/renderer/src/components/sidebar/smart-attention-host-observed-end.test.ts": 93, + "src/renderer/src/components/sidebar/smart-attention.test.ts": 136, + "src/renderer/src/components/sidebar/smart-sort.test.ts": 158, + "src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts": 54, + "src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts": 60, + "src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts": 51, + "src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts": 1936, + "src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx": 650, + "src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx": 200, + "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts": 318, + "src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts": 68, + "src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx": 330, + "src/renderer/src/components/sidebar/use-sidebar-feedback-images.test.tsx": 334, + "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx": 1548, + "src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts": 76, + "src/renderer/src/components/sidebar/use-workspace-kanban-drawer-lingering.test.tsx": 257, + "src/renderer/src/components/sidebar/use-workspace-kanban-outside-dismiss.test.ts": 64, + "src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx": 210, + "src/renderer/src/components/sidebar/use-workspace-reference-details.test.tsx": 1076, + "src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.test.tsx": 194, + "src/renderer/src/components/sidebar/use-workspace-status-drop.test.ts": 140, + "src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx": 185, + "src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts": 1394, + "src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx": 2686, + "src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx": 148, + "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts": 141, + "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts": 169, + "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts": 139, + "src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts": 73, + "src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts": 116, + "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts": 139, + "src/renderer/src/components/sidebar/useRenderedSetupScriptPromptState.test.ts": 253, + "src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx": 1633, + "src/renderer/src/components/sidebar/useSidebarProjectDrop.os-file-drop.test.tsx": 647, + "src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx": 245, + "src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts": 176, + "src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts": 58, + "src/renderer/src/components/sidebar/visible-worktree-indexes.test.ts": 1791, + "src/renderer/src/components/sidebar/visible-worktrees-manual-lineage-order.test.ts": 1627, + "src/renderer/src/components/sidebar/visible-worktrees.test.ts": 1757, + "src/renderer/src/components/sidebar/workspace-attachment-source-label.test.ts": 80, + "src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts": 1721, + "src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 127, + "src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 84, "src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts": 52, - "src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 343, - "src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts": 69, - "src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts": 54, - "src/renderer/src/components/sidebar/workspace-kanban-search.test.ts": 100, - "src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts": 253, - "src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts": 48, - "src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts": 142, - "src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts": 41, - "src/renderer/src/components/sidebar/workspace-status.test.ts": 254, - "src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts": 113, - "src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts": 82, - "src/renderer/src/components/sidebar/worktree-agent-host-attribution.test.ts": 36, - "src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts": 75, - "src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts": 163, - "src/renderer/src/components/sidebar/worktree-agent-remote-attribution.test.ts": 613, - "src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts": 98, - "src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx": 455, - "src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts": 1674, - "src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx": 777, - "src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx": 292, - "src/renderer/src/components/sidebar/worktree-card-dom-events.test.ts": 428, - "src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts": 49, - "src/renderer/src/components/sidebar/worktree-card-markdown-isolation.test.ts": 555, - "src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts": 35, - "src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts": 33, - "src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts": 51, - "src/renderer/src/components/sidebar/worktree-card-title-display.test.ts": 62, + "src/renderer/src/components/sidebar/workspace-hover-attachments.test.ts": 119, + "src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 181, + "src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts": 57, + "src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts": 51, + "src/renderer/src/components/sidebar/workspace-kanban-search.test.ts": 153, + "src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts": 172, + "src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts": 64, + "src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts": 164, + "src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts": 59, + "src/renderer/src/components/sidebar/workspace-reference-detail-read.test.ts": 310, + "src/renderer/src/components/sidebar/workspace-reference-refresh-targets.test.ts": 582, + "src/renderer/src/components/sidebar/workspace-status.test.ts": 151, + "src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts": 165, + "src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts": 126, + "src/renderer/src/components/sidebar/worktree-agent-host-attribution.test.ts": 64, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts": 79, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts": 120, + "src/renderer/src/components/sidebar/worktree-agent-remote-attribution.test.ts": 953, + "src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts": 82, + "src/renderer/src/components/sidebar/worktree-attachment-editing.test.ts": 83, + "src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx": 204, + "src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts": 1707, + "src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts": 1763, + "src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx": 520, + "src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx": 172, + "src/renderer/src/components/sidebar/worktree-card-dom-events.test.ts": 158, + "src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts": 45, + "src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts": 65, + "src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts": 65, + "src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts": 64, + "src/renderer/src/components/sidebar/worktree-card-title-display.test.ts": 47, "src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts": 72, - "src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts": 1795, - "src/renderer/src/components/sidebar/worktree-delete-position-scaling.test.ts": 669, - "src/renderer/src/components/sidebar/worktree-delete-request.test.ts": 46, - "src/renderer/src/components/sidebar/worktree-drag-units.test.ts": 194, - "src/renderer/src/components/sidebar/worktree-filter-visibility.test.ts": 466, - "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts": 206, - "src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts": 286, - "src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts": 483, - "src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts": 193, - "src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts": 43, + "src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts": 1033, + "src/renderer/src/components/sidebar/worktree-delete-position-scaling.test.ts": 306, + "src/renderer/src/components/sidebar/worktree-delete-request.test.ts": 52, + "src/renderer/src/components/sidebar/worktree-drag-units.test.ts": 162, + "src/renderer/src/components/sidebar/worktree-filter-visibility.test.ts": 536, + "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts": 171, + "src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts": 182, + "src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts": 179, + "src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts": 126, + "src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts": 52, "src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts": 56, - "src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts": 200, - "src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts": 238, - "src/renderer/src/components/sidebar/worktree-list-groups-imported-worktrees.test.ts": 243, - "src/renderer/src/components/sidebar/worktree-list-groups-lineage-nesting.test.ts": 173, - "src/renderer/src/components/sidebar/worktree-list-groups-nested-project-groups.test.ts": 187, - "src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts": 208, - "src/renderer/src/components/sidebar/worktree-list-groups-pending-creations.test.ts": 365, - "src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts": 238, - "src/renderer/src/components/sidebar/worktree-list-groups-project-groups.test.ts": 226, - "src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts": 181, - "src/renderer/src/components/sidebar/worktree-list-groups-project-order.test.ts": 1153, - "src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts": 221, - "src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts": 845, - "src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts": 57, - "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 240, - "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 319, - "src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.test.ts": 58, - "src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts": 1449, - "src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts": 37, - "src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts": 24, - "src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts": 54, - "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx": 1710, - "src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.test.ts": 235, - "src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts": 231, - "src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts": 253, - "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx": 794, - "src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx": 883, - "src/renderer/src/components/sidebar/worktree-list/navigation/use-selection-host-collision.test.tsx": 2056, - "src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx": 754, - "src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx": 2106, - "src/renderer/src/components/sidebar/worktree-list/rows/indentation.test.ts": 65, - "src/renderer/src/components/sidebar/worktree-list/rows/option-dom-host-collision.test.ts": 475, - "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx": 396, - "src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts": 64, - "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts": 2365, - "src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts": 303, - "src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts": 62, - "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts": 496, - "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx": 401, - "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts": 254, - "src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts": 1519, - "src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts": 106, - "src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts": 2148, - "src/renderer/src/components/sidebar/worktree-manual-order.test.ts": 386, - "src/renderer/src/components/sidebar/worktree-meta-updates.test.ts": 76, - "src/renderer/src/components/sidebar/worktree-multi-selection.test.ts": 27, - "src/renderer/src/components/sidebar/worktree-name-suggestions.test.ts": 168, - "src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts": 37, - "src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx": 266, - "src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts": 254, - "src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts": 46, - "src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts": 77, - "src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts": 56, - "src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts": 337, - "src/renderer/src/components/sidebar/worktree-sidebar-reveal-scroll-settle.test.ts": 28, - "src/renderer/src/components/sidebar/worktree-sidebar-reveal.test.ts": 134, - "src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts": 152, - "src/renderer/src/components/sidebar/worktree-sort-label-ordering.test.ts": 172, - "src/renderer/src/components/sidebar/worktree-subagent-child-rows.test.ts": 47, - "src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts": 164, - "src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts": 43, - "src/renderer/src/components/sidebar/worktree-unnest.test.ts": 103, - "src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts": 52, - "src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx": 443, - "src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx": 770, - "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx": 834, - "src/renderer/src/components/skills/SkillInstallAgentPicker.test.tsx": 869, - "src/renderer/src/components/skills/SkillInstallDialog.test.tsx": 4156, - "src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx": 3656, - "src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx": 1232, - "src/renderer/src/components/skills/SkillInstallWorkspaceCombobox.test.tsx": 965, - "src/renderer/src/components/skills/SkillShareDialog.test.tsx": 2329, - "src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx": 1251, - "src/renderer/src/components/skills/SkillsPage.test.tsx": 4219, - "src/renderer/src/components/skills/skill-agent-filter.test.ts": 202, - "src/renderer/src/components/skills/skill-bundle-name.test.ts": 122, - "src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts": 47, - "src/renderer/src/components/skills/skill-delete-copy.test.ts": 69, - "src/renderer/src/components/skills/skill-delete-selection.test.ts": 229, - "src/renderer/src/components/skills/skill-description-length.test.ts": 52, - "src/renderer/src/components/skills/skill-freshness-grouping.test.ts": 67, - "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts": 137, - "src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 673, + "src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts": 204, + "src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts": 185, + "src/renderer/src/components/sidebar/worktree-list-groups-imported-worktrees.test.ts": 206, + "src/renderer/src/components/sidebar/worktree-list-groups-lineage-nesting.test.ts": 220, + "src/renderer/src/components/sidebar/worktree-list-groups-nested-project-groups.test.ts": 176, + "src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts": 195, + "src/renderer/src/components/sidebar/worktree-list-groups-pending-creations.test.ts": 184, + "src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts": 208, + "src/renderer/src/components/sidebar/worktree-list-groups-project-groups.test.ts": 209, + "src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts": 216, + "src/renderer/src/components/sidebar/worktree-list-groups-project-order.test.ts": 873, + "src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts": 190, + "src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts": 420, + "src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts": 53, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 213, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 224, + "src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.test.ts": 53, + "src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts": 1443, + "src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts": 201, + "src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts": 58, + "src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts": 39, + "src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts": 50, + "src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx": 1814, + "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx": 1702, + "src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.test.ts": 181, + "src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts": 181, + "src/renderer/src/components/sidebar/worktree-list/navigation/folder-row-host-identity.test.ts": 170, + "src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts": 189, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.focus.test.tsx": 517, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx": 754, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx": 528, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-selection-host-collision.test.tsx": 1905, + "src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx": 313, + "src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx": 1640, + "src/renderer/src/components/sidebar/worktree-list/rows/indentation.test.ts": 56, + "src/renderer/src/components/sidebar/worktree-list/rows/option-dom-host-collision.test.ts": 179, + "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx": 259, + "src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts": 59, + "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts": 2225, + "src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts": 235, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts": 94, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts": 274, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx": 274, + "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts": 207, + "src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts": 1414, + "src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts": 152, + "src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts": 1611, + "src/renderer/src/components/sidebar/worktree-manual-order.test.ts": 306, + "src/renderer/src/components/sidebar/worktree-name-suggestions.test.ts": 164, + "src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts": 62, + "src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx": 178, + "src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts": 171, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts": 69, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts": 78, + "src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts": 65, + "src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts": 142, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal-scroll-settle.test.ts": 41, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal.test.ts": 207, + "src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts": 199, + "src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts": 122, + "src/renderer/src/components/sidebar/worktree-sort-label-ordering.test.ts": 159, + "src/renderer/src/components/sidebar/worktree-subagent-child-rows.test.ts": 62, + "src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts": 184, + "src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts": 52, + "src/renderer/src/components/sidebar/worktree-unnest.test.ts": 90, + "src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts": 64, + "src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx": 371, + "src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx": 343, + "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx": 782, + "src/renderer/src/components/skills/SkillInstallAgentPicker.test.tsx": 690, + "src/renderer/src/components/skills/SkillInstallDialog.test.tsx": 3927, + "src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx": 3175, + "src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx": 903, + "src/renderer/src/components/skills/SkillInstallWorkspaceCombobox.test.tsx": 705, + "src/renderer/src/components/skills/SkillShareDialog.test.tsx": 2543, + "src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx": 665, + "src/renderer/src/components/skills/SkillsPage.test.tsx": 3940, + "src/renderer/src/components/skills/skill-agent-filter.test.ts": 156, + "src/renderer/src/components/skills/skill-bundle-name.test.ts": 233, + "src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts": 55, + "src/renderer/src/components/skills/skill-delete-copy.test.ts": 110, + "src/renderer/src/components/skills/skill-delete-selection.test.ts": 321, + "src/renderer/src/components/skills/skill-description-length.test.ts": 57, + "src/renderer/src/components/skills/skill-freshness-grouping.test.ts": 63, + "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts": 93, + "src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 281, "src/renderer/src/components/skills/skill-install-provider-groups.test.ts": 62, - "src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 25, - "src/renderer/src/components/skills/skill-package-checklist-items.test.ts": 81, - "src/renderer/src/components/skills/skill-package-digest.test.ts": 46, - "src/renderer/src/components/skills/skill-package-install-risk.test.ts": 55, - "src/renderer/src/components/skills/skill-share-link.test.ts": 36, - "src/renderer/src/components/skills/skill-share-package-selection.test.ts": 45, - "src/renderer/src/components/skills/skill-share-preview-summary.test.ts": 80, - "src/renderer/src/components/skills/skill-share-selection.test.ts": 91, - "src/renderer/src/components/skills/skill-source-inventory.test.ts": 61, - "src/renderer/src/components/skills/skills-filter.test.ts": 113, - "src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx": 386, - "src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx": 2099, - "src/renderer/src/components/star-nag/StarNagToastHost.test.tsx": 984, - "src/renderer/src/components/stats/CodexUsagePane.test.tsx": 840, - "src/renderer/src/components/stats/GrokUsagePane.test.tsx": 1024, - "src/renderer/src/components/stats/UsageBreakdownSection.test.tsx": 622, - "src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx": 858, - "src/renderer/src/components/stats/usage-daily-chart.test.tsx": 668, - "src/renderer/src/components/stats/usage-overview-model.test.ts": 531, - "src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx": 1458, - "src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx": 2493, - "src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts": 40, - "src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx": 620, - "src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx": 1943, - "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx": 1928, - "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts": 50, - "src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx": 809, - "src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx": 485, - "src/renderer/src/components/status-bar/SshStatusSegment.test.ts": 1996, - "src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx": 2381, - "src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx": 794, - "src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx": 1073, - "src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts": 47, - "src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx": 1799, - "src/renderer/src/components/status-bar/icons.test.tsx": 92, - "src/renderer/src/components/status-bar/inline-usage-bars.test.tsx": 754, - "src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts": 79, - "src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx": 742, - "src/renderer/src/components/status-bar/provider-account-sync-key.test.ts": 53, - "src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx": 1108, - "src/renderer/src/components/status-bar/remote-host-connection-status.test.ts": 71, - "src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts": 62, - "src/renderer/src/components/status-bar/resource-manager-worktree-target.test.ts": 53, - "src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts": 27, - "src/renderer/src/components/status-bar/resource-session-bindings.test.ts": 43, - "src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts": 42, - "src/renderer/src/components/status-bar/resource-session-inventory.test.ts": 33, - "src/renderer/src/components/status-bar/resource-session-kill-confirmation.test.ts": 65, - "src/renderer/src/components/status-bar/resource-session-navigation.test.ts": 60, - "src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts": 94, - "src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts": 1536, - "src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts": 61, - "src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts": 40, + "src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 49, + "src/renderer/src/components/skills/skill-package-checklist-items.test.ts": 102, + "src/renderer/src/components/skills/skill-package-digest.test.ts": 44, + "src/renderer/src/components/skills/skill-package-install-risk.test.ts": 50, + "src/renderer/src/components/skills/skill-share-link.test.ts": 48, + "src/renderer/src/components/skills/skill-share-package-selection.test.ts": 63, + "src/renderer/src/components/skills/skill-share-preview-summary.test.ts": 95, + "src/renderer/src/components/skills/skill-share-selection.test.ts": 103, + "src/renderer/src/components/skills/skill-source-inventory.test.ts": 60, + "src/renderer/src/components/skills/skills-filter.test.ts": 87, + "src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx": 184, + "src/renderer/src/components/sparse/SparseCheckoutPresetDraftForm.test.tsx": 559, + "src/renderer/src/components/sparse/SparsePresetChooser.test.tsx": 521, + "src/renderer/src/components/sparse/sparse-directory-browse.test.ts": 59, + "src/renderer/src/components/sparse/sparse-directory-entry-input.test.ts": 123, + "src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx": 1675, + "src/renderer/src/components/star-nag/StarNagToastHost.test.tsx": 373, + "src/renderer/src/components/stats/CodexUsagePane.test.tsx": 615, + "src/renderer/src/components/stats/GrokUsagePane.test.tsx": 430, + "src/renderer/src/components/stats/UsageBreakdownSection.test.tsx": 406, + "src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx": 566, + "src/renderer/src/components/stats/usage-daily-chart.test.tsx": 231, + "src/renderer/src/components/stats/usage-overview-model.test.ts": 361, + "src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx": 907, + "src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx": 2014, + "src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx": 602, + "src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx": 2028, + "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx": 1713, + "src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx": 422, + "src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx": 270, + "src/renderer/src/components/status-bar/SshStatusSegment.test.ts": 1449, + "src/renderer/src/components/status-bar/StatusBarUsageChangeNotices.test.tsx": 561, + "src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx": 1978, + "src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx": 391, + "src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx": 677, + "src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx": 1075, + "src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts": 46, + "src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx": 1775, + "src/renderer/src/components/status-bar/cursor-usage-headline.test.tsx": 282, + "src/renderer/src/components/status-bar/icons.test.tsx": 81, + "src/renderer/src/components/status-bar/inline-usage-bars.test.tsx": 1440, + "src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts": 133, + "src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx": 651, + "src/renderer/src/components/status-bar/provider-account-sync-key.test.ts": 49, + "src/renderer/src/components/status-bar/provider-segment-balance.test.tsx": 1233, + "src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx": 1225, + "src/renderer/src/components/status-bar/remote-host-connection-status.test.ts": 90, + "src/renderer/src/components/status-bar/resource-manager-exit-render-budget.test.tsx": 1982, + "src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts": 58, + "src/renderer/src/components/status-bar/resource-manager-worktree-target.test.ts": 46, + "src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts": 67, + "src/renderer/src/components/status-bar/resource-session-bindings.test.ts": 52, + "src/renderer/src/components/status-bar/resource-session-inventory.test.ts": 80, + "src/renderer/src/components/status-bar/resource-session-kill-confirmation.test.ts": 62, + "src/renderer/src/components/status-bar/resource-session-navigation.test.ts": 61, + "src/renderer/src/components/status-bar/resource-session-removal-render-budget.test.tsx": 352, + "src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts": 100, + "src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts": 1354, + "src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts": 62, + "src/renderer/src/components/status-bar/runtime-host-resources.test.ts": 52, + "src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts": 48, "src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts": 45, - "src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts": 35, - "src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx": 1540, - "src/renderer/src/components/status-bar/status-bar-cursor-segment.test.tsx": 757, - "src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx": 1039, - "src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts": 35, - "src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts": 1341, - "src/renderer/src/components/status-bar/tooltip.test.ts": 139, - "src/renderer/src/components/status-bar/usage-error-copy.test.ts": 65, - "src/renderer/src/components/status-bar/usage-percentage-label.test.ts": 38, - "src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts": 37, - "src/renderer/src/components/status-bar/usage-roster-formatting.test.ts": 27, - "src/renderer/src/components/status-bar/usage-roster-row-state.test.ts": 53, - "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx": 1022, - "src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx": 472, - "src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx": 823, - "src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx": 448, - "src/renderer/src/components/status-bar/workspace-space-delete-host-routing.test.ts": 43, - "src/renderer/src/components/status-bar/workspace-space-format.test.ts": 97, - "src/renderer/src/components/status-bar/workspace-space-layout.test.ts": 51, - "src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts": 64, - "src/renderer/src/components/status-bar/workspace-space-presentation.test.ts": 217, - "src/renderer/src/components/tab-bar/BrowserTab.test.tsx": 1511, - "src/renderer/src/components/tab-bar/ClientHostedBrowserTabRows.test.tsx": 1260, - "src/renderer/src/components/tab-bar/EditorFileTab.test.tsx": 1020, - "src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx": 660, - "src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts": 667, - "src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx": 660, - "src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx": 831, - "src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 1389, - "src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 1143, - "src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts": 1679, - "src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 2690, - "src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 1682, - "src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts": 1585, - "src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts": 1937, - "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx": 1156, - "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx": 1568, - "src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx": 1865, - "src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx": 2588, - "src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx": 2749, - "src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx": 1967, - "src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx": 1179, - "src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx": 983, - "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts": 100, - "src/renderer/src/components/tab-bar/TabStripScrollIndicator.test.tsx": 608, - "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx": 313, - "src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts": 44, - "src/renderer/src/components/tab-bar/drop-indicator.test.ts": 50, - "src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts": 68, - "src/renderer/src/components/tab-bar/group-tab-order.test.ts": 1929, - "src/renderer/src/components/tab-bar/middle-button-default-guard.test.ts": 31, - "src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts": 52, - "src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts": 152, - "src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts": 450, - "src/renderer/src/components/tab-bar/open-tab-search.test.ts": 353, - "src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts": 625, - "src/renderer/src/components/tab-bar/query-token-match.test.ts": 48, - "src/renderer/src/components/tab-bar/recent-tab-switching.test.ts": 59, + "src/renderer/src/components/status-bar/status-bar-antigravity-segment.test.tsx": 1543, + "src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts": 54, + "src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx": 961, + "src/renderer/src/components/status-bar/status-bar-cursor-segment.test.tsx": 1640, + "src/renderer/src/components/status-bar/status-bar-density.test.ts": 61, + "src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx": 1470, + "src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts": 76, + "src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts": 2360, + "src/renderer/src/components/status-bar/status-bar-usage-collapse.test.ts": 184, + "src/renderer/src/components/status-bar/tooltip.test.ts": 167, + "src/renderer/src/components/status-bar/usage-error-copy.test.ts": 52, + "src/renderer/src/components/status-bar/usage-percentage-label.localization.test.ts": 134, + "src/renderer/src/components/status-bar/usage-percentage-label.test.ts": 44, + "src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts": 45, + "src/renderer/src/components/status-bar/usage-roster-formatting.test.ts": 64, + "src/renderer/src/components/status-bar/usage-roster-row-state.test.ts": 54, + "src/renderer/src/components/status-bar/usage-unit-label-parity.test.tsx": 344, + "src/renderer/src/components/status-bar/usage-unit-label.test.ts": 135, + "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx": 1000, + "src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx": 536, + "src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx": 666, + "src/renderer/src/components/status-bar/use-workspace-space-decision-projection.test.tsx": 1770, + "src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx": 403, + "src/renderer/src/components/status-bar/workspace-space-delete-host-routing.test.ts": 52, + "src/renderer/src/components/status-bar/workspace-space-format.test.ts": 103, + "src/renderer/src/components/status-bar/workspace-space-layout.test.ts": 54, + "src/renderer/src/components/status-bar/workspace-space-presentation.test.ts": 320, + "src/renderer/src/components/tab-bar/BrowserTab.test.tsx": 1750, + "src/renderer/src/components/tab-bar/ClientHostedBrowserTabRows.test.tsx": 1122, + "src/renderer/src/components/tab-bar/CopyTabIdMenuItem.test.tsx": 449, + "src/renderer/src/components/tab-bar/EditorFileTab.test.tsx": 1281, + "src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx": 1810, + "src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx": 746, + "src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts": 437, + "src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx": 329, + "src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx": 849, + "src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 2331, + "src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 914, + "src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts": 2769, + "src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 4026, + "src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx": 1358, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 2501, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts": 1701, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts": 2447, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx": 1338, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx": 1434, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx": 1393, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx": 2288, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx": 2607, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx": 2010, + "src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx": 662, + "src/renderer/src/components/tab-bar/TabBarItemRow.test.tsx": 1712, + "src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx": 352, + "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts": 139, + "src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx": 1749, + "src/renderer/src/components/tab-bar/TabStripScrollIndicator.test.tsx": 313, + "src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx": 592, + "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx": 220, + "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx": 371, + "src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts": 47, + "src/renderer/src/components/tab-bar/drop-indicator.test.ts": 53, + "src/renderer/src/components/tab-bar/group-tab-order.test.ts": 2175, + "src/renderer/src/components/tab-bar/middle-button-default-guard.test.ts": 45, + "src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts": 92, + "src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts": 301, + "src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts": 535, + "src/renderer/src/components/tab-bar/open-tab-search.test.ts": 408, + "src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts": 211, + "src/renderer/src/components/tab-bar/query-token-match.test.ts": 46, + "src/renderer/src/components/tab-bar/recent-tab-switching.test.ts": 142, "src/renderer/src/components/tab-bar/reconcile-order.test.ts": 52, - "src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts": 99, - "src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts": 80, - "src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx": 2562, - "src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx": 42, - "src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts": 1242, - "src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx": 2034, - "src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts": 1823, - "src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts": 93, - "src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts": 33, - "src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts": 158, - "src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts": 1887, - "src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts": 194, - "src/renderer/src/components/tab-bar/tab-create-menu-options.test.ts": 75, - "src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts": 1424, - "src/renderer/src/components/tab-bar/tab-strip-content-resize-observers.test.ts": 236, - "src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts": 73, - "src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx": 1784, - "src/renderer/src/components/tab-bar/tab-strip-scroll-metrics.test.ts": 38, - "src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx": 1090, - "src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts": 131, - "src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts": 53, - "src/renderer/src/components/tab-bar/use-open-tab-search.test.ts": 1977, - "src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts": 425, - "src/renderer/src/components/tab-bar/windows-shell-launch.test.ts": 29, - "src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts": 53, - "src/renderer/src/components/tab-group/RetainedPaneHost.test.tsx": 528, - "src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts": 35, - "src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx": 662, - "src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts": 319, - "src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx": 110, - "src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts": 527, - "src/renderer/src/components/tab-group/tab-drag-pointer.test.ts": 41, - "src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts": 1253, - "src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx": 2070, - "src/renderer/src/components/tab-group/tab-drop-zone.test.ts": 38, - "src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts": 38, - "src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts": 84, - "src/renderer/src/components/tab-group/tab-insertion.test.ts": 50, - "src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 1851, - "src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts": 734, - "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts": 263, - "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.test.tsx": 2028, - "src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts": 1358, - "src/renderer/src/components/task-drawer-source-boundary.test.ts": 40, - "src/renderer/src/components/task-page-cache-selectors.test.ts": 102, - "src/renderer/src/components/task-page-checks-pill.test.ts": 31, - "src/renderer/src/components/task-page-default-repo-selection.test.ts": 55, - "src/renderer/src/components/task-page-empty-state.test.ts": 95, - "src/renderer/src/components/task-page-github-dialog-state-authority.test.ts": 376, - "src/renderer/src/components/task-page-github-issue-creation.test.ts": 401, - "src/renderer/src/components/task-page-github-list-scroll-restore.test.ts": 277, + "src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts": 190, + "src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts": 66, + "src/renderer/src/components/tab-bar/tab-bar-item-model.git-status.test.ts": 1809, + "src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts": 1664, + "src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx": 1788, + "src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts": 1647, + "src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts": 226, + "src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts": 53, + "src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts": 312, + "src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts": 1511, + "src/renderer/src/components/tab-bar/tab-create-entry-search-ranking.test.ts": 313, + "src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts": 324, + "src/renderer/src/components/tab-bar/tab-create-menu-options.test.ts": 125, + "src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts": 1394, + "src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts": 1955, + "src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts": 1808, + "src/renderer/src/components/tab-bar/tab-strip-content-resize-observers.test.ts": 171, + "src/renderer/src/components/tab-bar/tab-strip-growth-scroll.test.tsx": 407, + "src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts": 60, + "src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx": 1868, + "src/renderer/src/components/tab-bar/tab-strip-scroll-metrics.test.ts": 67, + "src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx": 1644, + "src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts": 202, + "src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts": 103, + "src/renderer/src/components/tab-bar/use-open-tab-search.test.ts": 1793, + "src/renderer/src/components/tab-bar/use-tab-bar-item-projection.test.tsx": 2025, + "src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.tsx": 367, + "src/renderer/src/components/tab-bar/windows-shell-launch.test.ts": 51, + "src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts": 50, + "src/renderer/src/components/tab-group/RetainedPaneHost.test.tsx": 360, + "src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts": 43, + "src/renderer/src/components/tab-group/TabGroupPanel.os-file-drop.test.tsx": 1712, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx": 485, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts": 241, + "src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx": 99, + "src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts": 145, + "src/renderer/src/components/tab-group/tab-drag-pointer.test.ts": 48, + "src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts": 1235, + "src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx": 1438, + "src/renderer/src/components/tab-group/tab-drop-zone.test.ts": 50, + "src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts": 48, + "src/renderer/src/components/tab-group/tab-group-body-geometry.test.ts": 168, + "src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts": 74, + "src/renderer/src/components/tab-group/tab-insertion.test.ts": 67, + "src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 1883, + "src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts": 998, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts": 543, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.test.tsx": 1900, + "src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts": 1213, + "src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts": 1573, + "src/renderer/src/components/task-page-cache-selectors.test.ts": 71, + "src/renderer/src/components/task-page-checks-pill.test.ts": 46, + "src/renderer/src/components/task-page-default-repo-selection.test.ts": 80, + "src/renderer/src/components/task-page-empty-state.test.ts": 89, + "src/renderer/src/components/task-page-github-dialog-state-authority.test.ts": 191, + "src/renderer/src/components/task-page-github-list-scroll-restore.test.ts": 206, "src/renderer/src/components/task-page-github-resume-cache.test.ts": 52, - "src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts": 50, - "src/renderer/src/components/task-page-github-status-actions.test.ts": 52, + "src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts": 75, + "src/renderer/src/components/task-page-github-status-actions.test.ts": 49, "src/renderer/src/components/task-page-github-status-state.test.ts": 56, - "src/renderer/src/components/task-page-github-task-kind.test.ts": 56, - "src/renderer/src/components/task-page-github-work-item-filter-membership.test.ts": 51, - "src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts": 41, - "src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts": 92, - "src/renderer/src/components/task-page-github-work-item-mutations.test.ts": 123, - "src/renderer/src/components/task-page-github-work-item-status.test.ts": 61, - "src/renderer/src/components/task-page-gitlab-task-filters.test.ts": 1702, - "src/renderer/src/components/task-page-initial-selection-scaling.test.tsx": 1727, - "src/renderer/src/components/task-page-jira-cache-selectors.test.ts": 56, - "src/renderer/src/components/task-page-jira-create-fields.test.ts": 51, - "src/renderer/src/components/task-page-jira-grouping.test.ts": 1262, - "src/renderer/src/components/task-page-jira-item-source-context.test.ts": 29, - "src/renderer/src/components/task-page-jira-load-state.test.ts": 55, - "src/renderer/src/components/task-page-jira-project-selection.test.ts": 57, - "src/renderer/src/components/task-page-jira-sort-controls.test.tsx": 932, - "src/renderer/src/components/task-page-jira-sorting.test.ts": 236, - "src/renderer/src/components/task-page-linear-in-orca-issues.test.ts": 79, - "src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts": 35, - "src/renderer/src/components/task-page-linear-issue-empty-state.test.ts": 45, - "src/renderer/src/components/task-page-linear-issue-grouping.test.ts": 966, - "src/renderer/src/components/task-page-linear-issue-request.test.ts": 52, - "src/renderer/src/components/task-page-linear-team-selection.test.ts": 45, - "src/renderer/src/components/task-page-list-chrome-visibility.test.ts": 41, - "src/renderer/src/components/task-page-localized-options.test.ts": 264, - "src/renderer/src/components/task-page-mutation-page-allocation.test.ts": 50, - "src/renderer/src/components/task-page-new-issue-draft.test.ts": 64, - "src/renderer/src/components/task-page-pagination-page-numbers.test.ts": 60, - "src/renderer/src/components/task-page-pr-check-summary.test.ts": 40, - "src/renderer/src/components/task-page-repo-source-context.test.ts": 1554, - "src/renderer/src/components/task-page-repo-source-divergence.test.ts": 1245, - "src/renderer/src/components/task-page-source-switch-boundary.test.ts": 258, - "src/renderer/src/components/task-page-task-creation-drafts.test.ts": 407, - "src/renderer/src/components/task-page-task-source-host-availability.test.ts": 1030, - "src/renderer/src/components/task-page-work-item-pagination.test.ts": 70, - "src/renderer/src/components/task-page-workspace-composer-boundary.test.ts": 463, - "src/renderer/src/components/task-source-context-summary.test.ts": 58, - "src/renderer/src/components/task-source-provider-availability.test.ts": 60, - "src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx": 1022, - "src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx": 466, - "src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx": 2509, - "src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx": 1716, - "src/renderer/src/components/terminal-pane/SessionRestoredBanner.test.tsx": 306, - "src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx": 220, - "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx": 1724, - "src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts": 911, - "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx": 1966, - "src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx": 684, - "src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx": 1403, - "src/renderer/src/components/terminal-pane/TerminalProcessExitOverlay.test.tsx": 513, - "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx": 580, - "src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx": 1805, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts": 217, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts": 147, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts": 170, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts": 104, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts": 135, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts": 186, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts": 168, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts": 170, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts": 119, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts": 102, - "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts": 181, - "src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts": 966, - "src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts": 217, - "src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts": 112, - "src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts": 123, - "src/renderer/src/components/terminal-pane/agent-hook-terminal-lifecycle.test.ts": 50, - "src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts": 135, - "src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts": 46, - "src/renderer/src/components/terminal-pane/agent-process-inspection-round.test.ts": 48, - "src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts": 52, - "src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts": 84, - "src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts": 24, - "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts": 1008, - "src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts": 66, - "src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts": 34, - "src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts": 93, - "src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts": 144, - "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts": 50, - "src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts": 61, - "src/renderer/src/components/terminal-pane/direct-ssh-hidden-output-restore-unavailable-banner.test.ts": 921, - "src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts": 1446, - "src/renderer/src/components/terminal-pane/expand-collapse-render-stability.test.ts": 2192, - "src/renderer/src/components/terminal-pane/expand-collapse.test.ts": 86, - "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts": 43, - "src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts": 47, - "src/renderer/src/components/terminal-pane/git-bash-console-capacity.test.ts": 40, - "src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts": 69, - "src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts": 73, - "src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts": 7285, - "src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts": 220, - "src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts": 657, - "src/renderer/src/components/terminal-pane/issue-4631-terminal-hover-link-provider.repro.test.ts": 769, - "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx": 2192, - "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx": 2539, - "src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx": 2043, - "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts": 1892, - "src/renderer/src/components/terminal-pane/layout-serialization.test.ts": 78, - "src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.test.ts": 204, - "src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts": 1568, - "src/renderer/src/components/terminal-pane/merge-captured-leaf-state.test.ts": 39, - "src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts": 29, - "src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts": 50, - "src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts": 50, - "src/renderer/src/components/terminal-pane/mouse-hide-while-typing.test.ts": 53, - "src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts": 83, - "src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts": 389, - "src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts": 621, - "src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts": 130, - "src/renderer/src/components/terminal-pane/override-affected-panes.test.ts": 49, - "src/renderer/src/components/terminal-pane/paired-parking-unverifiable-host.test.ts": 82, - "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts": 2000, - "src/renderer/src/components/terminal-pane/pane-agent-session-id.test.ts": 28, - "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts": 513, - "src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts": 50, - "src/renderer/src/components/terminal-pane/pane-helpers.test.ts": 59, - "src/renderer/src/components/terminal-pane/pane-restart-transport-handoff.test.ts": 1459, - "src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts": 39, - "src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx": 3714, - "src/renderer/src/components/terminal-pane/parked-terminal-buffer-capture.test.ts": 183, - "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts": 1531, - "src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts": 130, - "src/renderer/src/components/terminal-pane/parked-terminal-tab-capture-episodes.test.ts": 150, - "src/renderer/src/components/terminal-pane/parse-osc7.test.ts": 56, - "src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts": 1233, - "src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts": 1002, - "src/renderer/src/components/terminal-pane/pending-split-close.test.ts": 1013, - "src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts": 32, - "src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts": 1595, - "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts": 1581, + "src/renderer/src/components/task-page-github-task-kind.test.ts": 70, + "src/renderer/src/components/task-page-github-work-item-filter-membership.test.ts": 47, + "src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts": 62, + "src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts": 98, + "src/renderer/src/components/task-page-github-work-item-mutations.test.ts": 168, + "src/renderer/src/components/task-page-github-work-item-status.test.ts": 58, + "src/renderer/src/components/task-page-gitlab-task-filters.test.ts": 1459, + "src/renderer/src/components/task-page-initial-selection-scaling.test.tsx": 1832, + "src/renderer/src/components/task-page-jira-cache-selectors.test.ts": 49, + "src/renderer/src/components/task-page-jira-create-fields.test.ts": 84, + "src/renderer/src/components/task-page-jira-grouping.test.ts": 577, + "src/renderer/src/components/task-page-jira-item-source-context.test.ts": 60, + "src/renderer/src/components/task-page-jira-load-state.test.ts": 54, + "src/renderer/src/components/task-page-jira-project-selection.test.ts": 49, + "src/renderer/src/components/task-page-jira-search.test.ts": 89, + "src/renderer/src/components/task-page-jira-sort-controls.test.tsx": 702, + "src/renderer/src/components/task-page-jira-sorting.test.ts": 134, + "src/renderer/src/components/task-page-jira-text-fallback-notice.test.tsx": 429, + "src/renderer/src/components/task-page-linear-in-orca-issues.test.ts": 94, + "src/renderer/src/components/task-page-linear-issue-empty-state.test.ts": 47, + "src/renderer/src/components/task-page-linear-issue-grouping.test.ts": 1537, + "src/renderer/src/components/task-page-linear-issue-request.test.ts": 60, + "src/renderer/src/components/task-page-linear-team-selection.test.ts": 48, + "src/renderer/src/components/task-page-list-chrome-visibility.test.ts": 58, + "src/renderer/src/components/task-page-localized-options.test.ts": 256, + "src/renderer/src/components/task-page-mutation-page-allocation.test.ts": 86, + "src/renderer/src/components/task-page-new-issue-draft.test.ts": 60, + "src/renderer/src/components/task-page-pagination-page-numbers.test.ts": 63, + "src/renderer/src/components/task-page-pr-check-summary.test.ts": 45, + "src/renderer/src/components/task-page-repo-source-context.test.ts": 1529, + "src/renderer/src/components/task-page-repo-source-divergence.test.ts": 1378, + "src/renderer/src/components/task-page-task-source-host-availability.test.ts": 1464, + "src/renderer/src/components/task-page-work-item-pagination.test.ts": 84, + "src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx": 1049, + "src/renderer/src/components/task-source-context-summary.test.ts": 92, + "src/renderer/src/components/task-source-provider-availability.test.ts": 64, + "src/renderer/src/components/terminal-agent-tab-shortcut.test.ts": 1561, + "src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx": 388, + "src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx": 181, + "src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx": 1699, + "src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx": 1847, + "src/renderer/src/components/terminal-pane/SessionRestoredBanner.test.tsx": 233, + "src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx": 171, + "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx": 1769, + "src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts": 626, + "src/renderer/src/components/terminal-pane/TerminalOverlaySlot.emptied-reaction.test.tsx": 439, + "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx": 1019, + "src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx": 327, + "src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx": 1492, + "src/renderer/src/components/terminal-pane/TerminalProcessExitOverlay.test.tsx": 370, + "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx": 380, + "src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx": 1832, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts": 223, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts": 206, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts": 219, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts": 196, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts": 204, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts": 341, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts": 251, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts": 270, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts": 191, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts": 175, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts": 227, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts": 208, + "src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts": 1486, + "src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts": 111, + "src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts": 147, + "src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts": 188, + "src/renderer/src/components/terminal-pane/agent-hook-terminal-lifecycle.test.ts": 45, + "src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts": 155, + "src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts": 43, + "src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts": 53, + "src/renderer/src/components/terminal-pane/agent-process-inspection-round.test.ts": 61, + "src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts": 108, + "src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts": 100, + "src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx": 1667, + "src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice.test.ts": 1078, + "src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts": 31, + "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts": 1928, + "src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx": 2177, + "src/renderer/src/components/terminal-pane/codex-shared-settings-notice.test.ts": 245, + "src/renderer/src/components/terminal-pane/codex-terminal-presence.test.ts": 83, + "src/renderer/src/components/terminal-pane/codex-terminal-server-isolation-notice.test.ts": 232, + "src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts": 53, + "src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts": 44, + "src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts": 135, + "src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts": 193, + "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts": 75, + "src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts": 85, + "src/renderer/src/components/terminal-pane/direct-ssh-hidden-output-restore-unavailable-banner.test.ts": 1301, + "src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts": 2360, + "src/renderer/src/components/terminal-pane/expand-collapse-render-stability.test.ts": 2055, + "src/renderer/src/components/terminal-pane/expand-collapse.test.ts": 101, + "src/renderer/src/components/terminal-pane/floating-file-link-selection.test.ts": 2261, + "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts": 55, + "src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts": 46, + "src/renderer/src/components/terminal-pane/git-bash-console-capacity.test.ts": 52, + "src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts": 90, + "src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts": 54, + "src/renderer/src/components/terminal-pane/ipc-pty-opencode-model.test.ts": 263, + "src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts": 530, + "src/renderer/src/components/terminal-pane/issue-21762-tracking-mode-middle-click-double-paste.repro.test.ts": 349, + "src/renderer/src/components/terminal-pane/issue-4631-terminal-hover-link-provider.repro.test.ts": 919, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx": 2146, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx": 1944, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx": 2229, + "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts": 1877, + "src/renderer/src/components/terminal-pane/layout-serialization.test.ts": 96, + "src/renderer/src/components/terminal-pane/leaf-scrollback-resolution.test.ts": 263, + "src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts": 1422, + "src/renderer/src/components/terminal-pane/merge-captured-leaf-state.test.ts": 56, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts": 47, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts": 45, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts": 38, + "src/renderer/src/components/terminal-pane/mouse-hide-while-typing.test.ts": 48, + "src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts": 87, + "src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts": 278, + "src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts": 1039, + "src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts": 170, + "src/renderer/src/components/terminal-pane/override-affected-panes.test.ts": 48, + "src/renderer/src/components/terminal-pane/paired-parking-unverifiable-host.test.ts": 93, + "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts": 1915, + "src/renderer/src/components/terminal-pane/pane-agent-session-id.test.ts": 45, + "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts": 349, + "src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts": 100, + "src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts": 223, + "src/renderer/src/components/terminal-pane/pane-helpers.test.ts": 73, + "src/renderer/src/components/terminal-pane/pane-restart-transport-handoff.test.ts": 556, + "src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts": 58, + "src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx": 3072, + "src/renderer/src/components/terminal-pane/parked-terminal-buffer-capture.test.ts": 308, + "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts": 2465, + "src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts": 428, + "src/renderer/src/components/terminal-pane/parked-terminal-tab-capture-episodes.test.ts": 301, + "src/renderer/src/components/terminal-pane/parse-osc7.test.ts": 50, + "src/renderer/src/components/terminal-pane/pending-pane-close-confirmation.test.ts": 1589, + "src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts": 1223, + "src/renderer/src/components/terminal-pane/pending-split-close.test.ts": 1550, + "src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts": 50, + "src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts": 2046, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts": 1677, "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts": 1145, - "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts": 1253, - "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts": 7780, - "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts": 1637, - "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts": 1762, - "src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts": 642, - "src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts": 1014, - "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts": 1017, - "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts": 1177, - "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts": 960, - "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts": 1709, - "src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts": 994, - "src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts": 1595, - "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts": 1457, - "src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts": 1511, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts": 1949, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts": 2482, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts": 2106, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts": 1397, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts": 1974, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts": 1212, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts": 1575, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts": 993, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts": 903, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts": 2255, - "src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts": 796, - "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts": 1097, - "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts": 1520, - "src/renderer/src/components/terminal-pane/pty-connection-hook-idle-arbitration.test.ts": 1109, - "src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts": 1194, - "src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts": 1194, - "src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts": 1082, - "src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts": 1779, - "src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts": 1899, - "src/renderer/src/components/terminal-pane/pty-connection-pi-synchronized-input.test.ts": 595, - "src/renderer/src/components/terminal-pane/pty-connection-post-dispose-restore-termination.test.ts": 829, - "src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts": 6007, - "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts": 1073, - "src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts": 4696, - "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts": 4241, - "src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts": 2097, - "src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-source-grid.test.ts": 852, - "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts": 1509, - "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts": 1105, - "src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts": 676, - "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts": 889, - "src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts": 1647, - "src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts": 1562, - "src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts": 1195, - "src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts": 703, - "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts": 729, - "src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts": 1351, - "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts": 2466, - "src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts": 964, - "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts": 1078, - "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts": 5951, - "src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts": 1837, - "src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts": 1270, - "src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts": 926, - "src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts": 1272, - "src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts": 1294, - "src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts": 37, - "src/renderer/src/components/terminal-pane/pty-connection/disposed-spawn-retention.test.ts": 65, - "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts": 52, - "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.test.ts": 63, - "src/renderer/src/components/terminal-pane/pty-connection/foreground-renderer-risk-scan-tail.test.ts": 74, - "src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts": 33, - "src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.test.ts": 993, - "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts": 1537, - "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts": 1308, - "src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts": 167, - "src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts": 186, - "src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts": 42, - "src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts": 41, - "src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts": 51, - "src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm-startup-callback.test.ts": 69, - "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts": 196, - "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts": 71, - "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts": 389, - "src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts": 142, - "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts": 877, - "src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts": 33, - "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer-warn-eviction.test.ts": 55, - "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts": 107, - "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts": 56, - "src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts": 61, - "src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts": 42, - "src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts": 133, - "src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts": 2353, - "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts": 138, - "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts": 49, - "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts": 60, - "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts": 435, - "src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts": 310, - "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts": 520, - "src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts": 277, - "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts": 1210, - "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts": 603, - "src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts": 229, - "src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts": 233, - "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts": 363, - "src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts": 258, - "src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts": 189, - "src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts": 459, - "src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts": 55, - "src/renderer/src/components/terminal-pane/remote-execution-host-pty.test.ts": 1028, - "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts": 1239, - "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts": 873, - "src/renderer/src/components/terminal-pane/remote-layout-scrollback-retention.test.ts": 43, - "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts": 66, - "src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts": 567, - "src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts": 1496, - "src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts": 1919, - "src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts": 1098, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts": 119, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts": 2442, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts": 5019, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts": 2214, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts": 79, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts": 1574, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts": 1311, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts": 1933, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts": 3688, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts": 2192, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts": 2020, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts": 1894, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts": 2760, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts": 1849, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts": 2998, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts": 5439, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts": 5313, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts": 1946, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts": 3291, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts": 2620, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts": 2114, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts": 3201, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts": 2981, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts": 2126, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts": 4074, - "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts": 2942, - "src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts": 3115, - "src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts": 34, - "src/renderer/src/components/terminal-pane/replay-guard.test.ts": 130, - "src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts": 147, - "src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts": 167, - "src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts": 1961, - "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts": 1427, - "src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts": 91, - "src/renderer/src/components/terminal-pane/scan22-ssh-new-tab-disposed-spawn-kill.repro.test.ts": 176, - "src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx": 409, - "src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts": 39, - "src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts": 48, - "src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts": 75, - "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 36, - "src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts": 48, - "src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts": 67, - "src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts": 51, - "src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts": 43, - "src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts": 59, - "src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts": 101, - "src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts": 599, - "src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts": 124, - "src/renderer/src/components/terminal-pane/terminal-appearance.test.ts": 303, - "src/renderer/src/components/terminal-pane/terminal-attention-surface.test.ts": 1639, - "src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts": 33, - "src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts": 144, - "src/renderer/src/components/terminal-pane/terminal-capped-buffer-retention.test.ts": 379, - "src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts": 39, - "src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx": 1028, - "src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts": 562, - "src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.test.ts": 57, - "src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts": 97, - "src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.test.ts": 63, - "src/renderer/src/components/terminal-pane/terminal-cold-park-exempt-flip.react185.test.tsx": 1585, - "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx": 581, - "src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts": 1522, - "src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx": 729, - "src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx": 946, - "src/renderer/src/components/terminal-pane/terminal-cold-park-timer-rearm.test.tsx": 704, - "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx": 587, - "src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts": 54, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts": 1608, + "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts": 7322, + "src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts": 1868, + "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts": 2005, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts": 1774, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts": 1819, + "src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts": 1469, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts": 1786, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts": 1772, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts": 1745, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts": 2594, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts": 879, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts": 1378, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts": 2002, + "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts": 2467, + "src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts": 2008, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts": 2262, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts": 1815, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts": 2838, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts": 1760, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts": 2566, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts": 1595, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts": 1664, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts": 914, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts": 1148, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts": 2595, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts": 1107, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts": 1929, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts": 1620, + "src/renderer/src/components/terminal-pane/pty-connection-hook-idle-arbitration.test.ts": 1272, + "src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts": 2067, + "src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts": 1436, + "src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts": 2527, + "src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts": 2233, + "src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts": 1856, + "src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts": 2290, + "src/renderer/src/components/terminal-pane/pty-connection-pi-synchronized-input.test.ts": 922, + "src/renderer/src/components/terminal-pane/pty-connection-post-dispose-restore-termination.test.ts": 1239, + "src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts": 4853, + "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts": 1191, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts": 4133, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts": 2681, + "src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts": 2433, + "src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-source-grid.test.ts": 1154, + "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts": 1895, + "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts": 2078, + "src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts": 1232, + "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts": 1812, + "src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts": 2447, + "src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts": 1546, + "src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts": 1242, + "src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts": 1408, + "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts": 950, + "src/renderer/src/components/terminal-pane/pty-connection-ssh-reattach-early-input.test.ts": 1045, + "src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts": 1661, + "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts": 1743, + "src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts": 1856, + "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts": 2005, + "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts": 3182, + "src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts": 2416, + "src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts": 1067, + "src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts": 2445, + "src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts": 1123, + "src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts": 1970, + "src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts": 2271, + "src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts": 132, + "src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-startup-snapshot.test.ts": 65, + "src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts": 54, + "src/renderer/src/components/terminal-pane/pty-connection/disposed-spawn-retention.test.ts": 77, + "src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts": 60, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts": 60, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.test.ts": 99, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-renderer-risk-scan-tail.test.ts": 112, + "src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts": 51, + "src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts": 39, + "src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.test.ts": 139, + "src/renderer/src/components/terminal-pane/pty-connection/park-reveal-snapshot-verdict.test.ts": 69, + "src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.test.ts": 1416, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts": 1490, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts": 1420, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts": 189, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts": 226, + "src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts": 46, + "src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts": 46, + "src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts": 50, + "src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm-startup-callback.test.ts": 73, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts": 258, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts": 155, + "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts": 857, + "src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts": 160, + "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts": 752, + "src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts": 53, + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts": 57, + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts": 130, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer-warn-eviction.test.ts": 69, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts": 115, + "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts": 85, + "src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts": 79, + "src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts": 61, + "src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts": 195, + "src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts": 824, + "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts": 551, + "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts": 56, + "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts": 73, + "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts": 1160, + "src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts": 814, + "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts": 1299, + "src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts": 738, + "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts": 2267, + "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts": 1185, + "src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts": 484, + "src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts": 500, + "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts": 1113, + "src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts": 591, + "src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts": 773, + "src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts": 539, + "src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts": 185, + "src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts": 46, + "src/renderer/src/components/terminal-pane/remote-execution-host-pty.test.ts": 1319, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts": 1795, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts": 1450, + "src/renderer/src/components/terminal-pane/remote-layout-scrollback-retention.test.ts": 64, + "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts": 71, + "src/renderer/src/components/terminal-pane/remote-park-reveal-unverifiable-retry.test.ts": 988, + "src/renderer/src/components/terminal-pane/remote-reattach-input-buffer.test.ts": 84, + "src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts": 1519, + "src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace-retry.test.ts": 2324, + "src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace.test.ts": 43, + "src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts": 1823, + "src/renderer/src/components/terminal-pane/remote-runtime-input-journal.test.ts": 70, + "src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts": 2091, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts": 105, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts": 3257, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts": 5440, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts": 2376, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts": 70, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts": 1605, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts": 1617, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts": 2219, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts": 3968, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts": 4354, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts": 3437, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts": 1769, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts": 2693, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts": 1553, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts": 3458, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts": 9187, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts": 4890, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts": 2895, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-replay.test.ts": 8016, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pairing-rotation.test.ts": 3856, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts": 2736, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts": 4764, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts": 3084, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts": 1622, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts": 2962, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts": 3183, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts": 2943, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts": 2889, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts": 5167, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unpublished-host-graph.test.ts": 3061, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts": 3266, + "src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.test.ts": 275, + "src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts": 3078, + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts": 10249, + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts": 1906, + "src/renderer/src/components/terminal-pane/remote-snapshot-history-seam.test.ts": 3139, + "src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts": 49, + "src/renderer/src/components/terminal-pane/replay-guard.test.ts": 127, + "src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts": 303, + "src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts": 122, + "src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts": 1708, + "src/renderer/src/components/terminal-pane/request-active-terminal-pane-close.test.ts": 178, + "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts": 1372, + "src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts": 50, + "src/renderer/src/components/terminal-pane/scan22-ssh-new-tab-disposed-spawn-kill.repro.test.ts": 412, + "src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx": 270, + "src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts": 52, + "src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts": 50, + "src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts": 60, + "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 58, + "src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts": 71, + "src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts": 62, + "src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts": 54, + "src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts": 90, + "src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts": 74, + "src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts": 70, + "src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts": 1153, + "src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts": 82, + "src/renderer/src/components/terminal-pane/terminal-appearance.test.ts": 341, + "src/renderer/src/components/terminal-pane/terminal-attention-surface.test.ts": 1370, + "src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts": 147, + "src/renderer/src/components/terminal-pane/terminal-capped-buffer-retention.test.ts": 646, + "src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts": 54, + "src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx": 834, + "src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts": 325, + "src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.test.ts": 55, + "src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts": 117, + "src/renderer/src/components/terminal-pane/terminal-clipboard-selection-text.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-cold-park-exempt-flip.react185.test.tsx": 1752, + "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx": 906, + "src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts": 1436, + "src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx": 556, + "src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx": 890, + "src/renderer/src/components/terminal-pane/terminal-cold-park-timer-rearm.test.tsx": 566, + "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx": 583, + "src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts": 69, "src/renderer/src/components/terminal-pane/terminal-command-lifecycle.test.ts": 42, - "src/renderer/src/components/terminal-pane/terminal-context-menu-dismiss.test.ts": 31, - "src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts": 49, - "src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts": 868, - "src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts": 86, - "src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts": 525, - "src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts": 205, - "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts": 39, - "src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts": 73, - "src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts": 346, - "src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts": 56, - "src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts": 411, - "src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts": 72, - "src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts": 115, - "src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts": 46, - "src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts": 52, + "src/renderer/src/components/terminal-pane/terminal-context-menu-dismiss.test.ts": 44, + "src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts": 43, + "src/renderer/src/components/terminal-pane/terminal-copy-shortcut.test.ts": 1732, + "src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts": 1612, + "src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts": 107, + "src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts": 317, + "src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts": 309, + "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts": 61, + "src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts": 117, + "src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts": 436, + "src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts": 568, + "src/renderer/src/components/terminal-pane/terminal-drop-local-workspace.test.ts": 574, + "src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.test.ts": 141, + "src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts": 120, + "src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts": 679, + "src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts": 326, + "src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts": 60, + "src/renderer/src/components/terminal-pane/terminal-drop-source-owner.test.ts": 509, + "src/renderer/src/components/terminal-pane/terminal-drop-transport-owner.test.ts": 338, + "src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts": 50, + "src/renderer/src/components/terminal-pane/terminal-drop-worktree-path.test.ts": 317, "src/renderer/src/components/terminal-pane/terminal-drop-write-failure.test.ts": 52, - "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts": 223, - "src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts": 289, - "src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts": 809, - "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts": 195, - "src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts": 85, - "src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts": 73, - "src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts": 70, - "src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts": 354, - "src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts": 23, - "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts": 966, - "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts": 851, - "src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts": 173, - "src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts": 76, - "src/renderer/src/components/terminal-pane/terminal-http-url-extraction.test.ts": 53, - "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts": 57, - "src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts": 1182, - "src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts": 281, - "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts": 479, - "src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts": 249, - "src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts": 324, - "src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts": 383, - "src/renderer/src/components/terminal-pane/terminal-ime-forwarder-space-claim.test.ts": 356, - "src/renderer/src/components/terminal-pane/terminal-ime-hangul-syllable-flush.test.ts": 1285, - "src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts": 268, - "src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts": 395, - "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts": 72, - "src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-release.test.ts": 546, - "src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-selector.test.ts": 104, - "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts": 482, - "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts": 429, - "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts": 703, - "src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts": 989, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts": 4544, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-cancelled-preedit-visibility.test.ts": 606, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-cancel.test.ts": 885, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts": 2544, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-consumed-key-commit.test.ts": 634, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-korean-enter-commit-order.test.ts": 670, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-linux-native-trace-replay.test.ts": 4862, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts": 1276, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-preedit-cell-grid.test.ts": 2107, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts": 897, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts": 965, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts": 899, - "src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts": 900, - "src/renderer/src/components/terminal-pane/terminal-inline-image-da1.test.ts": 118, - "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts": 1508, - "src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts": 46, - "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts": 869, - "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts": 656, - "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts": 683, - "src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts": 40, - "src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts": 2134, - "src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts": 355, - "src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts": 38, - "src/renderer/src/components/terminal-pane/terminal-keyboard-search-dispatch.test.ts": 1683, - "src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts": 39, + "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts": 127, + "src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts": 93, + "src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts": 880, + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts": 369, + "src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts": 1292, + "src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts": 88, + "src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts": 107, + "src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts": 63, + "src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts": 412, + "src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts": 32, + "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts": 1508, + "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts": 1920, + "src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts": 369, + "src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts": 95, + "src/renderer/src/components/terminal-pane/terminal-http-url-extraction.test.ts": 47, + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts": 58, + "src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts": 780, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts": 157, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts": 147, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts": 165, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts": 159, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts": 230, + "src/renderer/src/components/terminal-pane/terminal-ime-forwarder-space-claim.test.ts": 226, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-syllable-flush.test.ts": 688, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts": 211, + "src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts": 147, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts": 77, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-release.test.ts": 174, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-claimed-preedit-selector.test.ts": 81, + "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts": 266, + "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts": 233, + "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts": 438, + "src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts": 337, + "src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts": 454, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts": 2315, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-cancelled-preedit-visibility.test.ts": 385, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-cancel.test.ts": 462, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts": 1414, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-consumed-key-commit.test.ts": 532, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-korean-enter-commit-order.test.ts": 382, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-linux-native-trace-replay.test.ts": 4041, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts": 876, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-preedit-cell-grid.test.ts": 1043, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts": 420, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts": 586, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts": 550, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts": 728, + "src/renderer/src/components/terminal-pane/terminal-inline-image-da1.test.ts": 123, + "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts": 1406, + "src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts": 53, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts": 786, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts": 562, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts": 634, + "src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts": 47, + "src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts": 2037, + "src/renderer/src/components/terminal-pane/terminal-keyboard-native-chat-cover.test.ts": 1797, + "src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts": 140, + "src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts": 57, + "src/renderer/src/components/terminal-pane/terminal-keyboard-search-dispatch.test.ts": 1809, + "src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts": 69, "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts": 73, - "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 62, - "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 39, - "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 328, - "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 103, - "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 111, - "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 1448, - "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 226, - "src/renderer/src/components/terminal-pane/terminal-link-click-behavior.test.ts": 33, - "src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts": 902, - "src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts": 549, - "src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts": 398, - "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts": 263, - "src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts": 659, - "src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts": 384, - "src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts": 642, - "src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts": 478, - "src/renderer/src/components/terminal-pane/terminal-link-provider-link-detection.test.ts": 687, - "src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts": 716, - "src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts": 470, - "src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts": 412, - "src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts": 497, - "src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts": 191, - "src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts": 1437, - "src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.test.ts": 296, - "src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts": 66, - "src/renderer/src/components/terminal-pane/terminal-notification-state.test.ts": 960, - "src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts": 47, - "src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts": 32, - "src/renderer/src/components/terminal-pane/terminal-pane-attention-subscriptions.test.ts": 64, - "src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts": 51, - "src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts": 504, - "src/renderer/src/components/terminal-pane/terminal-pane-host-state-memo.test.ts": 1306, - "src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts": 1222, - "src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts": 491, - "src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts": 362, - "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts": 289, - "src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts": 38, - "src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts": 401, - "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts": 89, - "src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts": 2320, - "src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx": 5213, - "src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts": 254, - "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts": 119, - "src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts": 116, - "src/renderer/src/components/terminal-pane/terminal-park-verdict-worktree-driver-loop.test.tsx": 741, - "src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts": 1367, - "src/renderer/src/components/terminal-pane/terminal-parked-tab-eviction-exemption.test.ts": 298, - "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts": 253, - "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts": 1059, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts": 262, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts": 161, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts": 184, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts": 189, - "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sole-newborn-exit.test.ts": 316, - "src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts": 2679, - "src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts": 62, - "src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts": 105, - "src/renderer/src/components/terminal-pane/terminal-paste-executor-default-yield.test.ts": 52, - "src/renderer/src/components/terminal-pane/terminal-paste-multiline-policy.test.ts": 72, - "src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts": 1530, - "src/renderer/src/components/terminal-pane/terminal-paste-payload-metadata.test.ts": 45, - "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts": 39, - "src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts": 52, - "src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts": 40, - "src/renderer/src/components/terminal-pane/terminal-process-exit-restart.test.ts": 49, - "src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.test.ts": 1734, - "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts": 335, - "src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts": 28, - "src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts": 71, + "src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx": 356, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 56, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 57, + "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 190, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 98, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 118, + "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 1850, + "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 319, + "src/renderer/src/components/terminal-pane/terminal-link-click-behavior.test.ts": 55, + "src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts": 770, + "src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts": 704, + "src/renderer/src/components/terminal-pane/terminal-link-escaping-file-open.test.ts": 588, + "src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts": 780, + "src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts": 879, + "src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts": 820, + "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts": 297, + "src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts": 767, + "src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts": 645, + "src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts": 485, + "src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts": 737, + "src/renderer/src/components/terminal-pane/terminal-link-provider-link-detection.test.ts": 929, + "src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts": 461, + "src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts": 509, + "src/renderer/src/components/terminal-pane/terminal-link-same-id-ssh-file-host.test.ts": 735, + "src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts": 763, + "src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts": 723, + "src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts": 317, + "src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts": 1358, + "src/renderer/src/components/terminal-pane/terminal-native-copy-gutter.test.ts": 159, + "src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts": 59, + "src/renderer/src/components/terminal-pane/terminal-notification-state.test.ts": 1566, + "src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-option-punctuation.test.ts": 74, + "src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts": 56, + "src/renderer/src/components/terminal-pane/terminal-pane-attention-subscriptions.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts": 55, + "src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx": 1023, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state-memo.test.ts": 1887, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts": 1520, + "src/renderer/src/components/terminal-pane/terminal-pane-manager-options-gesture.test.ts": 2408, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts": 568, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts": 488, + "src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts": 559, + "src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts": 47, + "src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts": 151, + "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts": 87, + "src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts": 1735, + "src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx": 6838, + "src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts": 335, + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts": 118, + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts": 109, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts": 132, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-worktree-driver-loop.test.tsx": 467, + "src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts": 1644, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-eviction-exemption.test.ts": 342, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts": 454, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts": 464, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts": 415, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts": 307, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts": 341, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts": 404, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sole-newborn-exit.test.ts": 368, + "src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts": 2849, + "src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts": 85, + "src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts": 153, + "src/renderer/src/components/terminal-pane/terminal-paste-executor-default-yield.test.ts": 58, + "src/renderer/src/components/terminal-pane/terminal-paste-multiline-policy.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts": 1390, + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts": 59, + "src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts": 58, + "src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts": 70, + "src/renderer/src/components/terminal-pane/terminal-process-exit-restart.test.ts": 42, + "src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.test.ts": 1433, + "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts": 155, + "src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts": 57, + "src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts": 61, "src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts": 42, - "src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts": 67, - "src/renderer/src/components/terminal-pane/terminal-render-desync-frame.test.ts": 67, - "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts": 430, - "src/renderer/src/components/terminal-pane/terminal-render-desync-weight-probe.test.ts": 52, - "src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts": 115, - "src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts": 363, - "src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts": 117, - "src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts": 179, - "src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts": 42, - "src/renderer/src/components/terminal-pane/terminal-retention-churn-pins.test.tsx": 569, - "src/renderer/src/components/terminal-pane/terminal-retention-exempt-growth.test.ts": 1509, - "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts": 2008, - "src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts": 980, - "src/renderer/src/components/terminal-pane/terminal-selection-fit-guard.test.ts": 37, - "src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts": 57, - "src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts": 120, - "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts": 133, - "src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts": 85, - "src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts": 90, - "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts": 36, - "src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts": 704, - "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts": 44, - "src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts": 56, - "src/renderer/src/components/terminal-pane/terminal-startup-timing-scheduler.test.ts": 114, - "src/renderer/src/components/terminal-pane/terminal-startup-timing.test.ts": 62, - "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts": 58, - "src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts": 30, - "src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts": 84, - "src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts": 263, - "src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts": 42, - "src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts": 1229, - "src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts": 1570, - "src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts": 160, - "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts": 274, - "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts": 1249, - "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts": 41, - "src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts": 71, - "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts": 65, - "src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts": 1613, - "src/renderer/src/components/terminal-pane/title-agent-identity.test.ts": 35, - "src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts": 1809, - "src/renderer/src/components/terminal-pane/use-mobile-overlay-ticks.perf.test.tsx": 764, - "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts": 291, - "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 587, - "src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 45, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 1301, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 1149, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 1211, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 1904, - "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts": 1363, - "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts": 2216, - "src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx": 627, - "src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts": 50, - "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts": 975, - "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts": 611, - "src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts": 72, - "src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx": 511, - "src/renderer/src/components/terminal-pane/useTerminalFontZoom.test.ts": 340, - "src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts": 74, - "src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts": 77, - "src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts": 142, - "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts": 52, - "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts": 62, - "src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts": 82, - "src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx": 994, - "src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx": 1832, - "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.test.ts": 177, - "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts": 241, - "src/renderer/src/components/terminal-scrollback-decoration-eviction.test.ts": 1037, - "src/renderer/src/components/terminal-search-decoration-leak.test.ts": 2437, - "src/renderer/src/components/terminal-search-long-wrapped-line.test.ts": 5608, - "src/renderer/src/components/terminal-search-safe-find.test.ts": 51, - "src/renderer/src/components/terminal-workspace-keydown.test.ts": 822, - "src/renderer/src/components/terminal-workspace-surface-ids.test.tsx": 2751, - "src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts": 55, - "src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx": 1990, - "src/renderer/src/components/terminal/active-terminal-repair.test.ts": 47, - "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts": 1529, - "src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts": 43, - "src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts": 1885, - "src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx": 556, - "src/renderer/src/components/terminal/initial-terminal-wiring.test.ts": 41, - "src/renderer/src/components/terminal/initial-terminal.test.ts": 48, - "src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts": 33, - "src/renderer/src/components/terminal/running-terminal-close-guard.test.ts": 67, - "src/renderer/src/components/terminal/split-group-mount.test.ts": 30, - "src/renderer/src/components/terminal/tab-type-cycle.test.ts": 46, - "src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts": 226, - "src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts": 67, - "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts": 70, - "src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts": 2039, - "src/renderer/src/components/terminal/terminal-provider-snapshot-capability-resettlement.test.ts": 1467, - "src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts": 65, - "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts": 294, - "src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts": 1627, - "src/renderer/src/components/terminal/terminal-tab-actions.test.ts": 322, - "src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts": 178, - "src/renderer/src/components/terminal/unsaved-close-queue.test.ts": 49, - "src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx": 750, - "src/renderer/src/components/terminal/use-worktree-files.test.tsx": 359, - "src/renderer/src/components/terminal/window-close-running-work.test.ts": 67, - "src/renderer/src/components/ui/color-picker.test.tsx": 537, - "src/renderer/src/components/ui/popover-wheel-scroll.test.tsx": 931, - "src/renderer/src/components/ui/repo-multi-combobox.test.ts": 461, - "src/renderer/src/components/ui/scroll-area.test.tsx": 420, - "src/renderer/src/components/ui/slider.test.tsx": 848, - "src/renderer/src/components/ui/switch.test.tsx": 180, - "src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx": 1887, - "src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts": 59, - "src/renderer/src/components/use-github-task-search-commit.test.ts": 679, - "src/renderer/src/components/use-task-creation-draft-retention.test.ts": 550, - "src/renderer/src/components/use-task-page-jira-list-effects.test.ts": 2187, - "src/renderer/src/components/use-terminal-create-actions.test.tsx": 444, - "src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx": 1796, - "src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx": 381, - "src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts": 2178, - "src/renderer/src/components/virtualized-list.test.tsx": 788, - "src/renderer/src/components/window-close-request-coordinator.test.ts": 436, - "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx": 1869, - "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx": 2614, - "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx": 512, - "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx": 758, - "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx": 637, - "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts": 328, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts": 92, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts": 1640, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts": 1286, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts": 126, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx": 2448, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts": 175, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx": 2561, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx": 818, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts": 103, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx": 972, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx": 1353, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts": 115, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts": 68, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx": 1202, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx": 2644, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts": 92, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts": 106, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts": 116, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts": 77, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts": 1354, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx": 1810, - "src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx": 957, - "src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.test.tsx": 737, - "src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx": 786, - "src/renderer/src/components/workspace-surface-projection.test.ts": 57, - "src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx": 591, - "src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx": 5202, - "src/renderer/src/components/worktree-jump-palette-primitives.test.tsx": 836, - "src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx": 939, - "src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts": 2023, - "src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts": 52, - "src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts": 62, - "src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 334, - "src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts": 111, - "src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts": 471, - "src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts": 148, - "src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 371, - "src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 77, - "src/renderer/src/hooks/automation-agent-status-entry-change.test.ts": 45, - "src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts": 169, - "src/renderer/src/hooks/automations-changed-event-attribution.test.ts": 1405, - "src/renderer/src/hooks/composer-branch-selection.test.ts": 54, - "src/renderer/src/hooks/composer-drop-failure-toast.test.ts": 83, - "src/renderer/src/hooks/composer-drop-owner.test.ts": 32, - "src/renderer/src/hooks/composer-drop-result.test.ts": 33, - "src/renderer/src/hooks/composer-native-file-drop.test.ts": 57, - "src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx": 668, - "src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx": 1076, - "src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts": 560, - "src/renderer/src/hooks/composer-state/composer-drop-listener.test.ts": 663, - "src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts": 2276, - "src/renderer/src/hooks/composer-state/draft-target-sync.test.ts": 571, - "src/renderer/src/hooks/composer-state/full-creation-execution.test.ts": 1939, - "src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts": 1517, - "src/renderer/src/hooks/composer-state/full-submit-orchestration.test.ts": 610, - "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 1387, - "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 1981, - "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 113, - "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 113, - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 108, - "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 40, - "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 54, - "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 1496, - "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 302, - "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 63, - "src/renderer/src/hooks/fork-push-warning.test.ts": 59, + "src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts": 49, + "src/renderer/src/components/terminal-pane/terminal-render-desync-frame.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts": 458, + "src/renderer/src/components/terminal-pane/terminal-render-desync-weight-probe.test.ts": 63, + "src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts": 118, + "src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts": 393, + "src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts": 130, + "src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts": 155, + "src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-retention-churn-pins.test.tsx": 643, + "src/renderer/src/components/terminal-pane/terminal-retention-exempt-growth.test.ts": 2874, + "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts": 2218, + "src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts": 1451, + "src/renderer/src/components/terminal-pane/terminal-selection-fit-guard.test.ts": 45, + "src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts": 81, + "src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts": 154, + "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts": 132, + "src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts": 72, + "src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts": 209, + "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts": 79, + "src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts": 481, + "src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts": 52, + "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts": 53, + "src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-startup-timing-scheduler.test.ts": 116, + "src/renderer/src/components/terminal-pane/terminal-startup-timing.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index-retention.test.ts": 2034, + "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts": 71, + "src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts": 44, + "src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts": 102, + "src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts": 449, + "src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts": 59, + "src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts": 1895, + "src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts": 1787, + "src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts": 72, + "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts": 369, + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts": 1475, + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts": 75, + "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts": 105, + "src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts": 1473, + "src/renderer/src/components/terminal-pane/title-agent-identity.test.ts": 69, + "src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts": 1577, + "src/renderer/src/components/terminal-pane/use-mobile-overlay-ticks.perf.test.tsx": 337, + "src/renderer/src/components/terminal-pane/use-notification-dispatch-verdict.test.ts": 438, + "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts": 477, + "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 495, + "src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 47, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 1361, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 1425, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 1495, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 1703, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts": 1618, + "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts": 2584, + "src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx": 441, + "src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts": 77, + "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts": 746, + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts": 306, + "src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts": 54, + "src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx": 206, + "src/renderer/src/components/terminal-pane/useTerminalFontZoom.test.ts": 180, + "src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts": 65, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts": 87, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts": 100, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts": 102, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts": 102, + "src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx": 1186, + "src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx": 903, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.test.ts": 128, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts": 182, + "src/renderer/src/components/terminal-scrollback-decoration-eviction.test.ts": 504, + "src/renderer/src/components/terminal-search-decoration-leak.test.ts": 2052, + "src/renderer/src/components/terminal-search-long-wrapped-line.test.ts": 4844, + "src/renderer/src/components/terminal-search-safe-find.test.ts": 49, + "src/renderer/src/components/terminal-split-workspace-surfaces-startup-hold.test.tsx": 174, + "src/renderer/src/components/terminal-startup-hold-placeholder.test.tsx": 636, + "src/renderer/src/components/terminal-workspace-keydown.test.ts": 591, + "src/renderer/src/components/terminal-workspace-surface-ids.test.tsx": 2255, + "src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts": 61, + "src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx": 1667, + "src/renderer/src/components/terminal/active-terminal-repair.test.ts": 57, + "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts": 1773, + "src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts": 51, + "src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts": 1948, + "src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx": 1079, + "src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx": 461, + "src/renderer/src/components/terminal/initial-terminal.test.ts": 60, + "src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts": 57, + "src/renderer/src/components/terminal/running-terminal-close-guard.test.ts": 108, + "src/renderer/src/components/terminal/split-group-mount.test.ts": 67, + "src/renderer/src/components/terminal/startup-terminal-tab-hold.test.ts": 2061, + "src/renderer/src/components/terminal/tab-type-cycle.test.ts": 49, + "src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts": 389, + "src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts": 51, + "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts": 122, + "src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts": 1808, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability-resettlement.test.ts": 1356, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts": 77, + "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts": 414, + "src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts": 1714, + "src/renderer/src/components/terminal/terminal-tab-actions-unrouted-close.test.ts": 375, + "src/renderer/src/components/terminal/terminal-tab-actions.test.ts": 441, + "src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts": 440, + "src/renderer/src/components/terminal/unsaved-close-queue.test.ts": 54, + "src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx": 309, + "src/renderer/src/components/terminal/use-worktree-files.test.tsx": 349, + "src/renderer/src/components/terminal/window-close-running-work.test.ts": 118, + "src/renderer/src/components/ui/color-picker.test.tsx": 331, + "src/renderer/src/components/ui/popover-wheel-scroll.test.tsx": 436, + "src/renderer/src/components/ui/repo-multi-combobox.test.ts": 204, + "src/renderer/src/components/ui/scroll-area.test.tsx": 249, + "src/renderer/src/components/ui/slider.test.tsx": 606, + "src/renderer/src/components/ui/switch.test.tsx": 102, + "src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx": 1501, + "src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts": 61, + "src/renderer/src/components/use-github-task-search-commit.test.ts": 287, + "src/renderer/src/components/use-jira-create-assignee.test.tsx": 250, + "src/renderer/src/components/use-quick-open-interaction.test.tsx": 1646, + "src/renderer/src/components/use-task-creation-draft-retention.test.ts": 562, + "src/renderer/src/components/use-task-page-gitlab-loading.test.tsx": 1645, + "src/renderer/src/components/use-task-page-jira-list-effects.test.ts": 1835, + "src/renderer/src/components/use-terminal-create-actions.test.tsx": 313, + "src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx": 1581, + "src/renderer/src/components/use-terminal-editor-close-queue.reveal.test.tsx": 646, + "src/renderer/src/components/use-terminal-parking-pass.test.tsx": 1936, + "src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx": 240, + "src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts": 1489, + "src/renderer/src/components/virtualized-list.test.tsx": 391, + "src/renderer/src/components/window-close-request-coordinator.test.ts": 222, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx": 2431, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx": 2563, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx": 266, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx": 389, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx": 349, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts": 267, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts": 70, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts": 1523, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts": 1294, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts": 189, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx": 2157, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts": 124, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx": 2357, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx": 532, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts": 85, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx": 363, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx": 601, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts": 174, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts": 123, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx": 537, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx": 2613, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts": 91, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts": 130, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts": 97, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts": 95, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts": 1542, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx": 3533, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx": 439, + "src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.test.tsx": 390, + "src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx": 376, + "src/renderer/src/components/workspace-surface-projection.test.ts": 97, + "src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx": 315, + "src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx": 2544, + "src/renderer/src/components/worktree-jump-palette-primitives.test.tsx": 357, + "src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx": 998, + "src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts": 124, + "src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts": 1424, + "src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts": 65, + "src/renderer/src/hooks/agent-auto-ack-targets.test.ts": 1504, + "src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 351, + "src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts": 187, + "src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts": 454, + "src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts": 237, + "src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 504, + "src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 75, + "src/renderer/src/hooks/automation-agent-status-entry-change.test.ts": 50, + "src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts": 178, + "src/renderer/src/hooks/automations-changed-event-attribution.test.ts": 1801, + "src/renderer/src/hooks/composer-branch-selection.test.ts": 75, + "src/renderer/src/hooks/composer-drop-failure-toast.test.ts": 92, + "src/renderer/src/hooks/composer-drop-result.test.ts": 47, + "src/renderer/src/hooks/composer-native-file-drop.test.ts": 52, + "src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx": 633, + "src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx": 790, + "src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts": 323, + "src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts": 70, + "src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts": 2007, + "src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx": 639, + "src/renderer/src/hooks/composer-state/draft-target-sync.test.ts": 265, + "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, + "src/renderer/src/hooks/fork-push-warning.test.ts": 65, "src/renderer/src/hooks/installed-agent-skill-discovery-cache.test.ts": 67, - "src/renderer/src/hooks/installed-agent-skill-discovery.test.ts": 70, - "src/renderer/src/hooks/installed-agent-skill-runtime-eviction.test.ts": 50, - "src/renderer/src/hooks/installed-agent-skill-verdict.test.ts": 927, - "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 1777, - "src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 339, - "src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 1508, - "src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts": 461, - "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts": 503, - "src/renderer/src/hooks/ipc-events/browser-state-capture-paint-hold.test.ts": 198, - "src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts": 206, - "src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts": 1662, - "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts": 945, - "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts": 1392, - "src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts": 30, - "src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts": 1618, - "src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts": 207, - "src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts": 1063, - "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts": 1734, - "src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts": 361, - "src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts": 57, - "src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts": 69, - "src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts": 57, - "src/renderer/src/hooks/ipc-tab-switch.test.ts": 98, - "src/renderer/src/hooks/legacy-worker-terminal-recovery-event.test.ts": 61, - "src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts": 53, - "src/renderer/src/hooks/metadata-request-cache.test.ts": 179, - "src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts": 2329, - "src/renderer/src/hooks/modal-return-focus-action.test.ts": 27, + "src/renderer/src/hooks/installed-agent-skill-discovery.test.ts": 73, + "src/renderer/src/hooks/installed-agent-skill-runtime-eviction.test.ts": 54, + "src/renderer/src/hooks/installed-agent-skill-verdict.test.ts": 1648, + "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index-retention.test.ts": 2083, + "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 1458, + "src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 563, + "src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 1686, + "src/renderer/src/hooks/ipc-events/agent-status-unavailable.test.ts": 82, + "src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts": 144, + "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts": 487, + "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts": 535, + "src/renderer/src/hooks/ipc-events/browser-state-capture-paint-hold.test.ts": 293, + "src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts": 323, + "src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts": 1461, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts": 1432, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts": 1473, + "src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.test.ts": 1564, + "src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts": 60, + "src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts": 1416, + "src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts": 308, + "src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts": 1295, + "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts": 1629, + "src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts": 2199, + "src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.test.ts": 646, + "src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts": 1359, + "src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts": 261, + "src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts": 51, + "src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts": 1461, + "src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts": 79, + "src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts": 76, + "src/renderer/src/hooks/ipc-tab-switch.test.ts": 81, + "src/renderer/src/hooks/legacy-worker-terminal-recovery-event.test.ts": 67, + "src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts": 61, + "src/renderer/src/hooks/metadata-request-cache.test.ts": 140, + "src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts": 2706, + "src/renderer/src/hooks/modal-return-focus-action.test.ts": 53, "src/renderer/src/hooks/programmatic-scroll-marks.test.ts": 46, - "src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts": 329, - "src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts": 102, - "src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts": 1845, - "src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts": 94, - "src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts": 159, - "src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts": 1341, - "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts": 56, - "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts": 1373, - "src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts": 1712, - "src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts": 1572, - "src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts": 1734, - "src/renderer/src/hooks/remote-workspace-target-sync.test.ts": 336, - "src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts": 78, - "src/renderer/src/hooks/runtime-client-events-sync.test.ts": 100, - "src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts": 93, - "src/renderer/src/hooks/shortcut-label-cache.test.tsx": 2047, - "src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts": 71, - "src/renderer/src/hooks/structured-session-completion-focus.test.ts": 1246, - "src/renderer/src/hooks/unpaired-device-auth-notification.test.ts": 49, - "src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts": 393, - "src/renderer/src/hooks/use-clipboard-text-copy-feedback.test.ts": 420, - "src/renderer/src/hooks/use-now.test.ts": 318, - "src/renderer/src/hooks/use-palette-search-evaluation-context.test.ts": 586, - "src/renderer/src/hooks/use-task-page-github-work-item-mutation-host.test.ts": 1951, - "src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts": 675, - "src/renderer/src/hooks/use-window-stream-visibility.test.ts": 368, - "src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx": 2208, - "src/renderer/src/hooks/useAgentDetectionTarget.test.ts": 1593, - "src/renderer/src/hooks/useAppMenuPaste.test.tsx": 525, - "src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx": 561, - "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts": 2063, - "src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts": 2020, - "src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts": 1984, - "src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts": 1361, - "src/renderer/src/hooks/useAutoAckViewedAgent.mixed-workspace.test.ts": 1339, - "src/renderer/src/hooks/useAutoAckViewedAgent.test.ts": 1829, - "src/renderer/src/hooks/useAutomationDispatchEvents.test.ts": 716, - "src/renderer/src/hooks/useComposerState-decisions.test.ts": 2092, - "src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts": 7756, - "src/renderer/src/hooks/useComposerState-host-retarget.test.ts": 2531, - "src/renderer/src/hooks/useComposerState.integration.test.ts": 3242, - "src/renderer/src/hooks/useDetectedAgents.test.tsx": 1651, - "src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts": 5198, - "src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts": 533, - "src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx": 404, - "src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts": 324, - "src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts": 307, - "src/renderer/src/hooks/useEditorExternalWatch.test.ts": 492, - "src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 420, - "src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 556, - "src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 6914, - "src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 1496, - "src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 50, - "src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts": 2975, - "src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts": 2091, - "src/renderer/src/hooks/useIpcEvents-agent-status-hook-titles.test.ts": 3465, - "src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts": 1791, - "src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts": 1553, - "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts": 3794, - "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts": 1645, - "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts": 2844, - "src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts": 1232, - "src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts": 1339, - "src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts": 1691, - "src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts": 1564, - "src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts": 2045, - "src/renderer/src/hooks/useIpcEvents-client-hosted-browser-rows.test.ts": 2146, - "src/renderer/src/hooks/useIpcEvents-close-routing-active-browser-tab.test.ts": 2721, - "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts": 3181, - "src/renderer/src/hooks/useIpcEvents-close-routing-floating-guest.test.ts": 1386, - "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts": 3166, - "src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts": 1969, - "src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts": 1286, - "src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts": 63, - "src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts": 1530, - "src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts": 1613, - "src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts": 344, - "src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts": 1973, - "src/renderer/src/hooks/useIpcEvents-silent-terminal-adoption.test.ts": 1127, - "src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts": 1093, - "src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts": 2062, - "src/renderer/src/hooks/useIpcEvents-updater-status.test.ts": 1760, - "src/renderer/src/hooks/useIssueMetadata.test.tsx": 329, - "src/renderer/src/hooks/useLinearProviderConnected.test.tsx": 1480, - "src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx": 2293, - "src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx": 2393, - "src/renderer/src/hooks/useMetadataListRequest.test.tsx": 555, - "src/renderer/src/hooks/useModalReturnFocus.test.tsx": 1404, - "src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 342, - "src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx": 582, - "src/renderer/src/hooks/useResetCountdownClock.test.ts": 380, - "src/renderer/src/hooks/useRetiredWorktreeNames.test.ts": 729, - "src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx": 1128, - "src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx": 1648, - "src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts": 1342, - "src/renderer/src/hooks/useSidebarResize.test.ts": 42, - "src/renderer/src/hooks/useSkillFreshness.test.tsx": 461, - "src/renderer/src/hooks/useUnreadDockBadge.test.ts": 2344, - "src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts": 71, - "src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts": 56, - "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.ts": 49, - "src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx": 613, - "src/renderer/src/hooks/viewport-size-change-listener.test.ts": 46, - "src/renderer/src/hooks/worktree-change-refresh-queue.test.ts": 81, - "src/renderer/src/hooks/worktree-head-identity-apply.test.ts": 41, - "src/renderer/src/hooks/zoom-routing.test.ts": 195, - "src/renderer/src/i18n/I18nProvider.test.tsx": 2173, - "src/renderer/src/i18n/integration-card-status-localization.test.ts": 257, - "src/renderer/src/i18n/intl-locale.test.ts": 135, - "src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts": 158, - "src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts": 214, - "src/renderer/src/i18n/lazy-locale.test.ts": 299, - "src/renderer/src/i18n/locale-english-regression.test.ts": 1231, - "src/renderer/src/i18n/native-chat-locales.test.ts": 461, - "src/renderer/src/i18n/no-top-level-translate.test.ts": 3326, - "src/renderer/src/i18n/plugin-chrome-allowlist.test.ts": 170, - "src/renderer/src/i18n/pseudo-localization.test.ts": 89, - "src/renderer/src/i18n/relative-duration-copy-localization.test.ts": 810, - "src/renderer/src/i18n/relative-time-format.test.ts": 112, - "src/renderer/src/i18n/runtime-required-catalog.test.ts": 544, - "src/renderer/src/i18n/settings-status-label-localization.test.ts": 1029, - "src/renderer/src/i18n/smart-workspace-jira-locales.test.ts": 598, - "src/renderer/src/i18n/technical-literal-catalog-values.test.ts": 829, - "src/renderer/src/i18n/weekday-names.test.ts": 94, - "src/renderer/src/i18n/worktree-visibility-locales.test.ts": 120, - "src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts": 317, - "src/renderer/src/lazy-modal-mount-state.test.ts": 37, - "src/renderer/src/lazy-use-ref-ratchet.test.ts": 260, - "src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts": 1763, - "src/renderer/src/lib/activate-ai-vault-structured-session.test.ts": 1827, - "src/renderer/src/lib/activate-tab-and-focus-pane.test.ts": 34, - "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts": 818, - "src/renderer/src/lib/active-agent-note-send-focused-session.test.ts": 297, - "src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts": 61, - "src/renderer/src/lib/active-agent-note-send-target-detection.test.ts": 339, - "src/renderer/src/lib/active-view-persist.test.ts": 42, - "src/renderer/src/lib/activity-thread-display.test.ts": 84, - "src/renderer/src/lib/agent-background-session-launch-host.test.ts": 980, - "src/renderer/src/lib/agent-catalog-links.test.ts": 113, - "src/renderer/src/lib/agent-draft-readiness.test.ts": 79, - "src/renderer/src/lib/agent-followup-delivery.test.ts": 18686, - "src/renderer/src/lib/agent-hibernation-confirmation.test.ts": 35, - "src/renderer/src/lib/agent-hibernation-coordinator.test.ts": 1424, - "src/renderer/src/lib/agent-hibernation-output-activity.test.ts": 53, - "src/renderer/src/lib/agent-hibernation-pane-age.test.ts": 43, - "src/renderer/src/lib/agent-hibernation-planner.test.ts": 154, - "src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts": 1670, - "src/renderer/src/lib/agent-hibernation-visibility.test.ts": 1583, - "src/renderer/src/lib/agent-launch-caller-permissions.test.ts": 1042, - "src/renderer/src/lib/agent-launch-caller-placement.test.ts": 615, - "src/renderer/src/lib/agent-launch-caller-profile-census.test.ts": 625, - "src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts": 735, - "src/renderer/src/lib/agent-launch-caller-routing.test.ts": 667, - "src/renderer/src/lib/agent-launch-prompt-delivery.test.ts": 72, - "src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts": 1361, - "src/renderer/src/lib/agent-launch-route-input.test.ts": 200, - "src/renderer/src/lib/agent-launch-routing-caller-census.test.ts": 1308, - "src/renderer/src/lib/agent-launch-routing.test.ts": 64, - "src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts": 177, - "src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts": 188, - "src/renderer/src/lib/agent-paste-draft.test.ts": 610, - "src/renderer/src/lib/agent-picker-search.test.ts": 180, - "src/renderer/src/lib/agent-ready-wait.test.ts": 68, - "src/renderer/src/lib/agent-resume-launch-target.test.ts": 2569, - "src/renderer/src/lib/agent-row-primary-text.test.ts": 65, - "src/renderer/src/lib/agent-row-tool-preview.test.ts": 61, - "src/renderer/src/lib/agent-send-title-status.test.ts": 70, - "src/renderer/src/lib/agent-session-fork-context.test.ts": 49, - "src/renderer/src/lib/agent-session-launch-plan.test.ts": 46, - "src/renderer/src/lib/agent-skill-cli-prerequisite.test.ts": 135, - "src/renderer/src/lib/agent-skill-nav-install-status.test.ts": 1277, - "src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts": 1657, - "src/renderer/src/lib/agent-status-connection-ownership.test.ts": 53, - "src/renderer/src/lib/agent-status-count.test.ts": 86, - "src/renderer/src/lib/agent-status-epoch-clock.test.ts": 54, - "src/renderer/src/lib/agent-status-evidence-clock.test.ts": 68, - "src/renderer/src/lib/agent-status-terminal-title.test.ts": 92, - "src/renderer/src/lib/agent-status-worktree-attribution.test.ts": 43, - "src/renderer/src/lib/agent-status.test.ts": 1192, - "src/renderer/src/lib/agent-tab-shortcuts.test.ts": 77, - "src/renderer/src/lib/agent-trust-preflight.test.ts": 71, - "src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts": 1575, - "src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts": 226, - "src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts": 231, - "src/renderer/src/lib/ai-vault-resume-command.test.ts": 229, - "src/renderer/src/lib/ai-vault-resume-shell.test.ts": 214, - "src/renderer/src/lib/ai-vault-resume-target.test.ts": 59, - "src/renderer/src/lib/ai-vault-session-drag.test.ts": 58, - "src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts": 58, - "src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts": 246, - "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts": 763, - "src/renderer/src/lib/app-command-dispatch.test.ts": 53, - "src/renderer/src/lib/app-font-family.test.ts": 151, - "src/renderer/src/lib/app-menu-paste.test.ts": 494, - "src/renderer/src/lib/app-menu-selection-actions.test.ts": 465, - "src/renderer/src/lib/automation-session-observer.test.ts": 120, - "src/renderer/src/lib/automation-session-reuse.test.ts": 59, - "src/renderer/src/lib/automation-terminal-ownership.test.ts": 1270, - "src/renderer/src/lib/browser-cookie-import-toast.test.ts": 143, - "src/renderer/src/lib/browser-history-match.performance.test.ts": 185, - "src/renderer/src/lib/browser-history-match.test.ts": 175, - "src/renderer/src/lib/browser-page-conversion-history.test.ts": 64, - "src/renderer/src/lib/browser-page-palette-activation.test.ts": 1954, - "src/renderer/src/lib/browser-palette-page-entries.test.ts": 341, - "src/renderer/src/lib/browser-palette-search.test.ts": 193, - "src/renderer/src/lib/browser-uuid.test.ts": 48, - "src/renderer/src/lib/browser-workspace-tab-activation.test.ts": 2061, - "src/renderer/src/lib/client-creation-action-policy.test.ts": 199, - "src/renderer/src/lib/cmd-j-github-url-lookup.test.ts": 27, - "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts": 231, - "src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts": 38, - "src/renderer/src/lib/cmd-j-section-leadership.test.ts": 51, - "src/renderer/src/lib/codex-account-display-label.test.ts": 48, - "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 82, - "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 249, - "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 1603, - "src/renderer/src/lib/codex-session-restart.test.ts": 6689, - "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 1587, - "src/renderer/src/lib/codex-stale-pane-sweep.test.ts": 1143, - "src/renderer/src/lib/comment-body-line-count.test.ts": 78, - "src/renderer/src/lib/comment-body-submit-state.test.ts": 77, - "src/renderer/src/lib/composer-issue-command.test.ts": 1677, - "src/renderer/src/lib/composer-submit-cancellation.test.ts": 36, - "src/renderer/src/lib/connection-context.test.ts": 978, - "src/renderer/src/lib/crash-diagnostics.test.ts": 117, - "src/renderer/src/lib/create-untitled-markdown.test.ts": 332, - "src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts": 1574, - "src/renderer/src/lib/desktop-window-chrome.test.ts": 44, - "src/renderer/src/lib/diff-comment-compat.test.ts": 44, - "src/renderer/src/lib/diff-comments-format.test.ts": 64, - "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 33, - "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 38, - "src/renderer/src/lib/doc-preview-grants.test.ts": 338, - "src/renderer/src/lib/document-theme.test.ts": 53, - "src/renderer/src/lib/editable-target.test.ts": 436, - "src/renderer/src/lib/editor-file-operation-owner.test.ts": 1407, - "src/renderer/src/lib/editor-font-zoom.test.ts": 67, - "src/renderer/src/lib/ensure-hooks-confirmed.test.ts": 508, - "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts": 287, - "src/renderer/src/lib/ensure-simulator-tab.test.ts": 32, - "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts": 29, - "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts": 72, - "src/renderer/src/lib/ephemeral-vm-workspace-target.integration.test.ts": 238, - "src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts": 241, - "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts": 68, - "src/renderer/src/lib/execute-open-editor-path-move.test.ts": 1470, - "src/renderer/src/lib/external-editor-open-capability.test.ts": 40, - "src/renderer/src/lib/feature-education-telemetry.test.ts": 65, - "src/renderer/src/lib/feedback-image-attachments.test.ts": 598, - "src/renderer/src/lib/file-preview-action-visibility.test.tsx": 516, - "src/renderer/src/lib/file-preview.test.ts": 321, - "src/renderer/src/lib/file-search-result-owner.test.ts": 66, - "src/renderer/src/lib/file-search-selection.test.ts": 41, - "src/renderer/src/lib/file-type-icons.test.ts": 130, - "src/renderer/src/lib/find-query-bounds.test.ts": 29, - "src/renderer/src/lib/finished-agent-resume-resurrection.test.ts": 1379, - "src/renderer/src/lib/fix-checks-agent-launch.test.ts": 325, - "src/renderer/src/lib/flatten-retained-slice.test.ts": 163, - "src/renderer/src/lib/floating-terminal.test.ts": 43, - "src/renderer/src/lib/floating-workspace-terminal-actions.test.ts": 235, - "src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts": 33, - "src/renderer/src/lib/focus-terminal-tab-surface.test.ts": 81, - "src/renderer/src/lib/folder-workspace-path-status.test.ts": 76, - "src/renderer/src/lib/foreground-terminal-tabs.test.ts": 57, + "src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts": 375, + "src/renderer/src/hooks/remote-workspace-push-status-snapshot-lifetime.test.ts": 2187, + "src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts": 274, + "src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts": 1966, + "src/renderer/src/hooks/remote-workspace-session-merge-local-selection.test.ts": 205, + "src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts": 277, + "src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts": 311, + "src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts": 1919, + "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts": 51, + "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts": 1375, + "src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts": 1657, + "src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts": 1560, + "src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts": 1826, + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts": 394, + "src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts": 70, + "src/renderer/src/hooks/runtime-client-events-sync.test.ts": 114, + "src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts": 99, + "src/renderer/src/hooks/shortcut-label-cache.test.tsx": 2077, + "src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts": 58, + "src/renderer/src/hooks/structured-session-completion-focus.test.ts": 1950, + "src/renderer/src/hooks/unpaired-device-auth-notification.test.ts": 47, + "src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts": 350, + "src/renderer/src/hooks/use-clipboard-text-copy-feedback.test.ts": 262, + "src/renderer/src/hooks/use-document-dark-theme.test.tsx": 268, + "src/renderer/src/hooks/use-list-multi-selection.test.tsx": 243, + "src/renderer/src/hooks/use-now.test.ts": 250, + "src/renderer/src/hooks/use-os-file-drop-owner.test.tsx": 410, + "src/renderer/src/hooks/use-palette-search-evaluation-context.test.ts": 272, + "src/renderer/src/hooks/use-task-page-github-work-item-mutation-host.test.ts": 1506, + "src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts": 434, + "src/renderer/src/hooks/use-window-stream-visibility.test.ts": 274, + "src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx": 1752, + "src/renderer/src/hooks/useAgentDetectionTarget.test.ts": 1369, + "src/renderer/src/hooks/useAppMenuPaste.test.tsx": 225, + "src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx": 270, + "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts": 1687, + "src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts": 1904, + "src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts": 1665, + "src/renderer/src/hooks/useAutoAckViewedAgent.focus-group.test.ts": 1914, + "src/renderer/src/hooks/useAutoAckViewedAgent.mixed-workspace.test.ts": 1866, + "src/renderer/src/hooks/useAutoAckViewedAgent.test.ts": 1475, + "src/renderer/src/hooks/useAutomationDispatchEvents.test.ts": 1410, + "src/renderer/src/hooks/useComposerState-decisions.test.ts": 59, + "src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts": 118, + "src/renderer/src/hooks/useComposerState-host-retarget.test.ts": 98, + "src/renderer/src/hooks/useComposerState.integration.test.ts": 3120, + "src/renderer/src/hooks/useDetectedAgents.test.tsx": 1507, + "src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts": 745, + "src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts": 517, + "src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx": 272, + "src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts": 376, + "src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts": 579, + "src/renderer/src/hooks/useEditorExternalWatch.test.ts": 590, + "src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 219, + "src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 224, + "src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 1476, + "src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 1366, + "src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 48, + "src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts": 1906, + "src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts": 3396, + "src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts": 3402, + "src/renderer/src/hooks/useIpcEvents-agent-status-hook-titles.test.ts": 4162, + "src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts": 2945, + "src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts": 2132, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts": 10394, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts": 2794, + "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts": 3533, + "src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts": 1596, + "src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts": 2422, + "src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts": 1521, + "src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts": 1398, + "src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts": 1748, + "src/renderer/src/hooks/useIpcEvents-client-hosted-browser-rows.test.ts": 2627, + "src/renderer/src/hooks/useIpcEvents-close-routing-active-browser-tab.test.ts": 3432, + "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts": 2641, + "src/renderer/src/hooks/useIpcEvents-close-routing-floating-guest.test.ts": 1892, + "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts": 4606, + "src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts": 2911, + "src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts": 1437, + "src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts": 72, + "src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts": 1595, + "src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts": 2869, + "src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts": 325, + "src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts": 2114, + "src/renderer/src/hooks/useIpcEvents-silent-terminal-adoption.test.ts": 1752, + "src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts": 1524, + "src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts": 1746, + "src/renderer/src/hooks/useIpcEvents-updater-status.test.ts": 1988, + "src/renderer/src/hooks/useIssueMetadata.test.tsx": 262, + "src/renderer/src/hooks/useLinearProviderConnected.test.tsx": 1249, + "src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx": 2312, + "src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx": 1916, + "src/renderer/src/hooks/useMetadataListRequest.test.tsx": 277, + "src/renderer/src/hooks/useModalReturnFocus.test.tsx": 1441, + "src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 199, + "src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx": 282, + "src/renderer/src/hooks/useResetCountdownClock.test.ts": 303, + "src/renderer/src/hooks/useRetiredWorktreeNames.test.ts": 600, + "src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx": 966, + "src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx": 2576, + "src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts": 2176, + "src/renderer/src/hooks/useSidebarResize.test.ts": 59, + "src/renderer/src/hooks/useSkillFreshness.test.tsx": 273, + "src/renderer/src/hooks/useUnreadDockBadge.test.ts": 1844, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts": 88, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts": 144, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.ts": 53, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.tsx": 260, + "src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx": 226, + "src/renderer/src/hooks/viewport-size-change-listener.test.ts": 56, + "src/renderer/src/hooks/worktree-change-refresh-queue.test.ts": 61, + "src/renderer/src/hooks/worktree-head-identity-apply.test.ts": 52, + "src/renderer/src/hooks/zoom-routing.test.ts": 686, + "src/renderer/src/i18n/I18nProvider.test.tsx": 1693, + "src/renderer/src/i18n/intl-locale.test.ts": 148, + "src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts": 105, + "src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts": 101, + "src/renderer/src/i18n/lazy-locale.test.ts": 194, + "src/renderer/src/i18n/locale-english-regression.test.ts": 825, + "src/renderer/src/i18n/native-chat-locales.test.ts": 952, + "src/renderer/src/i18n/plugin-chrome-allowlist.test.ts": 96, + "src/renderer/src/i18n/provider-credit-balance-locales.test.ts": 364, + "src/renderer/src/i18n/pseudo-localization.test.ts": 80, + "src/renderer/src/i18n/relative-duration-copy-localization.test.ts": 693, + "src/renderer/src/i18n/relative-time-format.test.ts": 61, + "src/renderer/src/i18n/runtime-required-catalog.test.ts": 664, + "src/renderer/src/i18n/settings-status-label-localization.test.ts": 288, + "src/renderer/src/i18n/technical-literal-catalog-values.test.ts": 250, + "src/renderer/src/i18n/weekday-names.test.ts": 86, + "src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts": 135, + "src/renderer/src/lazy-modal-mount-state.test.ts": 57, + "src/renderer/src/lib/activate-ai-vault-structured-session-host.test.ts": 1143, + "src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts": 1773, + "src/renderer/src/lib/activate-ai-vault-structured-session.test.ts": 1867, + "src/renderer/src/lib/activate-tab-and-focus-pane.test.ts": 59, + "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts": 966, + "src/renderer/src/lib/active-agent-note-send-focused-session.test.ts": 638, + "src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts": 80, + "src/renderer/src/lib/active-agent-note-send-target-detection.test.ts": 525, + "src/renderer/src/lib/active-view-persist.test.ts": 46, + "src/renderer/src/lib/activity-thread-display.test.ts": 68, + "src/renderer/src/lib/agent-background-session-launch-host.test.ts": 1472, + "src/renderer/src/lib/agent-detection-target-inventory.test.ts": 1465, + "src/renderer/src/lib/agent-draft-readiness.test.ts": 142, + "src/renderer/src/lib/agent-followup-delivery.test.ts": 464, + "src/renderer/src/lib/agent-hibernation-confirmation.test.ts": 48, + "src/renderer/src/lib/agent-hibernation-coordinator.test.ts": 1779, + "src/renderer/src/lib/agent-hibernation-output-activity.test.ts": 48, + "src/renderer/src/lib/agent-hibernation-pane-age.test.ts": 56, + "src/renderer/src/lib/agent-hibernation-planner.test.ts": 207, + "src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts": 1423, + "src/renderer/src/lib/agent-hibernation-visibility.test.ts": 1582, + "src/renderer/src/lib/agent-launch-caller-permissions.test.ts": 1006, + "src/renderer/src/lib/agent-launch-caller-placement.test.ts": 1164, + "src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts": 1248, + "src/renderer/src/lib/agent-launch-caller-routing.test.ts": 1132, + "src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts": 47, + "src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts": 1221, + "src/renderer/src/lib/agent-launch-prompt-delivery.test.ts": 82, + "src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts": 1819, + "src/renderer/src/lib/agent-launch-route-input.test.ts": 1635, + "src/renderer/src/lib/agent-launch-route-registered-agents.test.ts": 1571, + "src/renderer/src/lib/agent-launch-routing.test.ts": 83, + "src/renderer/src/lib/agent-launch-tab-publication.test.ts": 1452, + "src/renderer/src/lib/agent-launch-through-host.test.ts": 1481, + "src/renderer/src/lib/agent-message-send.test.ts": 251, + "src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts": 175, + "src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts": 194, + "src/renderer/src/lib/agent-paste-draft.test.ts": 324, + "src/renderer/src/lib/agent-picker-search.test.ts": 167, + "src/renderer/src/lib/agent-ready-wait.test.ts": 125, + "src/renderer/src/lib/agent-resume-launch-target.test.ts": 3321, + "src/renderer/src/lib/agent-row-primary-text.test.ts": 61, + "src/renderer/src/lib/agent-row-tool-preview.test.ts": 59, + "src/renderer/src/lib/agent-send-title-status.test.ts": 98, + "src/renderer/src/lib/agent-session-fork-context.test.ts": 68, + "src/renderer/src/lib/agent-session-launch-plan.test.ts": 1740, + "src/renderer/src/lib/agent-skill-cli-prerequisite.test.ts": 143, + "src/renderer/src/lib/agent-skill-nav-install-status.test.ts": 1387, + "src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts": 1373, + "src/renderer/src/lib/agent-status-connection-ownership.test.ts": 127, + "src/renderer/src/lib/agent-status-count.test.ts": 101, + "src/renderer/src/lib/agent-status-epoch-clock.test.ts": 52, + "src/renderer/src/lib/agent-status-evidence-clock.test.ts": 89, + "src/renderer/src/lib/agent-status-terminal-title.test.ts": 117, + "src/renderer/src/lib/agent-status-worktree-attribution.test.ts": 57, + "src/renderer/src/lib/agent-status.test.ts": 489, + "src/renderer/src/lib/agent-tab-shortcuts.test.ts": 78, + "src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts": 450, + "src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts": 1762, + "src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts": 447, + "src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts": 459, + "src/renderer/src/lib/ai-vault-resume-command.test.ts": 412, + "src/renderer/src/lib/ai-vault-resume-shell.test.ts": 466, + "src/renderer/src/lib/ai-vault-resume-target.test.ts": 110, + "src/renderer/src/lib/ai-vault-session-drag.test.ts": 67, + "src/renderer/src/lib/ai-vault-session-fork-startup.test.ts": 413, + "src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts": 64, + "src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts": 423, + "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts": 957, + "src/renderer/src/lib/app-command-dispatch.test.ts": 79, + "src/renderer/src/lib/app-font-family.test.ts": 270, + "src/renderer/src/lib/app-menu-paste.test.ts": 209, + "src/renderer/src/lib/app-menu-selection-actions.test.ts": 122, + "src/renderer/src/lib/automation-session-observer.test.ts": 135, + "src/renderer/src/lib/automation-session-reuse.test.ts": 91, + "src/renderer/src/lib/automation-terminal-ownership.test.ts": 1337, + "src/renderer/src/lib/bounded-generation-map.test.ts": 46, + "src/renderer/src/lib/browser-cookie-import-toast.test.ts": 102, + "src/renderer/src/lib/browser-history-match.performance.test.ts": 344, + "src/renderer/src/lib/browser-history-match.test.ts": 334, + "src/renderer/src/lib/browser-page-conversion-history.test.ts": 59, + "src/renderer/src/lib/browser-page-palette-activation.test.ts": 1521, + "src/renderer/src/lib/browser-palette-page-entries.test.ts": 417, + "src/renderer/src/lib/browser-palette-search.test.ts": 322, + "src/renderer/src/lib/browser-uuid.test.ts": 54, + "src/renderer/src/lib/browser-workspace-source-resolution.test.ts": 48, + "src/renderer/src/lib/browser-workspace-tab-activation.test.ts": 1497, + "src/renderer/src/lib/client-creation-action-policy.test.ts": 298, + "src/renderer/src/lib/cmd-j-github-url-lookup.test.ts": 54, + "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts": 422, + "src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts": 55, + "src/renderer/src/lib/cmd-j-section-leadership.test.ts": 77, + "src/renderer/src/lib/codex-account-display-label.test.ts": 55, + "src/renderer/src/lib/codex-account-restart-startup.test.ts": 1537, + "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 81, + "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 379, + "src/renderer/src/lib/codex-session-restart-account-recheck.test.ts": 1182, + "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 1412, + "src/renderer/src/lib/codex-session-restart.test.ts": 3754, + "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 1583, + "src/renderer/src/lib/codex-stale-pane-sweep.test.ts": 1535, + "src/renderer/src/lib/comment-body-line-count.test.ts": 115, + "src/renderer/src/lib/comment-body-submit-state.test.ts": 85, + "src/renderer/src/lib/composer-issue-command.test.ts": 1472, + "src/renderer/src/lib/composer-submit-cancellation.test.ts": 54, + "src/renderer/src/lib/connection-context.test.ts": 1611, + "src/renderer/src/lib/crash-diagnostics.test.ts": 136, + "src/renderer/src/lib/create-untitled-markdown.test.ts": 444, + "src/renderer/src/lib/default-creation-host.test.ts": 48, + "src/renderer/src/lib/default-terminal-tabs-applied-persist.test.ts": 1617, + "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, + "src/renderer/src/lib/editable-target.test.ts": 175, + "src/renderer/src/lib/editor-file-operation-owner.test.ts": 1442, + "src/renderer/src/lib/editor-font-zoom.test.ts": 77, + "src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts": 1422, + "src/renderer/src/lib/ensure-hooks-confirmed.test.ts": 589, + "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts": 652, + "src/renderer/src/lib/ensure-simulator-tab.test.ts": 47, + "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts": 49, + "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts": 82, + "src/renderer/src/lib/ephemeral-vm-workspace-target.integration.test.ts": 297, + "src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts": 317, + "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts": 102, + "src/renderer/src/lib/execute-open-editor-path-move.test.ts": 1738, + "src/renderer/src/lib/external-editor-open-capability.test.ts": 53, + "src/renderer/src/lib/feature-education-telemetry.test.ts": 63, + "src/renderer/src/lib/feedback-image-attachments.test.ts": 314, + "src/renderer/src/lib/feedback-image-shrink.test.ts": 53, + "src/renderer/src/lib/file-owner-runtime-target.test.ts": 284, + "src/renderer/src/lib/file-preview-action-visibility.test.tsx": 826, + "src/renderer/src/lib/file-preview.test.ts": 588, + "src/renderer/src/lib/file-search-result-owner.test.ts": 56, + "src/renderer/src/lib/file-search-selection.test.ts": 52, + "src/renderer/src/lib/file-search-shortcut-policy.test.ts": 181, + "src/renderer/src/lib/file-type-icons.test.ts": 108, + "src/renderer/src/lib/find-query-bounds.test.ts": 49, + "src/renderer/src/lib/finished-agent-resume-resurrection.test.ts": 1532, + "src/renderer/src/lib/fix-checks-agent-launch.test.ts": 500, + "src/renderer/src/lib/flatten-retained-slice.test.ts": 177, + "src/renderer/src/lib/floating-terminal.test.ts": 41, + "src/renderer/src/lib/floating-workspace-panel-reveal.test.ts": 291, + "src/renderer/src/lib/floating-workspace-terminal-actions.test.ts": 449, + "src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts": 68, + "src/renderer/src/lib/focus-terminal-tab-surface.split-focus.test.ts": 131, + "src/renderer/src/lib/focus-terminal-tab-surface.test.ts": 71, + "src/renderer/src/lib/folder-workspace-path-status.test.ts": 108, + "src/renderer/src/lib/foreground-terminal-tabs.test.ts": 60, "src/renderer/src/lib/github-links.test.ts": 69, - "src/renderer/src/lib/github-pr-start-point.test.ts": 78, - "src/renderer/src/lib/github-source-runtime-context.test.ts": 63, - "src/renderer/src/lib/github-work-item-source-lookup.test.ts": 97, - "src/renderer/src/lib/github-work-item-workspace-attachment.test.ts": 31, - "src/renderer/src/lib/gitlab-links.test.ts": 71, - "src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts": 84, - "src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts": 1720, - "src/renderer/src/lib/hook-command-delayed-delivery.test.ts": 1168, - "src/renderer/src/lib/host-mirror-handle-gap-drain.test.ts": 1659, - "src/renderer/src/lib/host-mirror-handle-gap-landed-handle.test.ts": 1250, - "src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts": 4216, - "src/renderer/src/lib/host-mirror-handle-gap-subscription-lifetime.test.ts": 1642, - "src/renderer/src/lib/host-mirror-handle-gap-teardown.test.ts": 1351, - "src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts": 1927, - "src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 1687, - "src/renderer/src/lib/http-link-destinations.test.ts": 207, - "src/renderer/src/lib/http-link-modifier-routing.test.ts": 196, - "src/renderer/src/lib/http-link-routing.test.ts": 206, - "src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts": 57, - "src/renderer/src/lib/ime-composition-keyboard-event.test.ts": 477, - "src/renderer/src/lib/ios-web-platform.test.ts": 45, - "src/renderer/src/lib/ipc-error.test.ts": 50, - "src/renderer/src/lib/jira-source-host.test.ts": 30, - "src/renderer/src/lib/keyboard-layout/detect-option-as-alt.test.ts": 62, - "src/renderer/src/lib/keyboard-layout/input-source-id.test.ts": 50, - "src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts": 36, - "src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts": 87, + "src/renderer/src/lib/github-pr-start-point.test.ts": 91, + "src/renderer/src/lib/github-source-runtime-context.test.ts": 55, + "src/renderer/src/lib/github-work-item-source-lookup.test.ts": 93, + "src/renderer/src/lib/github-work-item-workspace-attachment.test.ts": 98, + "src/renderer/src/lib/gitlab-links.test.ts": 64, + "src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts": 139, + "src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts": 1322, + "src/renderer/src/lib/hook-command-delayed-delivery.test.ts": 1644, + "src/renderer/src/lib/host-mirror-handle-gap-drain.test.ts": 1339, + "src/renderer/src/lib/host-mirror-handle-gap-landed-handle.test.ts": 1317, + "src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts": 1612, + "src/renderer/src/lib/host-mirror-handle-gap-snapshot-lifetime.test.ts": 2086, + "src/renderer/src/lib/host-mirror-handle-gap-subscription-lifetime.test.ts": 1885, + "src/renderer/src/lib/host-mirror-handle-gap-teardown.test.ts": 1675, + "src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts": 1490, + "src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 1451, + "src/renderer/src/lib/http-link-destinations.test.ts": 308, + "src/renderer/src/lib/http-link-modifier-routing.test.ts": 307, + "src/renderer/src/lib/http-link-routing.test.ts": 326, + "src/renderer/src/lib/image-blob-png.test.ts": 234, + "src/renderer/src/lib/ime-composition-keyboard-event.test.ts": 299, + "src/renderer/src/lib/ime-overlay-escape.test.tsx": 647, + "src/renderer/src/lib/ime-text-field.test.tsx": 599, + "src/renderer/src/lib/ios-web-platform.test.ts": 53, + "src/renderer/src/lib/ipc-error.test.ts": 53, + "src/renderer/src/lib/jira-source-host.test.ts": 52, + "src/renderer/src/lib/keyboard-layout/detect-option-as-alt.test.ts": 60, + "src/renderer/src/lib/keyboard-layout/input-source-id.test.ts": 52, + "src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts": 52, + "src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts": 98, "src/renderer/src/lib/keyboard-layout/option-key-location-state.test.ts": 44, - "src/renderer/src/lib/language-detect.test.ts": 84, - "src/renderer/src/lib/large-text-control-paste.test.ts": 482, - "src/renderer/src/lib/launch-agent-background-session-remote.test.ts": 522, - "src/renderer/src/lib/launch-agent-background-session.test.ts": 367, - "src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts": 596, - "src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts": 777, - "src/renderer/src/lib/launch-agent-in-new-tab-main-surface.test.ts": 1850, - "src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts": 568, - "src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts": 1042, - "src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts": 477, - "src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts": 719, - "src/renderer/src/lib/launch-agent-in-new-tab.test.ts": 1384, - "src/renderer/src/lib/launch-agent-session-continuation.test.ts": 93, - "src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts": 1070, - "src/renderer/src/lib/launch-ai-vault-session.test.ts": 77, - "src/renderer/src/lib/launch-structured-agent-session.test.ts": 1925, - "src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts": 1859, - "src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 102, - "src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 67, - "src/renderer/src/lib/launch-work-item-direct.test.ts": 760, - "src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 529, - "src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 327, - "src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 487, - "src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts": 400, - "src/renderer/src/lib/lazy-with-retry.test.ts": 342, - "src/renderer/src/lib/left-sidebar-appearance.test.ts": 135, - "src/renderer/src/lib/linear-agent-skill-update-command.test.ts": 949, - "src/renderer/src/lib/linear-board-drag-payload.test.ts": 64, + "src/renderer/src/lib/language-detect.test.ts": 97, + "src/renderer/src/lib/large-text-control-paste.test.ts": 178, + "src/renderer/src/lib/launch-agent-background-session-extra-args.test.ts": 465, + "src/renderer/src/lib/launch-agent-background-session-remote.test.ts": 684, + "src/renderer/src/lib/launch-agent-background-session.test.ts": 556, + "src/renderer/src/lib/launch-agent-execution-context.test.ts": 1461, + "src/renderer/src/lib/launch-agent-fresh-new-tab-host-route.test.ts": 154, + "src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts": 1001, + "src/renderer/src/lib/launch-agent-in-new-tab-fresh-host.test.ts": 1688, + "src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts": 591, + "src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts": 965, + "src/renderer/src/lib/launch-agent-in-new-tab-main-surface.test.ts": 1838, + "src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts": 993, + "src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts": 1042, + "src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts": 1018, + "src/renderer/src/lib/launch-agent-in-new-tab.test.ts": 1018, + "src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts": 337, + "src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts": 1582, + "src/renderer/src/lib/launch-agent-session-continuation.test.ts": 71, + "src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts": 1111, + "src/renderer/src/lib/launch-ai-vault-session.test.ts": 61, + "src/renderer/src/lib/launch-structured-agent-session.test.ts": 2251, + "src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts": 1691, + "src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 163, + "src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 48, + "src/renderer/src/lib/launch-work-item-direct.test.ts": 1033, + "src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 848, + "src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 200, + "src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 172, + "src/renderer/src/lib/lazy-with-retry.test.ts": 232, + "src/renderer/src/lib/left-sidebar-appearance.test.ts": 314, + "src/renderer/src/lib/linear-agent-skill-update-command.test.ts": 1484, + "src/renderer/src/lib/linear-board-drag-payload.test.ts": 63, "src/renderer/src/lib/linear-issue-context-snapshot.test.ts": 95, - "src/renderer/src/lib/linear-issue-url-lookup.test.ts": 54, - "src/renderer/src/lib/linear-issue-workspace-attachment.test.ts": 34, - "src/renderer/src/lib/linear-issue-workspace-open.test.ts": 73, - "src/renderer/src/lib/linear-linked-work-item.test.ts": 58, - "src/renderer/src/lib/linked-work-item-context.test.ts": 1261, - "src/renderer/src/lib/linked-work-item-provider.test.ts": 1731, - "src/renderer/src/lib/list-row-interaction.test.ts": 249, - "src/renderer/src/lib/local-path-open-guard.test.ts": 96, - "src/renderer/src/lib/local-preflight-context-cache.test.ts": 204, - "src/renderer/src/lib/local-preflight-context.test.ts": 270, - "src/renderer/src/lib/locale-text-collators.test.ts": 108, - "src/renderer/src/lib/manual-terminal-worktree-parking.test.ts": 510, - "src/renderer/src/lib/markdown-comment-blocks.test.ts": 34, - "src/renderer/src/lib/markdown-document-templates.test.ts": 227, - "src/renderer/src/lib/markdown-review-note-copy.test.ts": 45, - "src/renderer/src/lib/markdown-review-notes.test.ts": 47, - "src/renderer/src/lib/migration-unsupported-agent-entry.test.ts": 44, - "src/renderer/src/lib/mobile-terminal-tab-mount.test.ts": 41, - "src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts": 182, - "src/renderer/src/lib/monaco-diff-editor-disposal.test.ts": 50, - "src/renderer/src/lib/monaco-languages/monarch-embed-entry-recursion.test.ts": 750, - "src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts": 5097, - "src/renderer/src/lib/monaco-languages/register-astro.test.ts": 129, - "src/renderer/src/lib/monaco-languages/register-jsonl.test.ts": 248, - "src/renderer/src/lib/monaco-languages/register-nim.test.ts": 78, - "src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.test.ts": 42, - "src/renderer/src/lib/monaco-languages/register-svelte.test.ts": 285, - "src/renderer/src/lib/monaco-languages/register-vue.test.ts": 442, - "src/renderer/src/lib/monaco-languages/textmate-language-registration.test.ts": 64, - "src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts": 232, - "src/renderer/src/lib/monaco-peek-preview-options.test.ts": 988, - "src/renderer/src/lib/monaco-setup-steps.test.ts": 28, - "src/renderer/src/lib/monospace-font-family.test.ts": 53, - "src/renderer/src/lib/mouse-shortcut-dispatch.test.ts": 366, - "src/renderer/src/lib/native-chat-initial-view-mode.test.ts": 51, - "src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts": 226, - "src/renderer/src/lib/native-chat-transcript-readability.test.ts": 49, - "src/renderer/src/lib/nested-repo-selected-paths.test.ts": 45, - "src/renderer/src/lib/new-workspace-composer-repo.test.ts": 38, - "src/renderer/src/lib/new-workspace-create-gates.test.ts": 43, - "src/renderer/src/lib/new-workspace-enter-guard.test.ts": 52, - "src/renderer/src/lib/new-workspace-project-options.test.ts": 72, - "src/renderer/src/lib/new-workspace-ssh-gate.test.ts": 72, - "src/renderer/src/lib/new-workspace.test.ts": 4497, - "src/renderer/src/lib/non-secure-context-crypto.repro.test.ts": 1038, - "src/renderer/src/lib/notes-send-agent-targets.test.ts": 155, - "src/renderer/src/lib/onboarding-project-checklist.test.ts": 114, - "src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts": 138, - "src/renderer/src/lib/open-mobile-emulator-tab.test.ts": 297, - "src/renderer/src/lib/open-tab-occupant-agent.test.ts": 102, - "src/renderer/src/lib/orca-hook-trust.test.ts": 59, - "src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts": 597, - "src/renderer/src/lib/orchestration-setup-state.test.ts": 61, - "src/renderer/src/lib/orchestration-skill-coverage.test.ts": 80, - "src/renderer/src/lib/order-empty-query-worktrees.test.ts": 38, - "src/renderer/src/lib/outside-dismiss-guard.test.ts": 61, - "src/renderer/src/lib/palette-match/cmd-j-ranking-contract.test.ts": 114, - "src/renderer/src/lib/palette-match/match-field-allocation.test.ts": 127, - "src/renderer/src/lib/palette-match/palette-match-core.test.ts": 115, - "src/renderer/src/lib/palette-match/palette-match-performance.test.ts": 10210, - "src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts": 65, - "src/renderer/src/lib/palette-match/palette-ranking.test.ts": 39, - "src/renderer/src/lib/palette-repo-resolution.test.ts": 40, - "src/renderer/src/lib/palette-type-alias-match.test.ts": 29, - "src/renderer/src/lib/pane-agent-evidence.test.ts": 74, - "src/renderer/src/lib/pane-manager/browser-mobile-driver-state.test.ts": 64, - "src/renderer/src/lib/pane-manager/browser-remote-viewer-state.test.ts": 41, - "src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts": 1735, - "src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.test.ts": 69, - "src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts": 40, - "src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts": 56, - "src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts": 56, - "src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts": 62, - "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts": 230, - "src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts": 1047, - "src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts": 45, - "src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts": 60, - "src/renderer/src/lib/pane-manager/pane-divider.test.ts": 74, - "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts": 485, - "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts": 81, - "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts": 237, - "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts": 79, - "src/renderer/src/lib/pane-manager/pane-fit.test.ts": 145, - "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts": 245, - "src/renderer/src/lib/pane-manager/pane-inline-image-recovery.test.ts": 67, - "src/renderer/src/lib/pane-manager/pane-inline-images.test.ts": 102, - "src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts": 42, - "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts": 435, - "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts": 41, - "src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts": 60, - "src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts": 722, - "src/renderer/src/lib/pane-manager/pane-pointer-focus.test.ts": 45, - "src/renderer/src/lib/pane-manager/pane-pty-resize-hold.test.ts": 53, - "src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts": 64, - "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts": 394, - "src/renderer/src/lib/pane-manager/pane-scroll.test.ts": 118, - "src/renderer/src/lib/pane-manager/pane-split-close.test.ts": 65, - "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts": 62, - "src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts": 57, - "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts": 107, - "src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts": 65, - "src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.test.ts": 47, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-ack-credit.test.ts": 95, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-backlog-cap.test.ts": 118, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-disposed-writes.test.ts": 79, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-foreground-refresh.test.ts": 160, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts": 266, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-synchronized-frames.test.ts": 178, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts": 312, - "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts": 181, - "src/renderer/src/lib/pane-manager/pane-tree-equalization-parity.test.ts": 1669, - "src/renderer/src/lib/pane-manager/pane-tree-equalization-scaling.test.ts": 411, - "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts": 140, - "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts": 53, - "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts": 173, - "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts": 382, - "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts": 423, - "src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts": 48, - "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts": 62, - "src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts": 52, - "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts": 40, - "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts": 187, - "src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts": 39, - "src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts": 487, - "src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts": 62, - "src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts": 66, - "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts": 38, - "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts": 53, - "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-write.test.ts": 41, - "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts": 58, - "src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts": 30, - "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts": 77, - "src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts": 78, - "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts": 125, - "src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts": 51, - "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts": 117, - "src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts": 62, - "src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.test.ts": 61, - "src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts": 321, - "src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts": 358, - "src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts": 71, - "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts": 87, - "src/renderer/src/lib/pane-manager/xterm-instance-disposed.test.ts": 131, - "src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts": 4939, + "src/renderer/src/lib/linear-issue-url-lookup.test.ts": 100, + "src/renderer/src/lib/linear-issue-workspace-attachment.test.ts": 88, + "src/renderer/src/lib/linear-issue-workspace-open.test.ts": 144, + "src/renderer/src/lib/linear-linked-work-item.test.ts": 81, + "src/renderer/src/lib/link-source-local-open-owner.test.ts": 295, + "src/renderer/src/lib/linked-work-item-context.test.ts": 67, + "src/renderer/src/lib/linked-work-item-provider.test.ts": 1457, + "src/renderer/src/lib/list-multi-selection.test.ts": 49, + "src/renderer/src/lib/list-row-interaction.test.ts": 141, + "src/renderer/src/lib/local-file-access.test.ts": 1545, + "src/renderer/src/lib/local-path-open-guard.test.ts": 87, + "src/renderer/src/lib/local-preflight-context-cache.test.ts": 397, + "src/renderer/src/lib/local-preflight-context.test.ts": 327, + "src/renderer/src/lib/locale-text-collators.test.ts": 48, + "src/renderer/src/lib/manual-terminal-worktree-parking.test.ts": 182, + "src/renderer/src/lib/markdown-comment-blocks.test.ts": 50, + "src/renderer/src/lib/markdown-document-templates.test.ts": 438, + "src/renderer/src/lib/markdown-review-note-copy.test.ts": 60, + "src/renderer/src/lib/markdown-review-notes-setting.test.ts": 47, + "src/renderer/src/lib/markdown-review-notes.test.ts": 81, + "src/renderer/src/lib/migration-unsupported-agent-entry.test.ts": 45, + "src/renderer/src/lib/mobile-terminal-tab-mount.test.ts": 55, + "src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts": 116, + "src/renderer/src/lib/monaco-diff-editor-disposal.test.ts": 49, + "src/renderer/src/lib/monaco-filename-language.test.ts": 68, + "src/renderer/src/lib/monaco-languages/monarch-embed-entry-recursion.test.ts": 530, + "src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts": 2807, + "src/renderer/src/lib/monaco-languages/register-astro.test.ts": 152, + "src/renderer/src/lib/monaco-languages/register-jsonl.test.ts": 345, + "src/renderer/src/lib/monaco-languages/register-nim.test.ts": 69, + "src/renderer/src/lib/monaco-languages/register-quarto-adversarial.test.ts": 171, + "src/renderer/src/lib/monaco-languages/register-quarto.test.ts": 153, + "src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.test.ts": 50, + "src/renderer/src/lib/monaco-languages/register-svelte.test.ts": 169, + "src/renderer/src/lib/monaco-languages/register-typst.test.ts": 64, + "src/renderer/src/lib/monaco-languages/register-vue.test.ts": 325, + "src/renderer/src/lib/monaco-languages/textmate-language-registration.test.ts": 60, + "src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts": 287, + "src/renderer/src/lib/monaco-peek-preview-options.test.ts": 911, + "src/renderer/src/lib/monaco-setup-steps.test.ts": 58, + "src/renderer/src/lib/monospace-font-family.test.ts": 54, + "src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts": 298, + "src/renderer/src/lib/native-chat-transcript-readability.test.ts": 50, + "src/renderer/src/lib/native-file-drop-rejection-message.test.ts": 89, + "src/renderer/src/lib/nested-repo-selected-paths.test.ts": 50, + "src/renderer/src/lib/new-agent-prompt-outcome.test.ts": 641, + "src/renderer/src/lib/new-workspace-composer-repo.test.ts": 64, + "src/renderer/src/lib/new-workspace-create-gates.test.ts": 54, + "src/renderer/src/lib/new-workspace-enter-guard.test.ts": 53, + "src/renderer/src/lib/new-workspace-project-options.test.ts": 74, + "src/renderer/src/lib/new-workspace-ssh-gate.test.ts": 52, + "src/renderer/src/lib/new-workspace.test.ts": 4504, + "src/renderer/src/lib/non-secure-context-crypto.repro.test.ts": 1464, + "src/renderer/src/lib/notes-send-agent-targets.test.ts": 369, + "src/renderer/src/lib/onboarding-project-checklist.test.ts": 54, + "src/renderer/src/lib/open-agent-message-sender.test.ts": 437, + "src/renderer/src/lib/open-document-in-floating-workspace.test.ts": 272, + "src/renderer/src/lib/open-mobile-emulator-tab.test.ts": 371, + "src/renderer/src/lib/open-tab-occupant-agent.test.ts": 123, + "src/renderer/src/lib/orca-hook-trust.test.ts": 53, + "src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts": 796, + "src/renderer/src/lib/orchestration-setup-state.test.ts": 51, + "src/renderer/src/lib/orchestration-skill-coverage.test.ts": 142, + "src/renderer/src/lib/order-empty-query-worktrees.test.ts": 62, + "src/renderer/src/lib/os-file-drop-cancellation-guard.test.ts": 173, + "src/renderer/src/lib/os-file-drop-preload-integration.test.tsx": 489, + "src/renderer/src/lib/outside-dismiss-guard.test.ts": 50, + "src/renderer/src/lib/paired-web-client-host.test.ts": 405, + "src/renderer/src/lib/palette-match/cmd-j-ranking-contract.test.ts": 83, + "src/renderer/src/lib/palette-match/match-field-allocation.test.ts": 154, + "src/renderer/src/lib/palette-match/palette-match-core.test.ts": 121, + "src/renderer/src/lib/palette-match/palette-match-performance.test.ts": 6685, + "src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts": 80, + "src/renderer/src/lib/palette-match/palette-ranking.test.ts": 63, + "src/renderer/src/lib/palette-repo-resolution.test.ts": 59, + "src/renderer/src/lib/palette-type-alias-match.test.ts": 51, + "src/renderer/src/lib/pane-agent-evidence.test.ts": 92, + "src/renderer/src/lib/pane-manager/browser-mobile-driver-state.test.ts": 61, + "src/renderer/src/lib/pane-manager/browser-remote-viewer-state.test.ts": 43, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts": 1413, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.test.ts": 63, + "src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts": 49, + "src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts": 43, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts": 54, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts": 66, + "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts": 607, + "src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts": 478, + "src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts": 59, + "src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts": 58, + "src/renderer/src/lib/pane-manager/pane-divider.test.ts": 78, + "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts": 274, + "src/renderer/src/lib/pane-manager/pane-dom-focus-class-sync-lifecycle.test.ts": 482, + "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts": 118, + "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts": 235, + "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts": 105, + "src/renderer/src/lib/pane-manager/pane-fit.test.ts": 139, + "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts": 197, + "src/renderer/src/lib/pane-manager/pane-inline-image-recovery.test.ts": 76, + "src/renderer/src/lib/pane-manager/pane-inline-images.test.ts": 97, + "src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts": 50, + "src/renderer/src/lib/pane-manager/pane-layout-geometry-apply.test.ts": 384, + "src/renderer/src/lib/pane-manager/pane-layout-gesture.test.ts": 228, + "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts": 243, + "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts": 70, + "src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts": 458, + "src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts": 63, + "src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts": 387, + "src/renderer/src/lib/pane-manager/pane-pointer-focus.test.ts": 52, + "src/renderer/src/lib/pane-manager/pane-pty-resize-hold.test.ts": 47, + "src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts": 62, + "src/renderer/src/lib/pane-manager/pane-reveal-lifetime.test.ts": 1002, + "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts": 350, + "src/renderer/src/lib/pane-manager/pane-scroll.test.ts": 179, + "src/renderer/src/lib/pane-manager/pane-split-close.test.ts": 67, + "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts": 85, + "src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts": 54, + "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts": 108, + "src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts": 82, + "src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.test.ts": 52, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-ack-credit.test.ts": 167, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-backlog-cap.test.ts": 160, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-disposed-writes.test.ts": 102, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-foreground-refresh.test.ts": 158, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts": 279, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-synchronized-frames.test.ts": 190, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts": 324, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts": 208, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-parity.test.ts": 676, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-scaling.test.ts": 200, + "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts": 148, + "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts": 80, + "src/renderer/src/lib/pane-manager/pane-viewport-present-lifetime.test.ts": 779, + "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts": 124, + "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts": 159, + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts": 356, + "src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts": 73, + "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts": 53, + "src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts": 75, + "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts": 43, + "src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts": 283, + "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts": 217, + "src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts": 47, + "src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts": 206, + "src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts": 63, + "src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts": 83, + "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts": 63, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts": 56, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-write.test.ts": 56, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts": 43, + "src/renderer/src/lib/pane-manager/terminal-mouse-encoding-tracker.test.ts": 126, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts": 60, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts": 59, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts": 71, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts": 106, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts": 56, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts": 93, + "src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts": 76, + "src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.test.ts": 55, + "src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts": 365, + "src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts": 196, + "src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts": 73, + "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts": 76, + "src/renderer/src/lib/pane-manager/xterm-instance-disposed.test.ts": 86, + "src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts": 2444, "src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts": 72, - "src/renderer/src/lib/parked-terminal-host-hydration.test.ts": 187, - "src/renderer/src/lib/passive-macos-app-data-access.test.ts": 26, - "src/renderer/src/lib/paste-payload-metadata.test.ts": 51, - "src/renderer/src/lib/path-head-elision.test.ts": 45, - "src/renderer/src/lib/path.test.ts": 42, - "src/renderer/src/lib/pending-worktree-creation.test.ts": 46, - "src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts": 1695, - "src/renderer/src/lib/pi-session-resume-wake.test.ts": 1680, - "src/renderer/src/lib/plugin-command-execution.test.ts": 480, - "src/renderer/src/lib/plugin-command-keybindings.test.ts": 100, - "src/renderer/src/lib/pr-bot-author-overrides.test.ts": 298, - "src/renderer/src/lib/pr-comment-action-state.test.ts": 46, - "src/renderer/src/lib/pr-comment-reactions.test.ts": 30, - "src/renderer/src/lib/primary-selection-paste.test.ts": 551, - "src/renderer/src/lib/primary-selection.test.ts": 67, - "src/renderer/src/lib/project-clone-url-prefill.test.ts": 63, - "src/renderer/src/lib/project-host-clone-url.test.ts": 39, - "src/renderer/src/lib/project-host-setup-options.test.ts": 182, - "src/renderer/src/lib/project-host-workspace-target.test.ts": 105, - "src/renderer/src/lib/project-skill-runtime.test.ts": 87, - "src/renderer/src/lib/provisioned-root-create-options.test.ts": 42, - "src/renderer/src/lib/quick-workspace-agent-selection.test.ts": 143, - "src/renderer/src/lib/react-commit-cascade-install-order.test.ts": 31, - "src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx": 465, - "src/renderer/src/lib/react-commit-cascade-observer.test.ts": 187, - "src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts": 55, - "src/renderer/src/lib/react-commit-cascade-telemetry.test.ts": 223, - "src/renderer/src/lib/react-error-boundary-reporting.test.ts": 43, - "src/renderer/src/lib/react-grab-dev-gate.test.ts": 39, - "src/renderer/src/lib/react-renderer-root.test.ts": 40, - "src/renderer/src/lib/recent-workspace-tab-rows.test.ts": 81, - "src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts": 1606, - "src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 136, - "src/renderer/src/lib/renderer-app-platform.test.ts": 338, - "src/renderer/src/lib/renderer-memory-profile.test.ts": 40, - "src/renderer/src/lib/repo-display-labels.test.ts": 55, - "src/renderer/src/lib/repo-runtime-owner.test.ts": 77, - "src/renderer/src/lib/repo-search.test.ts": 57, - "src/renderer/src/lib/repo-slug-cache.test.ts": 62, - "src/renderer/src/lib/repo-slug-index.test.ts": 2511, - "src/renderer/src/lib/resolved-worktree-execution-host.test.ts": 127, - "src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts": 1502, - "src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts": 1425, - "src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts": 1617, - "src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts": 1603, - "src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts": 1351, - "src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts": 1692, - "src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts": 1627, - "src/renderer/src/lib/resume-sleeping-agent-session.test.ts": 5799, - "src/renderer/src/lib/resume-stale-structured-agent-session.test.ts": 1670, - "src/renderer/src/lib/right-sidebar-visibility.test.ts": 30, - "src/renderer/src/lib/run-quick-command-in-new-tab.test.ts": 106, - "src/renderer/src/lib/running-agent-targets.test.ts": 61, - "src/renderer/src/lib/runtime-host-unverifiable-probe-readers.test.ts": 317, - "src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts": 67, - "src/renderer/src/lib/runtime-session-mirror-targets.test.ts": 67, - "src/renderer/src/lib/runtime-session-mirror-unverifiable-host.test.ts": 64, - "src/renderer/src/lib/runtime-workspace-file-route.test.ts": 140, - "src/renderer/src/lib/screen-submit-shortcut.test.ts": 50, - "src/renderer/src/lib/script-textarea-rows.test.ts": 123, - "src/renderer/src/lib/scroll-cache.test.ts": 51, - "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts": 1709, - "src/renderer/src/lib/session-write-subscriber-allocation.test.ts": 215, - "src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts": 1599, - "src/renderer/src/lib/session-write-subscriber.test.ts": 1398, - "src/renderer/src/lib/settled-worker-wake-policy.test.ts": 1859, - "src/renderer/src/lib/setup-runner.test.ts": 39, - "src/renderer/src/lib/setup-script-prompt.test.ts": 215, - "src/renderer/src/lib/sha256.test.ts": 46, - "src/renderer/src/lib/shutdown-checkpoint-guard.test.ts": 523, - "src/renderer/src/lib/sidebar-worktree-activation.test.ts": 91, - "src/renderer/src/lib/simulator-launch-coordination.test.ts": 173, - "src/renderer/src/lib/simulator-palette-search.test.ts": 101, - "src/renderer/src/lib/simulator-pane-shutdown-scheduler.test.ts": 1567, - "src/renderer/src/lib/simulator-tab-palette-activation.test.ts": 2039, - "src/renderer/src/lib/skill-freshness-display-status.test.ts": 36, - "src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts": 283, - "src/renderer/src/lib/smart-github-submit.test.ts": 80, - "src/renderer/src/lib/source-control-agent-action-plan.test.ts": 954, - "src/renderer/src/lib/source-control-generation-plan.test.ts": 130, - "src/renderer/src/lib/source-control-launch-agent-selection.test.ts": 180, - "src/renderer/src/lib/source-control-launch-platform.test.ts": 73, - "src/renderer/src/lib/source-control-remote-error.test.ts": 50, - "src/renderer/src/lib/sparse-preset-draft.test.ts": 84, - "src/renderer/src/lib/ssh-background-startup-delivery.test.ts": 79, - "src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts": 1878, - "src/renderer/src/lib/ssh-mutation-expectation.test.ts": 46, - "src/renderer/src/lib/startup-ui-hydration.test.ts": 152, - "src/renderer/src/lib/state-collection-byte-estimate.test.ts": 84, - "src/renderer/src/lib/structured-agent-launch-no-terminal-fallback.test.ts": 272, - "src/renderer/src/lib/structured-agent-launch-settlement.test.ts": 81, - "src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts": 2166, - "src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts": 948, - "src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts": 378, - "src/renderer/src/lib/structured-agent-session-launch-options.test.ts": 660, - "src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts": 449, - "src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts": 357, - "src/renderer/src/lib/structured-agent-session-launch-reload.test.ts": 576, - "src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts": 499, - "src/renderer/src/lib/structured-agent-session-launch.test.ts": 1259, - "src/renderer/src/lib/structured-agent-session-tab-activation.test.ts": 50, - "src/renderer/src/lib/structured-agent-startup-label-census.test.ts": 222, - "src/renderer/src/lib/tab-agent-identity-decision-table.test.ts": 420, - "src/renderer/src/lib/tab-agent-status-index.test.ts": 308, - "src/renderer/src/lib/tab-agent.test.ts": 74, - "src/renderer/src/lib/tab-has-live-pty.test.ts": 48, - "src/renderer/src/lib/tab-number-shortcuts.test.ts": 1493, - "src/renderer/src/lib/terminal-contrast-correction.test.ts": 44, - "src/renderer/src/lib/terminal-file-uri-link.test.ts": 33, - "src/renderer/src/lib/terminal-input-activity-coalescing.test.ts": 61, - "src/renderer/src/lib/terminal-links-redos.test.ts": 69, - "src/renderer/src/lib/terminal-links.test.ts": 274, - "src/renderer/src/lib/terminal-pane-title-sanitization.test.ts": 51, - "src/renderer/src/lib/terminal-quick-command-project-scope.test.ts": 76, - "src/renderer/src/lib/terminal-quick-command-score-budget.test.ts": 61, - "src/renderer/src/lib/terminal-quick-command-search.test.ts": 73, - "src/renderer/src/lib/terminal-reveal-identity.test.ts": 41, - "src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx": 1568, - "src/renderer/src/lib/terminal-tab-for-pty-id.test.ts": 31, - "src/renderer/src/lib/terminal-theme.test.ts": 80, - "src/renderer/src/lib/terminal-worktree-route.test.ts": 210, - "src/renderer/src/lib/text-control-paste-ownership.test.ts": 475, - "src/renderer/src/lib/text-control-paste.test.ts": 490, - "src/renderer/src/lib/titlebar-left-chrome.test.ts": 27, - "src/renderer/src/lib/titlebar-worktree-history-controls.test.ts": 50, - "src/renderer/src/lib/tui-agent-startup.test.ts": 103, - "src/renderer/src/lib/typing-latency/diagnostic-lifecycle.test.ts": 562, + "src/renderer/src/lib/parked-terminal-host-hydration.test.ts": 323, + "src/renderer/src/lib/passive-macos-app-data-access.test.ts": 49, + "src/renderer/src/lib/paste-payload-metadata.test.ts": 60, + "src/renderer/src/lib/path-head-elision.test.ts": 56, + "src/renderer/src/lib/path.test.ts": 59, + "src/renderer/src/lib/pending-worktree-creation.test.ts": 40, + "src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts": 1634, + "src/renderer/src/lib/pi-session-resume-wake.test.ts": 1408, + "src/renderer/src/lib/plugin-command-execution.test.ts": 165, + "src/renderer/src/lib/plugin-command-keybindings.test.ts": 121, + "src/renderer/src/lib/pr-bot-author-overrides.test.ts": 302, + "src/renderer/src/lib/pr-comment-action-state.test.ts": 54, + "src/renderer/src/lib/pr-comment-reactions.test.ts": 44, + "src/renderer/src/lib/primary-selection-paste.test.ts": 247, + "src/renderer/src/lib/primary-selection.test.ts": 70, + "src/renderer/src/lib/project-clone-url-prefill.test.ts": 59, + "src/renderer/src/lib/project-host-clone-url.test.ts": 49, + "src/renderer/src/lib/project-host-setup-options.test.ts": 325, + "src/renderer/src/lib/project-host-workspace-target-managed-host.test.ts": 340, + "src/renderer/src/lib/project-host-workspace-target.test.ts": 67, + "src/renderer/src/lib/project-skill-runtime.test.ts": 82, + "src/renderer/src/lib/provisioned-root-create-options.test.ts": 52, + "src/renderer/src/lib/quick-workspace-agent-selection.test.ts": 141, + "src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx": 271, + "src/renderer/src/lib/react-commit-cascade-observer.test.ts": 180, + "src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts": 66, + "src/renderer/src/lib/react-commit-cascade-telemetry.test.ts": 164, + "src/renderer/src/lib/react-error-boundary-reporting.test.ts": 73, + "src/renderer/src/lib/react-grab-dev-gate.test.ts": 54, + "src/renderer/src/lib/react-renderer-root.test.ts": 54, + "src/renderer/src/lib/recent-workspace-tab-rows.test.ts": 121, + "src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts": 1646, + "src/renderer/src/lib/rename-user-named-tab-access.test.ts": 1706, + "src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 115, + "src/renderer/src/lib/renderer-app-platform.test.ts": 156, + "src/renderer/src/lib/renderer-memory-profile.test.ts": 75, + "src/renderer/src/lib/repo-display-labels.test.ts": 60, + "src/renderer/src/lib/repo-runtime-owner.test.ts": 90, + "src/renderer/src/lib/repo-search.test.ts": 67, + "src/renderer/src/lib/repo-slug-cache.test.ts": 56, + "src/renderer/src/lib/repo-slug-index.test.ts": 2468, + "src/renderer/src/lib/resolve-owner.test.ts": 320, + "src/renderer/src/lib/resolved-worktree-execution-host.test.ts": 282, + "src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts": 1548, + "src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts": 1708, + "src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts": 1682, + "src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts": 1680, + "src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts": 1876, + "src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts": 1521, + "src/renderer/src/lib/resume-sleeping-agent-session.test.ts": 1401, + "src/renderer/src/lib/resume-stale-structured-agent-session.test.ts": 1623, + "src/renderer/src/lib/reveal-in-file-manager.test.ts": 220, + "src/renderer/src/lib/right-sidebar-visibility.test.ts": 61, + "src/renderer/src/lib/run-quick-command-in-new-tab.test.ts": 72, + "src/renderer/src/lib/running-agent-targets.test.ts": 341, + "src/renderer/src/lib/runtime-host-unverifiable-probe-readers.test.ts": 396, + "src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts": 47, + "src/renderer/src/lib/runtime-session-mirror-targets.test.ts": 63, + "src/renderer/src/lib/runtime-session-mirror-unverifiable-host.test.ts": 53, + "src/renderer/src/lib/runtime-workspace-file-route.test.ts": 319, + "src/renderer/src/lib/screen-submit-shortcut.test.ts": 62, + "src/renderer/src/lib/script-textarea-rows.test.ts": 106, + "src/renderer/src/lib/scroll-cache.test.ts": 56, + "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts": 1625, + "src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts": 1407, + "src/renderer/src/lib/session-write-subscriber-allocation.test.ts": 427, + "src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts": 1534, + "src/renderer/src/lib/session-write-subscriber.test.ts": 1444, + "src/renderer/src/lib/settled-worker-wake-policy.test.ts": 1691, + "src/renderer/src/lib/setup-runner.test.ts": 57, + "src/renderer/src/lib/setup-script-prompt.test.ts": 289, + "src/renderer/src/lib/setup-split-activation-single-owner.test.ts": 1879, + "src/renderer/src/lib/sha256.test.ts": 55, + "src/renderer/src/lib/shutdown-checkpoint-guard.test.ts": 201, + "src/renderer/src/lib/sidebar-worktree-activation.test.ts": 87, + "src/renderer/src/lib/simulator-launch-coordination.test.ts": 114, + "src/renderer/src/lib/simulator-palette-search.test.ts": 335, + "src/renderer/src/lib/simulator-pane-shutdown-scheduler.test.ts": 1417, + "src/renderer/src/lib/simulator-tab-palette-activation.test.ts": 1343, + "src/renderer/src/lib/skill-freshness-display-status.test.ts": 60, + "src/renderer/src/lib/sleeping-agent-pane-ownership.test.ts": 53, + "src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts": 524, + "src/renderer/src/lib/smart-github-submit.test.ts": 104, + "src/renderer/src/lib/solidity-language-id-registration.test.ts": 48, + "src/renderer/src/lib/source-control-agent-action-launch-check.test.ts": 1358, + "src/renderer/src/lib/source-control-generation-plan.test.ts": 114, + "src/renderer/src/lib/source-control-launch-platform.test.ts": 58, + "src/renderer/src/lib/source-control-remote-error.test.ts": 62, + "src/renderer/src/lib/sparse-preset-draft.test.ts": 168, + "src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts": 1527, + "src/renderer/src/lib/ssh-mutation-expectation.test.ts": 1556, + "src/renderer/src/lib/ssh-workspace-browser-route-eligibility.test.ts": 47, + "src/renderer/src/lib/startup-draft-input-kind.test.ts": 464, + "src/renderer/src/lib/startup-ui-hydration.test.ts": 270, + "src/renderer/src/lib/state-collection-byte-estimate.test.ts": 113, + "src/renderer/src/lib/structured-agent-launch-settlement.test.ts": 64, + "src/renderer/src/lib/structured-agent-session-launch-admission.test.ts": 3534, + "src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts": 585, + "src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts": 1639, + "src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts": 663, + "src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts": 1863, + "src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts": 607, + "src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts": 944, + "src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts": 508, + "src/renderer/src/lib/structured-agent-session-launch-options.test.ts": 602, + "src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts": 374, + "src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts": 1677, + "src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts": 511, + "src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts": 481, + "src/renderer/src/lib/structured-agent-session-launch-record.test.ts": 203, + "src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts": 610, + "src/renderer/src/lib/structured-agent-session-launch-reload.test.ts": 573, + "src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts": 602, + "src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts": 539, + "src/renderer/src/lib/structured-agent-session-launch.test.ts": 698, + "src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts": 656, + "src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts": 1115, + "src/renderer/src/lib/structured-agent-session-tab-activation.test.ts": 59, + "src/renderer/src/lib/structured-chat-tab-index.test.ts": 42, + "src/renderer/src/lib/syntax-highlighting/incremental-syntax-tokenizer.test.ts": 553, + "src/renderer/src/lib/syntax-highlighting/syntax-highlight-scheduler.test.ts": 151, + "src/renderer/src/lib/syntax-highlighting/syntax-highlighter.test.ts": 476, + "src/renderer/src/lib/tab-agent-identity-decision-table.test.ts": 542, + "src/renderer/src/lib/tab-agent-status-index.test.ts": 224, + "src/renderer/src/lib/tab-agent.test.ts": 127, + "src/renderer/src/lib/tab-has-live-pty.test.ts": 62, + "src/renderer/src/lib/tab-number-shortcuts.test.ts": 1566, + "src/renderer/src/lib/task-source-runtime-target.test.ts": 47, + "src/renderer/src/lib/terminal-contrast-correction.test.ts": 64, + "src/renderer/src/lib/terminal-file-uri-link.test.ts": 74, + "src/renderer/src/lib/terminal-input-activity-coalescing.test.ts": 58, + "src/renderer/src/lib/terminal-links-cjk.test.ts": 72, + "src/renderer/src/lib/terminal-links-redos.test.ts": 74, + "src/renderer/src/lib/terminal-links.test.ts": 282, + "src/renderer/src/lib/terminal-pane-title-sanitization.test.ts": 49, + "src/renderer/src/lib/terminal-path-normalization.test.ts": 50, + "src/renderer/src/lib/terminal-quick-command-project-scope.test.ts": 58, + "src/renderer/src/lib/terminal-quick-command-score-budget.test.ts": 57, + "src/renderer/src/lib/terminal-quick-command-search.test.ts": 70, + "src/renderer/src/lib/terminal-reveal-identity.test.ts": 42, + "src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx": 1656, + "src/renderer/src/lib/terminal-tab-for-pty-id.test.ts": 63, + "src/renderer/src/lib/terminal-tab-owner-activation.test.ts": 50, + "src/renderer/src/lib/terminal-theme.test.ts": 84, + "src/renderer/src/lib/terminal-worktree-route.test.ts": 312, + "src/renderer/src/lib/text-control-paste-ownership.test.ts": 190, + "src/renderer/src/lib/text-control-paste.test.ts": 166, + "src/renderer/src/lib/titlebar-left-chrome.test.ts": 51, + "src/renderer/src/lib/titlebar-worktree-history-controls.test.ts": 54, + "src/renderer/src/lib/tui-agent-startup.test.ts": 155, + "src/renderer/src/lib/typing-latency/diagnostic-lifecycle.test.ts": 183, "src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts": 61, - "src/renderer/src/lib/typing-latency/echo-instrumentation.test.ts": 246, - "src/renderer/src/lib/typing-latency/input-events.test.ts": 385, - "src/renderer/src/lib/typing-latency/input-source.test.ts": 75, - "src/renderer/src/lib/typing-latency/sample-window.test.ts": 57, - "src/renderer/src/lib/ui-zoom.test.ts": 59, - "src/renderer/src/lib/unread-badge-count.test.ts": 51, - "src/renderer/src/lib/update-check-click-options.test.ts": 40, - "src/renderer/src/lib/updater-beforeunload.test.ts": 60, - "src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx": 1567, - "src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts": 1480, - "src/renderer/src/lib/use-tab-agent-pi-identity.test.ts": 92, - "src/renderer/src/lib/use-tab-agent-process-signals.test.ts": 2086, - "src/renderer/src/lib/use-tab-agent-remote-pty-selector.test.ts": 118, - "src/renderer/src/lib/use-tab-agent-retained-identity.test.ts": 1549, - "src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts": 1948, - "src/renderer/src/lib/use-tab-agent.test.ts": 1893, - "src/renderer/src/lib/visible-overlay.test.ts": 464, - "src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts": 276, - "src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts": 437, - "src/renderer/src/lib/web-client-location.test.ts": 52, - "src/renderer/src/lib/window-label-formatter.test.ts": 27, - "src/renderer/src/lib/window-visibility-interval.test.ts": 70, - "src/renderer/src/lib/window-visibility-timeout-poller.test.ts": 67, - "src/renderer/src/lib/windows-terminal-capabilities-race.test.ts": 317, - "src/renderer/src/lib/windows-terminal-capabilities.test.ts": 401, - "src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts": 432, - "src/renderer/src/lib/work-item-link-query-bounds.test.ts": 49, - "src/renderer/src/lib/work-item-lookup-text.test.ts": 55, - "src/renderer/src/lib/worker-terminal-takeover-report.test.ts": 314, - "src/renderer/src/lib/workspace-activation-path-gate.test.ts": 1954, - "src/renderer/src/lib/workspace-activation-terminal-focus.test.ts": 345, - "src/renderer/src/lib/workspace-browser-tab-open.test.ts": 325, - "src/renderer/src/lib/workspace-composer-initial-focus.test.ts": 351, - "src/renderer/src/lib/workspace-create-error-format.test.ts": 99, - "src/renderer/src/lib/workspace-doc-address-input.test.ts": 208, - "src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts": 103, - "src/renderer/src/lib/workspace-emoji-shortcodes.test.ts": 79, - "src/renderer/src/lib/workspace-file-drag.test.ts": 94, - "src/renderer/src/lib/workspace-port-groups.test.ts": 49, - "src/renderer/src/lib/workspace-port-host-availability.test.ts": 40, - "src/renderer/src/lib/workspace-port-scan-debounce.test.ts": 48, - "src/renderer/src/lib/workspace-port-scan-publish.test.ts": 672, - "src/renderer/src/lib/workspace-session-browser-history.test.ts": 196, - "src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts": 146, - "src/renderer/src/lib/workspace-session-editor-drafts.test.ts": 156, - "src/renderer/src/lib/workspace-session-host-contention.test.ts": 236, - "src/renderer/src/lib/workspace-session-host-persistence.test.ts": 301, - "src/renderer/src/lib/workspace-session-host-split.test.ts": 128, - "src/renderer/src/lib/workspace-session-hydration-keys.test.ts": 36, - "src/renderer/src/lib/workspace-session-liveness.test.ts": 133, - "src/renderer/src/lib/workspace-session-patch.test.ts": 196, - "src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 209, - "src/renderer/src/lib/workspace-session-relevant-fields.test.ts": 200, - "src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts": 210, - "src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts": 235, - "src/renderer/src/lib/workspace-session-staged-browser-tabs.test.ts": 39, - "src/renderer/src/lib/workspace-session.test.ts": 150, - "src/renderer/src/lib/workspace-tab-agent-metadata.test.ts": 49, - "src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts": 1538, - "src/renderer/src/lib/workspace-tab-palette-activation.test.ts": 73, - "src/renderer/src/lib/workspace-tab-palette-results.test.ts": 105, - "src/renderer/src/lib/workspace-tab-palette-search.test.ts": 216, - "src/renderer/src/lib/workspace-terminal-host-authority.test.ts": 1735, - "src/renderer/src/lib/worktree-activation-agent-startup.test.ts": 1648, - "src/renderer/src/lib/worktree-activation-automation-filter.test.ts": 1897, - "src/renderer/src/lib/worktree-activation-created-agent.test.ts": 1592, - "src/renderer/src/lib/worktree-activation-default-tabs.test.ts": 1319, - "src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts": 2006, - "src/renderer/src/lib/worktree-activation-empty-remote.test.ts": 1783, - "src/renderer/src/lib/worktree-activation-issue-command.test.ts": 1036, - "src/renderer/src/lib/worktree-activation-pty-inventory.test.ts": 182, - "src/renderer/src/lib/worktree-activation-reveal.test.ts": 1804, - "src/renderer/src/lib/worktree-activation-setup-script.test.ts": 1979, - "src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts": 1880, - "src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts": 290, - "src/renderer/src/lib/worktree-activation-web-runtime.test.ts": 1340, - "src/renderer/src/lib/worktree-activity-state.test.ts": 69, - "src/renderer/src/lib/worktree-agent-activation-gate.test.ts": 1546, - "src/renderer/src/lib/worktree-agent-activation-seam.test.ts": 1685, - "src/renderer/src/lib/worktree-agent-structured-inventory.test.ts": 132, - "src/renderer/src/lib/worktree-attachment-label.test.ts": 56, - "src/renderer/src/lib/worktree-creation-agent-seeding.test.ts": 622, - "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts": 1725, - "src/renderer/src/lib/worktree-creation-chat-setup.test.ts": 1752, - "src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts": 34, - "src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts": 887, - "src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts": 1050, - "src/renderer/src/lib/worktree-creation-flow.test.ts": 2555, - "src/renderer/src/lib/worktree-creation-structured-session.test.ts": 922, - "src/renderer/src/lib/worktree-creation-surface.test.ts": 43, - "src/renderer/src/lib/worktree-default-display-name.test.ts": 67, - "src/renderer/src/lib/worktree-display-name-order.test.ts": 33, - "src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts": 1565, - "src/renderer/src/lib/worktree-git-identity-display.test.ts": 78, - "src/renderer/src/lib/worktree-jump-navigation.test.ts": 231, - "src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts": 210, - "src/renderer/src/lib/worktree-operation-generation.test.ts": 319, - "src/renderer/src/lib/worktree-operation-route.test.ts": 63, - "src/renderer/src/lib/worktree-palette-comment-snippet.test.ts": 56, - "src/renderer/src/lib/worktree-palette-create-action.test.ts": 52, - "src/renderer/src/lib/worktree-palette-multi-keyword.test.ts": 216, - "src/renderer/src/lib/worktree-palette-review-match.test.ts": 96, - "src/renderer/src/lib/worktree-palette-runtime-owner-identity.test.ts": 81, - "src/renderer/src/lib/worktree-palette-search.test.ts": 166, - "src/renderer/src/lib/worktree-palette-task-url-match.test.ts": 118, - "src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts": 2149, - "src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts": 1803, - "src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts": 1778, - "src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts": 956, - "src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts": 184, - "src/renderer/src/lib/worktree-runtime-owner-index.test.ts": 37, - "src/renderer/src/lib/worktree-runtime-owner.test.ts": 233, - "src/renderer/src/lib/worktree-sort-order-host-split.test.ts": 150, - "src/renderer/src/lib/worktree-sort-order-persistence.test.ts": 179, - "src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts": 59, - "src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts": 58, - "src/renderer/src/lib/worktree-status.interrupted.test.ts": 74, - "src/renderer/src/lib/worktree-status.test.ts": 98, - "src/renderer/src/lib/worktree-visit-recency.test.ts": 37, - "src/renderer/src/renderer-node-builtin-boundary.test.ts": 1218, - "src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts": 39, - "src/renderer/src/runtime/agent-session-keyboard-capability.test.ts": 66, - "src/renderer/src/runtime/agent-session-operation-id.test.ts": 64, - "src/renderer/src/runtime/antigravity-generation-compatibility.test.ts": 167, - "src/renderer/src/runtime/browser-client-host-identity.test.ts": 60, - "src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts": 361, - "src/renderer/src/runtime/browser-workspace-tab-close-plan.test.ts": 33, - "src/renderer/src/runtime/browser-workspace-tab-close.test.ts": 92, - "src/renderer/src/runtime/client-hosted-browser-close-intent-replay.test.ts": 172, - "src/renderer/src/runtime/client-hosted-browser-close-intents.test.ts": 103, - "src/renderer/src/runtime/client-hosted-browser-row-close.test.ts": 216, - "src/renderer/src/runtime/close-mirrored-editor-tab.test.ts": 107, - "src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts": 2077, - "src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts": 502, - "src/renderer/src/runtime/github-check-details-timeout.test.ts": 41, - "src/renderer/src/runtime/gitlab-job-trace-client.test.ts": 141, - "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts": 630, - "src/renderer/src/runtime/host-session-mirror-flap-hydration.test.ts": 197, - "src/renderer/src/runtime/host-session-mirror-hydration-drain.test.ts": 239, - "src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx": 1759, - "src/renderer/src/runtime/host-session-mirror-settle-census.test.ts": 505, - "src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx": 1864, - "src/renderer/src/runtime/host-session-snapshot-authority-client-hosted.test.ts": 52, - "src/renderer/src/runtime/local-runtime-capabilities.test.ts": 473, - "src/renderer/src/runtime/local-session-tab-close-owner.test.ts": 58, - "src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts": 1132, - "src/renderer/src/runtime/local-structured-session-retired-epoch-repair.test.ts": 1743, - "src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts": 1580, - "src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts": 779, - "src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts": 2524, - "src/renderer/src/runtime/local-structured-session-tabs-sync/retired-epoch-repair.test.ts": 67, - "src/renderer/src/runtime/mirrored-agent-status-clock-skew.test.ts": 1299, - "src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts": 1242, - "src/renderer/src/runtime/mobile-markdown-bridge.test.ts": 1361, - "src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts": 38, - "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts": 1002, - "src/renderer/src/runtime/remote-agent-row-last-assistant-message.test.ts": 1196, - "src/renderer/src/runtime/remote-agent-session-launch.test.ts": 62, - "src/renderer/src/runtime/remote-host-file-delete-repro.test.ts": 2161, - "src/renderer/src/runtime/remote-host-file-open-repro.test.ts": 1628, - "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts": 50, - "src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts": 118, - "src/renderer/src/runtime/remote-runtime-terminal-end-verdict.test.ts": 86, - "src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts": 109, - "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts": 334, - "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts": 96, - "src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts": 87, - "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts": 263, - "src/renderer/src/runtime/remote-server-install-failure-probe.test.ts": 61, - "src/renderer/src/runtime/remote-server-parity.test.ts": 1760, - "src/renderer/src/runtime/remote-server-restart-wait.test.ts": 43, - "src/renderer/src/runtime/remote-server-update-coordinator.test.ts": 95, - "src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts": 61, - "src/renderer/src/runtime/remote-terminal-stream-watchdog.test.ts": 52, - "src/renderer/src/runtime/restored-client-hosted-browser-host-attach.test.ts": 67, - "src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts": 286, - "src/renderer/src/runtime/runtime-client-events.test.ts": 55, - "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts": 1134, - "src/renderer/src/runtime/runtime-file-client-download.test.ts": 218, - "src/renderer/src/runtime/runtime-file-client-external-import.test.ts": 324, - "src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts": 208, - "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts": 276, - "src/renderer/src/runtime/runtime-file-client-watch.test.ts": 388, - "src/renderer/src/runtime/runtime-file-client.test.ts": 367, - "src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts": 311, - "src/renderer/src/runtime/runtime-file-search-bounds.test.ts": 38, - "src/renderer/src/runtime/runtime-git-client-api-contract.test.ts": 156, - "src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts": 182, - "src/renderer/src/runtime/runtime-git-client-merge.test.ts": 137, - "src/renderer/src/runtime/runtime-git-client.test.ts": 255, - "src/renderer/src/runtime/runtime-hooks-client.test.ts": 73, - "src/renderer/src/runtime/runtime-host-connection-state.test.ts": 50, - "src/renderer/src/runtime/runtime-host-contact-parity.test.ts": 81, - "src/renderer/src/runtime/runtime-jira-client.test.ts": 555, - "src/renderer/src/runtime/runtime-jira-payload-stream.test.ts": 242, - "src/renderer/src/runtime/runtime-linear-client.test.ts": 89, - "src/renderer/src/runtime/runtime-path-existence-batch.test.ts": 249, - "src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts": 192, - "src/renderer/src/runtime/runtime-path-existence-pairing.test.ts": 236, - "src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts": 252, - "src/renderer/src/runtime/runtime-provider-accounts-client.test.ts": 107, - "src/renderer/src/runtime/runtime-provider-search-bounds.test.ts": 44, - "src/renderer/src/runtime/runtime-repo-client.test.ts": 219, - "src/renderer/src/runtime/runtime-repo-search-bounds.test.ts": 38, - "src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts": 50, - "src/renderer/src/runtime/runtime-rpc-client.test.ts": 102, - "src/renderer/src/runtime/runtime-rpc-result.test.ts": 28, - "src/renderer/src/runtime/runtime-server-directory-browser.test.ts": 72, - "src/renderer/src/runtime/runtime-skills-client.test.ts": 78, - "src/renderer/src/runtime/runtime-skills-delete-client.test.ts": 61, - "src/renderer/src/runtime/runtime-terminal-inspection.test.ts": 1867, - "src/renderer/src/runtime/runtime-terminal-stream.test.ts": 341, - "src/renderer/src/runtime/runtime-worktree-selector.test.ts": 123, - "src/renderer/src/runtime/structured-agent-session-client.test.ts": 324, - "src/renderer/src/runtime/structured-agent-session-close.test.ts": 59, - "src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts": 37, - "src/renderer/src/runtime/structured-agent-session-status-feed.test.ts": 83, - "src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts": 55, - "src/renderer/src/runtime/structured-agent-session-turn-completion-feed.test.ts": 89, - "src/renderer/src/runtime/structured-conversation-tab-replacement.test.ts": 1641, - "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection-join.test.ts": 422, - "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts": 334, - "src/renderer/src/runtime/sync-runtime-graph-automation-leaf.test.ts": 289, - "src/renderer/src/runtime/sync-runtime-graph-browser.test.ts": 244, - "src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts": 1365, - "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts": 332, - "src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts": 358, - "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts": 237, - "src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts": 305, - "src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts": 542, - "src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts": 270, - "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts": 264, - "src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts": 1061, - "src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts": 349, - "src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts": 225, - "src/renderer/src/runtime/sync-runtime-graph-workspace-publication.test.ts": 369, - "src/renderer/src/runtime/sync-runtime-graph.test.ts": 342, - "src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts": 82, - "src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts": 555, - "src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts": 2476, - "src/renderer/src/runtime/use-worktree-runtime-target.test.ts": 2118, - "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts": 230, - "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts": 35, - "src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts": 54, - "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts": 508, - "src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts": 508, - "src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts": 494, - "src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts": 500, - "src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts": 530, - "src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts": 347, - "src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts": 876, - "src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts": 390, - "src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts": 470, - "src/renderer/src/runtime/web-runtime-session-tab-move.test.ts": 317, - "src/renderer/src/runtime/web-runtime-session-tab-props.test.ts": 512, - "src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts": 461, - "src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts": 336, - "src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts": 1290, - "src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts": 366, - "src/renderer/src/runtime/web-runtime-session.test.ts": 384, - "src/renderer/src/runtime/web-runtime-terminal-identity.test.ts": 57, - "src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts": 47, - "src/renderer/src/runtime/web-session-browser-placement.test.ts": 48, - "src/renderer/src/runtime/web-session-close-intent.test.ts": 53, - "src/renderer/src/runtime/web-session-existing-tab-index.test.ts": 35, - "src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts": 1811, - "src/renderer/src/runtime/web-session-intent-owner.test.ts": 41, - "src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts": 1150, - "src/renderer/src/runtime/web-session-structured-tab-focus.test.ts": 1370, - "src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts": 1511, - "src/renderer/src/runtime/web-session-tabs-late-merged-predecessor-lockout.test.ts": 699, - "src/renderer/src/runtime/web-session-tabs-publisher-identity-lineage.test.ts": 472, - "src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts": 648, - "src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts": 643, - "src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts": 732, - "src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts": 699, - "src/renderer/src/runtime/web-session-tabs-sync-client-owned-page-content.test.ts": 617, - "src/renderer/src/runtime/web-session-tabs-sync-client-owned-placement.test.ts": 727, - "src/renderer/src/runtime/web-session-tabs-sync-diff-focus-steal.test.ts": 468, - "src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts": 668, - "src/renderer/src/runtime/web-session-tabs-sync-emptied-workspace-tombstone.test.ts": 817, - "src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts": 603, - "src/renderer/src/runtime/web-session-tabs-sync-host-restart-browser-rows.test.ts": 722, - "src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts": 2099, - "src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts": 659, - "src/renderer/src/runtime/web-session-tabs-sync-initial-terminal-relaunch.test.tsx": 2154, - "src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts": 763, - "src/renderer/src/runtime/web-session-tabs-sync-layout-groups.test.ts": 671, - "src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts": 1309, - "src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts": 2175, - "src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx": 2266, - "src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts": 1052, - "src/renderer/src/runtime/web-session-tabs-sync-restored-browser-rows.test.ts": 735, - "src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts": 666, - "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-adoption.test.ts": 789, - "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-authority.test.ts": 732, - "src/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.ts": 603, - "src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts": 756, - "src/renderer/src/runtime/web-session-tabs-sync-tracking-teardown.test.ts": 700, - "src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx": 1481, - "src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx": 2322, - "src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx": 6067, - "src/renderer/src/runtime/web-session-tabs-sync.test.ts": 610, - "src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts": 1260, - "src/renderer/src/runtime/web-session-tabs-sync/mirrored-status-tab-retractions.test.ts": 1840, - "src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts": 1252, - "src/renderer/src/runtime/web-session-tabs-sync/retraction-pane-ownership.test.ts": 1429, - "src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.test.ts": 1590, - "src/renderer/src/runtime/web-session-terminal-handle-events.test.ts": 29, - "src/renderer/src/runtime/web-session-terminal-orphan-absence-across-republication.test.ts": 313, - "src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts": 1303, - "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts": 607, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts": 754, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts": 306, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-prior-removal.test.ts": 328, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts": 607, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts": 370, - "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts": 327, - "src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts": 229, - "src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.test.ts": 444, - "src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts": 350, - "src/renderer/src/runtime/window-visibility-subscription-parking.test.ts": 524, + "src/renderer/src/lib/typing-latency/echo-instrumentation.test.ts": 145, + "src/renderer/src/lib/typing-latency/input-events.test.ts": 205, + "src/renderer/src/lib/typing-latency/input-source.test.ts": 77, + "src/renderer/src/lib/typing-latency/sample-window.test.ts": 53, + "src/renderer/src/lib/ui-zoom.test.ts": 73, + "src/renderer/src/lib/unified-tab-anchor-insertion.test.ts": 48, + "src/renderer/src/lib/unread-badge-count-migration.test.ts": 306, + "src/renderer/src/lib/unread-badge-count-selector.test.ts": 1368, + "src/renderer/src/lib/unread-badge-count.test.ts": 190, + "src/renderer/src/lib/update-check-click-options.test.ts": 49, + "src/renderer/src/lib/updater-beforeunload.test.ts": 58, + "src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx": 1424, + "src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts": 1734, + "src/renderer/src/lib/use-tab-agent-pi-identity.test.ts": 125, + "src/renderer/src/lib/use-tab-agent-process-signals.test.ts": 1363, + "src/renderer/src/lib/use-tab-agent-remote-pty-selector.test.ts": 159, + "src/renderer/src/lib/use-tab-agent-retained-identity.test.ts": 1903, + "src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts": 1563, + "src/renderer/src/lib/use-tab-agent.test.ts": 1826, + "src/renderer/src/lib/user-opened-local-path.test.ts": 1464, + "src/renderer/src/lib/visible-overlay.test.ts": 165, + "src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts": 203, + "src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts": 161, + "src/renderer/src/lib/web-client-location.test.ts": 51, + "src/renderer/src/lib/window-label-formatter.test.ts": 44, + "src/renderer/src/lib/window-visibility-interval.test.ts": 66, + "src/renderer/src/lib/window-visibility-timeout-poller.test.ts": 173, + "src/renderer/src/lib/windows-terminal-capabilities-race.test.ts": 220, + "src/renderer/src/lib/windows-terminal-capabilities.test.ts": 282, + "src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts": 190, + "src/renderer/src/lib/work-item-link-query-bounds.test.ts": 55, + "src/renderer/src/lib/work-item-lookup-text.test.ts": 57, + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts": 299, + "src/renderer/src/lib/workspace-activation-path-gate.test.ts": 1672, + "src/renderer/src/lib/workspace-activation-terminal-focus.test.ts": 340, + "src/renderer/src/lib/workspace-attachment-terminal-origin.test.ts": 88, + "src/renderer/src/lib/workspace-browser-tab-open.test.ts": 398, + "src/renderer/src/lib/workspace-composer-initial-focus.test.ts": 148, + "src/renderer/src/lib/workspace-create-error-format.test.ts": 82, + "src/renderer/src/lib/workspace-directory.test.ts": 359, + "src/renderer/src/lib/workspace-doc-address-input.test.ts": 283, + "src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts": 95, + "src/renderer/src/lib/workspace-emoji-shortcodes.test.ts": 95, + "src/renderer/src/lib/workspace-file-drag-source-runtime.test.ts": 62, + "src/renderer/src/lib/workspace-file-drag.test.ts": 80, + "src/renderer/src/lib/workspace-port-groups.test.ts": 44, + "src/renderer/src/lib/workspace-port-host-availability.test.ts": 63, + "src/renderer/src/lib/workspace-port-host-scoped-scan.test.ts": 337, + "src/renderer/src/lib/workspace-port-scan-debounce.test.ts": 51, + "src/renderer/src/lib/workspace-port-scan-publish.test.ts": 445, + "src/renderer/src/lib/workspace-session-browser-history.test.ts": 331, + "src/renderer/src/lib/workspace-session-editor-drafts.test.ts": 312, + "src/renderer/src/lib/workspace-session-host-contention.test.ts": 357, + "src/renderer/src/lib/workspace-session-host-persistence.test.ts": 333, + "src/renderer/src/lib/workspace-session-host-split.test.ts": 316, + "src/renderer/src/lib/workspace-session-hydration-keys.test.ts": 58, + "src/renderer/src/lib/workspace-session-liveness.test.ts": 284, + "src/renderer/src/lib/workspace-session-patch.test.ts": 292, + "src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 287, + "src/renderer/src/lib/workspace-session-relevant-fields.test.ts": 325, + "src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts": 338, + "src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts": 347, + "src/renderer/src/lib/workspace-session-staged-browser-tabs.test.ts": 48, + "src/renderer/src/lib/workspace-session.test.ts": 318, + "src/renderer/src/lib/workspace-tab-agent-metadata.test.ts": 56, + "src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts": 1688, + "src/renderer/src/lib/workspace-tab-palette-activation.test.ts": 294, + "src/renderer/src/lib/workspace-tab-palette-results.test.ts": 350, + "src/renderer/src/lib/workspace-tab-palette-search.test.ts": 423, + "src/renderer/src/lib/workspace-terminal-host-authority.test.ts": 1753, + "src/renderer/src/lib/worktree-activation-agent-startup.test.ts": 2012, + "src/renderer/src/lib/worktree-activation-automation-filter.test.ts": 1819, + "src/renderer/src/lib/worktree-activation-created-agent.test.ts": 2057, + "src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts": 1659, + "src/renderer/src/lib/worktree-activation-default-tabs.test.ts": 1571, + "src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts": 1934, + "src/renderer/src/lib/worktree-activation-empty-remote.test.ts": 1634, + "src/renderer/src/lib/worktree-activation-issue-command.test.ts": 1693, + "src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts": 1657, + "src/renderer/src/lib/worktree-activation-pty-inventory.test.ts": 321, + "src/renderer/src/lib/worktree-activation-reveal.test.ts": 1759, + "src/renderer/src/lib/worktree-activation-setup-script.test.ts": 1905, + "src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts": 1865, + "src/renderer/src/lib/worktree-activation-web-runtime.test.ts": 2050, + "src/renderer/src/lib/worktree-activity-state.test.ts": 66, + "src/renderer/src/lib/worktree-agent-activation-gate.test.ts": 1647, + "src/renderer/src/lib/worktree-agent-activation-seam.test.ts": 1849, + "src/renderer/src/lib/worktree-agent-structured-inventory.test.ts": 282, + "src/renderer/src/lib/worktree-attachment-label.test.ts": 57, + "src/renderer/src/lib/worktree-creation-agent-seeding.test.ts": 1201, + "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts": 1452, + "src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts": 1881, + "src/renderer/src/lib/worktree-creation-background-mount.test.ts": 2059, + "src/renderer/src/lib/worktree-creation-chat-setup.test.ts": 2093, + "src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts": 1253, + "src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts": 1699, + "src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts": 1183, + "src/renderer/src/lib/worktree-creation-flow.test.ts": 1987, + "src/renderer/src/lib/worktree-creation-hook-flow.test.ts": 1338, + "src/renderer/src/lib/worktree-creation-hook-preparation.test.ts": 185, + "src/renderer/src/lib/worktree-creation-ready-toast.test.tsx": 173, + "src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts": 1994, + "src/renderer/src/lib/worktree-creation-structured-session.test.ts": 1041, + "src/renderer/src/lib/worktree-creation-surface.test.ts": 54, + "src/renderer/src/lib/worktree-default-display-name.test.ts": 52, + "src/renderer/src/lib/worktree-display-name-order.test.ts": 47, + "src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts": 1335, + "src/renderer/src/lib/worktree-git-identity-display.test.ts": 46, + "src/renderer/src/lib/worktree-host-connection-phase.test.ts": 1513, + "src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts": 1617, + "src/renderer/src/lib/worktree-jump-navigation.test.ts": 354, + "src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts": 314, + "src/renderer/src/lib/worktree-operation-generation.test.ts": 379, + "src/renderer/src/lib/worktree-operation-route.test.ts": 356, + "src/renderer/src/lib/worktree-palette-attachment-urls.test.ts": 109, + "src/renderer/src/lib/worktree-palette-comment-snippet.test.ts": 50, + "src/renderer/src/lib/worktree-palette-create-action.test.ts": 63, + "src/renderer/src/lib/worktree-palette-multi-keyword.test.ts": 235, + "src/renderer/src/lib/worktree-palette-review-match.test.ts": 194, + "src/renderer/src/lib/worktree-palette-runtime-owner-identity.test.ts": 402, + "src/renderer/src/lib/worktree-palette-search.test.ts": 236, + "src/renderer/src/lib/worktree-palette-task-url-match.test.ts": 133, + "src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts": 1546, + "src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts": 1410, + "src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts": 1937, + "src/renderer/src/lib/worktree-row-open-in-owner.test.ts": 364, + "src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts": 298, + "src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts": 102, + "src/renderer/src/lib/worktree-runtime-owner-index.test.ts": 49, + "src/renderer/src/lib/worktree-runtime-owner.test.ts": 356, + "src/renderer/src/lib/worktree-sort-order-host-split.test.ts": 320, + "src/renderer/src/lib/worktree-sort-order-persistence.test.ts": 342, + "src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts": 127, + "src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts": 115, + "src/renderer/src/lib/worktree-status.interrupted.test.ts": 106, + "src/renderer/src/lib/worktree-status.test.ts": 121, + "src/renderer/src/lib/worktree-visit-recency.test.ts": 45, + "src/renderer/src/runtime/abortable-runtime-environment-call.test.ts": 181, + "src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts": 70, + "src/renderer/src/runtime/agent-session-keyboard-capability.test.ts": 96, + "src/renderer/src/runtime/agent-session-operation-id.test.ts": 87, + "src/renderer/src/runtime/antigravity-generation-compatibility.test.ts": 349, + "src/renderer/src/runtime/browser-client-host-identity.test.ts": 52, + "src/renderer/src/runtime/browser-workspace-tab-close-plan.test.ts": 55, + "src/renderer/src/runtime/browser-workspace-tab-close.test.ts": 238, + "src/renderer/src/runtime/client-hosted-browser-close-intent-replay.test.ts": 374, + "src/renderer/src/runtime/client-hosted-browser-close-intents.test.ts": 234, + "src/renderer/src/runtime/client-hosted-browser-row-close.test.ts": 264, + "src/renderer/src/runtime/close-mirrored-editor-tab.test.ts": 224, + "src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts": 1862, + "src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts": 570, + "src/renderer/src/runtime/focus-runtime-terminal-surface-readiness.test.ts": 437, + "src/renderer/src/runtime/github-check-details-timeout.test.ts": 64, + "src/renderer/src/runtime/gitlab-job-trace-client.test.ts": 137, + "src/renderer/src/runtime/gitlab-owner-api.test.ts": 111, + "src/renderer/src/runtime/host-model-catalog-snapshots.test.ts": 1805, + "src/renderer/src/runtime/host-route-call.test.ts": 338, + "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts": 944, + "src/renderer/src/runtime/host-session-mirror-flap-hydration.test.ts": 384, + "src/renderer/src/runtime/host-session-mirror-hydration-drain.test.ts": 343, + "src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx": 2135, + "src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx": 2168, + "src/renderer/src/runtime/host-session-snapshot-authority-client-hosted.test.ts": 54, + "src/renderer/src/runtime/host-structured-agents.test.ts": 537, + "src/renderer/src/runtime/local-runtime-capabilities.test.ts": 139, + "src/renderer/src/runtime/local-session-tab-close-owner.test.ts": 44, + "src/renderer/src/runtime/local-structured-chats.test.ts": 69, + "src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts": 1662, + "src/renderer/src/runtime/local-structured-session-retired-epoch-repair.test.ts": 1933, + "src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts": 2162, + "src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts": 1048, + "src/renderer/src/runtime/local-structured-session-tabs-sync-hook.test.ts": 1933, + "src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts": 2619, + "src/renderer/src/runtime/local-structured-session-tabs-sync/retired-epoch-repair.test.ts": 63, + "src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts": 1704, + "src/renderer/src/runtime/mirrored-agent-status-clock-skew.test.ts": 1534, + "src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts": 1255, + "src/renderer/src/runtime/mobile-markdown-bridge.test.ts": 2156, + "src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts": 53, + "src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts": 1736, + "src/renderer/src/runtime/paired-host-client-capabilities.test.ts": 251, + "src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts": 487, + "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts": 1745, + "src/renderer/src/runtime/remote-agent-row-last-assistant-message.test.ts": 1747, + "src/renderer/src/runtime/remote-agent-session-launch.test.ts": 101, + "src/renderer/src/runtime/remote-host-file-delete-repro.test.ts": 1622, + "src/renderer/src/runtime/remote-host-file-open-repro.test.ts": 1387, + "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts": 64, + "src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts": 150, + "src/renderer/src/runtime/remote-runtime-terminal-color-push.test.ts": 89, + "src/renderer/src/runtime/remote-runtime-terminal-end-verdict.test.ts": 156, + "src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts": 152, + "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts": 442, + "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts": 141, + "src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts": 115, + "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts": 273, + "src/renderer/src/runtime/remote-server-install-failure-probe.test.ts": 49, + "src/renderer/src/runtime/remote-server-parity.test.ts": 1540, + "src/renderer/src/runtime/remote-server-restart-wait.test.ts": 64, + "src/renderer/src/runtime/remote-server-update-coordinator.test.ts": 112, + "src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts": 55, + "src/renderer/src/runtime/remote-terminal-stream-watchdog.test.ts": 54, + "src/renderer/src/runtime/restored-client-hosted-browser-host-attach.test.ts": 59, + "src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts": 301, + "src/renderer/src/runtime/runtime-antigravity-accounts-client.test.ts": 69, + "src/renderer/src/runtime/runtime-client-events.test.ts": 61, + "src/renderer/src/runtime/runtime-client-target.test.ts": 46, + "src/renderer/src/runtime/runtime-environment-pairing-refresh.test.ts": 98, + "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts": 1589, + "src/renderer/src/runtime/runtime-file-client-download.test.ts": 397, + "src/renderer/src/runtime/runtime-file-client-external-import.test.ts": 419, + "src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts": 406, + "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts": 606, + "src/renderer/src/runtime/runtime-file-client-watch.test.ts": 508, + "src/renderer/src/runtime/runtime-file-client.test.ts": 397, + "src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts": 394, + "src/renderer/src/runtime/runtime-file-range-client.test.ts": 338, + "src/renderer/src/runtime/runtime-file-search-bounds.test.ts": 49, + "src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts": 308, + "src/renderer/src/runtime/runtime-git-client-merge.test.ts": 338, + "src/renderer/src/runtime/runtime-git-client.test.ts": 421, + "src/renderer/src/runtime/runtime-git-worktree-scope-client.test.ts": 439, + "src/renderer/src/runtime/runtime-hooks-client.test.ts": 101, + "src/renderer/src/runtime/runtime-host-connection-state.test.ts": 57, + "src/renderer/src/runtime/runtime-host-contact-parity.test.ts": 58, + "src/renderer/src/runtime/runtime-host-contact-regained.test.ts": 342, + "src/renderer/src/runtime/runtime-jira-client.test.ts": 230, + "src/renderer/src/runtime/runtime-jira-connection-status.test.ts": 50, + "src/renderer/src/runtime/runtime-jira-payload-stream.test.ts": 198, + "src/renderer/src/runtime/runtime-layout-client.test.ts": 115, + "src/renderer/src/runtime/runtime-legacy-inventory-budget.test.ts": 58, + "src/renderer/src/runtime/runtime-legacy-quick-open-inventory-expiry.test.ts": 733, + "src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts": 343, + "src/renderer/src/runtime/runtime-linear-client.test.ts": 118, + "src/renderer/src/runtime/runtime-path-existence-batch.test.ts": 327, + "src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts": 339, + "src/renderer/src/runtime/runtime-path-existence-pairing.test.ts": 307, + "src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts": 320, + "src/renderer/src/runtime/runtime-provider-accounts-client.test.ts": 132, + "src/renderer/src/runtime/runtime-provider-search-bounds.test.ts": 50, + "src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts": 484, + "src/renderer/src/runtime/runtime-repo-client.test.ts": 183, + "src/renderer/src/runtime/runtime-repo-search-bounds.test.ts": 53, + "src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts": 89, + "src/renderer/src/runtime/runtime-rpc-client.test.ts": 128, + "src/renderer/src/runtime/runtime-rpc-result.test.ts": 46, + "src/renderer/src/runtime/runtime-server-directory-browser.test.ts": 86, + "src/renderer/src/runtime/runtime-skills-client.test.ts": 90, + "src/renderer/src/runtime/runtime-skills-delete-client.test.ts": 74, + "src/renderer/src/runtime/runtime-terminal-inspection.test.ts": 1879, + "src/renderer/src/runtime/runtime-terminal-stream.test.ts": 408, + "src/renderer/src/runtime/runtime-terminal-verified-input.test.ts": 1396, + "src/renderer/src/runtime/runtime-worktree-selector.test.ts": 310, + "src/renderer/src/runtime/structured-agent-session-client.test.ts": 184, + "src/renderer/src/runtime/structured-agent-session-close.test.ts": 92, + "src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx": 371, + "src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts": 95, + "src/renderer/src/runtime/structured-agent-session-status-feed.test.ts": 104, + "src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts": 1480, + "src/renderer/src/runtime/structured-agent-session-turn-completion-feed.test.ts": 102, + "src/renderer/src/runtime/structured-conversation-tab-replacement.test.ts": 1794, + "src/renderer/src/runtime/structured-session-orca-session-id.test.ts": 271, + "src/renderer/src/runtime/structured-session-tab-inventory.test.ts": 57, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection-join.test.ts": 448, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts": 488, + "src/renderer/src/runtime/sync-runtime-graph-automation-leaf.test.ts": 471, + "src/renderer/src/runtime/sync-runtime-graph-browser.test.ts": 411, + "src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts": 1416, + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts": 425, + "src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts": 504, + "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts": 445, + "src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts": 443, + "src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts": 513, + "src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts": 538, + "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts": 396, + "src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts": 877, + "src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts": 429, + "src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts": 513, + "src/renderer/src/runtime/sync-runtime-graph-workspace-publication.test.ts": 423, + "src/renderer/src/runtime/sync-runtime-graph.test.ts": 483, + "src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts": 74, + "src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts": 277, + "src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts": 1954, + "src/renderer/src/runtime/use-worktree-runtime-target.test.ts": 1773, + "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts": 603, + "src/renderer/src/runtime/web-runtime-browser-creation-placement.test.ts": 1772, + "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts": 44, + "src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts": 57, + "src/renderer/src/runtime/web-runtime-pane-layout-intent.test.ts": 561, + "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts": 681, + "src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts": 610, + "src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts": 871, + "src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts": 969, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts": 643, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts": 480, + "src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts": 1143, + "src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts": 606, + "src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts": 647, + "src/renderer/src/runtime/web-runtime-session-tab-move.test.ts": 580, + "src/renderer/src/runtime/web-runtime-session-tab-props.test.ts": 667, + "src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts": 636, + "src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts": 597, + "src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts": 1145, + "src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts": 612, + "src/renderer/src/runtime/web-runtime-session.test.ts": 512, + "src/renderer/src/runtime/web-runtime-terminal-identity.test.ts": 59, + "src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts": 53, + "src/renderer/src/runtime/web-session-browser-placement.test.ts": 87, + "src/renderer/src/runtime/web-session-close-intent.test.ts": 51, + "src/renderer/src/runtime/web-session-existing-tab-index.test.ts": 41, + "src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts": 1527, + "src/renderer/src/runtime/web-session-intent-owner.test.ts": 64, + "src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts": 1429, + "src/renderer/src/runtime/web-session-restored-placeholder-identity.test.ts": 743, + "src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts": 851, + "src/renderer/src/runtime/web-session-structured-tab-focus.test.ts": 1830, + "src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts": 1651, + "src/renderer/src/runtime/web-session-tabs-late-merged-predecessor-lockout.test.ts": 620, + "src/renderer/src/runtime/web-session-tabs-publisher-identity-lineage.test.ts": 566, + "src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts": 524, + "src/renderer/src/runtime/web-session-tabs-retired-epoch-census-repair.test.tsx": 1870, + "src/renderer/src/runtime/web-session-tabs-stream-end-resubscribe.test.tsx": 1954, + "src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts": 858, + "src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts": 936, + "src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts": 924, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-page-content.test.ts": 1153, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-placement.test.ts": 926, + "src/renderer/src/runtime/web-session-tabs-sync-diff-focus-steal.test.ts": 880, + "src/renderer/src/runtime/web-session-tabs-sync-editor-ownership.test.ts": 1610, + "src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts": 920, + "src/renderer/src/runtime/web-session-tabs-sync-emptied-workspace-tombstone.test.ts": 907, + "src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts": 904, + "src/renderer/src/runtime/web-session-tabs-sync-host-restart-browser-rows.test.ts": 889, + "src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts": 1787, + "src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts": 907, + "src/renderer/src/runtime/web-session-tabs-sync-initial-terminal-relaunch.test.tsx": 1999, + "src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts": 856, + "src/renderer/src/runtime/web-session-tabs-sync-layout-groups.test.ts": 849, + "src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts": 1630, + "src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts": 1875, + "src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx": 2282, + "src/renderer/src/runtime/web-session-tabs-sync-remote-main-agent-verdict.test.ts": 1574, + "src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts": 1543, + "src/renderer/src/runtime/web-session-tabs-sync-restored-browser-rows.test.ts": 891, + "src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts": 1094, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-adoption.test.ts": 847, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-authority.test.ts": 752, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.ts": 594, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts": 835, + "src/renderer/src/runtime/web-session-tabs-sync-tracking-teardown.test.ts": 764, + "src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx": 1909, + "src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx": 1961, + "src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx": 8419, + "src/renderer/src/runtime/web-session-tabs-sync.test.ts": 936, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts": 1550, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-status-tab-retractions.test.ts": 1499, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts": 1593, + "src/renderer/src/runtime/web-session-tabs-sync/retraction-pane-ownership.test.ts": 1249, + "src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts": 50, + "src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.test.ts": 1895, + "src/renderer/src/runtime/web-session-terminal-handle-events.test.ts": 57, + "src/renderer/src/runtime/web-session-terminal-orphan-absence-across-republication.test.ts": 646, + "src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts": 1437, + "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts": 911, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts": 983, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts": 568, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-prior-removal.test.ts": 563, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts": 1006, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts": 760, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts": 719, + "src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts": 679, + "src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.test.ts": 750, + "src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts": 528, + "src/renderer/src/runtime/window-visibility-subscription-parking-stream-end.test.ts": 189, + "src/renderer/src/runtime/window-visibility-subscription-parking.test.ts": 218, "src/renderer/src/runtime/worktree-create-base.test.ts": 45, - "src/renderer/src/ssh/ssh-connect-in-flight.test.ts": 69, - "src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts": 51, - "src/renderer/src/ssh/ssh-connect-verb.test.ts": 35, - "src/renderer/src/ssh/ssh-connection-recoverability.test.ts": 50, - "src/renderer/src/startup/active-workspace-ssh-targets.test.ts": 68, - "src/renderer/src/startup/ssh-startup-reconnect.test.ts": 68, - "src/renderer/src/startup/startup-ssh-connection-restore.test.ts": 51, - "src/renderer/src/store/active-terminal-chrome-selector.test.ts": 44, - "src/renderer/src/store/always-mounted-selector-scan-cost.test.ts": 1670, - "src/renderer/src/store/copy-on-write-record.test.ts": 31, - "src/renderer/src/store/folder-workspaces/folder-workspace-catalog.test.ts": 51, - "src/renderer/src/store/github/cache-persistence.test.ts": 1363, - "src/renderer/src/store/github/refresh-event-noop-notification.test.ts": 1047, - "src/renderer/src/store/pinned-tab-close-guard.test.ts": 44, - "src/renderer/src/store/plugin-language-packs.test.ts": 321, - "src/renderer/src/store/plugin-panels.test.ts": 372, - "src/renderer/src/store/project-host-setup-selector.test.ts": 92, - "src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts": 57, - "src/renderer/src/store/projects/project-wsl-filesystem-boundary-advisory.test.ts": 64, - "src/renderer/src/store/react-commit-cascade-write-probe.test.ts": 56, - "src/renderer/src/store/repos/safe-auto-fork-sync.test.ts": 349, - "src/renderer/src/store/right-sidebar-route.test.ts": 48, - "src/renderer/src/store/running-terminal-close-confirm.test.ts": 64, - "src/renderer/src/store/selectors.test.ts": 855, - "src/renderer/src/store/slices/active-tab-owner-worktree.test.ts": 35, - "src/renderer/src/store/slices/activity-cleared-at.test.ts": 1576, - "src/renderer/src/store/slices/agent-generated-tab-title.test.ts": 1560, - "src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts": 1193, - "src/renderer/src/store/slices/agent-pane-authority.test.ts": 1347, - "src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts": 1530, - "src/renderer/src/store/slices/agent-status-batch.test.ts": 1745, - "src/renderer/src/store/slices/agent-status-drop-ipc.test.ts": 2454, - "src/renderer/src/store/slices/agent-status-drop.test.ts": 1626, - "src/renderer/src/store/slices/agent-status-freshness-cache.test.ts": 53, - "src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts": 66, - "src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts": 105, - "src/renderer/src/store/slices/agent-status-live-map-leak.test.ts": 14465, - "src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts": 1154, - "src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts": 1706, - "src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts": 1545, - "src/renderer/src/store/slices/agent-status-pane-keyed-records.test.ts": 81, - "src/renderer/src/store/slices/agent-status-pr-refresh-handoff.test.ts": 1297, - "src/renderer/src/store/slices/agent-status-provider-session.test.ts": 1545, - "src/renderer/src/store/slices/agent-status-quit-capture-resumable-agents.test.ts": 1507, - "src/renderer/src/store/slices/agent-status-quit-capture.test.ts": 1864, - "src/renderer/src/store/slices/agent-status-reminted-pane-key.test.ts": 1189, - "src/renderer/src/store/slices/agent-status-retained-leak.test.ts": 1428, - "src/renderer/src/store/slices/agent-status-retention-prefix-sweep.test.ts": 1493, - "src/renderer/src/store/slices/agent-status-runtime-orchestration.test.ts": 1552, - "src/renderer/src/store/slices/agent-status-session-boundary-done.test.ts": 1464, - "src/renderer/src/store/slices/agent-status-ssh-connection-clear.test.ts": 1418, - "src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts": 1612, - "src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts": 1555, - "src/renderer/src/store/slices/agent-status.test.ts": 1634, - "src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts": 1263, - "src/renderer/src/store/slices/browser-cleanup-close.test.ts": 1227, - "src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts": 1104, - "src/renderer/src/store/slices/browser-page-conversion.test.ts": 1151, - "src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts": 575, - "src/renderer/src/store/slices/browser-remote-tab-creation.test.ts": 381, - "src/renderer/src/store/slices/browser-session-host-selection.test.ts": 570, - "src/renderer/src/store/slices/browser-session-profiles.test.ts": 260, - "src/renderer/src/store/slices/browser-webview-cleanup.test.ts": 58, - "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts": 1651, - "src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts": 331, - "src/renderer/src/store/slices/browser.test.ts": 315, - "src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts": 959, - "src/renderer/src/store/slices/cmd-j-create-actions.test.ts": 1073, - "src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts": 1538, - "src/renderer/src/store/slices/degraded-repo-hydration.test.ts": 1492, - "src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts": 998, - "src/renderer/src/store/slices/detected-agents.test.ts": 330, - "src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts": 66, - "src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts": 270, - "src/renderer/src/store/slices/diffComments.test.ts": 948, - "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts": 951, - "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts": 1210, - "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts": 1067, - "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts": 75, - "src/renderer/src/store/slices/editor-branch-diff-snapshots.test.ts": 600, - "src/renderer/src/store/slices/editor-branch-line-total.test.ts": 345, - "src/renderer/src/store/slices/editor-check-details-tabs.test.ts": 568, - "src/renderer/src/store/slices/editor-close-file-cleanup.test.ts": 563, - "src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts": 392, - "src/renderer/src/store/slices/editor-hydration-scaling.test.ts": 1744, - "src/renderer/src/store/slices/editor-markdown-link-activation.test.ts": 432, - "src/renderer/src/store/slices/editor-markdown-view-state.test.ts": 273, - "src/renderer/src/store/slices/editor-open-diff.test.ts": 567, - "src/renderer/src/store/slices/editor-preview-tab-setting.test.ts": 318, - "src/renderer/src/store/slices/editor-read-only-tabs.test.ts": 391, - "src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts": 635, - "src/renderer/src/store/slices/editor-rekey-open-files.test.ts": 918, - "src/renderer/src/store/slices/editor-remote-branch-actions.test.ts": 1644, - "src/renderer/src/store/slices/editor-rich-markdown-size-override.test.ts": 519, - "src/renderer/src/store/slices/editor-right-sidebar-state.test.ts": 451, - "src/renderer/src/store/slices/editor-session-duplicate-restore.test.ts": 1199, - "src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts": 1615, - "src/renderer/src/store/slices/editor-tab-placement.test.ts": 440, - "src/renderer/src/store/slices/editor/actions/editor-cursor-line.test.ts": 537, - "src/renderer/src/store/slices/editor/actions/editor-draft-state.test.ts": 351, - "src/renderer/src/store/slices/editor/actions/markdown-preview-actions.test.ts": 121, - "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts": 788, - "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts": 367, - "src/renderer/src/store/slices/editor/git/git-status-reconciliation.perf.test.ts": 71, - "src/renderer/src/store/slices/empty-update-notifications.test.ts": 1193, - "src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts": 1724, - "src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts": 540, - "src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts": 1058, - "src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts": 1546, - "src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts": 232, - "src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts": 472, - "src/renderer/src/store/slices/github-checks-cache.test.ts": 227, - "src/renderer/src/store/slices/github-checks.test.ts": 49, - "src/renderer/src/store/slices/github-issue-source-indicator-suppression.test.ts": 261, - "src/renderer/src/store/slices/github-issue-state-machine.test.ts": 219, - "src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts": 182, - "src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts": 183, - "src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts": 224, - "src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts": 218, - "src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts": 195, - "src/renderer/src/store/slices/github-pr-checks-fetch.test.ts": 260, - "src/renderer/src/store/slices/github-pr-comments.test.ts": 338, - "src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts": 165, - "src/renderer/src/store/slices/github-pr-refresh-hosted-review-cache-leak.test.ts": 878, - "src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts": 387, - "src/renderer/src/store/slices/github-pr-refresh-sequences-leak.test.ts": 132, - "src/renderer/src/store/slices/github-pr-refresh-states-leak.test.ts": 4096, - "src/renderer/src/store/slices/github-project-request-coordination.test.ts": 199, - "src/renderer/src/store/slices/github-project-row-owner.test.ts": 58, - "src/renderer/src/store/slices/github-project-view-tables.test.ts": 260, - "src/renderer/src/store/slices/github-provider-request-concurrency.test.ts": 214, - "src/renderer/src/store/slices/github-refresh-sweep.test.ts": 487, - "src/renderer/src/store/slices/github-repo-lookup-index.test.ts": 42, - "src/renderer/src/store/slices/github-review-thread-actions.test.ts": 105, - "src/renderer/src/store/slices/github-work-item-cache-identity.test.ts": 165, - "src/renderer/src/store/slices/github-work-items-error-envelope.test.ts": 211, - "src/renderer/src/store/slices/github-work-items-pagination.test.ts": 147, - "src/renderer/src/store/slices/github-work-items-query-bounds.test.ts": 43, - "src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts": 433, - "src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts": 251, - "src/renderer/src/store/slices/hosted-review-cache-race.test.ts": 63, - "src/renderer/src/store/slices/hosted-review-cache.test.ts": 112, - "src/renderer/src/store/slices/hosted-review.test.ts": 74, - "src/renderer/src/store/slices/jira.test.ts": 234, - "src/renderer/src/store/slices/linear-credential-error-recovery.test.ts": 124, - "src/renderer/src/store/slices/linear-invalidation.test.ts": 174, - "src/renderer/src/store/slices/linear-issue-cache-refresh.test.ts": 190, - "src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts": 220, - "src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts": 257, - "src/renderer/src/store/slices/linear.test.ts": 172, - "src/renderer/src/store/slices/local-detected-agent-state.test.ts": 217, - "src/renderer/src/store/slices/memory.test.ts": 44, - "src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts": 1308, - "src/renderer/src/store/slices/new-issue-draft.test.ts": 32, - "src/renderer/src/store/slices/new-markdown.test.ts": 548, - "src/renderer/src/store/slices/orca-profiles-auth-actions-connect-overlap.test.ts": 1110, - "src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts": 1336, - "src/renderer/src/store/slices/orca-profiles.test.ts": 1591, - "src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts": 36, - "src/renderer/src/store/slices/pane-foreground-agent.test.ts": 1584, - "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts": 31, - "src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts": 46, - "src/renderer/src/store/slices/preflight.test.ts": 198, - "src/renderer/src/store/slices/project-group-removal-targets.test.ts": 73, - "src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts": 979, - "src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts": 1687, - "src/renderer/src/store/slices/rate-limits.test.ts": 40, - "src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts": 55, - "src/renderer/src/store/slices/recently-closed-tabs.test.ts": 1723, - "src/renderer/src/store/slices/remote-server-updates.integration.test.ts": 239, - "src/renderer/src/store/slices/repo-identity-reconcile.test.ts": 55, - "src/renderer/src/store/slices/repo-owner-cache-identity.test.ts": 36, - "src/renderer/src/store/slices/repo-reorder-host-split.test.ts": 29, - "src/renderer/src/store/slices/repos-add-races.test.ts": 934, - "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts": 774, - "src/renderer/src/store/slices/repos-all-hosts-generation.test.ts": 1525, - "src/renderer/src/store/slices/repos-all-hosts.test.ts": 1100, - "src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts": 1706, - "src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts": 1577, - "src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts": 1132, - "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts": 1217, - "src/renderer/src/store/slices/repos-host-identity-routing.test.ts": 1791, - "src/renderer/src/store/slices/repos-manual-order-hydration.test.ts": 1316, - "src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts": 1062, - "src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts": 1660, - "src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts": 1438, - "src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts": 1911, - "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts": 1182, - "src/renderer/src/store/slices/repos-project-group-create-race.test.ts": 1670, - "src/renderer/src/store/slices/repos-project-groups-delete.test.ts": 1213, - "src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts": 1649, - "src/renderer/src/store/slices/repos-project-groups.test.ts": 2702, - "src/renderer/src/store/slices/repos-project-host-capability.test.ts": 1491, - "src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts": 1516, - "src/renderer/src/store/slices/repos-project-runtime.test.ts": 1510, - "src/renderer/src/store/slices/repos-refresh-identity.test.ts": 1727, - "src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts": 1182, - "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts": 1574, - "src/renderer/src/store/slices/repos-runtime-project-groups.test.ts": 1141, - "src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts": 1266, - "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts": 1652, - "src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts": 1196, - "src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts": 1489, - "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts": 1869, - "src/renderer/src/store/slices/repos-stale-fetch.test.ts": 1671, - "src/renderer/src/store/slices/repos-update-serialization.test.ts": 1181, - "src/renderer/src/store/slices/repos.runtime-fallback.test.ts": 1080, - "src/renderer/src/store/slices/repos.test.ts": 1828, - "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts": 2296, - "src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts": 964, - "src/renderer/src/store/slices/runtime-environment-ssh.test.ts": 1253, - "src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts": 1218, - "src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts": 27, - "src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts": 1555, - "src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts": 1572, - "src/renderer/src/store/slices/runtime-status-first-publication.test.ts": 231, - "src/renderer/src/store/slices/runtime-status-reconnect-connection-generation.test.ts": 940, - "src/renderer/src/store/slices/runtime-status-refresh-diagnostics.test.ts": 43, - "src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts": 85, - "src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts": 1361, - "src/renderer/src/store/slices/runtime-status-snapshot.test.ts": 159, - "src/renderer/src/store/slices/runtime-status.test.ts": 744, - "src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts": 1535, - "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts": 1441, - "src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts": 1292, - "src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts": 1693, - "src/renderer/src/store/slices/settings-search-state.test.ts": 28, - "src/renderer/src/store/slices/settings.test.ts": 1774, - "src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts": 1003, - "src/renderer/src/store/slices/sparse-presets.test.ts": 154, - "src/renderer/src/store/slices/ssh-target-cleanup.test.ts": 1183, - "src/renderer/src/store/slices/ssh.test.ts": 1594, - "src/renderer/src/store/slices/stale-runtime-host-rows.test.ts": 66, - "src/renderer/src/store/slices/store-active-worktree-selection.test.ts": 1363, - "src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts": 936, - "src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts": 1825, - "src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts": 1764, - "src/renderer/src/store/slices/store-create-tab-id-hint.test.ts": 1193, - "src/renderer/src/store/slices/store-session-browser-hydration.test.ts": 1304, - "src/renderer/src/store/slices/store-session-cascades.test.ts": 1595, - "src/renderer/src/store/slices/store-session-editor-hydration.test.ts": 1660, - "src/renderer/src/store/slices/store-session-terminal-activity.test.ts": 1743, - "src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts": 1259, - "src/renderer/src/store/slices/store-session-workspace-hydration.test.ts": 1696, - "src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts": 1821, - "src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts": 1576, - "src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts": 4925, - "src/renderer/src/store/slices/store-sleep-runtime-convergence.test.ts": 2006, - "src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts": 1064, - "src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts": 1619, - "src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts": 47, - "src/renderer/src/store/slices/tab-group-reference-repair.test.ts": 44, - "src/renderer/src/store/slices/tab-group-state.test.ts": 48, - "src/renderer/src/store/slices/tab-view-mode.test.ts": 1209, - "src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts": 1020, - "src/renderer/src/store/slices/tabs-empty-split-activation.test.ts": 1261, - "src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts": 30, - "src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts": 74, - "src/renderer/src/store/slices/tabs-hydration.test.ts": 40, - "src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts": 963, - "src/renderer/src/store/slices/tabs-model-reconciliation.test.ts": 1467, - "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts": 1598, - "src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts": 1646, - "src/renderer/src/store/slices/tabs-session-hydration.test.ts": 1685, - "src/renderer/src/store/slices/tabs-unread-and-focus.test.ts": 931, - "src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts": 1267, - "src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts": 79, - "src/renderer/src/store/slices/tabs/tabs-reconciliation-batch-identity.test.ts": 1578, - "src/renderer/src/store/slices/task-creation-drafts.test.ts": 57, - "src/renderer/src/store/slices/terminal-helpers.test.ts": 83, - "src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts": 1592, - "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts": 1199, - "src/renderer/src/store/slices/terminal-orphan-helpers.test.ts": 157, - "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts": 1531, - "src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts": 1305, - "src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts": 187, - "src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts": 1171, - "src/renderer/src/store/slices/terminal-quick-command-hosts.test.ts": 1465, - "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts": 1601, - "src/renderer/src/store/slices/terminal-tab-id-hydration.test.ts": 1215, - "src/renderer/src/store/slices/terminal-tab-owner-index.test.ts": 42, - "src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts": 1542, - "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts": 1666, - "src/renderer/src/store/slices/terminal-tab-retirement.test.ts": 195, - "src/renderer/src/store/slices/terminal-tab-title-batch.test.ts": 1100, - "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts": 1607, - "src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts": 1078, - "src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts": 1366, - "src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts": 1490, - "src/renderer/src/store/slices/terminals-hydration.test.ts": 2826, - "src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts": 355, - "src/renderer/src/store/slices/ui-agent-send-target.test.ts": 413, - "src/renderer/src/store/slices/ui-contextual-tours.test.ts": 211, - "src/renderer/src/store/slices/ui-feature-interactions.test.ts": 408, - "src/renderer/src/store/slices/ui-hydration-view-layout.test.ts": 224, - "src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts": 284, - "src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts": 462, - "src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts": 580, - "src/renderer/src/store/slices/ui-new-workspace-draft.test.ts": 247, - "src/renderer/src/store/slices/ui-notice-dismissals.test.ts": 465, - "src/renderer/src/store/slices/ui-page-navigation.test.ts": 403, - "src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts": 1254, - "src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts": 55, - "src/renderer/src/store/slices/usage-web-client-fallback.test.ts": 51, - "src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 62, - "src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 369, - "src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 404, - "src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 1418, - "src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 196, - "src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 260, - "src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 64, - "src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 818, - "src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 138, - "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 1879, - "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 1391, - "src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 1426, - "src/renderer/src/store/slices/workspace-space.test.ts": 40, - "src/renderer/src/store/slices/worktree-by-id-index.test.ts": 52, - "src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 38, - "src/renderer/src/store/slices/worktree-helpers.test.ts": 55, - "src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 52, - "src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 71, - "src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 68, - "src/renderer/src/store/slices/worktree-nav-history.test.ts": 51, - "src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 1074, - "src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 1593, - "src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 1486, - "src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 67, - "src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 1508, - "src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 937, - "src/renderer/src/store/slices/worktrees-create-base-status.test.ts": 516, - "src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts": 587, - "src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts": 520, - "src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts": 1805, - "src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts": 566, - "src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts": 689, - "src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts": 377, - "src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts": 920, - "src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts": 629, - "src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts": 515, - "src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts": 1035, - "src/renderer/src/store/slices/worktrees-hydration-purge.test.ts": 604, - "src/renderer/src/store/slices/worktrees-identity-migration.test.ts": 1146, - "src/renderer/src/store/slices/worktrees-lineage-state.test.ts": 642, - "src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts": 506, - "src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts": 853, - "src/renderer/src/store/slices/worktrees-pending-creation-state.test.ts": 915, - "src/renderer/src/store/slices/worktrees-purge-terminal-state.test.ts": 442, - "src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts": 749, - "src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts": 909, - "src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts": 949, - "src/renderer/src/store/slices/worktrees-runtime-connection-generation-fence.test.ts": 1207, - "src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts": 819, - "src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts": 729, - "src/renderer/src/store/slices/worktrees-unread-state.test.ts": 663, - "src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts": 726, - "src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts": 102, - "src/renderer/src/store/slices/worktrees/listing/detected-worktree-unavailable-reason.test.ts": 52, - "src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts": 1634, - "src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts": 178, - "src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts": 842, - "src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts": 839, - "src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts": 673, - "src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts": 39, - "src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts": 340, - "src/renderer/src/store/slices/worktrees/teardown/worktree-teardown-array-identity.test.ts": 228, - "src/renderer/src/store/store-identity-churn-probe.test.ts": 73, - "src/renderer/src/store/store-listener-census.test.ts": 32, - "src/renderer/src/store/terminals/restored-relay-session-identity.test.ts": 1454, - "src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts": 1252, - "src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts": 1508, - "src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx": 1675, - "src/renderer/src/store/terminals/terminal-shutdown-guards-identity.test.ts": 39, - "src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts": 213, - "src/renderer/src/store/terminals/terminal-tab-close-map-identity.test.ts": 2043, - "src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts": 1822, - "src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts": 182, - "src/renderer/src/store/terminals/workspace-terminal-placeholders.test.ts": 102, - "src/renderer/src/store/worktree-diff-comments-selector.test.ts": 86, - "src/renderer/src/store/worktree-host-collision-index.test.ts": 53, - "src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts": 36, - "src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts": 195, - "src/renderer/src/web/preload-api/web-gitlab-api.test.ts": 45, - "src/renderer/src/web/web-clipboard-copy-fallback.test.ts": 75, - "src/renderer/src/web/web-clipboard-copy-terminal-selection.test.ts": 443, - "src/renderer/src/web/web-file-mutation-methods.test.ts": 234, - "src/renderer/src/web/web-pairing.test.ts": 54, - "src/renderer/src/web/web-preload-api-agent-providers.test.ts": 494, - "src/renderer/src/web/web-preload-api-clipboard.test.ts": 940, - "src/renderer/src/web/web-preload-api-composition.test.ts": 532, - "src/renderer/src/web/web-preload-api-filesystem.test.ts": 519, - "src/renderer/src/web/web-preload-api-git.test.ts": 455, - "src/renderer/src/web/web-preload-api-github.test.ts": 506, - "src/renderer/src/web/web-preload-api-gitlab.test.ts": 962, - "src/renderer/src/web/web-preload-api-keybindings.test.ts": 505, - "src/renderer/src/web/web-preload-api-runtime-calls.test.ts": 554, - "src/renderer/src/web/web-preload-api-runtime-environment.test.ts": 1261, - "src/renderer/src/web/web-preload-api-settings.test.ts": 1773, - "src/renderer/src/web/web-preload-api-ssh.test.ts": 398, - "src/renderer/src/web/web-preload-api-ui.test.ts": 1867, - "src/renderer/src/web/web-preload-api-workspace-catalog.test.ts": 1039, - "src/renderer/src/web/web-preload-updater-package-recovery.test.ts": 292, - "src/renderer/src/web/web-runtime-client-export-parity.test.ts": 214, - "src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts": 282, - "src/renderer/src/web/web-runtime-client-heartbeat.test.ts": 254, - "src/renderer/src/web/web-runtime-client-timeout-budget.test.ts": 217, - "src/renderer/src/web/web-runtime-client.test.ts": 593, - "src/renderer/src/web/web-runtime-connection-frame-router.test.ts": 140, - "src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts": 76, - "src/renderer/src/web/web-runtime-direct-proxy-live.integration.test.ts": 138, - "src/renderer/src/web/web-runtime-status-owner.test.ts": 882, - "src/renderer/src/web/web-shell-paths-exist.test.ts": 34, - "src/renderer/src/web/web-viewport-shell.test.ts": 48, - "src/renderer/src/web/web-workspace-session.test.ts": 169, - "src/shared/absolute-dir-override.test.ts": 80, - "src/shared/add-repo-existing-workspaces-telemetry.test.ts": 104, - "src/shared/agent-cli-flag-detection.test.ts": 40, - "src/shared/agent-cli-install-dir-fallback.test.ts": 65, - "src/shared/agent-decorative-title-signature.test.ts": 54, - "src/shared/agent-detection.test.ts": 112, - "src/shared/agent-feature-install-commands.test.ts": 48, - "src/shared/agent-hook-endpoint-file.test.ts": 36, - "src/shared/agent-hook-endpoint-temp-cleanup.test.ts": 53, - "src/shared/agent-hook-listener-antigravity.test.ts": 102, - "src/shared/agent-hook-listener-claude-compatible-vendors.test.ts": 174, - "src/shared/agent-hook-listener-claude-subagents.test.ts": 150, - "src/shared/agent-hook-listener-claude-turn-state.test.ts": 112, - "src/shared/agent-hook-listener-codex-approval-ownership.test.ts": 174, - "src/shared/agent-hook-listener-codex-main-agent.test.ts": 165, - "src/shared/agent-hook-listener-command-code-transcript.test.ts": 126, - "src/shared/agent-hook-listener-extraction-characterization.test.ts": 89, - "src/shared/agent-hook-listener-grok-completion.test.ts": 155, - "src/shared/agent-hook-listener-grok.test.ts": 160, - "src/shared/agent-hook-listener-hermes-codex-droid.test.ts": 206, - "src/shared/agent-hook-listener-interactive-prompts.test.ts": 207, - "src/shared/agent-hook-listener-omp-approval-ownership.test.ts": 166, - "src/shared/agent-hook-listener-opencode-reattribution.test.ts": 154, - "src/shared/agent-hook-listener-opencode-session-correlation.test.ts": 44, - "src/shared/agent-hook-listener-opencode-session-registry.test.ts": 38, - "src/shared/agent-hook-listener-pi-compatible.test.ts": 89, - "src/shared/agent-hook-listener-relay-dependency.test.ts": 33, - "src/shared/agent-hook-listener-roster-retention.test.ts": 177, - "src/shared/agent-hook-listener-session-replacement.test.ts": 130, - "src/shared/agent-hook-listener-startless-child-lifecycle.test.ts": 180, - "src/shared/agent-hook-listener-transport.test.ts": 151, - "src/shared/agent-hook-listener/providers/muse-events.test.ts": 192, - "src/shared/agent-hook-listener/providers/zcode-events.test.ts": 107, - "src/shared/agent-hook-listener/transcript-reader.test.ts": 170, - "src/shared/agent-hook-relay.test.ts": 34, - "src/shared/agent-hook-request-body-memory.test.ts": 63, - "src/shared/agent-hook-spool-read.test.ts": 72, - "src/shared/agent-hook-status-cache.test.ts": 60, - "src/shared/agent-hook-transport-interference.test.ts": 64, - "src/shared/agent-image-paste.test.ts": 34, - "src/shared/agent-kind.test.ts": 131, - "src/shared/agent-launch-operation.test.ts": 43, - "src/shared/agent-launch-preferences.test.ts": 47, - "src/shared/agent-launch-remote.test.ts": 45, - "src/shared/agent-lead-status-fold.test.ts": 93, - "src/shared/agent-model-probe-spec.test.ts": 61, - "src/shared/agent-notification-id.test.ts": 28, - "src/shared/agent-process-recognition.test.ts": 139, - "src/shared/agent-prompt-injection.test.ts": 59, - "src/shared/agent-resume-argv-drop.test.ts": 87, - "src/shared/agent-resume-launch-command.test.ts": 87, - "src/shared/agent-row-conversation-name.test.ts": 51, - "src/shared/agent-scratch-worktrees.test.ts": 67, - "src/shared/agent-session-conversation-name.test.ts": 32, - "src/shared/agent-session-definitive-refusal.test.ts": 100, - "src/shared/agent-session-fence-mint-boundary.test.ts": 2032, - "src/shared/agent-session-journal-producer.test.ts": 36, - "src/shared/agent-session-journal-schemas.test.ts": 184, - "src/shared/agent-session-lease-adjudication.test.ts": 40, - "src/shared/agent-session-legacy-handoff-lease.test.ts": 89, - "src/shared/agent-session-mutation-envelope.test.ts": 183, - "src/shared/agent-session-operation-ledger.test.ts": 115, - "src/shared/agent-session-option-catalog-grok.test.ts": 106, + "src/renderer/src/ssh/ssh-connect-in-flight.test.ts": 77, + "src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts": 58, + "src/renderer/src/ssh/ssh-connect-verb.test.ts": 57, + "src/renderer/src/ssh/ssh-connection-recoverability.test.ts": 52, + "src/renderer/src/ssh/ssh-managed-server-move-terminal-tabs.test.ts": 1540, + "src/renderer/src/startup/active-workspace-ssh-targets.test.ts": 70, + "src/renderer/src/startup/ssh-startup-reconnect.test.ts": 65, + "src/renderer/src/startup/startup-degraded-recovery.test.ts": 408, + "src/renderer/src/startup/startup-ssh-connection-restore.test.ts": 77, + "src/renderer/src/store/active-terminal-chrome-selector.test.ts": 52, + "src/renderer/src/store/adopt-from-endpoint.test.ts": 344, + "src/renderer/src/store/always-mounted-selector-scan-cost.test.ts": 1434, + "src/renderer/src/store/copy-on-write-record.test.ts": 46, + "src/renderer/src/store/folder-workspaces/folder-workspace-catalog.test.ts": 378, + "src/renderer/src/store/github/cache-persistence.test.ts": 1784, + "src/renderer/src/store/github/check-actions-errors.test.ts": 192, + "src/renderer/src/store/github/project-field-mutations.test.ts": 421, + "src/renderer/src/store/github/provider-request-coalescing.test.ts": 542, + "src/renderer/src/store/github/refresh-event-noop-notification.test.ts": 1347, + "src/renderer/src/store/github/visible-hosted-review-refresh-scheduler.test.ts": 91, + "src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts": 490, + "src/renderer/src/store/github/work-item-routing.test.ts": 281, + "src/renderer/src/store/host-catalog-fencing.test.ts": 75, + "src/renderer/src/store/pinned-tab-close-guard.test.ts": 53, + "src/renderer/src/store/plugin-language-packs-startup.test.ts": 375, + "src/renderer/src/store/plugin-language-packs.test.ts": 206, + "src/renderer/src/store/plugin-panel-retry-timer-cleanup.test.ts": 103, + "src/renderer/src/store/plugin-panels.test.ts": 145, + "src/renderer/src/store/project-host-setup-selector.test.ts": 76, + "src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts": 101, + "src/renderer/src/store/projects/project-wsl-filesystem-boundary-advisory.test.ts": 63, + "src/renderer/src/store/react-commit-cascade-write-probe.test.ts": 80, + "src/renderer/src/store/repos/converted-ssh-browser-pages.test.ts": 349, + "src/renderer/src/store/repos/converted-ssh-host-rows.test.ts": 60, + "src/renderer/src/store/repos/repo-removal.test.ts": 324, + "src/renderer/src/store/repos/safe-auto-fork-sync.test.ts": 320, + "src/renderer/src/store/right-sidebar-route.test.ts": 55, + "src/renderer/src/store/running-terminal-close-confirm.test.ts": 73, + "src/renderer/src/store/selectors.test.ts": 1496, + "src/renderer/src/store/settled-sort-epoch.test.ts": 1678, + "src/renderer/src/store/slices/active-selection-owner-scoping.test.ts": 1368, + "src/renderer/src/store/slices/active-tab-owner-worktree.test.ts": 70, + "src/renderer/src/store/slices/activity-cleared-at.test.ts": 1529, + "src/renderer/src/store/slices/agent-generated-tab-title.test.ts": 1429, + "src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts": 1457, + "src/renderer/src/store/slices/agent-pane-authority.test.ts": 1460, + "src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts": 1469, + "src/renderer/src/store/slices/agent-status-batch.test.ts": 1496, + "src/renderer/src/store/slices/agent-status-cursor-checkpoint.test.ts": 1418, + "src/renderer/src/store/slices/agent-status-drop-ipc.test.ts": 1802, + "src/renderer/src/store/slices/agent-status-freshness-cache.test.ts": 68, + "src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts": 63, + "src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts": 143, + "src/renderer/src/store/slices/agent-status-live-map-leak.test.ts": 7374, + "src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts": 1387, + "src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts": 1473, + "src/renderer/src/store/slices/agent-status-pane-keyed-records.test.ts": 84, + "src/renderer/src/store/slices/agent-status-pr-refresh-handoff.test.ts": 1504, + "src/renderer/src/store/slices/agent-status-provider-session.test.ts": 1961, + "src/renderer/src/store/slices/agent-status-quit-capture-resumable-agents.test.ts": 1365, + "src/renderer/src/store/slices/agent-status-quit-capture.test.ts": 1559, + "src/renderer/src/store/slices/agent-status-reminted-pane-key.test.ts": 1580, + "src/renderer/src/store/slices/agent-status-retention-prefix-sweep.test.ts": 1850, + "src/renderer/src/store/slices/agent-status-runtime-orchestration.test.ts": 1464, + "src/renderer/src/store/slices/agent-status-session-boundary-done.test.ts": 1563, + "src/renderer/src/store/slices/agent-status-ssh-connection-clear.test.ts": 1659, + "src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts": 1413, + "src/renderer/src/store/slices/agent-status-turn-started-at.test.ts": 1312, + "src/renderer/src/store/slices/agent-status-verdict-copies.test.ts": 431, + "src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts": 1377, + "src/renderer/src/store/slices/agent-status.test.ts": 1842, + "src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts": 1533, + "src/renderer/src/store/slices/browser-annotation-retention.test.ts": 603, + "src/renderer/src/store/slices/browser-cleanup-close.test.ts": 1587, + "src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts": 1828, + "src/renderer/src/store/slices/browser-page-conversion.test.ts": 1558, + "src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts": 671, + "src/renderer/src/store/slices/browser-remote-tab-creation.test.ts": 384, + "src/renderer/src/store/slices/browser-session-host-selection.test.ts": 672, + "src/renderer/src/store/slices/browser-session-profiles.test.ts": 434, + "src/renderer/src/store/slices/browser-tab-creation-placement.test.ts": 1348, + "src/renderer/src/store/slices/browser-webview-cleanup.test.ts": 76, + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts": 1525, + "src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts": 441, + "src/renderer/src/store/slices/browser.test.ts": 560, + "src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts": 1419, + "src/renderer/src/store/slices/cmd-j-create-actions.test.ts": 1303, + "src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts": 1446, + "src/renderer/src/store/slices/degraded-repo-hydration.test.ts": 1498, + "src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts": 1505, + "src/renderer/src/store/slices/detected-agents.test.ts": 482, + "src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts": 61, + "src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts": 179, + "src/renderer/src/store/slices/diffComments.test.ts": 1533, + "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts": 1425, + "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts": 1416, + "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts": 1261, + "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts": 86, + "src/renderer/src/store/slices/editor-branch-diff-snapshots.test.ts": 765, + "src/renderer/src/store/slices/editor-branch-line-total.test.ts": 637, + "src/renderer/src/store/slices/editor-chat-visual-tabs.test.ts": 700, + "src/renderer/src/store/slices/editor-check-details-tabs.test.ts": 745, + "src/renderer/src/store/slices/editor-close-document-siblings.test.ts": 1085, + "src/renderer/src/store/slices/editor-close-file-cleanup.test.ts": 1090, + "src/renderer/src/store/slices/editor-close-file-stale-active.test.ts": 1353, + "src/renderer/src/store/slices/editor-close-workspace-selection.test.ts": 784, + "src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts": 712, + "src/renderer/src/store/slices/editor-hydration-scaling.test.ts": 1448, + "src/renderer/src/store/slices/editor-markdown-link-activation.test.ts": 803, + "src/renderer/src/store/slices/editor-markdown-view-state.test.ts": 756, + "src/renderer/src/store/slices/editor-open-diff.test.ts": 831, + "src/renderer/src/store/slices/editor-preview-tab-setting.test.ts": 735, + "src/renderer/src/store/slices/editor-read-only-tabs.test.ts": 625, + "src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts": 882, + "src/renderer/src/store/slices/editor-rekey-open-files.test.ts": 1416, + "src/renderer/src/store/slices/editor-remote-branch-actions.test.ts": 766, + "src/renderer/src/store/slices/editor-rich-markdown-size-override.test.ts": 649, + "src/renderer/src/store/slices/editor-right-sidebar-state.test.ts": 860, + "src/renderer/src/store/slices/editor-session-duplicate-restore.test.ts": 1413, + "src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts": 1441, + "src/renderer/src/store/slices/editor-tab-host-ownership.test.ts": 651, + "src/renderer/src/store/slices/editor-tab-placement.test.ts": 729, + "src/renderer/src/store/slices/editor/actions/editor-cursor-line.test.ts": 667, + "src/renderer/src/store/slices/editor/actions/editor-draft-state.test.ts": 645, + "src/renderer/src/store/slices/editor/actions/markdown-preview-actions.test.ts": 294, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts": 484, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts": 451, + "src/renderer/src/store/slices/editor/git/git-status-reconciliation.perf.test.ts": 84, + "src/renderer/src/store/slices/empty-update-notifications.test.ts": 1428, + "src/renderer/src/store/slices/floating-workspace-row.test.ts": 1455, + "src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts": 1470, + "src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts": 809, + "src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts": 1733, + "src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts": 1460, + "src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts": 229, + "src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts": 330, + "src/renderer/src/store/slices/github-checks-cache.test.ts": 235, + "src/renderer/src/store/slices/github-checks.test.ts": 56, + "src/renderer/src/store/slices/github-issue-source-indicator-suppression.test.ts": 252, + "src/renderer/src/store/slices/github-issue-state-machine.test.ts": 224, + "src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts": 247, + "src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts": 228, + "src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts": 235, + "src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts": 234, + "src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts": 248, + "src/renderer/src/store/slices/github-pr-checks-fetch.test.ts": 292, + "src/renderer/src/store/slices/github-pr-comments.test.ts": 233, + "src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts": 202, + "src/renderer/src/store/slices/github-pr-refresh-hosted-review-cache-leak.test.ts": 362, + "src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts": 364, + "src/renderer/src/store/slices/github-pr-refresh-sequences-leak.test.ts": 230, + "src/renderer/src/store/slices/github-pr-refresh-states-leak.test.ts": 903, + "src/renderer/src/store/slices/github-pr-request-lifetime.test.ts": 713, + "src/renderer/src/store/slices/github-project-request-coordination.test.ts": 233, + "src/renderer/src/store/slices/github-project-row-owner.test.ts": 53, + "src/renderer/src/store/slices/github-project-view-tables.test.ts": 237, + "src/renderer/src/store/slices/github-provider-request-concurrency.test.ts": 206, + "src/renderer/src/store/slices/github-refresh-sweep.test.ts": 348, + "src/renderer/src/store/slices/github-repo-lookup-index.test.ts": 47, + "src/renderer/src/store/slices/github-review-thread-actions.test.ts": 217, + "src/renderer/src/store/slices/github-work-item-cache-identity.test.ts": 204, + "src/renderer/src/store/slices/github-work-items-error-envelope.test.ts": 1787, + "src/renderer/src/store/slices/github-work-items-pagination.test.ts": 1619, + "src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts": 1676, + "src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts": 272, + "src/renderer/src/store/slices/hidden-ssh-host-session-round-trip.test.ts": 1423, + "src/renderer/src/store/slices/hosted-review-cache-race.test.ts": 76, + "src/renderer/src/store/slices/hosted-review-cache.test.ts": 130, + "src/renderer/src/store/slices/hosted-review-request-lifetime.test.ts": 89, + "src/renderer/src/store/slices/hosted-review.test.ts": 130, + "src/renderer/src/store/slices/jira-connection-identity.test.ts": 90, + "src/renderer/src/store/slices/jira-web-client-status.test.ts": 126, + "src/renderer/src/store/slices/jira.test.ts": 166, + "src/renderer/src/store/slices/linear-credential-error-recovery.test.ts": 189, + "src/renderer/src/store/slices/linear-invalidation.test.ts": 142, + "src/renderer/src/store/slices/linear-issue-cache-refresh.test.ts": 177, + "src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts": 184, + "src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts": 200, + "src/renderer/src/store/slices/linear.test.ts": 168, + "src/renderer/src/store/slices/local-detected-agent-state.test.ts": 357, + "src/renderer/src/store/slices/memory.test.ts": 58, + "src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts": 1473, + "src/renderer/src/store/slices/new-issue-draft.test.ts": 50, + "src/renderer/src/store/slices/new-markdown.test.ts": 654, + "src/renderer/src/store/slices/orca-profiles-auth-actions-connect-overlap.test.ts": 1439, + "src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts": 1418, + "src/renderer/src/store/slices/orca-profiles.test.ts": 1424, + "src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts": 46, + "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts": 57, + "src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts": 69, + "src/renderer/src/store/slices/preflight.test.ts": 347, + "src/renderer/src/store/slices/project-group-removal-targets.test.ts": 69, + "src/renderer/src/store/slices/project-removal-owner-host.test.ts": 1320, + "src/renderer/src/store/slices/purge-stale-runtime-host-groups.test.ts": 1389, + "src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts": 1523, + "src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts": 1413, + "src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts": 49, + "src/renderer/src/store/slices/recently-closed-tabs.test.ts": 1467, + "src/renderer/src/store/slices/remote-server-updates.integration.test.ts": 305, + "src/renderer/src/store/slices/repo-identity-reconcile.test.ts": 72, + "src/renderer/src/store/slices/repo-owner-cache-identity.test.ts": 53, + "src/renderer/src/store/slices/repo-reorder-host-split.test.ts": 49, + "src/renderer/src/store/slices/repos-add-races.test.ts": 1465, + "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts": 1523, + "src/renderer/src/store/slices/repos-all-hosts-generation.test.ts": 1480, + "src/renderer/src/store/slices/repos-all-hosts.test.ts": 1476, + "src/renderer/src/store/slices/repos-catalog-connection-fence.test.ts": 1552, + "src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts": 1544, + "src/renderer/src/store/slices/repos-catalog-notifications.test.ts": 1367, + "src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts": 1355, + "src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts": 1448, + "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts": 2007, + "src/renderer/src/store/slices/repos-host-identity-routing.test.ts": 1747, + "src/renderer/src/store/slices/repos-manual-order-hydration.test.ts": 1398, + "src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts": 1925, + "src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts": 1395, + "src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts": 1383, + "src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts": 1708, + "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts": 1444, + "src/renderer/src/store/slices/repos-project-group-create-race.test.ts": 1513, + "src/renderer/src/store/slices/repos-project-groups-delete.test.ts": 1217, + "src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts": 1410, + "src/renderer/src/store/slices/repos-project-groups.test.ts": 1564, + "src/renderer/src/store/slices/repos-project-host-capability.test.ts": 1159, + "src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts": 2194, + "src/renderer/src/store/slices/repos-project-runtime.test.ts": 1478, + "src/renderer/src/store/slices/repos-refresh-identity.test.ts": 1791, + "src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts": 1545, + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts": 1355, + "src/renderer/src/store/slices/repos-runtime-project-groups.test.ts": 1437, + "src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts": 1554, + "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts": 1507, + "src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts": 1336, + "src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts": 1400, + "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts": 1557, + "src/renderer/src/store/slices/repos-stale-fetch.test.ts": 1405, + "src/renderer/src/store/slices/repos-update-serialization.test.ts": 1554, + "src/renderer/src/store/slices/repos.runtime-fallback.test.ts": 1456, + "src/renderer/src/store/slices/repos.test.ts": 1736, + "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts": 1695, + "src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts": 1585, + "src/renderer/src/store/slices/runtime-detected-agents.test.ts": 1466, + "src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts": 47, + "src/renderer/src/store/slices/runtime-environment-ssh.test.ts": 1392, + "src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts": 1569, + "src/renderer/src/store/slices/runtime-managed-repair-keeps-workspaces.test.ts": 1477, + "src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts": 1397, + "src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts": 1377, + "src/renderer/src/store/slices/runtime-status-first-publication.test.ts": 340, + "src/renderer/src/store/slices/runtime-status-reconnect-connection-generation.test.ts": 1417, + "src/renderer/src/store/slices/runtime-status-refresh-diagnostics.test.ts": 83, + "src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts": 158, + "src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts": 1723, + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts": 194, + "src/renderer/src/store/slices/runtime-status.test.ts": 497, + "src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts": 1350, + "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts": 1402, + "src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts": 1800, + "src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts": 1615, + "src/renderer/src/store/slices/settings-search-state.test.ts": 57, + "src/renderer/src/store/slices/settings.test.ts": 1698, + "src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts": 1627, + "src/renderer/src/store/slices/sparse-presets.test.ts": 93, + "src/renderer/src/store/slices/ssh-target-cleanup.test.ts": 1693, + "src/renderer/src/store/slices/ssh.test.ts": 1441, + "src/renderer/src/store/slices/stale-runtime-host-rows.test.ts": 59, + "src/renderer/src/store/slices/store-active-worktree-selection.test.ts": 1522, + "src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts": 1521, + "src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts": 1491, + "src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts": 1552, + "src/renderer/src/store/slices/store-create-tab-id-hint.test.ts": 1663, + "src/renderer/src/store/slices/store-session-browser-hydration.test.ts": 1483, + "src/renderer/src/store/slices/store-session-cascades.test.ts": 1360, + "src/renderer/src/store/slices/store-session-editor-hydration.test.ts": 1343, + "src/renderer/src/store/slices/store-session-terminal-activity.test.ts": 1448, + "src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts": 1555, + "src/renderer/src/store/slices/store-session-workspace-hydration.test.ts": 1701, + "src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts": 2125, + "src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts": 1412, + "src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts": 1442, + "src/renderer/src/store/slices/store-sleep-runtime-convergence.test.ts": 1758, + "src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts": 1456, + "src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts": 1459, + "src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts": 54, + "src/renderer/src/store/slices/tab-group-reference-repair.test.ts": 74, + "src/renderer/src/store/slices/tab-group-state.test.ts": 69, + "src/renderer/src/store/slices/tab-view-mode.test.ts": 1235, + "src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts": 1589, + "src/renderer/src/store/slices/tabs-close-dangling-group.test.ts": 1520, + "src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts": 67, + "src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts": 59, + "src/renderer/src/store/slices/tabs-hydration.test.ts": 68, + "src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts": 1510, + "src/renderer/src/store/slices/tabs-model-reconciliation.test.ts": 1681, + "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts": 1560, + "src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts": 1393, + "src/renderer/src/store/slices/tabs-session-hydration.test.ts": 1813, + "src/renderer/src/store/slices/tabs-unread-and-focus.test.ts": 1566, + "src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts": 1722, + "src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts": 1367, + "src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts": 67, + "src/renderer/src/store/slices/tabs/tabs-reconciliation-batch-identity.test.ts": 1401, + "src/renderer/src/store/slices/task-creation-drafts.test.ts": 45, + "src/renderer/src/store/slices/terminal-helpers.test.ts": 91, + "src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts": 1964, + "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts": 1509, + "src/renderer/src/store/slices/terminal-orphan-helpers.test.ts": 393, + "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts": 1381, + "src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts": 1758, + "src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts": 420, + "src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts": 1511, + "src/renderer/src/store/slices/terminal-quick-command-hosts.test.ts": 1285, + "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts": 2880, + "src/renderer/src/store/slices/terminal-tab-id-hydration.test.ts": 1368, + "src/renderer/src/store/slices/terminal-tab-owner-index.test.ts": 57, + "src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts": 1395, + "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts": 1489, + "src/renderer/src/store/slices/terminal-tab-retirement.test.ts": 446, + "src/renderer/src/store/slices/terminal-tab-title-batch.test.ts": 1406, + "src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts": 1342, + "src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts": 1470, + "src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts": 1346, + "src/renderer/src/store/slices/terminals-hydration.test.ts": 1435, + "src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts": 411, + "src/renderer/src/store/slices/ui-agent-send-target.test.ts": 589, + "src/renderer/src/store/slices/ui-contextual-tours.test.ts": 556, + "src/renderer/src/store/slices/ui-feature-interactions.test.ts": 660, + "src/renderer/src/store/slices/ui-host-filter-sync.test.ts": 485, + "src/renderer/src/store/slices/ui-hydration-view-layout.test.ts": 618, + "src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts": 426, + "src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts": 520, + "src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts": 507, + "src/renderer/src/store/slices/ui-notice-dismissals.test.ts": 536, + "src/renderer/src/store/slices/ui-page-navigation.test.ts": 563, + "src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts": 1598, + "src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts": 56, + "src/renderer/src/store/slices/usage-web-client-fallback.test.ts": 44, + "src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts": 1530, + "src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 69, + "src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 551, + "src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 671, + "src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 1355, + "src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 423, + "src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 423, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 96, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 1070, + "src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 432, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 1619, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 1785, + "src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 1408, + "src/renderer/src/store/slices/workspace-space.test.ts": 55, + "src/renderer/src/store/slices/worktree-by-id-index.test.ts": 42, + "src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 49, + "src/renderer/src/store/slices/worktree-helpers.test.ts": 88, + "src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 50, + "src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 104, + "src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 111, + "src/renderer/src/store/slices/worktree-nav-history.test.ts": 100, + "src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 1546, + "src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 1394, + "src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 1404, + "src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 158, + "src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 1445, + "src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 824, + "src/renderer/src/store/slices/worktrees-always-force-delete.test.ts": 786, + "src/renderer/src/store/slices/worktrees-background-removal.test.ts": 1562, + "src/renderer/src/store/slices/worktrees-create-base-status.test.ts": 887, + "src/renderer/src/store/slices/worktrees-create-captured-host.test.ts": 787, + "src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts": 962, + "src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts": 814, + "src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts": 1908, + "src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts": 886, + "src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts": 715, + "src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts": 883, + "src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts": 800, + "src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts": 866, + "src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts": 885, + "src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts": 1395, + "src/renderer/src/store/slices/worktrees-hydration-purge.test.ts": 965, + "src/renderer/src/store/slices/worktrees-identity-migration.test.ts": 1031, + "src/renderer/src/store/slices/worktrees-lineage-state.test.ts": 863, + "src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts": 829, + "src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts": 993, + "src/renderer/src/store/slices/worktrees-multiple-attachments.test.ts": 808, + "src/renderer/src/store/slices/worktrees-pending-creation-state.test.ts": 794, + "src/renderer/src/store/slices/worktrees-purge-terminal-state.test.ts": 832, + "src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts": 867, + "src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts": 879, + "src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts": 888, + "src/renderer/src/store/slices/worktrees-runtime-connection-generation-fence.test.ts": 1005, + "src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts": 779, + "src/renderer/src/store/slices/worktrees-terminal-pr-exact-observation.test.ts": 805, + "src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts": 1028, + "src/renderer/src/store/slices/worktrees-terminal-reference-admission.test.ts": 1524, + "src/renderer/src/store/slices/worktrees-terminal-reference-origins.test.ts": 914, + "src/renderer/src/store/slices/worktrees-terminal-reference-source-race.test.ts": 720, + "src/renderer/src/store/slices/worktrees-unread-state.test.ts": 932, + "src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts": 740, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts": 338, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-unavailable-reason.test.ts": 325, + "src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts": 1688, + "src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts": 98, + "src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts": 359, + "src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts": 797, + "src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts": 883, + "src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts": 388, + "src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts": 50, + "src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts": 299, + "src/renderer/src/store/slices/worktrees/teardown/worktree-teardown-array-identity.test.ts": 443, + "src/renderer/src/store/store-identity-churn-probe.test.ts": 71, + "src/renderer/src/store/store-listener-census.test.ts": 59, + "src/renderer/src/store/terminals/restored-relay-session-identity.test.ts": 1782, + "src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts": 1376, + "src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts": 1393, + "src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts": 1428, + "src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx": 1787, + "src/renderer/src/store/terminals/terminal-shutdown-guards-identity.test.ts": 57, + "src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts": 245, + "src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts": 1531, + "src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts": 347, + "src/renderer/src/store/terminals/workspace-terminal-placeholders.test.ts": 273, + "src/renderer/src/store/workspace-layout-cache.test.ts": 66, + "src/renderer/src/store/worktree-diff-comments-selector.test.ts": 101, + "src/renderer/src/store/worktree-host-collision-index.test.ts": 51, + "src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts": 45, + "src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts": 52, + "src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts": 267, + "src/renderer/src/web/preload-api/web-fallback-api.test.ts": 59, + "src/renderer/src/web/preload-api/web-git-stage-scope.test.ts": 528, + "src/renderer/src/web/preload-api/web-gitlab-api.test.ts": 56, + "src/renderer/src/web/preload-api/web-ui-compact-notice.test.ts": 818, + "src/renderer/src/web/preload-api/web-ui-explorer-root.test.ts": 695, + "src/renderer/src/web/preload-api/web-worktree-attachment-mutations.test.ts": 314, + "src/renderer/src/web/web-clipboard-copy-fallback.test.ts": 46, + "src/renderer/src/web/web-clipboard-copy-terminal-selection.test.ts": 140, + "src/renderer/src/web/web-clipboard-image-presence.test.ts": 367, + "src/renderer/src/web/web-file-mutation-methods.test.ts": 281, + "src/renderer/src/web/web-native-chat-appearance-settings.test.ts": 259, + "src/renderer/src/web/web-pairing.test.ts": 250, + "src/renderer/src/web/web-preload-api-agent-providers.test.ts": 795, + "src/renderer/src/web/web-preload-api-clipboard.test.ts": 1407, + "src/renderer/src/web/web-preload-api-composition.test.ts": 768, + "src/renderer/src/web/web-preload-api-filesystem.test.ts": 694, + "src/renderer/src/web/web-preload-api-git.test.ts": 605, + "src/renderer/src/web/web-preload-api-github.test.ts": 710, + "src/renderer/src/web/web-preload-api-gitlab.test.ts": 833, + "src/renderer/src/web/web-preload-api-jira.test.ts": 1051, + "src/renderer/src/web/web-preload-api-keybindings.test.ts": 597, + "src/renderer/src/web/web-preload-api-rate-limits.test.ts": 1529, + "src/renderer/src/web/web-preload-api-runtime-calls.test.ts": 1289, + "src/renderer/src/web/web-preload-api-runtime-environment.test.ts": 1527, + "src/renderer/src/web/web-preload-api-settings.test.ts": 2698, + "src/renderer/src/web/web-preload-api-ssh.test.ts": 451, + "src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts": 2567, + "src/renderer/src/web/web-preload-api-ui.test.ts": 2101, + "src/renderer/src/web/web-preload-api-workspace-catalog.test.ts": 2033, + "src/renderer/src/web/web-preload-updater-package-recovery.test.ts": 554, + "src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts": 213, + "src/renderer/src/web/web-runtime-client-heartbeat.test.ts": 234, + "src/renderer/src/web/web-runtime-client-timeout-budget.test.ts": 264, + "src/renderer/src/web/web-runtime-client.test.ts": 603, + "src/renderer/src/web/web-runtime-connection-frame-router.test.ts": 223, + "src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts": 46, + "src/renderer/src/web/web-runtime-direct-proxy-live.integration.test.ts": 251, + "src/renderer/src/web/web-runtime-environment-repair.test.ts": 827, + "src/renderer/src/web/web-runtime-status-owner.test.ts": 981, + "src/renderer/src/web/web-shell-paths-exist.test.ts": 40, + "src/renderer/src/web/web-workspace-session.test.ts": 272, + "src/renderer/src/web/web-zcode-plan-settings.test.ts": 726, + "src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts": 98, + "src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts": 71, + "src/shared/__tests__/process-host/windows-command-line.win32.test.ts": 53, + "src/shared/abort-signal-reason.test.ts": 46, + "src/shared/absolute-dir-override.test.ts": 62, + "src/shared/add-repo-existing-workspaces-telemetry.test.ts": 281, + "src/shared/agent-child-row-model.test.ts": 85, + "src/shared/agent-cli-flag-detection.test.ts": 56, + "src/shared/agent-cli-install-dir-fallback.test.ts": 73, + "src/shared/agent-decorative-title-signature.test.ts": 67, + "src/shared/agent-detection.test.ts": 146, + "src/shared/agent-feature-install-commands.test.ts": 65, + "src/shared/agent-hook-endpoint-file.test.ts": 49, + "src/shared/agent-hook-endpoint-temp-cleanup.test.ts": 58, + "src/shared/agent-hook-listener-antigravity.test.ts": 202, + "src/shared/agent-hook-listener-claude-compatible-vendors.test.ts": 187, + "src/shared/agent-hook-listener-claude-reordered-task-notification.test.ts": 192, + "src/shared/agent-hook-listener-claude-subagents.test.ts": 204, + "src/shared/agent-hook-listener-claude-task-notification.test.ts": 353, + "src/shared/agent-hook-listener-claude-turn-state.test.ts": 158, + "src/shared/agent-hook-listener-codex-approval-ownership.test.ts": 211, + "src/shared/agent-hook-listener-codex-interrupt.test.ts": 164, + "src/shared/agent-hook-listener-codex-main-agent.test.ts": 190, + "src/shared/agent-hook-listener-command-code-transcript.test.ts": 255, + "src/shared/agent-hook-listener-extraction-characterization.test.ts": 207, + "src/shared/agent-hook-listener-grok-completion.test.ts": 232, + "src/shared/agent-hook-listener-grok.test.ts": 232, + "src/shared/agent-hook-listener-hermes-codex-droid.test.ts": 203, + "src/shared/agent-hook-listener-interactive-prompts.test.ts": 179, + "src/shared/agent-hook-listener-jcode.test.ts": 214, + "src/shared/agent-hook-listener-omp-approval-ownership.test.ts": 200, + "src/shared/agent-hook-listener-opencode-reattribution.test.ts": 192, + "src/shared/agent-hook-listener-opencode-session-correlation.test.ts": 71, + "src/shared/agent-hook-listener-opencode-session-registry.test.ts": 71, + "src/shared/agent-hook-listener-opencode-tui-identity.test.ts": 180, + "src/shared/agent-hook-listener-pi-compatible.test.ts": 182, + "src/shared/agent-hook-listener-roster-retention.test.ts": 266, + "src/shared/agent-hook-listener-session-replacement.test.ts": 186, + "src/shared/agent-hook-listener-startless-child-lifecycle.test.ts": 169, + "src/shared/agent-hook-listener-transport.test.ts": 239, + "src/shared/agent-hook-listener/providers/dsh-events.test.ts": 161, + "src/shared/agent-hook-listener/providers/hermes-events.test.ts": 112, + "src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts": 115, + "src/shared/agent-hook-listener/providers/kiro-events.test.ts": 196, + "src/shared/agent-hook-listener/providers/muse-events.test.ts": 207, + "src/shared/agent-hook-listener/providers/zcode-events.test.ts": 197, + "src/shared/agent-hook-listener/reverse-file-region-scan.test.ts": 57, + "src/shared/agent-hook-listener/transcript-reader.test.ts": 94, + "src/shared/agent-hook-relay.test.ts": 54, + "src/shared/agent-hook-request-body-memory.test.ts": 92, + "src/shared/agent-hook-scrub-safe-env.test.ts": 58, + "src/shared/agent-hook-spool-read.test.ts": 62, + "src/shared/agent-hook-status-cache.test.ts": 80, + "src/shared/agent-hook-transport-interference.test.ts": 47, + "src/shared/agent-image-paste.test.ts": 57, + "src/shared/agent-kind.test.ts": 280, + "src/shared/agent-launch-operation.test.ts": 255, + "src/shared/agent-launch-preferences.test.ts": 45, + "src/shared/agent-launch-remote.test.ts": 43, + "src/shared/agent-lead-status-fold.test.ts": 55, + "src/shared/agent-main-agent-verdict.test.ts": 102, + "src/shared/agent-model-probe-spec.test.ts": 76, + "src/shared/agent-notification-id.test.ts": 48, + "src/shared/agent-process-presence-platform.test.ts": 55, + "src/shared/agent-process-presence-probe.test.ts": 69, + "src/shared/agent-process-recognition.test.ts": 204, + "src/shared/agent-prompt-injection.test.ts": 91, + "src/shared/agent-resume-argv-drop.test.ts": 134, + "src/shared/agent-resume-launch-command.test.ts": 195, + "src/shared/agent-row-conversation-name.test.ts": 70, + "src/shared/agent-scratch-worktrees.test.ts": 53, + "src/shared/agent-session-account-home.test.ts": 270, + "src/shared/agent-session-approval-blocked-path.test.ts": 50, + "src/shared/agent-session-approval-subject.test.ts": 228, + "src/shared/agent-session-attachments.test.ts": 46, + "src/shared/agent-session-availability.test.ts": 271, + "src/shared/agent-session-context-clear.test.ts": 362, + "src/shared/agent-session-conversation-name.test.ts": 64, + "src/shared/agent-session-definitive-refusal.test.ts": 277, + "src/shared/agent-session-failure-words.test.ts": 369, + "src/shared/agent-session-failure.test.ts": 269, + "src/shared/agent-session-journal-body-admission.test.ts": 303, + "src/shared/agent-session-journal-producer.test.ts": 47, + "src/shared/agent-session-journal-schemas.test.ts": 296, + "src/shared/agent-session-journal-submission-schema.test.ts": 239, + "src/shared/agent-session-lease-adjudication.test.ts": 62, + "src/shared/agent-session-legacy-handoff-lease.test.ts": 259, + "src/shared/agent-session-message-source.test.ts": 286, + "src/shared/agent-session-mutation-envelope.test.ts": 264, + "src/shared/agent-session-operation-ledger.test.ts": 284, + "src/shared/agent-session-option-catalog-grok.test.ts": 96, "src/shared/agent-session-option-catalog-omp.test.ts": 86, - "src/shared/agent-session-option-catalog.test.ts": 94, - "src/shared/agent-session-provider-handle.test.ts": 75, - "src/shared/agent-session-question-answer.test.ts": 38, - "src/shared/agent-session-restart-continuation.test.ts": 32, - "src/shared/agent-session-resume.test.ts": 53, - "src/shared/agent-session-thread-goal.test.ts": 60, - "src/shared/agent-session-turn-record.test.ts": 58, - "src/shared/agent-skill-sharing-contract.test.ts": 67, - "src/shared/agent-skill-sharing-gate.test.ts": 138, - "src/shared/agent-startup-plan-inputs.test.ts": 84, - "src/shared/agent-status-child-work-admission-activity.test.ts": 304, - "src/shared/agent-status-child-work-admission-collision.test.ts": 163, - "src/shared/agent-status-child-work-admission-parse.test.ts": 418, - "src/shared/agent-status-child-work-admission-sparse.test.ts": 228, - "src/shared/agent-status-child-work-admission.test.ts": 295, - "src/shared/agent-status-child-work-codec-boundary.test.ts": 740, - "src/shared/agent-status-child-work-legacy-golden.test.ts": 57, - "src/shared/agent-status-child-work-legality.test.ts": 234, - "src/shared/agent-status-child-work-lifetime.test.ts": 216, - "src/shared/agent-status-child-work-liveness.test.ts": 28, - "src/shared/agent-status-child-work-projection.test.ts": 54, - "src/shared/agent-status-child-work-reconciliation.test.ts": 439, - "src/shared/agent-status-child-work-view.test.ts": 128, - "src/shared/agent-status-child-work.test.ts": 192, - "src/shared/agent-status-legacy-adapter.test.ts": 38, - "src/shared/agent-status-legacy-ingress-ratchet.test.ts": 5709, - "src/shared/agent-status-observation.test.ts": 55, - "src/shared/agent-status-osc-pending-retention.test.ts": 96, - "src/shared/agent-status-osc-scan-budget.test.ts": 340, - "src/shared/agent-status-osc-split-frame-scan-budget.test.ts": 170, - "src/shared/agent-status-osc.test.ts": 65, - "src/shared/agent-status-run-alias-index.test.ts": 245, - "src/shared/agent-status-run-capability.test.ts": 42, + "src/shared/agent-session-option-catalog-opencode.test.ts": 79, + "src/shared/agent-session-option-catalog-removers.test.ts": 87, + "src/shared/agent-session-option-catalog.test.ts": 73, + "src/shared/agent-session-provider-context.test.ts": 267, + "src/shared/agent-session-provider-handle.test.ts": 100, + "src/shared/agent-session-question-answer.test.ts": 51, + "src/shared/agent-session-question-free-text.test.ts": 53, + "src/shared/agent-session-record-stored-form.test.ts": 264, + "src/shared/agent-session-refusal-notice.test.ts": 433, + "src/shared/agent-session-refusal-retry.test.ts": 53, + "src/shared/agent-session-registered-agents.test.ts": 233, + "src/shared/agent-session-restart-continuation.test.ts": 51, + "src/shared/agent-session-resume-cursor.test.ts": 144, + "src/shared/agent-session-resume.test.ts": 83, + "src/shared/agent-session-sign-in.test.ts": 269, + "src/shared/agent-session-status-snapshot-fold.test.ts": 62, + "src/shared/agent-session-stored-agent.test.ts": 250, + "src/shared/agent-session-thread-goal.test.ts": 56, + "src/shared/agent-session-turn-record.test.ts": 244, + "src/shared/agent-session-wire-refusals.test.ts": 250, + "src/shared/agent-session-write-failure.test.ts": 269, + "src/shared/agent-skill-sharing-contract.test.ts": 218, + "src/shared/agent-skill-sharing-gate.test.ts": 253, + "src/shared/agent-startup-plan-inputs-extra-agent-args.test.ts": 167, + "src/shared/agent-startup-plan-inputs.test.ts": 149, + "src/shared/agent-status-child-work-admission-activity.test.ts": 400, + "src/shared/agent-status-child-work-admission-collision.test.ts": 313, + "src/shared/agent-status-child-work-admission-parse.test.ts": 483, + "src/shared/agent-status-child-work-admission-sparse.test.ts": 334, + "src/shared/agent-status-child-work-admission.test.ts": 383, + "src/shared/agent-status-child-work-legacy-golden.test.ts": 77, + "src/shared/agent-status-child-work-legality.test.ts": 395, + "src/shared/agent-status-child-work-lifetime.test.ts": 464, + "src/shared/agent-status-child-work-liveness.test.ts": 47, + "src/shared/agent-status-child-work-projection.test.ts": 66, + "src/shared/agent-status-child-work-reconciliation.test.ts": 596, + "src/shared/agent-status-child-work-view-wire.test.ts": 49, + "src/shared/agent-status-child-work-view.test.ts": 339, + "src/shared/agent-status-child-work.test.ts": 311, + "src/shared/agent-status-legacy-adapter.test.ts": 65, + "src/shared/agent-status-observation.test.ts": 51, + "src/shared/agent-status-osc-pending-retention.test.ts": 126, + "src/shared/agent-status-osc-scan-budget.test.ts": 267, + "src/shared/agent-status-osc-split-frame-scan-budget.test.ts": 128, + "src/shared/agent-status-osc.test.ts": 98, + "src/shared/agent-status-run-alias-index.test.ts": 327, + "src/shared/agent-status-run-capability.test.ts": 87, "src/shared/agent-status-run.test.ts": 64, - "src/shared/agent-status-serving-readiness.test.ts": 32, - "src/shared/agent-status-store-bounds.test.ts": 2561, - "src/shared/agent-status-store-byte-budget.test.ts": 1188, - "src/shared/agent-status-store-in-place.test.ts": 40880, - "src/shared/agent-status-store-reopen.test.ts": 168, - "src/shared/agent-status-store-scaling.test.ts": 904, - "src/shared/agent-status-store.test.ts": 361, - "src/shared/agent-status-subject.test.ts": 174, - "src/shared/agent-status-transport-envelope.test.ts": 295, - "src/shared/agent-status-types.test.ts": 130, - "src/shared/agent-tab-title.test.ts": 76, - "src/shared/agent-terminal-status-equivalence.test.ts": 59, - "src/shared/agent-title-agy-gemini-collision.test.ts": 70, - "src/shared/agent-title-decoration.test.ts": 48, - "src/shared/agent-title-evidence.test.ts": 122, - "src/shared/agent-title-identity-characterization.test.ts": 73, - "src/shared/agent-tui-input-clear.test.ts": 39, - "src/shared/ai-vault-resume-command.test.ts": 68, - "src/shared/ai-vault-resume-preparation.test.ts": 49, - "src/shared/ai-vault-scan-error-message.test.ts": 26, - "src/shared/ai-vault-search-contract.test.ts": 138, + "src/shared/agent-status-serving-readiness.test.ts": 45, + "src/shared/agent-status-store-bounds.test.ts": 1432, + "src/shared/agent-status-store-byte-budget.test.ts": 1322, + "src/shared/agent-status-store-in-place.test.ts": 3761, + "src/shared/agent-status-store-parent.test.ts": 262, + "src/shared/agent-status-store-reopen.test.ts": 301, + "src/shared/agent-status-store-scaling.test.ts": 771, + "src/shared/agent-status-store.test.ts": 408, + "src/shared/agent-status-subject.test.ts": 317, + "src/shared/agent-status-transport-envelope.test.ts": 332, + "src/shared/agent-status-types.test.ts": 163, + "src/shared/agent-stop-display-status.test.ts": 51, + "src/shared/agent-tab-title.test.ts": 53, + "src/shared/agent-terminal-status-equivalence.test.ts": 40, + "src/shared/agent-title-agy-gemini-collision.test.ts": 84, + "src/shared/agent-title-decoration.test.ts": 46, + "src/shared/agent-title-evidence.test.ts": 114, + "src/shared/agent-title-identity-characterization.test.ts": 75, + "src/shared/agent-transcript-poll-scheduler.test.ts": 165, + "src/shared/agent-tui-input-clear.test.ts": 59, + "src/shared/ai-vault-resume-command.test.ts": 65, + "src/shared/ai-vault-resume-preparation.test.ts": 48, + "src/shared/ai-vault-scan-error-message.test.ts": 61, + "src/shared/ai-vault-search-agent-history.test.ts": 291, + "src/shared/ai-vault-search-contract.test.ts": 258, "src/shared/ai-vault-search-query-operators.test.ts": 70, - "src/shared/ai-vault-search-scope-contract.test.ts": 178, - "src/shared/ai-vault-search-settings.test.ts": 115, - "src/shared/ai-vault-session-depth.test.ts": 65, - "src/shared/ai-vault-session-filters.test.ts": 172, - "src/shared/ai-vault-types.test.ts": 52, - "src/shared/antigravity-source-control.test.ts": 165, - "src/shared/app-version.test.ts": 44, - "src/shared/artifact-sharing-gate.test.ts": 149, - "src/shared/automation-cron-dialect.test.ts": 50, - "src/shared/automation-cron-input-validation.test.ts": 78, - "src/shared/automation-execution-target.test.ts": 54, - "src/shared/automation-host-filter.test.ts": 53, - "src/shared/automation-legacy-list-partition.test.ts": 33, - "src/shared/automation-list-response.test.ts": 44, - "src/shared/automation-list-scope.test.ts": 51, - "src/shared/automation-owner-key.test.ts": 43, - "src/shared/automation-owner-precondition.test.ts": 75, - "src/shared/automation-run-identity.test.ts": 47, - "src/shared/automation-run-retention.test.ts": 45, - "src/shared/automation-schedule-drift.test.ts": 87, - "src/shared/automation-schedules.test.ts": 199, - "src/shared/automation-usage-summary.test.ts": 64, - "src/shared/base-ref-search-result.test.ts": 40, - "src/shared/binary-file-extensions.test.ts": 54, - "src/shared/bounded-map.test.ts": 68, + "src/shared/ai-vault-search-scope-contract.test.ts": 247, + "src/shared/ai-vault-search-settings.test.ts": 214, + "src/shared/ai-vault-search-status-fallback.test.ts": 278, + "src/shared/ai-vault-session-depth.test.ts": 66, + "src/shared/ai-vault-session-filters.test.ts": 173, + "src/shared/ai-vault-types.test.ts": 54, + "src/shared/antigravity-source-control.test.ts": 317, + "src/shared/antigravity-transcript-reference.test.ts": 59, + "src/shared/app-version.test.ts": 54, + "src/shared/artifact-sharing-gate.test.ts": 312, + "src/shared/automation-cron-dialect.test.ts": 77, + "src/shared/automation-cron-input-validation.test.ts": 66, + "src/shared/automation-execution-target.test.ts": 50, + "src/shared/automation-extra-agent-args.test.ts": 166, + "src/shared/automation-host-filter.test.ts": 68, + "src/shared/automation-legacy-list-partition.test.ts": 53, + "src/shared/automation-list-response.test.ts": 53, + "src/shared/automation-list-scope.test.ts": 96, + "src/shared/automation-owner-key.test.ts": 62, + "src/shared/automation-owner-precondition.test.ts": 80, + "src/shared/automation-run-identity.test.ts": 40, + "src/shared/automation-run-retention.test.ts": 87, + "src/shared/automation-schedule-drift.test.ts": 97, + "src/shared/automation-schedules.test.ts": 154, + "src/shared/automation-usage-summary.test.ts": 48, + "src/shared/background-task-roster.test.ts": 98, + "src/shared/base-ref-search-result.test.ts": 46, + "src/shared/binary-file-extensions.test.ts": 52, + "src/shared/bounded-map.test.ts": 73, "src/shared/branch-name-from-work.test.ts": 59, - "src/shared/branch-prefix.test.ts": 55, - "src/shared/browser-client-automation-protocol.test.ts": 145, - "src/shared/browser-client-host-id-argument.test.ts": 51, - "src/shared/browser-client-host-protocol.test.ts": 255, - "src/shared/browser-client-host-reconciliation-protocol.test.ts": 81, - "src/shared/browser-client-hosting-eligibility.test.ts": 39, - "src/shared/browser-cookie-import-sources.test.ts": 31, - "src/shared/browser-grab-types.test.ts": 49, - "src/shared/browser-network-capabilities.test.ts": 28, - "src/shared/browser-network-tunnel-protocol.test.ts": 86, - "src/shared/browser-network-tunnel-stream-framing.test.ts": 1565, - "src/shared/browser-screencast-protocol.test.ts": 48, - "src/shared/browser-url.test.ts": 128, - "src/shared/bundled-ripgrep.test.ts": 24, - "src/shared/cheap-process-table-snapshot.test.ts": 55, - "src/shared/check-job-log-byte-cap.test.ts": 77, - "src/shared/check-job-log-retention.test.ts": 186, - "src/shared/check-job-log-tail-slice.test.ts": 55, - "src/packages/process-host/src/bounded-output-sink.test.ts": 48, - "src/packages/process-host/src/close-process-registry.test.ts": 40, - "src/packages/process-host/src/posix-lsof-probe.test.ts": 4289, - "src/packages/process-host/src/process-tree-termination.test.ts": 68, - "src/packages/process-host/src/retryable-process-exit-proof.test.ts": 38, - "src/packages/process-host/src/run-process-termination-failure.test.ts": 49, - "src/packages/process-host/src/run-process.test.ts": 7245, - "src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts": 57, - "src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts": 42, - "src/packages/process-host/src/windows-command-line.test.ts": 55, - "src/shared/__tests__/process-host/windows-command-line.win32.test.ts": 39, - "src/shared/child-process/windows-console-visibility.test.ts": 3436, - "src/shared/claimed-agent-pty-owner.test.ts": 94, - "src/shared/claude-agent-teams-tmux-compat.test.ts": 57, - "src/shared/claude-background-task-kind.test.ts": 26, - "src/shared/claude-background-task-status.test.ts": 168, - "src/shared/claude-model-list-probe.test.ts": 50, - "src/shared/claude-statusline-rate-limits.test.ts": 54, - "src/shared/claude-subagent-roster.test.ts": 65, - "src/shared/claude-subagent-row-lifecycle.test.ts": 193, - "src/shared/cli-argument-boundary.test.ts": 35, - "src/shared/cli-runtime-pairing-boundary.test.ts": 201, - "src/shared/cli-workspace-provenance.test.ts": 35, - "src/shared/client-environment-info.test.ts": 55, - "src/shared/clipboard-image.test.ts": 36, - "src/shared/clipboard-text.test.ts": 75, - "src/shared/closed-terminal-tab-tombstones.test.ts": 125, - "src/shared/cloud-service-url.test.ts": 38, - "src/shared/codex-auth-errors.test.ts": 51, - "src/shared/codex-pet-sprite-defaults.test.ts": 62, - "src/shared/codex-reset-credit-attempt-ledger.test.ts": 129, - "src/shared/codex-reset-credit-scope.test.ts": 48, - "src/shared/codex-startup-delivery.test.ts": 43, - "src/shared/codex-subagent-poll-scheduler.test.ts": 54, - "src/shared/codex-subagent-rollout-lifecycle.test.ts": 109, - "src/shared/codex-subagent-roster.test.ts": 75, - "src/shared/codex-subagent-transcript.test.ts": 72, - "src/shared/combined-diff-file-tree-width.test.ts": 49, - "src/shared/command-code-output-retention.test.ts": 64, - "src/shared/command-code-output-status.test.ts": 214, - "src/shared/command-code-turn-boundary.test.ts": 48, - "src/shared/command-environment.test.ts": 69, - "src/shared/command-token-scanner.test.ts": 48, - "src/shared/commit-message-agent-spec.test.ts": 122, - "src/shared/commit-message-generation.test.ts": 57, - "src/shared/commit-message-omp.test.ts": 239, - "src/shared/commit-message-plan.test.ts": 51, - "src/shared/commit-message-prompt.test.ts": 60, - "src/shared/computer-awake-mode.test.ts": 36, - "src/shared/computer-use-error-recovery.test.ts": 50, - "src/shared/computer-use-key-spec.test.ts": 27, - "src/shared/constants.test.ts": 193, - "src/shared/contextual-tours.test.ts": 63, - "src/shared/crash-reporting.test.ts": 45, - "src/shared/cross-platform-path-guards.test.ts": 1653, - "src/shared/cross-platform-path.test.ts": 47, - "src/shared/daemon-adoption-telemetry.test.ts": 168, - "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 152, - "src/shared/doc-preview-file-access.test.ts": 80, - "src/shared/doc-preview-scheme.test.ts": 46, - "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 98, - "src/shared/draft-paste-ready-scanner.test.ts": 46, - "src/shared/emoji-shortcode-catalog.lazy.test.ts": 116, - "src/shared/emulator-keyboard-frame.test.ts": 53, - "src/shared/emulator-touch-frame.test.ts": 36, - "src/shared/ephemeral-setup-terminal-worktree-id.test.ts": 50, - "src/shared/ephemeral-vm-recipe-checkout-mode.test.ts": 130, - "src/shared/ephemeral-vm-recipe-doctor.test.ts": 47, - "src/shared/ephemeral-vm-recipe-output-capture.test.ts": 223, - "src/shared/ephemeral-vm-recipe-process.test.ts": 237, - "src/shared/ephemeral-vm-recipe-repo-url.test.ts": 51, - "src/shared/ephemeral-vm-recipes.test.ts": 122, - "src/shared/ephemeral-vm-runtime-feature-sorting.test.ts": 285, - "src/shared/ephemeral-vm-runtime-store-rollback.test.ts": 661, - "src/shared/ephemeral-vm-runtime-store.test.ts": 213, - "src/shared/event-loop-yield.test.ts": 68, - "src/shared/execution-host-registry.test.ts": 70, - "src/shared/execution-host.test.ts": 66, - "src/shared/export-let-function-initializer-ban.test.ts": 3945, - "src/shared/external-automation-jobs-file.test.ts": 95, - "src/shared/external-worktree-inbox.test.ts": 35, - "src/shared/feature-education-telemetry.test.ts": 38, - "src/shared/feature-interactions.test.ts": 2141, - "src/shared/feature-tips.test.ts": 68, - "src/shared/feature-wall-tour-depth.test.ts": 27, - "src/shared/feedback-image-limits.test.ts": 49, - "src/shared/file-link-location.test.ts": 51, - "src/shared/file-name-sort.test.ts": 48, - "src/shared/filesystem-directory-listing-limit.test.ts": 53, - "src/shared/fish-binary-requirement.test.ts": 45, - "src/shared/folder-workspace-execution-host.test.ts": 79, - "src/shared/folder-workspace-worktree.test.ts": 46, - "src/shared/folder-workspaces.test.ts": 33, - "src/shared/foreground-process-ancestry-parity.test.ts": 379, - "src/shared/foreground-process-selection.test.ts": 96, - "src/shared/foreground-wrapper-agent.test.ts": 51, - "src/shared/generated-code-path.test.ts": 46, - "src/shared/git-binary-compatibility.test.ts": 52, - "src/shared/git-branch-cleanup.test.ts": 62, - "src/shared/git-branch-compare-head.test.ts": 30, - "src/shared/git-branch-line-total-soft-deadline.test.ts": 3579, - "src/shared/git-branch-line-total.test.ts": 60, - "src/shared/git-capability-cache.test.ts": 58, - "src/shared/git-check-ignore-stdio.test.ts": 54, - "src/shared/git-clone-failure-message.test.ts": 74, - "src/shared/git-config-snapshot-runner.test.ts": 67, - "src/shared/git-configured-branch-target.test.ts": 60, - "src/shared/git-cquoted-path.test.ts": 29, - "src/shared/git-diff-transport-budget.test.ts": 2035, - "src/shared/git-discard-path-safety.test.ts": 40, - "src/shared/git-exec-mutation.test.ts": 53, - "src/shared/git-fetch-head-capability.test.ts": 39, - "src/shared/git-fetch-head-lock-key-derivation.test.ts": 133, - "src/shared/git-fetch-head-lock.test.ts": 169, - "src/shared/git-fork-sync.test.ts": 54, - "src/shared/git-history-graph.test.ts": 95, - "src/shared/git-history-message-allocation.test.ts": 45, - "src/shared/git-history-ref-display.test.ts": 65, - "src/shared/git-history.test.ts": 68, - "src/shared/git-merge-tree-capability.test.ts": 31, - "src/shared/git-metadata-path.test.ts": 63, - "src/shared/git-push-target-validation.test.ts": 44, - "src/shared/git-remote-error.test.ts": 73, - "src/shared/git-remote-identity.test.ts": 56, - "src/shared/git-remote-url-index.test.ts": 53, - "src/shared/git-rev-list-output.test.ts": 43, - "src/shared/git-status-branch-line-total-cache.test.ts": 55, - "src/shared/git-status-conflict-entries.test.ts": 33, - "src/shared/git-status-limit.test.ts": 37, - "src/shared/git-status-line-stat-inputs.test.ts": 32, - "src/shared/git-status-line-stats-cache.test.ts": 67, - "src/shared/git-status-upstream-ref.test.ts": 59, - "src/shared/git-tracked-pathspecs.test.ts": 207, - "src/shared/git-uncommitted-line-stats.test.ts": 85, - "src/shared/git-upstream-status.test.ts": 33, - "src/shared/git-worktree-operation-lock.test.ts": 32, - "src/shared/github/account-binding.test.ts": 41, - "src/shared/github/api-availability.test.ts": 38, - "src/shared/github/owner-slug.test.ts": 40, - "src/shared/github/project-identity.test.ts": 25, - "src/shared/github/project-ref-input.test.ts": 39, - "src/shared/github/project-roadmap-timeline.test.ts": 77, - "src/shared/github/pull-request-auto-merge-availability.test.ts": 64, - "src/shared/github/pull-request-for-branch-outcome.test.ts": 45, - "src/shared/github/pull-request-merge-methods.test.ts": 50, - "src/shared/github/repository-identity-key.test.ts": 49, - "src/shared/github/work-items-query-bounds.test.ts": 58, - "src/shared/gitlab-job-log-excerpt.test.ts": 91, - "src/shared/gitlab-job-trace-check-details.test.ts": 32, - "src/shared/gitlab-pipeline-checks.test.ts": 34, - "src/shared/gitlab-projects.test.ts": 40, - "src/shared/grok-model-list-probe.test.ts": 49, - "src/shared/grok-session-paths.test.ts": 87, - "src/packages/process-host/src/growing-byte-buffer.test.ts": 96, - "src/shared/handled-wire-discriminant.test.ts": 36, - "src/shared/harness-injected-user-turns.test.ts": 42, - "src/shared/hermes-run-ref-retention.test.ts": 29, - "src/shared/hermes-session-run-index.test.ts": 692, - "src/shared/hermes-startup-query.windows.test.ts": 41, - "src/shared/hook-command-source-policy.test.ts": 52, - "src/shared/host-balanced-listing-scaling.test.ts": 56, - "src/shared/host-display-resolution.test.ts": 33, - "src/shared/host-setting-overrides.test.ts": 37, + "src/shared/branch-prefix.test.ts": 63, + "src/shared/browser-annotation-viewport-lifecycle.test.ts": 186, + "src/shared/browser-client-automation-protocol.test.ts": 240, + "src/shared/browser-client-host-id-argument.test.ts": 52, + "src/shared/browser-client-host-protocol.test.ts": 320, + "src/shared/browser-client-host-reconciliation-protocol.test.ts": 254, + "src/shared/browser-client-hosting-eligibility.test.ts": 67, + "src/shared/browser-cookie-import-sources.test.ts": 57, + "src/shared/browser-grab-types.test.ts": 56, + "src/shared/browser-network-tunnel-protocol.test.ts": 45, + "src/shared/browser-network-tunnel-stream-framing.test.ts": 1622, + "src/shared/browser-screencast-protocol.test.ts": 108, + "src/shared/browser-url.test.ts": 308, + "src/shared/bundled-ripgrep.test.ts": 37, + "src/shared/cheap-process-table-snapshot.test.ts": 77, + "src/shared/check-job-log-byte-cap.test.ts": 94, + "src/shared/check-job-log-retention.test.ts": 222, + "src/shared/check-job-log-tail-slice.test.ts": 71, + "src/shared/claimed-agent-pty-owner.test.ts": 71, + "src/shared/claude-agent-teams-tmux-compat.test.ts": 62, + "src/shared/claude-background-task-kind.test.ts": 42, + "src/shared/claude-background-task-status.test.ts": 260, + "src/shared/claude-model-list-probe.test.ts": 57, + "src/shared/claude-owed-notification-resource-contract.test.ts": 183, + "src/shared/claude-profile-activation-contract.test.ts": 68, + "src/shared/claude-shell-function.test.ts": 196, + "src/shared/claude-sign-in-link.test.ts": 47, + "src/shared/claude-statusline-rate-limits.test.ts": 47, + "src/shared/claude-subagent-roster.test.ts": 111, + "src/shared/claude-subagent-row-lifecycle.test.ts": 205, + "src/shared/claude-terminal-interrupt.test.ts": 54, + "src/shared/cli-argument-boundary.test.ts": 54, + "src/shared/cli-workspace-provenance.test.ts": 50, + "src/shared/client-environment-info.test.ts": 42, + "src/shared/clipboard-image.test.ts": 40, + "src/shared/clipboard-text.test.ts": 86, + "src/shared/closed-terminal-tab-tombstones.test.ts": 242, + "src/shared/cloud-service-url.test.ts": 45, + "src/shared/coalescing-keyed-runner.test.ts": 112, + "src/shared/codebuddy-agent.test.ts": 234, + "src/shared/codex-auth-errors.test.ts": 55, + "src/shared/codex-composer-ready-scanner.test.ts": 97, + "src/shared/codex-pet-sprite-defaults.test.ts": 70, + "src/shared/codex-reset-credit-attempt-ledger.test.ts": 213, + "src/shared/codex-reset-credit-scope.test.ts": 54, + "src/shared/codex-shared-server-command.test.ts": 65, + "src/shared/codex-startup-delivery.test.ts": 85, + "src/shared/codex-status-transcript-line.test.ts": 83, + "src/shared/codex-subagent-poll-scheduler.test.ts": 57, + "src/shared/codex-subagent-rollout-lifecycle.test.ts": 172, + "src/shared/codex-subagent-roster.test.ts": 60, + "src/shared/codex-subagent-transcript.test.ts": 100, + "src/shared/codex-turn-transcript.test.ts": 46, + "src/shared/combined-diff-file-tree-width.test.ts": 46, + "src/shared/command-code-output-retention.test.ts": 109, + "src/shared/command-code-output-status.test.ts": 205, + "src/shared/command-code-turn-boundary.test.ts": 42, + "src/shared/command-environment.test.ts": 86, + "src/shared/command-token-scanner.test.ts": 54, + "src/shared/commit-message-agent-spec.test.ts": 118, + "src/shared/commit-message-generation.test.ts": 62, + "src/shared/commit-message-omp.test.ts": 390, + "src/shared/commit-message-plan.test.ts": 117, + "src/shared/commit-message-prompt.test.ts": 87, + "src/shared/computer-awake-mode.test.ts": 43, + "src/shared/computer-use-error-recovery.test.ts": 59, + "src/shared/computer-use-key-spec.test.ts": 59, + "src/shared/constants.test.ts": 282, + "src/shared/contextual-tours.test.ts": 67, + "src/shared/conversation-name-action.test.ts": 263, + "src/shared/conversation-name-generation.test.ts": 50, + "src/shared/crash-reporting.test.ts": 80, + "src/shared/credit-count-format.test.ts": 58, + "src/shared/cross-platform-path-guards.test.ts": 943, + "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, + "src/shared/draft-paste-ready-scanner.test.ts": 92, + "src/shared/dsb-headless-command.test.ts": 55, + "src/shared/dsb-native-one-shot-recognition.test.ts": 89, + "src/shared/dsb-terminal-title.test.ts": 105, + "src/shared/dsh-launch-command.test.ts": 115, + "src/shared/dsh-real-cli.test.ts": 73, + "src/shared/dsh-startup.test.ts": 140, + "src/shared/dsh-terminal-title.test.ts": 90, + "src/shared/emoji-shortcode-catalog.lazy.test.ts": 79, + "src/shared/emulator-keyboard-frame.test.ts": 61, + "src/shared/emulator-touch-frame.test.ts": 45, + "src/shared/ephemeral-setup-terminal-worktree-id.test.ts": 55, + "src/shared/ephemeral-vm-recipe-checkout-mode.test.ts": 261, + "src/shared/ephemeral-vm-recipe-doctor.test.ts": 51, + "src/shared/ephemeral-vm-recipe-output-capture.test.ts": 260, + "src/shared/ephemeral-vm-recipe-process.test.ts": 180, + "src/shared/ephemeral-vm-recipe-repo-url.test.ts": 52, + "src/shared/ephemeral-vm-recipes.test.ts": 291, + "src/shared/ephemeral-vm-runtime-feature-sorting.test.ts": 350, + "src/shared/ephemeral-vm-runtime-store-rollback.test.ts": 548, + "src/shared/ephemeral-vm-runtime-store.test.ts": 314, + "src/shared/event-loop-yield.test.ts": 69, + "src/shared/execution-host-registry-shared-control-connect.test.ts": 82, + "src/shared/execution-host-registry.test.ts": 91, + "src/shared/execution-host.test.ts": 68, + "src/shared/export-let-function-initializer-ban.test.ts": 2472, + "src/shared/external-automation-jobs-file.test.ts": 73, + "src/shared/external-worktree-inbox.test.ts": 71, + "src/shared/feature-education-telemetry.test.ts": 47, + "src/shared/feature-interactions.test.ts": 44, + "src/shared/feature-tips.test.ts": 66, + "src/shared/feature-wall-tour-depth.test.ts": 44, + "src/shared/feedback-image-limits.test.ts": 47, + "src/shared/file-explorer-display-root.test.ts": 53, + "src/shared/file-inventory-budget.test.ts": 54, + "src/shared/file-link-location.test.ts": 56, + "src/shared/file-name-sort.test.ts": 51, + "src/shared/fish-binary-requirement.test.ts": 46, + "src/shared/folder-workspace-execution-host.test.ts": 84, + "src/shared/folder-workspace-worktree.test.ts": 89, + "src/shared/folder-workspaces.test.ts": 82, + "src/shared/foreground-process-ancestry-parity.test.ts": 259, + "src/shared/foreground-process-selection.test.ts": 85, + "src/shared/foreground-wrapper-agent.test.ts": 87, + "src/shared/freebuff-launch.test.ts": 147, + "src/shared/freebuff-startup.test.ts": 120, + "src/shared/generated-code-path.test.ts": 53, + "src/shared/git-admission-node-compatibility.test.ts": 73, + "src/shared/git-binary-compatibility.test.ts": 123, + "src/shared/git-blob-absence.test.ts": 63, + "src/shared/git-branch-cleanup.test.ts": 73, + "src/shared/git-branch-compare-head.test.ts": 47, + "src/shared/git-branch-line-total-soft-deadline.test.ts": 3606, + "src/shared/git-branch-line-total.test.ts": 102, + "src/shared/git-capability-cache.test.ts": 59, + "src/shared/git-change-list.test.ts": 115, + "src/shared/git-check-ignore-stdio.test.ts": 47, + "src/shared/git-clone-failure-message.test.ts": 55, + "src/shared/git-config-snapshot-runner.test.ts": 68, + "src/shared/git-configured-branch-target.test.ts": 55, + "src/shared/git-cquoted-path.test.ts": 45, + "src/shared/git-diff-transport-budget.test.ts": 483, + "src/shared/git-discard-path-safety.test.ts": 68, + "src/shared/git-exec-mutation.test.ts": 40, + "src/shared/git-fetch-head-capability.test.ts": 45, + "src/shared/git-fetch-head-lock-key-derivation.test.ts": 123, + "src/shared/git-fetch-head-lock.test.ts": 162, + "src/shared/git-fork-sync.test.ts": 85, + "src/shared/git-history-graph.test.ts": 70, + "src/shared/git-history-message-allocation.test.ts": 49, + "src/shared/git-history-real-repository.test.ts": 465, + "src/shared/git-history-ref-display.test.ts": 63, + "src/shared/git-history.test.ts": 69, + "src/shared/git-lock-contention.test.ts": 48, + "src/shared/git-merge-tree-capability.test.ts": 53, + "src/shared/git-metadata-path.test.ts": 70, + "src/shared/git-push-target-validation.test.ts": 50, + "src/shared/git-remote-error.test.ts": 74, + "src/shared/git-remote-identity.test.ts": 61, + "src/shared/git-remote-url-index.test.ts": 58, + "src/shared/git-resolution-config-snapshot.test.ts": 73, + "src/shared/git-rev-list-output.test.ts": 58, + "src/shared/git-ssh-policy-env.test.ts": 204, + "src/shared/git-status-branch-line-total-cache.test.ts": 83, + "src/shared/git-status-conflict-entries.test.ts": 60, + "src/shared/git-status-limit.test.ts": 46, + "src/shared/git-status-line-stat-inputs.test.ts": 51, + "src/shared/git-status-line-stats-cache.test.ts": 82, + "src/shared/git-status-read-lease-expiry.test.ts": 79, + "src/shared/git-status-upstream-ref.test.ts": 58, + "src/shared/git-tracked-pathspecs.test.ts": 163, + "src/shared/git-uncommitted-line-stats.test.ts": 94, + "src/shared/git-upstream-status.test.ts": 53, + "src/shared/git-worktree-admin.test.ts": 130, + "src/shared/git-worktree-operation-lock.test.ts": 59, + "src/shared/github/account-binding.test.ts": 65, + "src/shared/github/api-availability.test.ts": 54, + "src/shared/github/owner-slug.test.ts": 49, + "src/shared/github/project-board-columns.test.ts": 65, + "src/shared/github/project-identity.test.ts": 46, + "src/shared/github/project-ref-input.test.ts": 52, + "src/shared/github/project-roadmap-timeline.test.ts": 70, + "src/shared/github/pull-request-auto-merge-availability.test.ts": 59, + "src/shared/github/pull-request-for-branch-outcome.test.ts": 46, + "src/shared/github/pull-request-merge-methods.test.ts": 49, + "src/shared/github/repository-identity-key.test.ts": 46, + "src/shared/gitlab-job-log-excerpt.test.ts": 80, + "src/shared/gitlab-job-trace-check-details.test.ts": 67, + "src/shared/gitlab-pipeline-checks.test.ts": 53, + "src/shared/gitlab-projects.test.ts": 50, + "src/shared/grok-model-list-probe.test.ts": 69, + "src/shared/grok-session-paths.test.ts": 93, + "src/shared/handled-wire-discriminant.test.ts": 48, + "src/shared/harness-injected-user-turns.test.ts": 64, + "src/shared/hermes-run-ref-retention.test.ts": 66, + "src/shared/hermes-session-run-index.test.ts": 293, + "src/shared/hermes-startup-query.windows.test.ts": 49, + "src/shared/home-or-filesystem-root.test.ts": 50, + "src/shared/hook-command-source-policy.test.ts": 43, + "src/shared/host-authority.test.ts": 66, + "src/shared/host-balanced-listing-scaling.test.ts": 74, + "src/shared/host-display-resolution.test.ts": 59, + "src/shared/host-operation-ownership.test.ts": 758, + "src/shared/host-setting-overrides.test.ts": 53, "src/shared/hosted-review-creation-providers.test.ts": 52, - "src/shared/hosted-review-github.test.ts": 48, - "src/shared/hosted-review-ready-capabilities.test.ts": 37, - "src/shared/hosted-review-refs.test.ts": 109, - "src/shared/image-data-uri.test.ts": 41, - "src/shared/image-paste-following-text.test.ts": 68, - "src/shared/in-flight-promise-dedupe.test.ts": 61, - "src/shared/issue-link-input.test.ts": 60, - "src/shared/jira-issue-url.test.ts": 57, - "src/shared/json-text-structure-limit.test.ts": 40, - "src/shared/keybindings-conflicts.test.ts": 94, - "src/shared/keybindings-default-bindings.test.ts": 109, - "src/shared/keybindings-digit-index.test.ts": 110, - "src/shared/keybindings-double-tap.test.ts": 85, - "src/shared/keybindings-keyboard-layout.test.ts": 56, - "src/shared/keybindings-mouse.test.ts": 44, - "src/shared/keybindings-parse-cache.test.ts": 142, - "src/shared/keybindings-parsing.test.ts": 47, - "src/shared/keybindings-terminal-context.test.ts": 49, - "src/shared/keybindings-unassigned-actions.test.ts": 61, - "src/shared/linear/inline-media.test.ts": 36, - "src/shared/linear/issue-attribute-filter.test.ts": 58, - "src/shared/linear/issue-view-resume-state.test.ts": 67, - "src/shared/linear/links.test.ts": 74, - "src/shared/linear/workspace-types.test.ts": 25, - "src/shared/linux-proc-port-scan-limits.test.ts": 42, - "src/shared/local-account-runtime.test.ts": 93, - "src/shared/local-build-compatibility.test.ts": 34, - "src/shared/localhost-worktree-labels.test.ts": 52, - "src/shared/loose-ref-count.test.ts": 358, - "src/shared/machine-name.test.ts": 35, - "src/shared/macos-symbolic-hotkeys.test.ts": 100, - "src/shared/main-agent-status-parity.test.ts": 206, - "src/shared/managed-agent-command-token.test.ts": 26, - "src/shared/manual-repo-order.test.ts": 36, - "src/shared/map-settled-with-concurrency.test.ts": 60, - "src/shared/map-with-concurrency.test.ts": 82, - "src/shared/markdown-document-listing-limits.test.ts": 47, - "src/shared/markdown-toc-panel-width.test.ts": 53, - "src/shared/mcp-config.test.ts": 128, - "src/shared/mobile-e2ee-v2-contract.test.ts": 41, - "src/shared/mobile-e2ee-v2-framing.test.ts": 55, - "src/shared/mobile-file-directory-limit.test.ts": 49, - "src/shared/mobile-markdown-document.test.ts": 40, - "src/shared/mobile-pairing-connection-mode.test.ts": 49, - "src/shared/mobile-pairing-custom-address.test.ts": 57, - "src/shared/mobile-push-contract.test.ts": 44, - "src/shared/mobile-relay-close-codes.test.ts": 49, - "src/shared/mobile-relay-mint-failure.test.ts": 33, - "src/shared/mobile-relay-pairing-offer.test.ts": 151, - "src/shared/mobile-relay-phone-protocol.test.ts": 109, - "src/shared/mobile-web-bundle/bundle-range-rpc-contract.test.ts": 108, - "src/shared/mobile-web-bundle/bundle-rpc-contract.test.ts": 153, - "src/shared/mobile-web-bundle/manifest-contract.test.ts": 204, - "src/shared/model-id-label.test.ts": 45, - "src/shared/modifier-double-tap-detector.test.ts": 47, - "src/shared/mouse-shortcut-input.test.ts": 49, + "src/shared/hosted-review-github.test.ts": 98, + "src/shared/hosted-review-refs.test.ts": 144, + "src/shared/image-data-uri.test.ts": 57, + "src/shared/image-paste-following-text.test.ts": 47, + "src/shared/import-copy-name.test.ts": 56, + "src/shared/in-flight-promise-dedupe.test.ts": 80, + "src/shared/issue-link-input.test.ts": 65, + "src/shared/jcode-runtime-dir.test.ts": 54, + "src/shared/jcode-session-files.test.ts": 58, + "src/shared/jcode-terminal-title.test.ts": 50, + "src/shared/jira-issue-url.test.ts": 47, + "src/shared/jira-search-input-jql.test.ts": 71, + "src/shared/json-text-structure-limit.test.ts": 70, + "src/shared/json-token-reader.test.ts": 309, + "src/shared/keybindings-conflicts.test.ts": 97, + "src/shared/keybindings-default-bindings.test.ts": 115, + "src/shared/keybindings-digit-index.test.ts": 96, + "src/shared/keybindings-double-tap.test.ts": 83, + "src/shared/keybindings-keyboard-layout.test.ts": 72, + "src/shared/keybindings-parse-cache.test.ts": 70, + "src/shared/keybindings-parsing.test.ts": 91, + "src/shared/keybindings-terminal-context.test.ts": 76, + "src/shared/keybindings-unassigned-actions.test.ts": 85, + "src/shared/kiro-home.test.ts": 46, + "src/shared/linear/inline-media.test.ts": 50, + "src/shared/linear/issue-attribute-filter.test.ts": 55, + "src/shared/linear/issue-view-resume-state.test.ts": 182, + "src/shared/linear/links.test.ts": 68, + "src/shared/linear/workspace-types.test.ts": 59, + "src/shared/linux-proc-port-scan-limits.test.ts": 53, + "src/shared/local-account-runtime.test.ts": 279, + "src/shared/local-build-compatibility.test.ts": 58, + "src/shared/local-windows-terminal-runtime.test.ts": 52, + "src/shared/localhost-worktree-labels.test.ts": 58, + "src/shared/loose-ref-count.test.ts": 139, + "src/shared/machine-name.test.ts": 44, + "src/shared/macos-symbolic-hotkeys.test.ts": 75, + "src/shared/main-agent-status-parity.test.ts": 224, + "src/shared/managed-agent-command-token.test.ts": 53, + "src/shared/managed-orcad-execution-host.test.ts": 85, + "src/shared/managed-orcad-ssh-owner.test.ts": 56, + "src/shared/manual-repo-order.test.ts": 56, + "src/shared/map-settled-with-concurrency.test.ts": 54, + "src/shared/map-with-concurrency.test.ts": 106, + "src/shared/markdown-document-listing-limits.test.ts": 45, + "src/shared/markdown-toc-panel-width.test.ts": 48, + "src/shared/mcp-config.test.ts": 95, + "src/shared/mobile-e2ee-v2-contract.test.ts": 59, + "src/shared/mobile-e2ee-v2-framing.test.ts": 65, + "src/shared/mobile-file-directory-limit.test.ts": 48, + "src/shared/mobile-markdown-document.test.ts": 61, + "src/shared/mobile-pairing-connection-mode.test.ts": 59, + "src/shared/mobile-pairing-custom-address.test.ts": 62, + "src/shared/mobile-push-contract.test.ts": 48, + "src/shared/mobile-relay-close-codes.test.ts": 42, + "src/shared/mobile-relay-mint-failure.test.ts": 43, + "src/shared/mobile-relay-pairing-offer.test.ts": 246, + "src/shared/mobile-relay-phone-protocol.test.ts": 223, + "src/shared/mobile-web-bundle/bundle-range-rpc-contract.test.ts": 241, + "src/shared/mobile-web-bundle/bundle-rpc-contract.test.ts": 254, + "src/shared/mobile-web-bundle/manifest-contract.test.ts": 284, + "src/shared/model-id-label.test.ts": 46, + "src/shared/modifier-double-tap-detector.test.ts": 62, "src/shared/muse-session-log.test.ts": 63, - "src/shared/native-chat-activity-tail-line.test.ts": 155, - "src/shared/native-chat-agent-profiles.test.ts": 68, - "src/shared/native-chat-agent-support.test.ts": 52, - "src/shared/native-chat-ask-fifo.test.ts": 386, - "src/shared/native-chat-ask-row.test.ts": 41, - "src/shared/native-chat-ask.test.ts": 41, - "src/shared/native-chat-background-task-row.test.ts": 61, - "src/shared/native-chat-command-envelope.test.ts": 45, - "src/shared/native-chat-edit-normalize.test.ts": 189, - "src/shared/native-chat-href-routing.test.ts": 32, - "src/shared/native-chat-image-transcript-markers.test.ts": 45, - "src/shared/native-chat-session-option-commands.test.ts": 61, - "src/shared/native-chat-session-option-defaults.test.ts": 59, - "src/shared/native-chat-session-option-snapshot.test.ts": 107, - "src/shared/native-chat-session-option-state.test.ts": 31, - "src/shared/native-chat-shell-environment.test.ts": 39, - "src/shared/native-chat-slash-commands.omp.test.ts": 152, - "src/shared/native-chat-slash-commands.test.ts": 47, - "src/shared/native-chat-stream-unsubscribe.test.ts": 52, - "src/shared/native-chat-streaming.test.ts": 53, - "src/shared/native-chat-subagent-summary.test.ts": 42, - "src/shared/native-chat-task-list.test.ts": 40, - "src/shared/native-chat-tool-activity.test.ts": 30, - "src/shared/native-chat-tool-attribution-allocation.test.ts": 79, - "src/shared/native-chat-tool-icon.test.ts": 70, - "src/shared/native-chat-tool-identity.test.ts": 150, - "src/shared/native-chat-tool-pair-limit.test.ts": 119, - "src/shared/native-chat-tool-pairing.test.ts": 56, - "src/shared/native-chat-tool-preview-prefix.test.ts": 60, - "src/shared/native-chat-tool-preview-scan.test.ts": 40, - "src/shared/native-chat-tool-run-outcome.test.ts": 59, - "src/shared/native-chat-tool-run-sentence.test.ts": 66, - "src/shared/native-chat-tool-summary.test.ts": 55, - "src/shared/native-chat-transcript-retention.test.ts": 43, - "src/shared/native-chat-turn-activity.test.ts": 74, - "src/shared/native-chat-turn-fold.test.ts": 29, - "src/shared/native-chat-turn-status.test.ts": 65, - "src/shared/native-chat-types.test.ts": 46, - "src/shared/native-chat-unverifiable-turn-status.test.ts": 44, - "src/shared/native-file-drop.test.ts": 73, - "src/shared/nested-repo-telemetry-schema.test.ts": 152, + "src/shared/native-chat-activity-tail-line.test.ts": 268, + "src/shared/native-chat-agent-profiles.test.ts": 58, + "src/shared/native-chat-agent-support.test.ts": 54, + "src/shared/native-chat-appearance-settings.test.ts": 56, + "src/shared/native-chat-ask-fifo.test.ts": 218, + "src/shared/native-chat-ask-row.test.ts": 62, + "src/shared/native-chat-ask.test.ts": 69, + "src/shared/native-chat-background-task-row.test.ts": 60, + "src/shared/native-chat-command-envelope.test.ts": 49, + "src/shared/native-chat-command-output.test.ts": 55, + "src/shared/native-chat-context-usage.test.ts": 52, + "src/shared/native-chat-cut-turn-notice.test.ts": 342, + "src/shared/native-chat-edit-normalize.test.ts": 326, + "src/shared/native-chat-href-routing.test.ts": 58, + "src/shared/native-chat-image-transcript-markers.test.ts": 85, + "src/shared/native-chat-live-line.test.ts": 74, + "src/shared/native-chat-merge-pairs.test.ts": 343, + "src/shared/native-chat-orca-stop-cut.test.ts": 273, + "src/shared/native-chat-provider-frame-summary.test.ts": 49, + "src/shared/native-chat-provider-retry-runs.test.ts": 340, + "src/shared/native-chat-reasoning-order.test.ts": 283, + "src/shared/native-chat-reasoning-row.test.ts": 78, + "src/shared/native-chat-scope-cache.test.ts": 47, + "src/shared/native-chat-session-option-commands.test.ts": 79, + "src/shared/native-chat-session-option-defaults.test.ts": 95, + "src/shared/native-chat-session-option-snapshot.test.ts": 126, + "src/shared/native-chat-session-option-state.test.ts": 57, + "src/shared/native-chat-shell-environment.test.ts": 53, + "src/shared/native-chat-slash-commands.omp.test.ts": 48, + "src/shared/native-chat-slash-commands.test.ts": 59, + "src/shared/native-chat-stopped-send-turn.test.ts": 336, + "src/shared/native-chat-stream-unsubscribe.test.ts": 50, + "src/shared/native-chat-streaming.test.ts": 46, + "src/shared/native-chat-subagent-group-header.test.ts": 60, + "src/shared/native-chat-subagent-summary.test.ts": 58, + "src/shared/native-chat-task-list.test.ts": 58, + "src/shared/native-chat-tool-activity.test.ts": 57, + "src/shared/native-chat-tool-attribution-allocation.test.ts": 102, + "src/shared/native-chat-tool-fold-attribution.test.ts": 52, + "src/shared/native-chat-tool-icon.test.ts": 73, + "src/shared/native-chat-tool-identity.test.ts": 66, + "src/shared/native-chat-tool-line-identity.test.ts": 42, + "src/shared/native-chat-tool-pair-limit.test.ts": 128, + "src/shared/native-chat-tool-pairing.performance.test.ts": 500, + "src/shared/native-chat-tool-pairing.test.ts": 53, + "src/shared/native-chat-tool-preview-prefix.test.ts": 52, + "src/shared/native-chat-tool-preview-scan.test.ts": 52, + "src/shared/native-chat-tool-result-orphans.test.ts": 282, + "src/shared/native-chat-tool-run-outcome.test.ts": 81, + "src/shared/native-chat-tool-run-sentence.test.ts": 64, + "src/shared/native-chat-tool-summary.test.ts": 80, + "src/shared/native-chat-transcript-projection.test.ts": 268, + "src/shared/native-chat-transcript-retention.test.ts": 57, + "src/shared/native-chat-turn-activity.test.ts": 230, + "src/shared/native-chat-turn-fold.test.ts": 57, + "src/shared/native-chat-turn-grouping.test.ts": 231, + "src/shared/native-chat-turn-membership-derived-tool-row.test.ts": 340, + "src/shared/native-chat-turn-membership.test.ts": 287, + "src/shared/native-chat-turn-status.test.ts": 75, + "src/shared/native-chat-types.test.ts": 55, + "src/shared/native-chat-unverifiable-turn-status.test.ts": 257, + "src/shared/native-chat-upgrade-tip-audience.test.ts": 45, + "src/shared/native-chat-visual-directive.test.ts": 64, + "src/shared/native-chat-visual-height-governor.test.ts": 289, + "src/shared/native-chat-visual-shell.test.ts": 274, + "src/shared/native-chat-work-run.test.ts": 310, + "src/shared/native-file-drop.test.ts": 60, + "src/shared/nested-repo-telemetry-schema.test.ts": 299, "src/shared/nested-repo-telemetry.test.ts": 50, - "src/shared/nested-worker-depth.test.ts": 37, - "src/shared/network-proxy.test.ts": 52, - "src/shared/network/manual-address.test.ts": 82, - "src/shared/network/server-share-address.test.ts": 30, - "src/shared/new-workspace-dialog-repo.test.ts": 25, - "src/shared/new-workspace/smart-workspace-url-source-results.test.ts": 92, - "src/shared/new-workspace/work-item-lookup-text.test.ts": 26, - "src/shared/new-workspace/workspace-source.test.ts": 51, - "src/shared/new-workspace/worktree-create-retry-policy.test.ts": 38, - "src/shared/node-bounded-file-reader-sync.test.ts": 36, - "src/shared/node-bounded-file-reader.test.ts": 35, - "src/shared/node-bounded-json-stringify.test.ts": 73, - "src/shared/node-file-content-equality.test.ts": 55, - "src/shared/node-markdown-document-discovery.test.ts": 57, - "src/shared/node-pty-spawn-helper.test.ts": 34, - "src/shared/node-readable-text.test.ts": 54, - "src/shared/node-source-copy-content-equality.test.ts": 69, - "src/shared/notebook-venv-location.test.ts": 44, - "src/shared/nul-delimited-fields.test.ts": 30, - "src/shared/nvm-default-alias.test.ts": 56, - "src/shared/omp-draft-launch-shell.test.ts": 173, - "src/shared/omp-draft-launch-windows.test.ts": 86, - "src/shared/omp-fresh-launch-shell.test.ts": 134, - "src/shared/omp-fresh-launch.test.ts": 102, - "src/shared/omp-owner-state-title.test.ts": 88, - "src/shared/omp-pi-semantic-title-preservation.test.ts": 49, - "src/shared/omp-source-control-discovery.test.ts": 150, - "src/shared/onboarding-tour-telemetry-events.test.ts": 165, - "src/shared/open-in-applications.test.ts": 36, - "src/shared/opencode-launch-command.test.ts": 52, - "src/shared/opencode-permission-status.test.ts": 154, - "src/shared/opencode-terminal-title.test.ts": 41, - "src/shared/orca-session-address.test.ts": 189, - "src/shared/orca-yaml-alias-bounds.test.ts": 102, - "src/shared/orca-yaml-bounds.test.ts": 64, - "src/shared/orcad-artifacts.test.ts": 27, - "src/shared/orcad-profile-preflight.test.ts": 135, - "src/shared/orchestration-ask-timeout.test.ts": 46, - "src/shared/orchestration-check-output.test.ts": 50, - "src/shared/orchestration-compatibility-evidence.test.ts": 41, - "src/shared/orchestration-dispatch-refusal-contract.test.ts": 71, - "src/shared/orchestration-fleet-attention.test.ts": 38, - "src/shared/orchestration-fleet-evidence-clock.test.ts": 39, - "src/shared/orchestration-fleet-projection.test.ts": 64, - "src/shared/orchestration-rpc-contract.test.ts": 34, - "src/shared/orchestration-task-display.test.ts": 38, - "src/shared/orchestration-task-summary.test.ts": 45, - "src/shared/orchestration-timing-budgets.test.ts": 38, - "src/shared/osc-title-scan-tail-retention.test.ts": 59, - "src/shared/osc-title-scan-tail.test.ts": 36, - "src/shared/osc52-clipboard-settings.test.ts": 40, - "src/shared/own-retained-string.test.ts": 115, - "src/shared/pairing-address-auto-selection.test.ts": 58, - "src/shared/pairing-local-ui-fields.test.ts": 29, - "src/shared/pairing.test.ts": 112, - "src/shared/pane-agent-identity-adapter.test.ts": 83, - "src/shared/pane-agent-identity-inventory.test.ts": 1015, - "src/shared/pane-agent-identity-resolver.test.ts": 90, - "src/shared/pane-agent-identity-surface-inventory.test.ts": 2736, - "src/shared/pane-agent-identity-title-corpus.test.ts": 95, - "src/shared/pane-agent-owner.test.ts": 53, - "src/shared/pane-key-alias.test.ts": 69, - "src/shared/physical-exit-tracker.test.ts": 45, - "src/shared/pi-agent-kind.test.ts": 58, + "src/shared/nested-worker-depth.test.ts": 51, + "src/shared/network-proxy.test.ts": 42, + "src/shared/network/manual-address.test.ts": 60, + "src/shared/network/server-share-address.test.ts": 58, + "src/shared/new-workspace-dialog-repo.test.ts": 51, + "src/shared/new-workspace/smart-workspace-linear-intent.test.ts": 67, + "src/shared/new-workspace/smart-workspace-url-source-results.test.ts": 77, + "src/shared/new-workspace/work-item-lookup-text.test.ts": 59, + "src/shared/new-workspace/workspace-source.test.ts": 65, + "src/shared/new-workspace/worktree-create-retry-policy.test.ts": 49, + "src/shared/node-bounded-file-reader-sync.test.ts": 53, + "src/shared/node-bounded-file-reader.test.ts": 68, + "src/shared/node-bounded-json-stringify.test.ts": 77, + "src/shared/node-file-content-equality.test.ts": 44, + "src/shared/node-pty-spawn-helper.test.ts": 48, + "src/shared/node-readable-text.test.ts": 72, + "src/shared/node-source-copy-content-equality.test.ts": 65, + "src/shared/notebook-venv-location.test.ts": 46, + "src/shared/nul-delimited-fields.test.ts": 45, + "src/shared/nvm-default-alias.test.ts": 90, + "src/shared/omp-draft-launch-shell.test.ts": 247, + "src/shared/omp-draft-launch-windows.test.ts": 122, + "src/shared/omp-fresh-launch-shell.test.ts": 176, + "src/shared/omp-fresh-launch.test.ts": 130, + "src/shared/omp-owner-state-title.test.ts": 91, + "src/shared/omp-pi-semantic-title-preservation.test.ts": 107, + "src/shared/omp-source-control-discovery.test.ts": 287, + "src/shared/onboarding-tour-telemetry-events.test.ts": 342, + "src/shared/open-in-applications.test.ts": 54, + "src/shared/opencode-cli-version.test.ts": 64, + "src/shared/opencode-config-directory.test.ts": 44, + "src/shared/opencode-generation-command.test.ts": 82, + "src/shared/opencode-generation-output.test.ts": 230, + "src/shared/opencode-headless-command.test.ts": 63, + "src/shared/opencode-installed-plugin.test.ts": 61, + "src/shared/opencode-launch-command.test.ts": 61, + "src/shared/opencode-permission-status.test.ts": 213, + "src/shared/opencode-plugin-atomic-write.test.ts": 84, + "src/shared/opencode-plugin-permissions.test.ts": 117, + "src/shared/opencode-startup-prompt.test.ts": 178, + "src/shared/opencode-terminal-title.test.ts": 50, + "src/shared/opencode-tui-config-registration.test.ts": 85, + "src/shared/opencode-tui-plugin-install.test.ts": 59, + "src/shared/opencode2-resume-launch-mode.test.ts": 158, + "src/shared/openssh-default-shell.test.ts": 59, + "src/shared/orca-session-address.test.ts": 291, + "src/shared/orca-yaml-alias-bounds.test.ts": 398, + "src/shared/orca-yaml-bounds.test.ts": 216, + "src/shared/orcad-artifacts.test.ts": 85, + "src/shared/orcad-build-identity.test.ts": 61, + "src/shared/orcad-migration-dormant-session-validation.test.ts": 344, + "src/shared/orcad-migration-manifest-contract.test.ts": 358, + "src/shared/orcad-migration-manifest.test.ts": 432, + "src/shared/orcad-migration-scrollback.test.ts": 271, + "src/shared/orcad-migration-staged-catalog-validation.test.ts": 381, + "src/shared/orcad-profile-preflight.test.ts": 217, + "src/shared/orcad-stop-request.test.ts": 217, + "src/shared/orchestration-ask-timeout.test.ts": 49, + "src/shared/orchestration-check-output.test.ts": 66, + "src/shared/orchestration-compatibility-evidence.test.ts": 53, + "src/shared/orchestration-dispatch-refusal-contract.test.ts": 88, + "src/shared/orchestration-fleet-attention.test.ts": 46, + "src/shared/orchestration-fleet-evidence-clock.test.ts": 63, + "src/shared/orchestration-fleet-projection.test.ts": 105, + "src/shared/orchestration-rpc-contract.test.ts": 87, + "src/shared/orchestration-task-display.test.ts": 40, + "src/shared/orchestration-task-summary.test.ts": 53, + "src/shared/orchestration-timing-budgets.test.ts": 49, + "src/shared/osc-title-scan-tail-retention.test.ts": 82, + "src/shared/osc-title-scan-tail.test.ts": 51, + "src/shared/osc52-clipboard-settings.test.ts": 66, + "src/shared/own-retained-string.test.ts": 181, + "src/shared/pairing-address-auto-selection.test.ts": 54, + "src/shared/pairing-local-ui-fields.test.ts": 46, + "src/shared/pairing.test.ts": 249, + "src/shared/pane-agent-identity-adapter.test.ts": 95, + "src/shared/pane-agent-identity-resolver.test.ts": 89, + "src/shared/pane-agent-identity-title-corpus.test.ts": 73, + "src/shared/pane-agent-owner.test.ts": 52, + "src/shared/pane-key-alias.test.ts": 52, + "src/shared/physical-exit-tracker.test.ts": 51, + "src/shared/pi-agent-kind.test.ts": 68, "src/shared/pi-overlay-ui-settings.test.ts": 50, - "src/shared/pi-state-title-marker.test.ts": 72, - "src/shared/plugins/plugin-consent-fingerprint.test.ts": 159, - "src/shared/plugins/plugin-consent-request.test.ts": 84, - "src/shared/plugins/plugin-content-pack-contributions.test.ts": 136, - "src/shared/plugins/plugin-demo-fixture.test.ts": 177, - "src/shared/plugins/plugin-hostile-fixture.test.ts": 53, - "src/shared/plugins/plugin-install-lockfile.test.ts": 134, - "src/shared/plugins/plugin-kill-list.test.ts": 208, - "src/shared/plugins/plugin-language-pack-artifact.test.ts": 176, - "src/shared/plugins/plugin-manifest.test.ts": 145, - "src/shared/plugins/plugin-marketplace.test.ts": 224, - "src/shared/plugins/plugin-panel-call-admission.test.ts": 139, - "src/shared/plugins/plugin-panel-message-budget-traversal.test.ts": 196, - "src/shared/plugins/plugin-panel-message-budget.test.ts": 140, - "src/shared/plugins/plugin-panel-pong-frame.test.ts": 134, - "src/shared/plugins/plugin-panel-shell.test.ts": 566, - "src/shared/plugins/plugin-path-safety.test.ts": 161, - "src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 172, - "src/shared/posix-wait-status.test.ts": 61, - "src/shared/powershell-native-argument.test.ts": 23, - "src/shared/pr-bot-author-overrides.test.ts": 36, - "src/shared/pr-check-severity-order.test.ts": 36, + "src/shared/pi-state-title-marker.test.ts": 76, + "src/shared/plugins/plugin-consent-fingerprint.test.ts": 290, + "src/shared/plugins/plugin-consent-request.test.ts": 215, + "src/shared/plugins/plugin-content-pack-contributions.test.ts": 292, + "src/shared/plugins/plugin-install-lockfile.test.ts": 285, + "src/shared/plugins/plugin-kill-list.test.ts": 298, + "src/shared/plugins/plugin-language-pack-artifact.test.ts": 177, + "src/shared/plugins/plugin-manifest.test.ts": 265, + "src/shared/plugins/plugin-marketplace.test.ts": 314, + "src/shared/plugins/plugin-panel-call-admission.test.ts": 246, + "src/shared/plugins/plugin-panel-message-budget-traversal.test.ts": 297, + "src/shared/plugins/plugin-panel-message-budget.test.ts": 278, + "src/shared/plugins/plugin-panel-pong-frame.test.ts": 243, + "src/shared/plugins/plugin-panel-shell.test.ts": 343, + "src/shared/plugins/plugin-path-safety.test.ts": 304, + "src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 342, + "src/shared/posix-wait-status.test.ts": 50, + "src/shared/powershell-native-argument.test.ts": 33, + "src/shared/powershell-native-argument.windows.test.ts": 65, + "src/shared/pr-bot-author-overrides.test.ts": 54, + "src/shared/pr-check-severity-order.test.ts": 56, "src/shared/pr-check-status.test.ts": 50, - "src/shared/pr-comment-audience.test.ts": 52, - "src/shared/pr-comment-groups.test.ts": 33, - "src/shared/pr-comment-time.test.ts": 49, - "src/shared/preferred-git-remote.test.ts": 27, - "src/shared/priority-semaphore.test.ts": 38, - "src/shared/process-output-field-scanner.test.ts": 49, - "src/shared/process-table-index.test.ts": 152, - "src/shared/process-table-snapshot.test.ts": 174, - "src/shared/profile-state-recovery-command.test.ts": 156, - "src/shared/profile-state-telemetry-schema.test.ts": 187, - "src/shared/project-catalog-row-normalization.test.ts": 64, - "src/shared/project-execution-runtime.test.ts": 47, - "src/shared/project-groups.test.ts": 141, - "src/shared/project-host-setup-projection.test.ts": 45, - "src/shared/project-identity-succession.test.ts": 54, - "src/shared/promise-settlement-waiters.test.ts": 305, - "src/shared/protocol-compat.test.ts": 43, - "src/shared/pty-consumer-session.test.ts": 34, - "src/shared/pty-delivery-diagnostics.test.ts": 46, - "src/shared/pty-liveness-verdict.test.ts": 27, - "src/shared/pty-owner-backend.test.ts": 25, - "src/shared/pty-slave-line-discipline-echo.test.ts": 42, - "src/shared/pty-startup-ingress-kitty.test.ts": 67, - "src/shared/pty-startup-ingress-live-query-reply.test.ts": 54, - "src/shared/pty-startup-ingress.test.ts": 148, - "src/shared/pty-startup-reply-echo-shapes.test.ts": 67, - "src/shared/published-pane-agent-identity.test.ts": 82, - "src/shared/pull-request-generation.test.ts": 79, - "src/shared/quick-open-directory-reader.test.ts": 64, - "src/shared/quick-open-expansion-paths.test.ts": 60, - "src/shared/quick-open-filter.renderer-safety.test.ts": 53, - "src/shared/quick-open-filter.test.ts": 66, - "src/shared/quick-open-fuzzy-scan.test.ts": 1098, - "src/shared/quick-open-git-directory-collapse.test.ts": 140, - "src/shared/quick-open-install-rg.test.ts": 27, + "src/shared/pr-comment-audience.test.ts": 54, + "src/shared/pr-comment-groups.test.ts": 43, + "src/shared/pr-comment-time.test.ts": 45, + "src/shared/preferred-git-remote.test.ts": 53, + "src/shared/priority-semaphore.test.ts": 63, + "src/shared/process-output-field-scanner.test.ts": 55, + "src/shared/process-table-index.test.ts": 176, + "src/shared/process-table-snapshot.test.ts": 175, + "src/shared/profile-state-build-compatibility.test.ts": 52, + "src/shared/profile-state-recovery-command.test.ts": 248, + "src/shared/profile-state-telemetry-schema.test.ts": 295, + "src/shared/project-catalog-row-normalization.test.ts": 65, + "src/shared/project-execution-runtime.test.ts": 62, + "src/shared/project-groups.test.ts": 148, + "src/shared/project-host-setup-projection.test.ts": 99, + "src/shared/project-identity-succession.test.ts": 81, + "src/shared/promise-settlement-waiters.test.ts": 169, + "src/shared/protocol-compat.test.ts": 88, + "src/shared/pty-consumer-session.test.ts": 77, + "src/shared/pty-delivery-diagnostics.test.ts": 55, + "src/shared/pty-liveness-verdict.test.ts": 46, + "src/shared/pty-owner-backend.test.ts": 45, + "src/shared/pty-owner-color-query-colors.test.ts": 66, + "src/shared/pty-running-work-probe.test.ts": 61, + "src/shared/pty-slave-line-discipline-echo.test.ts": 64, + "src/shared/pty-startup-ingress-kitty.test.ts": 106, + "src/shared/pty-startup-ingress-lifetime-colors.test.ts": 90, + "src/shared/pty-startup-ingress-live-query-reply.test.ts": 119, + "src/shared/pty-startup-ingress.test.ts": 155, + "src/shared/pty-startup-reply-echo-shapes.test.ts": 59, + "src/shared/published-pane-agent-identity.test.ts": 90, + "src/shared/pull-request-generation.test.ts": 71, + "src/shared/qoder-agent.test.ts": 293, + "src/shared/qoder-cn-launch-recipe.test.ts": 147, + "src/shared/quick-open-directory-reader.test.ts": 67, + "src/shared/quick-open-expansion-paths.test.ts": 72, + "src/shared/quick-open-filter.renderer-safety.test.ts": 59, + "src/shared/quick-open-filter.test.ts": 93, + "src/shared/quick-open-fuzzy-scan.test.ts": 996, + "src/shared/quick-open-git-directory-collapse.test.ts": 88, + "src/shared/quick-open-install-rg.test.ts": 55, "src/shared/quick-open-listing-limits.test.ts": 45, - "src/shared/quick-open-readdir-memory.test.ts": 73, - "src/shared/quick-open-readdir-walk.test.ts": 445, - "src/shared/quick-open-transport-budget.test.ts": 43, - "src/shared/raster-image-base64-preview.test.ts": 501, - "src/shared/raster-image-dimensions.test.ts": 53, - "src/shared/raster-image-preview-limits.test.ts": 57, - "src/shared/rate-limit-reset-format.test.ts": 50, - "src/shared/rate-limit-types.test.ts": 32, - "src/shared/react-update-depth-attribution.test.ts": 48, - "src/shared/reconnect-jitter.test.ts": 34, - "src/shared/relay-frame-buffer.test.ts": 517, - "src/shared/relay-optional-artifacts.test.ts": 42, - "src/shared/relay-version-marker.test.ts": 42, - "src/shared/release-channel.test.ts": 36, - "src/shared/remote-foreground-evidence.test.ts": 30, - "src/shared/remote-pairing-address.test.ts": 143, - "src/shared/remote-pairing-verification.test.ts": 52, - "src/shared/remote-rpc-content-budget.test.ts": 93, - "src/shared/remote-runtime-client-error-classification.test.ts": 70, - "src/shared/remote-runtime-client.test.ts": 4934, - "src/shared/remote-runtime-connect-bound.test.ts": 387, - "src/shared/remote-runtime-memory-limits.test.ts": 159, - "src/shared/remote-runtime-outbound-admission.test.ts": 1513, - "src/shared/remote-runtime-request-connection-stale.test.ts": 297, - "src/shared/remote-runtime-request-connection.test.ts": 349, - "src/shared/remote-runtime-request-frames.test.ts": 99, - "src/shared/remote-runtime-request-response-router.test.ts": 174, - "src/shared/remote-runtime-request-websocket.test.ts": 180, - "src/shared/remote-runtime-shared-control-boundary.test.ts": 3236, - "src/shared/remote-runtime-shared-control-connection.test.ts": 3372, - "src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts": 150, - "src/shared/remote-runtime-shared-control-reconnect.test.ts": 176, - "src/shared/remote-runtime-shared-control-retired-request-ids.test.ts": 47, - "src/shared/remote-runtime-shared-control-socket-generation.test.ts": 111, - "src/shared/remote-runtime-shared-control-standing-intent.test.ts": 883, - "src/shared/remote-runtime-shared-control-subscription-close.test.ts": 128, - "src/shared/remote-runtime-shared-control-subscriptions.test.ts": 161, - "src/shared/remote-runtime-socket-liveness.test.ts": 33, - "src/shared/remote-runtime-subscription-connect-bound.test.ts": 293, - "src/shared/remote-runtime-subscription-frame-router.test.ts": 89, - "src/shared/remote-runtime-subscription-request.test.ts": 526, - "src/shared/remote-runtime-tailscale-hint.test.ts": 41, - "src/shared/remote-runtime-transport-error-agreement.test.ts": 353, - "src/shared/remote-workspace-session-projection.test.ts": 156, - "src/shared/renderer-restart-preparation.test.ts": 537, - "src/shared/repo-badge-color.test.ts": 181, - "src/shared/repo-icon.test.ts": 68, - "src/shared/repo-ref-maintenance.test.ts": 861, - "src/shared/repo-search-limits.test.ts": 41, - "src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts": 82, - "src/shared/repro-7732-gitlab-job-id-dropped.test.ts": 52, - "src/shared/repro-8478-opencode-native-title-icon.test.ts": 69, - "src/shared/require-tui-agent-config.test.ts": 51, - "src/shared/resolved-worktree-lineage.test.ts": 38, - "src/shared/retired-pty-incarnations.test.ts": 44, - "src/shared/review-head-tracking-ref.test.ts": 56, - "src/shared/ripgrep-process-availability.test.ts": 70, - "src/shared/rpc-contract/agent-launch-params.test.ts": 167, - "src/shared/rpc-contract/terminal-create-shell-param.test.ts": 156, - "src/shared/runtime-client-export-parity.test.ts": 136, - "src/shared/runtime-environment-store.test.ts": 259, - "src/shared/runtime-host-status-owner.test.ts": 40, - "src/shared/runtime-listing-host-scope.test.ts": 38, - "src/shared/runtime-navigation.test.ts": 49, - "src/shared/runtime-rpc-call-queue-retention.test.ts": 215, - "src/shared/runtime-rpc-call-queue.test.ts": 77, - "src/shared/runtime-workspace-file-owner.test.ts": 45, - "src/shared/runtime-workspace-window-availability.test.ts": 57, - "src/shared/search-match-count.test.ts": 48, - "src/shared/search-subprocess-lines.test.ts": 117, - "src/shared/secret-store.test.ts": 39, - "src/shared/secure-file-coarse-ctime.test.ts": 51, - "src/shared/secure-file-fsync-flags.test.ts": 93, - "src/shared/secure-file.test.ts": 713, - "src/shared/secure-path-hardening-cache.test.ts": 37, - "src/shared/secure-path-hardening-retry-budget.test.ts": 65, - "src/shared/secure-path-windows-acl.win32.test.ts": 84, - "src/shared/serve-option-validation.test.ts": 65, - "src/shared/session-tab-placement.test.ts": 40, - "src/shared/setup-agent-sequencing.test.ts": 4298, - "src/shared/setup-agent-sequencing.windows.test.ts": 39, - "src/shared/setup-runner-command.test.ts": 58, - "src/shared/setup-script-imports.test.ts": 101, - "src/shared/setup-script-package-manager-suggestion.test.ts": 37, - "src/shared/setup-script-shebang.test.ts": 53, - "src/shared/setup-script-telemetry-events.test.ts": 153, - "src/shared/setup-script-telemetry.test.ts": 63, - "src/shared/sha256.test.ts": 75, - "src/shared/shell-foreground-snapshot.test.ts": 157, - "src/shared/shell-process-readiness.test.ts": 66, - "src/shared/skill-bundle-install-contract.test.ts": 123, - "src/shared/skill-bundle-name.test.ts": 37, - "src/shared/skill-delete-contract.test.ts": 127, - "src/shared/skill-deletion-eligibility.test.ts": 62, - "src/shared/skill-install-contract.test.ts": 145, - "src/shared/skill-install-failure.test.ts": 88, - "src/shared/skill-metadata.test.ts": 40, - "src/shared/skill-package-manifest.test.ts": 233, - "src/shared/skill-path-containment.test.ts": 35, - "src/shared/skills-cli-agent-keys.test.ts": 40, - "src/shared/source-control-ai-action-recipes.test.ts": 176, - "src/shared/source-control-ai-action-variables.test.ts": 52, - "src/shared/source-control-ai-actions.test.ts": 65, - "src/shared/source-control-ai-policy-regression.test.ts": 220, - "src/shared/source-control-ai-recipe-save.test.ts": 179, - "src/shared/source-control-ai.test.ts": 148, - "src/shared/source-control-create-review-intent.test.ts": 37, - "src/shared/source-control-group-order.test.ts": 52, - "src/shared/source-control-primary-action-decision.test.ts": 55, - "src/shared/source-control-push-failure.test.ts": 60, - "src/shared/source-control-recovery-agent-command.test.ts": 62, - "src/shared/source-scan/source-tree-scan.test.ts": 80, - "src/shared/source-scan/source-tree-walk.test.ts": 58, - "src/shared/ssh-pending-pty-kill.test.ts": 47, - "src/shared/ssh-pty-id.test.ts": 56, - "src/shared/ssh-relay-pty-ownership-proof.test.ts": 53, - "src/shared/ssh-retained-payload-admission.test.ts": 68, - "src/shared/ssh-target-generation.test.ts": 50, - "src/shared/ssh-types.test.ts": 61, - "src/shared/stable-pane-id.test.ts": 37, - "src/shared/startup-command-submission.test.ts": 35, - "src/shared/status-bar-usage-mode.test.ts": 40, - "src/shared/string-chunk-compaction.test.ts": 44, - "src/shared/structural-value-equality.test.ts": 47, - "src/shared/structured-agent-session-agent-status.test.ts": 67, - "src/shared/structured-agent-session-coalescer.test.ts": 49, - "src/shared/structured-agent-session-composer.test.ts": 50, - "src/shared/structured-agent-session-context-usage.test.ts": 44, - "src/shared/structured-agent-session-create.test.ts": 64, - "src/shared/structured-agent-session-item-retention.test.ts": 937, - "src/shared/structured-agent-session-live-turn.test.ts": 117, - "src/shared/structured-agent-session-model-catalog-application.test.ts": 61, - "src/shared/structured-agent-session-mutation.test.ts": 49, - "src/shared/structured-agent-session-option-codec.test.ts": 25, - "src/shared/structured-agent-session-option-picks.test.ts": 79, - "src/shared/structured-agent-session-options.test.ts": 81, - "src/shared/structured-agent-session-projection.test.ts": 83, - "src/shared/structured-agent-session-reducer.test.ts": 146, - "src/shared/structured-agent-session-send-disposition.test.ts": 54, - "src/shared/structured-agent-session-status-started-at.test.ts": 94, - "src/shared/structured-agent-session-status-tool-line.test.ts": 77, - "src/shared/structured-agent-session-turn-timing-retention.test.ts": 185, - "src/shared/structured-agent-session-turn-timing.test.ts": 95, - "src/shared/structured-agent-turn-clock-anchor.test.ts": 36, - "src/shared/structured-native-chat-launch-route.test.ts": 38, - "src/shared/subprocess-stdin-write.test.ts": 54, - "src/shared/synthetic-agent-title.test.ts": 35, - "src/shared/tab-title-resolution.test.ts": 32, - "src/shared/tailnet-address.test.ts": 44, - "src/shared/task-providers.test.ts": 41, - "src/shared/task-query.test.ts": 51, - "src/shared/task-source-context.test.ts": 93, - "src/shared/telemetry-common-props.test.ts": 183, - "src/shared/telemetry-events-feature-education.test.ts": 198, - "src/shared/telemetry-events.test.ts": 189, - "src/shared/telemetry-feature-wall-events.test.ts": 175, - "src/shared/telemetry-orca-cli-feature-tip.test.ts": 182, - "src/shared/terminal-bell-detector.test.ts": 42, - "src/shared/terminal-color-scheme-protocol.test.ts": 50, - "src/shared/terminal-composer-draft.test.ts": 49, - "src/shared/terminal-custom-themes.test.ts": 42, - "src/shared/terminal-escape-introducer.test.ts": 61, - "src/shared/terminal-exit-cause.test.ts": 30, - "src/shared/terminal-file-url-target.test.ts": 36, - "src/shared/terminal-fonts.test.ts": 28, - "src/shared/terminal-github-pr-link-detector.test.ts": 73, - "src/shared/terminal-input.test.ts": 68, - "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts": 61, - "src/shared/terminal-kitty-keyboard-tail-retention.test.ts": 180, - "src/shared/terminal-line-height-settings.test.ts": 51, - "src/shared/terminal-mode-2031-final-state.test.ts": 90, - "src/shared/terminal-mode-reset-profiles.test.ts": 95, - "src/shared/terminal-osc-color-reply.test.ts": 28, - "src/shared/terminal-osc133-carry-retention.test.ts": 266, - "src/shared/terminal-output-side-effects.test.ts": 79, - "src/shared/terminal-partial-escape-tail-ground-scan.test.ts": 41, - "src/shared/terminal-partial-escape-tail.fuzz.test.ts": 1380, + "src/shared/quick-open-query-target.test.ts": 64, + "src/shared/quick-open-readdir-memory.test.ts": 100, + "src/shared/quick-open-readdir-walk.test.ts": 230, + "src/shared/quick-open-recent-candidates.test.ts": 63, + "src/shared/quick-open-transport-budget.test.ts": 55, + "src/shared/raster-image-base64-preview.test.ts": 391, + "src/shared/raster-image-dimensions.test.ts": 58, + "src/shared/raster-image-preview-limits.test.ts": 63, + "src/shared/rate-limit-reset-format.test.ts": 46, + "src/shared/react-update-depth-attribution.test.ts": 46, + "src/shared/reconnect-jitter.test.ts": 42, + "src/shared/relay-frame-buffer.test.ts": 275, + "src/shared/relay-optional-artifacts.test.ts": 53, + "src/shared/relay-version-marker.test.ts": 51, + "src/shared/release-channel.test.ts": 75, + "src/shared/remote-foreground-evidence.test.ts": 64, + "src/shared/remote-pairing-address.test.ts": 268, + "src/shared/remote-pairing-verification.test.ts": 64, + "src/shared/remote-rpc-content-budget.test.ts": 105, + "src/shared/remote-runtime-client-error-classification.test.ts": 50, + "src/shared/remote-runtime-client.test.ts": 5122, + "src/shared/remote-runtime-connect-bound.test.ts": 558, + "src/shared/remote-runtime-memory-limits.test.ts": 144, + "src/shared/remote-runtime-outbound-admission.test.ts": 1054, + "src/shared/remote-runtime-pty-id.test.ts": 45, + "src/shared/remote-runtime-request-connection-stale.test.ts": 312, + "src/shared/remote-runtime-request-connection.test.ts": 410, + "src/shared/remote-runtime-request-frames.test.ts": 208, + "src/shared/remote-runtime-request-response-router.test.ts": 244, + "src/shared/remote-runtime-request-websocket.test.ts": 300, + "src/shared/remote-runtime-shared-control-connection.test.ts": 3555, + "src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts": 279, + "src/shared/remote-runtime-shared-control-reconnect.test.ts": 263, + "src/shared/remote-runtime-shared-control-retired-request-ids.test.ts": 42, + "src/shared/remote-runtime-shared-control-socket-generation.test.ts": 221, + "src/shared/remote-runtime-shared-control-standing-intent.test.ts": 956, + "src/shared/remote-runtime-shared-control-subscription-close.test.ts": 255, + "src/shared/remote-runtime-shared-control-subscription-start.test.ts": 267, + "src/shared/remote-runtime-shared-control-subscriptions.test.ts": 277, + "src/shared/remote-runtime-socket-liveness.test.ts": 58, + "src/shared/remote-runtime-subscription-connect-bound.test.ts": 652, + "src/shared/remote-runtime-subscription-frame-router.test.ts": 249, + "src/shared/remote-runtime-subscription-request.test.ts": 699, + "src/shared/remote-runtime-tailscale-hint.test.ts": 59, + "src/shared/remote-runtime-transport-error-agreement.test.ts": 219, + "src/shared/remote-workspace-session-projection.test.ts": 346, + "src/shared/renderer-restart-preparation.test.ts": 169, + "src/shared/repo-badge-color.test.ts": 310, + "src/shared/repo-icon.test.ts": 80, + "src/shared/repo-pack-index-scheduling.test.ts": 86, + "src/shared/repo-ref-maintenance.test.ts": 507, + "src/shared/repo-search-limits.test.ts": 49, + "src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts": 105, + "src/shared/repro-7732-gitlab-job-id-dropped.test.ts": 51, + "src/shared/repro-8478-opencode-native-title-icon.test.ts": 86, + "src/shared/require-tui-agent-config.test.ts": 60, + "src/shared/resolved-worktree-lineage.test.ts": 60, + "src/shared/retired-pty-incarnations.test.ts": 50, + "src/shared/returned-draft-text.test.ts": 45, + "src/shared/review-head-tracking-ref.test.ts": 50, + "src/shared/review-refresh-policy.test.ts": 47, + "src/shared/ripgrep-dense-match-json.test.ts": 186, + "src/shared/ripgrep-filename-decoder.test.ts": 51, + "src/shared/ripgrep-line-decoding.test.ts": 388, + "src/shared/ripgrep-match-offsets.test.ts": 132, + "src/shared/ripgrep-process-availability.test.ts": 80, + "src/shared/ripgrep-search-diagnostics.test.ts": 72, + "src/shared/rpc-contract/agent-launch-params.test.ts": 408, + "src/shared/rpc-contract/automation-params-extra-agent-args.test.ts": 256, + "src/shared/rpc-contract/structured-agent-session-agent-params.test.ts": 292, + "src/shared/rpc-contract/terminal-create-shell-param.test.ts": 219, + "src/shared/rpc-contract/worktree-review-metadata-compatibility.test.ts": 277, + "src/shared/runtime-environment-managed-orcad-store.test.ts": 332, + "src/shared/runtime-environment-sidecar.test.ts": 340, + "src/shared/runtime-environment-ssh-access-store.test.ts": 416, + "src/shared/runtime-environment-store.test.ts": 353, + "src/shared/runtime-environments-ssh-access.test.ts": 247, + "src/shared/runtime-host-pairing-identity.test.ts": 52, + "src/shared/runtime-host-status-owner.test.ts": 83, + "src/shared/runtime-listing-host-scope.test.ts": 57, + "src/shared/runtime-navigation.test.ts": 47, + "src/shared/runtime-rpc-call-queue-retention.test.ts": 279, + "src/shared/runtime-rpc-call-queue.test.ts": 87, + "src/shared/runtime-source-env.test.ts": 51, + "src/shared/runtime-workspace-file-owner.test.ts": 50, + "src/shared/runtime-workspace-window-availability.test.ts": 49, + "src/shared/search-match-count.test.ts": 43, + "src/shared/search-subprocess-lines.test.ts": 99, + "src/shared/secret-at-rest-protection.test.ts": 50, + "src/shared/secret-store.test.ts": 43, + "src/shared/secure-file-coarse-ctime.test.ts": 61, + "src/shared/secure-file-fsync-flags.test.ts": 98, + "src/shared/secure-file.test.ts": 736, + "src/shared/secure-path-hardening-cache.test.ts": 59, + "src/shared/secure-path-hardening-retry-budget.test.ts": 91, + "src/shared/secure-path-windows-acl.win32.test.ts": 68, + "src/shared/serve-option-validation.test.ts": 63, + "src/shared/session-tab-placement.test.ts": 53, + "src/shared/setup-agent-sequencing-codex-no-daemon.test.ts": 1135, + "src/shared/setup-agent-sequencing.test.ts": 5411, + "src/shared/setup-agent-sequencing.windows.test.ts": 54, + "src/shared/setup-runner-command.test.ts": 80, + "src/shared/setup-script-imports.test.ts": 118, + "src/shared/setup-script-package-manager-suggestion.test.ts": 65, + "src/shared/setup-script-shebang.test.ts": 60, + "src/shared/setup-script-telemetry-events.test.ts": 297, + "src/shared/setup-script-telemetry.test.ts": 49, + "src/shared/sha256.test.ts": 61, + "src/shared/shell-foreground-snapshot.test.ts": 173, + "src/shared/shell-process-detection.test.ts": 55, + "src/shared/shell-process-readiness.test.ts": 68, + "src/shared/skill-bundle-install-contract.test.ts": 218, + "src/shared/skill-bundle-name.test.ts": 50, + "src/shared/skill-delete-contract.test.ts": 241, + "src/shared/skill-deletion-eligibility.test.ts": 56, + "src/shared/skill-install-contract.test.ts": 220, + "src/shared/skill-install-failure.test.ts": 202, + "src/shared/skill-metadata.test.ts": 47, + "src/shared/skill-package-manifest.test.ts": 350, + "src/shared/skill-path-containment.test.ts": 53, + "src/shared/skills-cli-agent-keys.test.ts": 49, + "src/shared/source-control-ai-action-recipes.test.ts": 306, + "src/shared/source-control-ai-action-variables.test.ts": 64, + "src/shared/source-control-ai-actions.test.ts": 66, + "src/shared/source-control-ai-policy-regression.test.ts": 281, + "src/shared/source-control-ai-recipe-save.test.ts": 276, + "src/shared/source-control-ai.test.ts": 278, + "src/shared/source-control-create-review-intent.test.ts": 60, + "src/shared/source-control-group-order.test.ts": 49, + "src/shared/source-control-launch-agent-selection.test.ts": 289, + "src/shared/source-control-primary-action-decision.test.ts": 62, + "src/shared/source-control-push-failure.test.ts": 68, + "src/shared/source-control-recovery-agent-command.test.ts": 77, + "src/shared/source-scan/source-tree-scan.test.ts": 100, + "src/shared/source-scan/source-tree-walk.test.ts": 71, + "src/shared/ssh-pending-pty-kill.test.ts": 55, + "src/shared/ssh-pty-id.test.ts": 49, + "src/shared/ssh-relay-pty-ownership-proof.test.ts": 76, + "src/shared/ssh-remote-pty-lease-predicates.test.ts": 43, + "src/shared/ssh-retained-payload-admission.test.ts": 78, + "src/shared/ssh-target-generation.test.ts": 57, + "src/shared/stable-pane-id.test.ts": 46, + "src/shared/startup-command-staging.node-pty.test.ts": 2051, + "src/shared/startup-command-staging.test.ts": 143, + "src/shared/startup-command-submission.test.ts": 42, + "src/shared/startup-line-prompt-carry.test.ts": 175, + "src/shared/status-bar-compact-change-notice.test.ts": 60, + "src/shared/status-bar-usage-mode.test.ts": 49, + "src/shared/string-chunk-compaction.test.ts": 63, + "src/shared/structural-value-equality.test.ts": 52, + "src/shared/structured-agent-session-agent-status.test.ts": 71, + "src/shared/structured-agent-session-child-work-legacy.test.ts": 65, + "src/shared/structured-agent-session-coalescer.test.ts": 215, + "src/shared/structured-agent-session-composer.test.ts": 82, + "src/shared/structured-agent-session-context-usage.test.ts": 226, + "src/shared/structured-agent-session-create.test.ts": 86, + "src/shared/structured-agent-session-dispatch-rejection.test.ts": 283, + "src/shared/structured-agent-session-draft-hand-off.test.ts": 46, + "src/shared/structured-agent-session-first-frame-picker.test.ts": 86, + "src/shared/structured-agent-session-first-prompt.test.ts": 227, + "src/shared/structured-agent-session-item-retention.test.ts": 941, + "src/shared/structured-agent-session-latest-request.test.ts": 353, + "src/shared/structured-agent-session-live-turn.test.ts": 248, + "src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts": 315, + "src/shared/structured-agent-session-message-projection.test.ts": 267, + "src/shared/structured-agent-session-model-catalog-application.test.ts": 77, + "src/shared/structured-agent-session-mutation.test.ts": 260, + "src/shared/structured-agent-session-opening-send.test.ts": 233, + "src/shared/structured-agent-session-option-codec.test.ts": 40, + "src/shared/structured-agent-session-option-picks.test.ts": 80, + "src/shared/structured-agent-session-options.test.ts": 78, + "src/shared/structured-agent-session-projection-sender.test.ts": 307, + "src/shared/structured-agent-session-projection.test.ts": 414, + "src/shared/structured-agent-session-reasoning-projection.test.ts": 308, + "src/shared/structured-agent-session-reducer.queued-messages.test.ts": 228, + "src/shared/structured-agent-session-reducer.test.ts": 274, + "src/shared/structured-agent-session-rejection-words.test.ts": 209, + "src/shared/structured-agent-session-send-evidence.test.ts": 225, + "src/shared/structured-agent-session-send-mutation.test.ts": 305, + "src/shared/structured-agent-session-status-started-at.test.ts": 291, + "src/shared/structured-agent-session-status-tool-line.test.ts": 324, + "src/shared/structured-agent-session-subagent-roster.test.ts": 64, + "src/shared/structured-agent-session-turn-bars.test.ts": 255, + "src/shared/structured-agent-session-turn-timing-retention.test.ts": 355, + "src/shared/structured-agent-session-turn-timing.test.ts": 301, + "src/shared/structured-agent-session-unknown-item.test.ts": 260, + "src/shared/structured-agent-turn-clock-anchor.test.ts": 290, + "src/shared/structured-chat-naming-eligibility.test.ts": 236, + "src/shared/structured-native-chat-launch-route.test.ts": 87, + "src/shared/structured-session-background-tasks-view.test.ts": 67, + "src/shared/structured-session-foreground-commands.test.ts": 230, + "src/shared/subprocess-stdin-write.test.ts": 49, + "src/shared/synthetic-agent-title.test.ts": 52, + "src/shared/tab-title-resolution.test.ts": 60, + "src/shared/tailnet-address.test.ts": 45, + "src/shared/task-providers.test.ts": 57, + "src/shared/task-query.test.ts": 61, + "src/shared/task-source-context.test.ts": 211, + "src/shared/telemetry-common-props.test.ts": 271, + "src/shared/telemetry-events-feature-education.test.ts": 298, + "src/shared/telemetry-events.test.ts": 361, + "src/shared/telemetry-feature-wall-events.test.ts": 290, + "src/shared/telemetry-orca-cli-feature-tip.test.ts": 307, + "src/shared/telemetry-workspace-create-schemas.test.ts": 242, + "src/shared/terminal-bell-detector.test.ts": 45, + "src/shared/terminal-color-scheme-protocol.test.ts": 45, + "src/shared/terminal-composer-draft.test.ts": 72, + "src/shared/terminal-custom-themes.test.ts": 54, + "src/shared/terminal-escape-introducer.test.ts": 55, + "src/shared/terminal-exit-cause.test.ts": 51, + "src/shared/terminal-file-url-target.test.ts": 42, + "src/shared/terminal-fonts.test.ts": 49, + "src/shared/terminal-github-pr-link-detector.test.ts": 71, + "src/shared/terminal-input.test.ts": 90, + "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts": 91, + "src/shared/terminal-kitty-keyboard-tail-retention.test.ts": 212, + "src/shared/terminal-leaf-move.test.ts": 43, + "src/shared/terminal-line-height-settings.test.ts": 55, + "src/shared/terminal-mode-2031-final-state.test.ts": 102, + "src/shared/terminal-mode-reset-profiles.test.ts": 84, + "src/shared/terminal-mouse-encoding.test.ts": 72, + "src/shared/terminal-osc-color-override-tracker.test.ts": 71, + "src/shared/terminal-osc-color-reply.test.ts": 45, + "src/shared/terminal-osc133-carry-retention.test.ts": 340, + "src/shared/terminal-output-side-effects.test.ts": 103, + "src/shared/terminal-pane-placement.test.ts": 328, + "src/shared/terminal-partial-escape-tail-ground-scan.test.ts": 61, + "src/shared/terminal-partial-escape-tail.fuzz.test.ts": 1435, "src/shared/terminal-partial-escape-tail.test.ts": 61, - "src/shared/terminal-query-reply.test.ts": 86, - "src/shared/terminal-quick-commands.test.ts": 33, - "src/shared/terminal-reply-query-scan.test.ts": 43, - "src/shared/terminal-scrollback-policy.test.ts": 53, - "src/shared/terminal-selection-gutter.test.ts": 50, - "src/shared/terminal-startup-cwd.test.ts": 220, - "src/shared/terminal-stream-json-byte-length.test.ts": 41, - "src/shared/terminal-stream-protocol.test.ts": 55, - "src/shared/terminal-title-agent-type.test.ts": 62, - "src/shared/terminal-title-classification-corpus.test.ts": 94, - "src/shared/terminal-title-classification-memo.test.ts": 49, - "src/shared/terminal-view-attributes.test.ts": 35, - "src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts": 33, - "src/shared/terminal-zero-dimensions-diagnostic.test.ts": 45, - "src/shared/test-code-path.test.ts": 41, - "src/shared/text-search.test.ts": 114, - "src/shared/timer-delay.test.ts": 52, - "src/shared/tui-agent-config.test.ts": 65, - "src/shared/tui-agent-launch-defaults.test.ts": 61, - "src/shared/tui-agent-permissions.test.ts": 41, - "src/shared/tui-agent-prompt-transport.test.ts": 94, - "src/shared/tui-agent-selection.test.ts": 47, - "src/shared/tui-agent-startup-copilot-resume.test.ts": 53, - "src/shared/tui-agent-startup-hermes.test.ts": 139, - "src/shared/tui-agent-startup-session-options.test.ts": 88, - "src/shared/tui-agent-startup-shell.test.ts": 108, - "src/shared/tui-agent-startup.test.ts": 119, - "src/shared/ui-language.test.ts": 36, - "src/shared/ui-locale.test.ts": 48, - "src/shared/ui-zoom-level.test.ts": 45, - "src/shared/updated-at-order.test.ts": 93, - "src/shared/updater-windows-signature-check.test.ts": 39, - "src/shared/usage-percentage-display-change-notice.test.ts": 94, - "src/shared/usage-percentage-display.test.ts": 40, - "src/shared/utf8-byte-limits.test.ts": 117, - "src/shared/uuid-v4.test.ts": 56, - "src/shared/vscode-remote-ssh-launcher.test.ts": 27, - "src/shared/window-shortcut-policy-agent-dashboard.test.ts": 48, - "src/shared/window-shortcut-policy.test.ts": 78, - "src/shared/windows-cmd-runner-delayed-launch.test.ts": 60, - "src/shared/windows-command-line-budget.test.ts": 1127, - "src/shared/windows-console-input.test.ts": 41, - "src/shared/windows-environment-expansion.test.ts": 132, - "src/shared/windows-interactive-login-spawn.test.ts": 42, - "src/shared/windows-lane-tree-removal-boundary.test.ts": 57, - "src/shared/windows-long-path-git-args.test.ts": 41, - "src/shared/windows-security-descriptor.test.ts": 33, - "src/shared/windows-terminal-shell.test.ts": 60, - "src/shared/windows-transient-lock-removal.test.ts": 1028, - "src/shared/worker-terminal-host-scope.test.ts": 65, - "src/shared/worker-transcript-background-task-text.test.ts": 56, - "src/shared/worker-transcript-text-scaling.test.ts": 46, - "src/shared/workspace-cleanup-applied-filters.test.ts": 60, - "src/shared/workspace-cleanup-browse-state-persistence.test.ts": 65, - "src/shared/workspace-cleanup-ui-state.test.ts": 39, - "src/shared/workspace-cleanup.test.ts": 40, - "src/shared/workspace-doc-history.test.ts": 59, - "src/shared/workspace-linked-item-equality.test.ts": 32, - "src/shared/workspace-linked-item-source-context.test.ts": 55, - "src/shared/workspace-name.test.ts": 55, - "src/shared/workspace-session-browser-history.test.ts": 173, - "src/shared/workspace-session-partition-owner.test.ts": 38, - "src/shared/workspace-session-salvage-equivalence.test.ts": 226, - "src/shared/workspace-session-salvage.test.ts": 1142, - "src/shared/workspace-session-schema-field-coverage.test.ts": 205, - "src/shared/workspace-session-schema.sleeping-agent.test.ts": 247, - "src/shared/workspace-session-schema.test.ts": 289, - "src/shared/workspace-session-tab-focus-schema.test.ts": 174, - "src/shared/workspace-session-terminal-buffers.test.ts": 276, - "src/shared/workspace-session-terminal-schema.test.ts": 155, - "src/shared/workspace-session-terminal-tab-close.test.ts": 242, - "src/shared/workspace-session-validation-work.test.ts": 279, - "src/shared/workspace-space-compaction.test.ts": 49, - "src/shared/workspace-space-entry-traversal.test.ts": 1467, - "src/shared/workspace-space-scan-budget.test.ts": 80, - "src/shared/workspace-statuses.test.ts": 70, - "src/shared/worktree-execution-host-resolution.test.ts": 48, - "src/shared/worktree-name-suggestion.test.ts": 39, - "src/shared/worktree-scan-failure.test.ts": 39, - "src/shared/worktree/archive-failure-blocking-capability.test.ts": 31, - "src/shared/worktree/base-ref.test.ts": 43, - "src/shared/worktree/card-properties.test.ts": 56, - "src/shared/worktree/catalog-version.test.ts": 29, - "src/shared/worktree/create-preparation.test.ts": 35, - "src/shared/worktree/github-pr-suppression.test.ts": 57, - "src/shared/worktree/host-context-labels.test.ts": 59, - "src/shared/worktree/host-qualified-identity.test.ts": 61, - "src/shared/worktree/id.test.ts": 58, - "src/shared/worktree/identity.test.ts": 38, - "src/shared/worktree/ownership-configured-base-visibility.test.ts": 77, - "src/shared/worktree/ownership-worktree-base-path.test.ts": 57, - "src/shared/worktree/ownership.test.ts": 264, - "src/shared/worktree/removal-fence-error.test.ts": 27, - "src/shared/worktree/removal-force-classification.test.ts": 30, + "src/shared/terminal-query-reply.test.ts": 112, + "src/shared/terminal-quick-commands.test.ts": 56, + "src/shared/terminal-reply-query-scan.test.ts": 56, + "src/shared/terminal-restore-parity-writes.test.ts": 135, + "src/shared/terminal-scrollback-policy.test.ts": 55, + "src/shared/terminal-selection-gutter.test.ts": 47, + "src/shared/terminal-session-state-save-failure.test.ts": 46, + "src/shared/terminal-startup-cwd.test.ts": 308, + "src/shared/terminal-stream-json-byte-length.test.ts": 65, + "src/shared/terminal-stream-protocol.test.ts": 63, + "src/shared/terminal-surface-close-target.test.ts": 51, + "src/shared/terminal-synchronized-output-scan.test.ts": 80, + "src/shared/terminal-theme-selection.test.ts": 53, + "src/shared/terminal-title-agent-type.test.ts": 90, + "src/shared/terminal-title-classification-corpus.test.ts": 152, + "src/shared/terminal-title-classification-memo.test.ts": 95, + "src/shared/terminal-view-attributes.test.ts": 60, + "src/shared/terminal-viewport.test.ts": 189, + "src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts": 48, + "src/shared/terminal-zero-dimensions-diagnostic.test.ts": 47, + "src/shared/test-code-path.test.ts": 54, + "src/shared/text-search-dense-matches.test.ts": 450, + "src/shared/text-search-glob-patterns.test.ts": 243, + "src/shared/text-search-invalid-filename.test.ts": 62, + "src/shared/text-search-paths.test.ts": 68, + "src/shared/text-search-snippet-boundaries.test.ts": 66, + "src/shared/text-search-snippet-retention.test.ts": 603, + "src/shared/text-search.test.ts": 129, + "src/shared/timer-delay.test.ts": 54, + "src/shared/tmux-agent-hook-owner.test.ts": 467, + "src/shared/tmux-client-attachment.test.ts": 64, + "src/shared/tmux-hook-refresh-retirement.test.ts": 621, + "src/shared/tmux-host-attachment-probe.test.ts": 45, + "src/shared/tui-agent-config.test.ts": 58, + "src/shared/tui-agent-fork-startup.test.ts": 112, + "src/shared/tui-agent-launch-command-argument-values.test.ts": 118, + "src/shared/tui-agent-launch-command-override.test.ts": 61, + "src/shared/tui-agent-launch-command-pick-flags.test.ts": 97, + "src/shared/tui-agent-launch-defaults.test.ts": 96, + "src/shared/tui-agent-permissions.test.ts": 42, + "src/shared/tui-agent-prompt-transport.test.ts": 147, + "src/shared/tui-agent-rest-signal.test.ts": 64, + "src/shared/tui-agent-selection.test.ts": 62, + "src/shared/tui-agent-startup-copilot-resume.test.ts": 117, + "src/shared/tui-agent-startup-hermes.test.ts": 145, + "src/shared/tui-agent-startup-kiro-resume.test.ts": 65, + "src/shared/tui-agent-startup-session-options.test.ts": 135, + "src/shared/tui-agent-startup-shell.test.ts": 129, + "src/shared/tui-agent-startup.test.ts": 191, + "src/shared/ui-language.test.ts": 41, + "src/shared/ui-locale.test.ts": 45, + "src/shared/ui-zoom-level.test.ts": 55, + "src/shared/updated-at-order.test.ts": 104, + "src/shared/updater-windows-signature-check.test.ts": 43, + "src/shared/usage-percentage-display-change-notice.test.ts": 51, + "src/shared/usage-percentage-display.test.ts": 44, + "src/shared/utf8-byte-limits.test.ts": 149, + "src/shared/uuid-v4.test.ts": 47, + "src/shared/vscode-remote-ssh-launcher.test.ts": 53, + "src/shared/window-shortcut-policy-agent-dashboard.test.ts": 72, + "src/shared/window-shortcut-policy.test.ts": 119, + "src/shared/windows-breakaway-launcher.test.ts": 82, + "src/shared/windows-breakaway-previous-stderr.test.ts": 67, + "src/shared/windows-cmd-runner-delayed-launch.test.ts": 43, + "src/shared/windows-command-line-budget.test.ts": 278, + "src/shared/windows-console-input.test.ts": 65, + "src/shared/windows-environment-expansion.test.ts": 108, + "src/shared/windows-interactive-login-spawn.test.ts": 64, + "src/shared/windows-long-path-git-args.test.ts": 49, + "src/shared/windows-security-descriptor.test.ts": 57, + "src/shared/windows-terminal-shell.test.ts": 53, + "src/shared/windows-transient-lock-removal.test.ts": 1054, + "src/shared/worker-terminal-host-scope.test.ts": 98, + "src/shared/worker-transcript-background-task-text.test.ts": 54, + "src/shared/worker-transcript-text-scaling.test.ts": 57, + "src/shared/workspace-attachment-mutation.test.ts": 112, + "src/shared/workspace-attachment-normalization.test.ts": 67, + "src/shared/workspace-attachment-origins.test.ts": 53, + "src/shared/workspace-attachments.test.ts": 291, + "src/shared/workspace-cleanup-applied-filters.test.ts": 76, + "src/shared/workspace-cleanup-browse-state-persistence.test.ts": 61, + "src/shared/workspace-cleanup-ui-state.test.ts": 50, + "src/shared/workspace-cleanup.test.ts": 49, + "src/shared/workspace-doc-history.test.ts": 70, + "src/shared/workspace-layout/tab-group-moves.test.ts": 58, + "src/shared/workspace-layout/workspace-layout-command-model.test.ts": 470, + "src/shared/workspace-layout/workspace-layout-command-persistence.test.ts": 339, + "src/shared/workspace-layout/workspace-layout-commands.test.ts": 187, + "src/shared/workspace-layout/workspace-layout-fact-locations.test.ts": 442, + "src/shared/workspace-layout/workspace-layout-legacy-persistence.test.ts": 359, + "src/shared/workspace-layout/workspace-layout-load-normalization.test.ts": 379, + "src/shared/workspace-layout/workspace-layout-minted-ids.test.ts": 41, + "src/shared/workspace-layout/workspace-layout-model-rules.test.ts": 340, + "src/shared/workspace-layout/workspace-layout-published.test.ts": 303, + "src/shared/workspace-layout/workspace-layout-round-trip.test.ts": 345, + "src/shared/workspace-layout/workspace-layout-stream-frames.test.ts": 275, + "src/shared/workspace-layout/workspace-layout-transitions.test.ts": 89, + "src/shared/workspace-linked-item-equality.test.ts": 45, + "src/shared/workspace-linked-item-source-context.test.ts": 62, + "src/shared/workspace-name.test.ts": 78, + "src/shared/workspace-reference-identity.test.ts": 96, + "src/shared/workspace-review-checks.test.ts": 86, + "src/shared/workspace-session-browser-history.test.ts": 307, + "src/shared/workspace-session-partition-owner.test.ts": 37, + "src/shared/workspace-session-salvage-equivalence.test.ts": 472, + "src/shared/workspace-session-salvage.test.ts": 951, + "src/shared/workspace-session-schema-field-coverage.test.ts": 345, + "src/shared/workspace-session-schema.sleeping-agent.test.ts": 377, + "src/shared/workspace-session-schema.test.ts": 379, + "src/shared/workspace-session-tab-focus-schema.test.ts": 360, + "src/shared/workspace-session-terminal-buffers.test.ts": 316, + "src/shared/workspace-session-terminal-schema.test.ts": 313, + "src/shared/workspace-session-terminal-tab-close.test.ts": 315, + "src/shared/workspace-session-validation-work.test.ts": 377, + "src/shared/workspace-space-compaction.test.ts": 61, + "src/shared/workspace-space-entry-traversal.test.ts": 849, + "src/shared/workspace-space-scan-budget.test.ts": 49, + "src/shared/workspace-statuses.test.ts": 63, + "src/shared/worktree-execution-host-resolution.test.ts": 106, + "src/shared/worktree-name-suggestion.test.ts": 72, + "src/shared/worktree-scan-failure.test.ts": 44, + "src/shared/worktree/archive-failure-blocking-capability.test.ts": 68, + "src/shared/worktree/base-ref.test.ts": 63, + "src/shared/worktree/card-properties.test.ts": 55, + "src/shared/worktree/catalog-version.test.ts": 45, + "src/shared/worktree/create-preparation.test.ts": 47, + "src/shared/worktree/github-pr-suppression.test.ts": 66, + "src/shared/worktree/host-context-labels.test.ts": 50, + "src/shared/worktree/host-qualified-identity.test.ts": 50, + "src/shared/worktree/id.test.ts": 71, + "src/shared/worktree/identity.test.ts": 57, + "src/shared/worktree/local-base-branch-fast-forward-real-git.test.ts": 1130, + "src/shared/worktree/local-base-branch-fast-forward.test.ts": 98, + "src/shared/worktree/ownership-configured-base-visibility.test.ts": 69, + "src/shared/worktree/ownership-worktree-base-path.test.ts": 56, + "src/shared/worktree/ownership.test.ts": 251, + "src/shared/worktree/removal-fence-error.test.ts": 54, + "src/shared/worktree/removal-force-classification.test.ts": 52, "src/shared/worktree/retired-name-cache.test.ts": 52, - "src/shared/worktree/retired-name-registry.test.ts": 289, - "src/shared/worktree/sort-order-update.test.ts": 43, - "src/shared/worktree/submodule-removal.test.ts": 48, - "src/shared/worktree/visibility-sources.test.ts": 87, - "src/shared/ws-outbound-backpressure-queue.test.ts": 67, - "src/shared/wsl-exec-mode-separator.test.ts": 1946, - "src/shared/wsl-login-shell-command.test.ts": 140, - "src/shared/wsl-paths.test.ts": 93, - "src/shared/zcode-missing-tui.test.ts": 43, - "src/shared/zcode-orchestration-contract.test.ts": 98, - "src/shared/zod-salvage-absence.test.ts": 106, - "tests/e2e/accumulated-workspace-state-builder.unit.test.ts": 88, - "tests/e2e/alternate-screen-fixture-script.unit.test.ts": 55, - "tests/e2e/codex-child-approval-activity-row.unit.test.ts": 4306, - "tests/e2e/completed-worker-retirement-resume.unit.test.ts": 5834, - "tests/e2e/daemon-running-work-probe.unit.test.ts": 311, - "tests/e2e/direct-ssh-snapshot-parking.unit.test.ts": 2238, - "tests/e2e/e2e-worker-env-isolation.unit.test.ts": 84, - "tests/e2e/folder-upgrade-identity-persistence.unit.test.ts": 1410, - "tests/e2e/github-work-item-search-burst.unit.test.ts": 1152, - "tests/e2e/global-teardown.unit.test.ts": 136, - "tests/e2e/helpers/alt-screen-frame.unit.test.ts": 110, - "tests/e2e/helpers/client-hosted-browser-fixture.unit.test.ts": 753, - "tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts": 50, - "tests/e2e/helpers/electron-home-isolation.unit.test.ts": 37, - "tests/e2e/helpers/electron-launch-args.unit.test.ts": 52, - "tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts": 2464, - "tests/e2e/helpers/electron-process-shutdown.unit.test.ts": 60, - "tests/e2e/helpers/fake-agent-command-override.unit.test.ts": 35, - "tests/e2e/helpers/fake-agent-paste-end-scanner.unit.test.ts": 79, - "tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 57, - "tests/e2e/helpers/golden-source-control.unit.test.ts": 640, - "tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 59, - "tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 30, - "tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 82, - "tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 67, - "tests/e2e/helpers/persisted-profile-state.unit.test.ts": 356, - "tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 328, - "tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 46, - "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 107, + "src/shared/worktree/retired-name-registry.test.ts": 306, + "src/shared/worktree/sort-order-update.test.ts": 42, + "src/shared/worktree/visibility-sources.test.ts": 65, + "src/shared/ws-outbound-backpressure-queue.test.ts": 79, + "src/shared/wsl-login-shell-command.test.ts": 105, + "src/shared/wsl-paths.test.ts": 64, + "src/shared/zcode-missing-tui.test.ts": 56, + "src/shared/zcode-orchestration-contract.test.ts": 161, + "src/shared/zod-salvage-absence.test.ts": 208, + "src/wsl-guest/agent-hook-canonical-status.test.ts": 2544, + "src/wsl-guest/agent-hook-envelope-publication.test.ts": 385, + "src/wsl-guest/agent-hook-integration.test.ts": 1405, + "src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts": 540, + "src/wsl-guest/agent-hook-opencode-tui-identity.test.ts": 638, + "src/wsl-guest/agent-hook-server-claude-owed-notification-expiry.test.ts": 700, + "src/wsl-guest/agent-hook-server-codex-subagent-transcript.test.ts": 1475, + "src/wsl-guest/agent-hook-server-codex-turn-interruption.test.ts": 1828, + "src/wsl-guest/agent-hook-server.test.ts": 1582, + "src/wsl-guest/dispatcher-bulk-admission.test.ts": 415, + "src/wsl-guest/dispatcher-bulk-failed-admission.test.ts": 262, + "src/wsl-guest/dispatcher-bulk-retirement-admission.test.ts": 3089, + "src/wsl-guest/dispatcher-capacity-degradation.test.ts": 314, + "src/wsl-guest/dispatcher-client-close-cause.test.ts": 257, + "src/wsl-guest/dispatcher-client-writer.test.ts": 86, + "src/wsl-guest/dispatcher-frame-guard-regressions.test.ts": 280, + "src/wsl-guest/dispatcher-json-payload.test.ts": 343, + "src/wsl-guest/dispatcher-notification-ownership.test.ts": 218, + "src/wsl-guest/dispatcher-per-connection-state-baseline.test.ts": 266, + "src/wsl-guest/dispatcher-rpc-cancel-id-coercion.test.ts": 245, + "src/wsl-guest/dispatcher-silent-client-reaper.test.ts": 279, + "src/wsl-guest/dispatcher-structured-error.test.ts": 275, + "src/wsl-guest/dispatcher-timeout.test.ts": 247, + "src/wsl-guest/dispatcher-writer-admission.test.ts": 111, + "src/wsl-guest/dispatcher.test.ts": 414, + "src/wsl-guest/filesystem-capacity-error-wire.test.ts": 281, + "src/wsl-guest/legacy-relay-publication-ledger.test.ts": 50, + "src/wsl-guest/omp-config-root.test.ts": 177, + "src/wsl-guest/opencode-startup-prompt-install.test.ts": 139, + "src/wsl-guest/plugin-overlay-env.test.ts": 98, + "src/wsl-guest/plugin-overlay.test.ts": 164, + "src/wsl-guest/plugin-source-limit.test.ts": 46, + "src/wsl-guest/preflight-handler.test.ts": 84, + "src/wsl-guest/protocol-backpressure.test.ts": 109, + "src/wsl-guest/protocol-handshake.test.ts": 59, + "src/wsl-guest/protocol-json-payload.test.ts": 143, + "src/wsl-guest/relay-agent-presence.test.ts": 398, + "src/wsl-guest/relay-command-env.test.ts": 77, + "src/wsl-guest/relay-hot-path-operation-counts.test.ts": 279, + "src/wsl-guest/wsl-agent-hook-relay.test.ts": 454, + "src/wsl-guest/wsl-hook-fs-bridge.test.ts": 80, + "src/wsl-guest/wsl-install-plugins-handler.test.ts": 147, + "tests/e2e/accumulated-workspace-state-builder.unit.test.ts": 120, + "tests/e2e/alternate-screen-fixture-script.unit.test.ts": 60, + "tests/e2e/codex-child-approval-activity-row.unit.test.ts": 4279, + "tests/e2e/completed-worker-retirement-resume.unit.test.ts": 14396, + "tests/e2e/cursor-quota-transport.unit.test.ts": 1102, + "tests/e2e/daemon-running-work-probe.unit.test.ts": 315, + "tests/e2e/direct-ssh-snapshot-parking.unit.test.ts": 2079, + "tests/e2e/folder-upgrade-identity-persistence.unit.test.ts": 2238, + "tests/e2e/github-work-item-search-burst.unit.test.ts": 1180, + "tests/e2e/global-setup-cli-artifact.unit.test.ts": 55, + "tests/e2e/global-teardown.unit.test.ts": 124, + "tests/e2e/helpers/alt-screen-frame.unit.test.ts": 96, + "tests/e2e/helpers/client-hosted-browser-fixture.unit.test.ts": 752, + "tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts": 49, + "tests/e2e/helpers/electron-home-isolation.unit.test.ts": 51, + "tests/e2e/helpers/electron-launch-args.unit.test.ts": 57, + "tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts": 75, + "tests/e2e/helpers/electron-process-shutdown.unit.test.ts": 69, + "tests/e2e/helpers/fake-agent-command-override.unit.test.ts": 66, + "tests/e2e/helpers/fake-agent-paste-end-scanner.unit.test.ts": 68, + "tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 59, + "tests/e2e/helpers/golden-source-control.unit.test.ts": 645, + "tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 56, + "tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 45, + "tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 59, + "tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 179, + "tests/e2e/helpers/persisted-profile-state.unit.test.ts": 470, + "tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 421, + "tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 65, + "tests/e2e/helpers/remote-typing-load-script.unit.test.ts": 276, + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 117, "tests/e2e/helpers/streaming-terminal-cleanup.unit.test.ts": 51, - "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 5055, - "tests/e2e/host-guest-paint-retention-remote-viewer.unit.test.ts": 3075, - "tests/e2e/omp-child-session-recovery.unit.test.ts": 1630, - "tests/e2e/omp-retired-host-renderer.unit.test.ts": 2215, - "tests/e2e/omp-risk-remote-review.unit.test.ts": 816, - "tests/e2e/orca-restart-navigation.unit.test.ts": 622, + "tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts": 64, + "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 7476, + "tests/e2e/host-guest-paint-retention-remote-viewer.unit.test.ts": 5043, + "tests/e2e/native-chat-ssh-delivery.unit.test.ts": 5842, + "tests/e2e/omp-child-session-recovery.unit.test.ts": 1983, + "tests/e2e/omp-retired-host-renderer.unit.test.ts": 1794, + "tests/e2e/omp-risk-remote-review.unit.test.ts": 965, + "tests/e2e/orca-restart-navigation.unit.test.ts": 537, "tests/e2e/orchestration-run-pagination.unit.test.ts": 52, - "tests/e2e/paced-terminal-typing.unit.test.ts": 656, - "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts": 2375, - "tests/e2e/remote-agent-completion-authority.unit.test.ts": 1540, - "tests/e2e/remote-terminal-tab-retirement.unit.test.ts": 4050, - "tests/e2e/restored-terminal-input-readiness.unit.test.ts": 57, - "tests/e2e/runtime-graph-publication-probe.unit.test.ts": 40, - "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts": 3808, - "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 775, - "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 1914, - "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts": 287, - "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 53, - "tests/e2e/structured-agent-session-read-owner.unit.test.ts": 739, - "tests/e2e/structured-chat-owner-status-activation.unit.test.ts": 1707, - "tests/e2e/structured-native-chat-routing-authority.unit.test.ts": 214, - "tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts": 2194, - "tests/e2e/terminal-binding-crash-hydration.unit.test.ts": 920, - "tests/e2e/terminal-foreground-confirmation.unit.test.ts": 196, - "tests/e2e/terminal-perf-presentation.unit.test.ts": 44, - "tests/e2e/terminal-probe-input-sequence.unit.test.ts": 53, - "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts": 53, - "tests/e2e/terminal-split-activation-latency-report.unit.test.ts": 68, - "tests/tools/omp-close-lifecycle.test.mjs": 255, - "tests/tools/omp-config-root-local-shell.test.mjs": 196, - "tests/tools/omp-config-root-relay-shell.test.mjs": 169, - "tests/tools/omp-relay-close-lifecycle.test.mjs": 329, - "tests/tools/relay-bench/phone-e2ee-desktop-parity.test.mjs": 126, - "tests/tools/relay-bench/region-probe-replay.test.mjs": 134, - "tests/tools/relay-bench/relay-bench-invocation.test.mjs": 94, - "tests/tools/relay-bench/relay-bench-state-file.test.mjs": 52, - "tests/tools/relay-bench/relay-phone-connect-bench.test.mjs": 134, - "tests/tools/win-update-e2e/app-driver.test.mjs": 671, - "tests/tools/win-update-e2e/app-shutdown.test.mjs": 656, - "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs": 46, - "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs": 89, - "tests/tools/windows-pty-native-capability-smoke/run.test.mjs": 32 + "tests/e2e/paced-terminal-typing.unit.test.ts": 475, + "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts": 4522, + "tests/e2e/remote-agent-completion-authority.unit.test.ts": 1490, + "tests/e2e/remote-terminal-input-delivery-model.unit.test.ts": 18232, + "tests/e2e/remote-terminal-tab-retirement.unit.test.ts": 5251, + "tests/e2e/restored-terminal-input-readiness.unit.test.ts": 71, + "tests/e2e/runtime-graph-publication-probe.unit.test.ts": 65, + "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts": 5357, + "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 1142, + "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 5281, + "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts": 362, + "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 64, + "tests/e2e/structured-agent-session-read-owner.unit.test.ts": 2988, + "tests/e2e/structured-attention-explicit-read.unit.test.ts": 13645, + "tests/e2e/structured-attention-headless-delivery.unit.test.ts": 259, + "tests/e2e/structured-attention-host-read-retirement.unit.test.ts": 3238, + "tests/e2e/structured-attention-prompt-relay.unit.test.ts": 17392, + "tests/e2e/structured-attention-read-owner-lifecycle.unit.test.ts": 17537, + "tests/e2e/structured-attention-read-retirement.unit.test.ts": 13824, + "tests/e2e/structured-attention-relay-settlement.unit.test.ts": 19420, + "tests/e2e/structured-chat-owner-status-activation.unit.test.ts": 3376, + "tests/e2e/structured-native-chat-routing-authority.unit.test.ts": 1170, + "tests/e2e/structured-session-reopened-tab-close.unit.test.ts": 25072, + "tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts": 2142, + "tests/e2e/terminal-account-restart.unit.test.ts": 2678, + "tests/e2e/terminal-binding-crash-hydration.unit.test.ts": 1477, + "tests/e2e/terminal-foreground-confirmation.unit.test.ts": 477, + "tests/e2e/terminal-layout-parity-snapshot.unit.test.ts": 91, + "tests/e2e/terminal-perf-presentation.unit.test.ts": 64, + "tests/e2e/terminal-probe-input-sequence.unit.test.ts": 42, + "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts": 64, + "tests/e2e/terminal-split-activation-latency-report.unit.test.ts": 70, + "tests/tools/omp-close-lifecycle.test.mjs": 283, + "tests/tools/omp-config-root-local-shell.test.mjs": 249, + "tests/tools/omp-config-root-relay-shell.test.mjs": 162, + "tests/tools/omp-relay-close-lifecycle.test.mjs": 581, + "tests/tools/relay-bench/phone-e2ee-desktop-parity.test.mjs": 114, + "tests/tools/relay-bench/region-probe-replay.test.mjs": 84, + "tests/tools/relay-bench/relay-bench-invocation.test.mjs": 102, + "tests/tools/relay-bench/relay-bench-state-file.test.mjs": 65, + "tests/tools/relay-bench/relay-phone-connect-bench.test.mjs": 89, + "tests/tools/win-update-e2e/app-driver.test.mjs": 541, + "tests/tools/win-update-e2e/app-shutdown.test.mjs": 644, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs": 59, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs": 253, + "tests/tools/windows-pty-native-capability-smoke/run.test.mjs": 57 } }, "e2e": { diff --git a/config/scripts/ci-unit-dependency-graph.mjs b/config/scripts/ci-unit-dependency-graph.mjs index 766550679c71..7182f58f17a8 100644 --- a/config/scripts/ci-unit-dependency-graph.mjs +++ b/config/scripts/ci-unit-dependency-graph.mjs @@ -16,7 +16,7 @@ const EXTENSIONS = [ '/index.js' ] const INDIRECT_INPUT = - /\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/ + /\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker|createRequire|getBuiltinModule)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/ function localPath(file, specifier) { if (specifier.startsWith('.')) { @@ -45,6 +45,9 @@ export function buildUnitDependencyGraph(sources, workspace = { resolve: () => n opaque.add(file) } for (const imported of ts.preProcessFile(source, true, true).importedFiles) { + if (imported.fileName === 'module' || imported.fileName === 'node:module') { + opaque.add(file) + } const workspaceSource = workspace.resolve(imported.fileName) const path = workspaceSource?.file ?? localPath(file, imported.fileName) if (path === null) { diff --git a/config/scripts/ci-unit-dependency-graph.test.mjs b/config/scripts/ci-unit-dependency-graph.test.mjs index 96a53192b77a..3859d72949ee 100644 --- a/config/scripts/ci-unit-dependency-graph.test.mjs +++ b/config/scripts/ci-unit-dependency-graph.test.mjs @@ -86,3 +86,58 @@ it.each(['@orca/unknown', '@orca/process-host/private'])( ).toThrow('No public orca-source export') } ) + +it.each([ + [ + 'an assigned native loader', + 'src/main/windows-native-registry.ts', + "import { createRequire } from 'node:module'; const requireFromMain = createRequire(__filename); export const loadWindowsNativeRegistry = () => requireFromMain('@orca/windows-registry')", + 'src/main/windows-registry-addon.test.ts', + "import { loadWindowsNativeRegistry } from './windows-native-registry'" + ], + [ + 'a direct loader with a runtime serializer path', + 'src/main/daemon/serialize-grid-roundtrip.ts', + "import { createRequire } from 'node:module'; export const loadOldSerializer = (path) => createRequire(import.meta.url)(path)", + 'src/main/daemon/serialize-grid.differential.fuzz.test.ts', + "import { loadOldSerializer } from './serialize-grid-roundtrip'" + ], + [ + 'a module namespace whose computed loader escapes through an object', + 'src/main/stored-native-loader.ts', + "import * as hostModule from 'node:module'; const native = hostModule; const load = native['create' + 'Require'](__filename); const api = { load }; export const open = (path) => api.load(path)", + 'src/main/stored-native-loader.test.ts', + "import { open } from './stored-native-loader'" + ], + [ + 'an optional builtin loader', + 'src/main/sqlite/sync-database.ts', + "export const isSqliteAvailable = () => process.getBuiltinModule?.('node:sqlite')", + 'src/main/sqlite/sync-database-portability.test.ts', + "import { isSqliteAvailable } from './sync-database'" + ], + [ + 'an assigned builtin loader', + 'src/main/sqlite/assigned-reader.ts', + "const getBuiltin = process.getBuiltinModule; export const sqlite = () => getBuiltin('node:sqlite')", + 'src/main/sqlite/assigned-reader.test.ts', + "import { sqlite } from './assigned-reader'" + ] +])( + 'keeps real test consumers of %s when another covered source changes', + (_label, owner, source, consumer, testSource) => { + const graph = collectUnitDependencyGraph( + fixture({ + [owner]: source, + [consumer]: testSource, + 'src/selected-input.ts': 'export const value = 1', + 'src/selected-input.test.ts': "import { value } from './selected-input'" + }) + ) + const tests = [...graph.files].filter((file) => file.endsWith('.test.ts')).sort() + expect(selectUnitFiles(tests, ['src/selected-input.ts'], graph)).toMatchObject({ + full: false, + files: [consumer, 'src/selected-input.test.ts'].sort() + }) + } +) diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index 95c85720f78b..e7ac814bf032 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' -import { ORCHESTRATION_TARGET_PARAM } from '../orchestration/orchestration-party' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -100,20 +99,6 @@ function assignTerminal(runId: string): void { } describe('every target param resolves both spellings of a party to one canonical address', () => { - it('covers exactly the target params the contract lists', () => { - expect(Object.keys(ORCHESTRATION_TARGET_PARAM).sort()).toEqual( - [ - 'orchestration.send', - 'orchestration.ask', - 'orchestration.dispatch', - 'orchestration.inbox', - 'orchestration.sessionAddress', - 'orchestration.partyLocation', - 'orchestration.workerStart' - ].sort() - ) - }) - it.each(WORKER_SPELLINGS)('send: a worker at %s gets its Dispatch mailbox', async (_l, to) => { const dispatchId = assignWorker(await chatRun()) const { message } = await as(undefined, 'orchestration.send', { diff --git a/src/relay/git-handler-push-target.test.ts b/src/relay/git-handler-push-target.test.ts index 0357c5614d17..3b9e85ab6e6a 100644 --- a/src/relay/git-handler-push-target.test.ts +++ b/src/relay/git-handler-push-target.test.ts @@ -77,34 +77,6 @@ describe('resolveRelayPushTarget', () => { ]) }) - it('does not combine remote.pushDefault with a base-branch merge target', async () => { - const git = gitForConfig({ - branch: 'feature/fix', - pushRemote: new Error('missing pushRemote'), - pushDefault: 'fork', - branchRemote: 'origin', - merge: 'refs/heads/main', - base: 'refs/remotes/origin/main' - }) - - await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toBeNull() - }) - - it('keeps a fork head target when the contributor branch matches the base branch name', async () => { - const git = gitForConfig({ - branch: 'review/pr-1', - pushRemote: 'fork', - branchRemote: 'fork', - merge: 'refs/heads/main', - base: 'refs/remotes/origin/main' - }) - - await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toEqual({ - remote: 'fork', - refspec: 'HEAD:main' - }) - }) - it('uses remote.pushDefault when branch pushRemote is missing', async () => { const git = gitForConfig({ pushRemote: new Error('missing pushRemote'), @@ -118,42 +90,6 @@ describe('resolveRelayPushTarget', () => { }) }) - it('normalizes a URL-valued branch remote to a matching named remote', async () => { - const forkUrl = 'https://github.com/contributor/orca.git' - const git = gitForConfig({ - pushRemote: new Error('missing pushRemote'), - pushDefault: new Error('missing pushDefault'), - branchRemote: forkUrl, - remotes: ['origin', 'pr-contributor-orca'], - remoteUrls: { - origin: 'https://github.com/stablyai/orca.git', - 'pr-contributor-orca': forkUrl - } - }) - - await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toEqual({ - remote: 'pr-contributor-orca', - refspec: 'HEAD:feature/fix' - }) - }) - - it('keeps a URL-valued pushRemote when no named remote matches it', async () => { - const forkUrl = 'git@github.com:contributor/orca.git' - const git = gitForConfig({ - pushRemote: forkUrl, - branchRemote: forkUrl, - remotes: ['origin'], - remoteUrls: { - origin: 'git@github.com:stablyai/orca.git' - } - }) - - await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toEqual({ - remote: forkUrl, - refspec: 'HEAD:feature/fix' - }) - }) - it('uses an explicit push target without reading branch config', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index 5b3029847f7e..f520d6985a4e 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -53,11 +53,6 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) }) - it('still defaults to a single Ctrl+U when no draft is parked', () => { - sendNativeChatMessage(PTY, 'plain') - expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) - }) - it('holds the body until the clear is confirmed, then submits after the gap', () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) sendNativeChatMessage(PTY, 'edited', { @@ -117,18 +112,6 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { ]) }) - it('re-clears before the body, never after it', () => { - sendNativeChatMessage(PTY, 'edited', { - clearInput: buildAgentTuiClearInputForText(DRAFT), - confirmCleared: () => false - }) - vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS) - const order = writes() - expect(order.indexOf(AGENT_TUI_CLEAR_INPUT_MAX)).toBeLessThan( - order.indexOf(buildNativeChatPasteBytes('edited')) - ) - }) - it('charges the confirm gap to the handle so the send card outlives the Enter', () => { const withConfirm = sendNativeChatMessage(PTY, 'a', { clearInput: '\x15', diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index 33462dcb95a5..69804b63c9c7 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -90,8 +90,6 @@ describe('standalone runtime artifacts', () => { it('keeps target-specific files out of the shared template set', () => { const common = orcadTemplateCommonFilenames() for (const target of SERVER_TARGETS) { - const specific = orcadArtifactFilenames(target).filter((file) => !common.includes(file)) - expect(specific.every((file) => !common.includes(file))).toBe(true) expect(common.every((file) => orcadArtifactFilenames(target).includes(file))).toBe(true) } }) diff --git a/tests/e2e/structured-attention-explicit-read.unit.test.ts b/tests/e2e/structured-attention-explicit-read.unit.test.ts deleted file mode 100644 index ce98cd432e1e..000000000000 --- a/tests/e2e/structured-attention-explicit-read.unit.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -// @vitest-environment happy-dom -// Register preload/store seams before importing their consumers. -import { - fixture, - transport, - WORKSPACE, - SUBJECT, - SCOPE, - NOTIFICATION_SETTINGS, - addPrompt, - AttentionPolicy, - dismissIds -} from './structured-attention-read-retirement.test-fixture' -import { Fragment, createElement } from 'react' -import { act, render, waitFor } from '@testing-library/react' -import { expect, it } from 'vitest' -import type { AgentJournalRenderItem } from '../../src/shared/agent-session-journal-types' -import { - agentSessionAttentionSubjectPrefix, - agentSessionPromptAttentionKey -} from '../../src/shared/agent-session-attention' -import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture' -import { - projectStructuredAgentSessionStatusState, - structuredAgentSessionPaneKey -} from '../../src/shared/structured-agent-session-projection' -import { StructuredAgentSessionTurnCompletionFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed' -import { createStructuredAttentionMobileDelivery } from '../../src/main/runtime/structured-agent-session-mobile-attention' -import { makeUnifiedTab } from '@/store/slices/store-test-helpers' -import { useAppStore } from '@/store' -import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge' -import { useStructuredAgentSessionRead } from '@/components/native-chat/use-structured-agent-session-read' - -const TARGET = { kind: 'local' } as const - -function Transcript({ visible }: { visible: boolean }): null { - useStructuredAgentSessionRead({ - sessionId: SESSION, - target: TARGET, - isVisible: visible, - isViewed: visible - }) - return null -} - -function bridge(visible?: boolean) { - return createElement( - Fragment, - null, - createElement(StructuredAgentSessionAttentionBridge), - createElement(AttentionPolicy), - ...(visible === undefined ? [] : [createElement(Transcript, { visible })]) - ) -} - -function settleTurn(): void { - fixture.items = fixture.items.map((item): AgentJournalRenderItem => - item.body.kind === 'turn' - ? { - ...item, - revision: item.revision + 1, - body: { ...item.body, state: 'completed', outcome: 'success' } - } - : item - ) - fixture.sequence += 1 - fixture.hostFeed.observe(SESSION) -} - -async function flush(): Promise { - for (let i = 0; i < 5; i += 1) { - await act(async () => {}) - } -} - -const promptKey = (id: string) => agentSessionPromptAttentionKey(SCOPE, SESSION, id) - -it('Mark read on a chat never opened withdraws the prompt alert it surfaced', async () => { - useAppStore.setState({ activeWorktreeId: 'elsewhere' }) - render(bridge()) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - act(() => addPrompt('A')) - await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1)) - act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit')) - await flush() - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() - expect(dismissIds()).toEqual([promptKey('A')]) -}) - -it('Mark read on a chat never opened withdraws its completion alert, as before prompt alerts', async () => { - useAppStore.setState({ activeWorktreeId: 'elsewhere' }) - render(bridge()) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - act(() => settleTurn()) - await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1)) - act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit')) - await flush() - expect(dismissIds()).toEqual([`${agentSessionAttentionSubjectPrefix(SCOPE, SESSION)}turn:turn-1`]) -}) - -it('Mark read on a chat hidden since an earlier view covers the newer prompt it surfaced', async () => { - const screen = render(bridge(true)) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - act(() => addPrompt('A')) - await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) - await act(async () => fixture.hydrate?.()) - await waitFor(() => expect(dismissIds()).toHaveLength(1)) - act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) - screen.rerender(bridge(false)) - await flush() - transport.dispatch.mockClear() - act(() => addPrompt('B')) - await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1)) - act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit')) - await flush() - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() - expect(dismissIds()).toEqual([promptKey('A'), promptKey('B')]) -}) - -it('Mark read never covers a prompt raised after the click', async () => { - useAppStore.setState({ activeWorktreeId: 'elsewhere' }) - render(bridge()) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - act(() => addPrompt('A')) - await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1)) - act(() => { - useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit') - addPrompt('B') - }) - await flush() - await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(2)) - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') - expect(dismissIds()).toEqual([promptKey('A')]) -}) - -it('Mark all read covers every chat it marks, viewed earlier or never opened', async () => { - const OTHER = 'session-beta' - const otherSubject = structuredAgentSessionPaneKey('chat-2', OTHER) - useAppStore.setState({ - unifiedTabsByWorktree: { - [WORKSPACE]: [ - ...(useAppStore.getState().unifiedTabsByWorktree[WORKSPACE] ?? []), - makeUnifiedTab({ - id: 'chat-2', - worktreeId: WORKSPACE, - groupId: 'group', - contentType: 'agent-session', - entityId: OTHER, - agentSessionAgent: 'claude' - }) - ] - } - }) - // The second chat's host side: its own journal, the same phone delivery and renderer stream. - let otherItems = fixture.items.map((item) => ({ ...item })) - let otherSequence = fixture.sequence - const otherFeed = new StructuredAgentSessionTurnCompletionFeed({ - sessions: new Map([ - [ - OTHER, - { - journal: { cursor: () => ({ epoch: 'journal-b', sequence: otherSequence }) }, - params: { location: SCOPE } - } - ] - ]), - readStatusState: () => projectStructuredAgentSessionStatusState(otherItems), - now: () => 42 - }) - const delivery = createStructuredAttentionMobileDelivery({ - readNotificationSettings: () => ({ ...NOTIFICATION_SETTINGS, suppressWhenFocused: false }), - readWorkspaceLabels: () => ({}), - dispatch: (event) => fixture.controller.dispatch(event), - reconcile: (state) => fixture.controller.reconcileStructuredPromptAttention(state), - now: () => 42 - }) - otherFeed.subscribe({ - id: 'other', - includePrompts: true, - emit: (event) => { - if (event.type !== 'end') { - delivery.deliver(event, undefined) - fixture.completion?.({ - id: 'completion', - ok: true, - _meta: { runtimeId: 'h' }, - result: event - }) - } - } - }) - otherFeed.observe(OTHER) - const screen = render(bridge(true)) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - act(() => addPrompt('A')) - await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) - await act(async () => fixture.hydrate?.()) - await waitFor(() => expect(dismissIds()).toHaveLength(1)) - act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) - screen.rerender(bridge(false)) - await flush() - transport.dispatch.mockClear() - act(() => { - addPrompt('B') - const promptB = fixture.items.find((item) => item.itemId === 'B') - if (!promptB) { - throw new Error('prompt B missing') - } - otherItems = [...otherItems, { ...promptB, itemId: 'C', sequence: ++otherSequence }] - otherFeed.observe(OTHER) - }) - await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(2)) - act(() => - useAppStore.getState().acknowledgeAgents([SUBJECT, otherSubject], undefined, 'explicit') - ) - await flush() - expect(dismissIds()).toEqual([ - promptKey('A'), - promptKey('B'), - agentSessionPromptAttentionKey(SCOPE, OTHER, 'C') - ]) -}) diff --git a/tests/e2e/structured-attention-prompt-relay.unit.test.ts b/tests/e2e/structured-attention-prompt-relay.unit.test.ts index 4fdabfab82ce..4744934afc79 100644 --- a/tests/e2e/structured-attention-prompt-relay.unit.test.ts +++ b/tests/e2e/structured-attention-prompt-relay.unit.test.ts @@ -14,11 +14,12 @@ import { ReadSurface, AttentionPolicy, readCalls, - dismissIds + dismissIds, + publishStatus } from './structured-attention-read-retirement.test-fixture' import { Fragment, createElement } from 'react' import { act, render, waitFor } from '@testing-library/react' -import { expect, it } from 'vitest' +import { expect, it, describe } from 'vitest' import { join } from 'node:path' import type { NotificationDispatchRequest, @@ -41,357 +42,686 @@ import { makeUnifiedTab, makeWorktree } from '@/store/slices/store-test-helpers' import { captureAgentSubjectReads } from '@/attention/agent-subject-read-actions' import { useAppStore } from '@/store' import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge' +import type { AgentSessionExecutionLocation } from '../../src/shared/agent-session-record' -async function mountPausedRelay({ viewed = true, away = false } = {}) { - const relayDirectory = join(fixture.directory, 'relay') - const relay = new RuntimeMobileNotificationController() - relay.configureDismissalStore(relayDirectory) - const relayEvents: MobileNotificationEvent[] = [] - relay.onDispatched((event) => relayEvents.push(event)) - const delivery = createNotificationDeliveryService({ - readNotificationSettings: () => NOTIFICATION_SETTINGS, - findActiveWindow: () => null, - isWindowVisible: () => true, - setTrayAttention: () => {}, - isNotificationSupported: () => false, - dispatchMobileNotification: relay.dispatch.bind(relay), - readAuthorizationStatus: async () => 'authorized', - recordDeliveryOutcome: () => {}, - deliverNative: () => ({ delivered: true }), - platform: 'linux', - now: () => 20_000 - }) - transport.dispatch.mockImplementation(async (request: NotificationDispatchRequest) => - delivery.dispatch(request) - ) - transport.dismiss.mockImplementation( - async (_ids, _panes, reads?: StructuredNotificationRead[]) => { - for (const read of reads ?? []) { - relay.retireStructuredAttention(read) +describe('Delayed remote prompt delivery', () => { + async function mountPausedRelay({ viewed = true, away = false } = {}) { + const relayDirectory = join(fixture.directory, 'relay') + const relay = new RuntimeMobileNotificationController() + relay.configureDismissalStore(relayDirectory) + const relayEvents: MobileNotificationEvent[] = [] + relay.onDispatched((event) => relayEvents.push(event)) + const delivery = createNotificationDeliveryService({ + readNotificationSettings: () => NOTIFICATION_SETTINGS, + findActiveWindow: () => null, + isWindowVisible: () => true, + setTrayAttention: () => {}, + isNotificationSupported: () => false, + dispatchMobileNotification: relay.dispatch.bind(relay), + readAuthorizationStatus: async () => 'authorized', + recordDeliveryOutcome: () => {}, + deliverNative: () => ({ delivered: true }), + platform: 'linux', + now: () => 20_000 + }) + transport.dispatch.mockImplementation(async (request: NotificationDispatchRequest) => + delivery.dispatch(request) + ) + transport.dismiss.mockImplementation( + async (_ids, _panes, reads?: StructuredNotificationRead[]) => { + for (const read of reads ?? []) { + relay.retireStructuredAttention(read) + } + return { dismissed: 0 } } - return { dismissed: 0 } - } - ) - transport.away.mockResolvedValue(away) - useAppStore.setState({ - activeWorkspaceExecutionHostId: 'runtime:relay-host', - worktreesByRepo: { - repo1: [ - makeWorktree({ - id: WORKSPACE, - repoId: 'repo1', - hostId: 'runtime:relay-host', - runtimeOwnerEnvironmentId: 'relay-host' - }) - ] - }, - unifiedTabsByWorktree: { - [WORKSPACE]: [ - makeUnifiedTab({ - id: TAB, - worktreeId: WORKSPACE, - groupId: 'group', - contentType: 'agent-session', - entityId: SESSION, - agentSessionAgent: 'claude', - executionHostId: 'runtime:relay-host' - }) + ) + transport.away.mockResolvedValue(away) + useAppStore.setState({ + activeWorkspaceExecutionHostId: 'runtime:relay-host', + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: WORKSPACE, + repoId: 'repo1', + hostId: 'runtime:relay-host', + runtimeOwnerEnvironmentId: 'relay-host' + }) + ] + }, + unifiedTabsByWorktree: { + [WORKSPACE]: [ + makeUnifiedTab({ + id: TAB, + worktreeId: WORKSPACE, + groupId: 'group', + contentType: 'agent-session', + entityId: SESSION, + agentSessionAgent: 'claude', + executionHostId: 'runtime:relay-host' + }) + ] + }, + runtimeEnvironments: [ + { + id: 'relay-host', + name: 'Remote', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'endpoint' + } ] - }, - runtimeEnvironments: [ - { - id: 'relay-host', - name: 'Remote', - createdAt: 1, - updatedAt: 1, - lastUsedAt: null, - runtimeId: null, - endpoints: [], - preferredEndpointId: 'endpoint' - } - ] - }) - render( - createElement( - Fragment, - null, - createElement(StructuredAgentSessionAttentionBridge), - createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: viewed, target: REMOTE_TARGET }) + }) + render( + createElement( + Fragment, + null, + createElement(StructuredAgentSessionAttentionBridge), + createElement(AttentionPolicy), + createElement(ReadSurface, { viewed: viewed, target: REMOTE_TARGET }) + ) ) - ) - await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) - await act(async () => fixture.hydrate?.()) - await waitFor(() => expect(fixture.journal).toBeTypeOf('function')) - const attention = fixture.completion - if (!attention) { - throw new Error('attention transport not subscribed') - } - const edges: AgentSessionAttentionEdge[] = [] - fixture.hostFeed.subscribe({ - id: 'paused-relay', - includePrompts: true, - emit: (edge) => { - if (edge.type !== 'end') { - edges.push(edge) + await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + await act(async () => fixture.hydrate?.()) + await waitFor(() => expect(fixture.journal).toBeTypeOf('function')) + const attention = fixture.completion + if (!attention) { + throw new Error('attention transport not subscribed') + } + const edges: AgentSessionAttentionEdge[] = [] + fixture.hostFeed.subscribe({ + id: 'paused-relay', + includePrompts: true, + emit: (edge) => { + if (edge.type !== 'end') { + edges.push(edge) + } } + }) + fixture.completion = undefined + return { + relayEvents, + edges, + release: (edge = edges.at(-1)) => { + if (!edge) { + throw new Error('host did not commit attention') + } + attention({ + id: 'completion', + ok: true, + _meta: { runtimeId: 'attention-host' }, + result: edge + }) + }, + liveDeliveries: () => new MobileNotificationDismissalStore(relayDirectory).liveDeliveries() } - }) - fixture.completion = undefined - return { - relayEvents, - edges, - release: (edge = edges.at(-1)) => { - if (!edge) { - throw new Error('host did not commit attention') + } + + it.each(['visible', 'read then away', 'tab/store rerender', 'failed retirement'] as const)( + 'a read prompt creates no delayed relay notification or withdrawal: %s', + async (mode) => { + const relay = await mountPausedRelay() + if (mode === 'failed retirement') { + const original = transport.call.getMockImplementation() + transport.call.mockImplementation(async (...args) => { + if (args[1] === 'agentSession.acknowledgeAttention') { + throw new Error('scripted host retirement failure') + } + return original?.(...args) + }) } - attention({ - id: 'completion', - ok: true, - _meta: { runtimeId: 'attention-host' }, - result: edge + const priorReads = readCalls() + act(() => { + addPrompt('A') + publishView() }) - }, - liveDeliveries: () => new MobileNotificationDismissalStore(relayDirectory).liveDeliveries() - } -} + await waitFor(() => expect(readCalls()).toBeGreaterThan(priorReads)) + await act(async () => {}) + expect(dismissIds()).toHaveLength(mode === 'failed retirement' ? 0 : 1) + expect(relay.edges.at(-1)).toMatchObject({ + type: 'prompt', + prompt: { promptId: 'A', journalCursor: { epoch: 'journal-a', sequence: 3 } } + }) + if (mode === 'read then away' || mode === 'tab/store rerender') { + transport.away.mockResolvedValue(true) + act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) + } + if (mode === 'tab/store rerender') { + act(() => + useAppStore.setState((state) => ({ + settings: state.settings ? { ...state.settings } : null, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE].map((tab) => ({ + ...tab, + label: 'Renamed chat' + })) + } + })) + ) + } + await act(async () => relay.release()) + expect(transport.dispatch).not.toHaveBeenCalled() + expect(relay.relayEvents).toEqual([]) + expect(relay.liveDeliveries()).toEqual([]) + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() + } + ) -it.each(['visible', 'read then away', 'tab/store rerender', 'failed retirement'] as const)( - 'a read prompt creates no delayed relay notification or withdrawal: %s', - async (mode) => { + it('reading A does not cover unseen B on the same remote subject', async () => { const relay = await mountPausedRelay() - if (mode === 'failed retirement') { - const original = transport.call.getMockImplementation() - transport.call.mockImplementation(async (...args) => { - if (args[1] === 'agentSession.acknowledgeAttention') { - throw new Error('scripted host retirement failure') - } - return original?.(...args) - }) + act(() => { + addPrompt('A') + publishView() + }) + await waitFor(() => expect(dismissIds()).toHaveLength(1)) + transport.away.mockResolvedValue(true) + act(() => { + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + addPrompt('B') + }) + const [a, b] = relay.edges + await act(async () => { + relay.release(a) + relay.release(b) + }) + expect(relay.relayEvents).toMatchObject([ + { type: 'notification', notificationId: agentSessionPromptAttentionKey(SCOPE, SESSION, 'B') } + ]) + expect(relay.liveDeliveries()).toHaveLength(1) + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') + }) + + it('captured read cursors are cloned and a late older read cannot lower their frontier', async () => { + const relay = await mountPausedRelay({ away: true }) + const olderRead = captureAgentSubjectReads([SUBJECT]) + act(() => { + addPrompt('A') + publishView() + }) + await act(async () => {}) + const currentRead = captureAgentSubjectReads([SUBJECT]) + expect(currentRead[0].structured?.observedCursor).toEqual({ epoch: 'journal-a', sequence: 3 }) + await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT], currentRead)) + const structured = currentRead[0].structured + if (!structured) { + throw new Error('accepted read owner not captured') } - const priorReads = readCalls() + structured.observedCursor.sequence = 0 + await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT], olderRead)) + await act(async () => relay.release()) + expect(relay.relayEvents).toEqual([]) + act(() => addPrompt('B')) + await act(async () => relay.release()) + expect(relay.relayEvents).toMatchObject([ + { type: 'notification', notificationId: agentSessionPromptAttentionKey(SCOPE, SESSION, 'B') } + ]) + }) + + it('accepting a transcript while away does not fabricate a read frontier', async () => { + const relay = await mountPausedRelay({ away: true }) act(() => { addPrompt('A') publishView() }) - await waitFor(() => expect(readCalls()).toBeGreaterThan(priorReads)) await act(async () => {}) - expect(dismissIds()).toHaveLength(mode === 'failed retirement' ? 0 : 1) - expect(relay.edges.at(-1)).toMatchObject({ - type: 'prompt', - prompt: { promptId: 'A', journalCursor: { epoch: 'journal-a', sequence: 3 } } + expect(readCalls()).toBe(0) + expect(dismissIds()).toEqual([]) + await act(async () => relay.release()) + expect(relay.relayEvents).toMatchObject([{ type: 'notification' }]) + expect(relay.liveDeliveries()).toHaveLength(1) + }) + + it('a genuine read during unread writes prevents the final relay delivery', async () => { + const relay = await mountPausedRelay({ viewed: false, away: true }) + act(() => { + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + addPrompt('A') + publishView() }) - if (mode === 'read then away' || mode === 'tab/store rerender') { + expect(readCalls()).toBe(0) + let readDuringUnread = false + const stop = useAppStore.subscribe((state) => { + if (!readDuringUnread && state.unreadAgentCompletionPanes[SUBJECT]) { + readDuringUnread = true + state.acknowledgeAgents([SUBJECT]) + } + }) + try { + await act(async () => relay.release()) + expect(readDuringUnread).toBe(true) + await waitFor(() => expect(dismissIds()).toHaveLength(1)) + expect(relay.relayEvents).toEqual([]) + expect(relay.liveDeliveries()).toEqual([]) + } finally { + stop() + } + }) + + it.each(['missing cursor', 'different epoch', 'different target'] as const)( + 'read coverage never spends unproven prompt identity: %s', + async (mode) => { + const relay = await mountPausedRelay() + act(() => { + addPrompt('A') + publishView() + }) + await waitFor(() => expect(dismissIds()).toHaveLength(1)) transport.away.mockResolvedValue(true) act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) + const edge = relay.edges.at(-1) + if (edge?.type !== 'prompt') { + throw new Error('expected actual prompt frame') + } + if (mode === 'different target') { + act(() => + useAppStore.setState((state) => ({ + unifiedTabsByWorktree: { + [WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE].map((tab) => ({ + ...tab, + executionHostId: 'runtime:another-host' + })) + } + })) + ) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + await act(async () => + fixture.completion?.({ + id: 'rebound', + ok: true, + _meta: { runtimeId: 'attention-host' }, + result: edge + }) + ) + } else { + const { journalCursor, ...oldPrompt } = edge.prompt + expect(journalCursor).toEqual({ epoch: 'journal-a', sequence: 3 }) + await act(async () => + relay.release({ + type: 'prompt', + prompt: { + ...oldPrompt, + ...(mode === 'different epoch' + ? { journalCursor: { epoch: 'journal-b', sequence: 3 } } + : {}) + } + }) + ) + } + expect(relay.relayEvents).toMatchObject([{ type: 'notification' }]) + expect(relay.liveDeliveries()).toHaveLength(1) } - if (mode === 'tab/store rerender') { - act(() => - useAppStore.setState((state) => ({ - settings: state.settings ? { ...state.settings } : null, - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE].map((tab) => ({ - ...tab, - label: 'Renamed chat' - })) + ) + + it.each(['success', 'failure'] as const)( + 'prompt read coverage leaves independent %s completion delivery unchanged', + async (outcome) => { + const relay = await mountPausedRelay() + act(() => { + addPrompt('A') + publishView() + }) + await waitFor(() => expect(dismissIds()).toHaveLength(1)) + act(() => { + fixture.items = fixture.items.map((item): AgentJournalRenderItem => { + if (item.body.kind === 'turn') { + return { + ...item, + revision: item.revision + 1, + body: { ...item.body, state: 'completed', outcome } + } + } + if (outcome === 'success' && item.body.kind === 'approval') { + return { + ...item, + revision: item.revision + 1, + body: { ...item.body, resolution: { ...item.body.resolution, state: 'resolved' } } + } } - })) + return item + }) + fixture.sequence += 1 + fixture.hostFeed.observe(SESSION) + publishView() + }) + await act(async () => {}) + expect(relay.edges.at(-1)).toMatchObject({ type: 'completion', completion: { outcome } }) + await act(async () => relay.release()) + expect(relay.relayEvents).toMatchObject([ + { + type: 'notification', + notificationId: `${agentSessionAttentionSubjectPrefix(SCOPE, SESSION)}turn:turn-1` + } + ]) + expect(relay.liveDeliveries()).toHaveLength(1) + } + ) +}) + +describe('Remote prompt settlement', () => { + /** A remote-host chat this desktop relays to its own phones while the user is away. */ + async function mountRemoteRelay(options?: { promptStatus?: false; raise?: false }) { + const relayDirectory = join(fixture.directory, 'relay') + const relay = new RuntimeMobileNotificationController() + relay.configureDismissalStore(relayDirectory) + const delivery = createNotificationDeliveryService({ + readNotificationSettings: () => NOTIFICATION_SETTINGS, + findActiveWindow: () => null, + isWindowVisible: () => false, + setTrayAttention: () => {}, + isNotificationSupported: () => false, + dispatchMobileNotification: relay.dispatch.bind(relay), + readAuthorizationStatus: async () => 'authorized', + recordDeliveryOutcome: () => {}, + deliverNative: () => ({ delivered: true }), + platform: 'linux', + now: () => 20_000 + }) + transport.dispatch.mockImplementation(async (request: NotificationDispatchRequest) => + delivery.dispatch(request) + ) + // What the notifications:settleStructuredPrompts handler does with it. + transport.settle.mockImplementation(async (scope: AgentSessionExecutionLocation, id: string) => + relay.reconcileStructuredPromptAttention({ scope, sessionId: id, pendingPromptIds: [] }) + ) + transport.away.mockResolvedValue(true) + useAppStore.setState({ + activeWorktreeId: 'elsewhere', + activeWorkspaceExecutionHostId: 'runtime:relay-host', + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: WORKSPACE, + repoId: 'repo1', + hostId: 'runtime:relay-host', + runtimeOwnerEnvironmentId: 'relay-host' + }) + ] + }, + unifiedTabsByWorktree: { + [WORKSPACE]: [ + makeUnifiedTab({ + id: TAB, + worktreeId: WORKSPACE, + groupId: 'group', + contentType: 'agent-session', + entityId: SESSION, + agentSessionAgent: 'claude', + executionHostId: 'runtime:relay-host' + }) + ] + }, + runtimeEnvironments: [ + { + id: 'relay-host', + name: 'Remote', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'endpoint' + } + ] + }) + render( + createElement( + Fragment, + null, + createElement(StructuredAgentSessionAttentionBridge), + createElement(AttentionPolicy), + createElement(ReadSurface, { viewed: false, target: REMOTE_TARGET }) ) + ) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + await waitFor(() => expect(fixture.status).toBeTypeOf('function')) + act(() => publishStatus()) + const live = (kind: 'prompt' | 'completion') => + new MobileNotificationDismissalStore(relayDirectory) + .liveDeliveries() + .filter((entry) => entry.structuredOrigin?.cause.kind === kind) + if (options?.raise === false) { + return { live } } - await act(async () => relay.release()) - expect(transport.dispatch).not.toHaveBeenCalled() - expect(relay.relayEvents).toEqual([]) - expect(relay.liveDeliveries()).toEqual([]) - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() + act(() => { + addPrompt('A') + if (options?.promptStatus !== false) { + publishStatus() + } + publishView() + }) + await waitFor(() => expect(live('prompt')).toHaveLength(1)) + return { live } } -) -it('reading A does not cover unseen B on the same remote subject', async () => { - const relay = await mountPausedRelay() - act(() => { - addPrompt('A') - publishView() - }) - await waitFor(() => expect(dismissIds()).toHaveLength(1)) - transport.away.mockResolvedValue(true) - act(() => { - useAppStore.setState({ activeWorktreeId: 'elsewhere' }) - addPrompt('B') + function revise(update: (item: AgentJournalRenderItem) => AgentJournalRenderItem): void { + fixture.sequence += 1 + fixture.items = fixture.items.map((item) => { + const next = update(item) + return next === item + ? item + : { ...next, revision: item.revision + 1, sequence: fixture.sequence } + }) + } + + const resolvePrompt = + (id: string, state: 'resolved' | 'cancelled') => + (item: AgentJournalRenderItem): AgentJournalRenderItem => + item.itemId === id && item.body.kind === 'approval' + ? { + ...item, + body: { + ...item.body, + resolution: { + state, + selectedOptionId: state === 'resolved' ? 'yes' : null, + resolvedBy: state === 'resolved' ? 'second-client' : null, + resolvedAt: fixture.sequence + } + } + } + : item + + const resolveA = (state: 'resolved' | 'cancelled') => resolvePrompt('A', state) + + const interrupt = (item: AgentJournalRenderItem): AgentJournalRenderItem => + item.body.kind === 'turn' + ? { ...item, body: { ...item.body, state: 'interrupted', outcome: 'cancellation' } } + : item + + /** A frame the status socket queued before A's commit: another session's row. */ + function otherSessionStatus(): void { + fixture.status?.({ + id: 'status', + ok: true, + _meta: { runtimeId: 'h' }, + result: { + type: 'status', + session: { + sessionId: 'other-session', + workspaceId: WORKSPACE, + agent: 'claude', + status: 'working', + latestPrompt: '', + updatedAt: 1 + } + } + }) + } + + function endStatusStream(): void { + fixture.status?.({ id: 'status', ok: true, _meta: { runtimeId: 'h' }, result: { type: 'end' } }) + } + + it.each([ + ['answered from another client', () => revise(resolveA('resolved'))], + ['turn cancelled', () => revise((item) => interrupt(resolveA('cancelled')(item)))] + ] as const)( + 'withdraws the relayed prompt alert once the remote host reports none pending: %s', + async (_mode, resolve) => { + const relay = await mountRemoteRelay() + act(() => { + resolve() + fixture.hostFeed.observe(SESSION) + publishStatus() + }) + await waitFor(() => expect(relay.live('prompt')).toEqual([])) + expect(readCalls()).toBe(0) + expect(transport.settle).toHaveBeenCalledOnce() + // Prompt settlement never touches a completion alert. + const relayedCompletions = transport.dispatch.mock.calls.filter( + ([request]: [NotificationDispatchRequest]) => + request.structuredOrigin?.cause.kind === 'completion' + ) + expect(relay.live('completion')).toHaveLength(relayedCompletions.length) + } + ) + + it('withdraws it when a restarted remote host comes back with the prompt cancelled', async () => { + const relay = await mountRemoteRelay() + const before = fixture.status + act(() => endStatusStream()) + revise((item) => interrupt(resolveA('cancelled')(item))) + await waitFor(() => expect(fixture.status).not.toBe(before)) + act(() => publishStatus()) + await waitFor(() => expect(relay.live('prompt')).toEqual([])) }) - const [a, b] = relay.edges - await act(async () => { - relay.release(a) - relay.release(b) + + it('keeps the relayed prompt alert while the host still reports a prompt pending', async () => { + const relay = await mountRemoteRelay() + act(() => { + revise((item) => item) + publishStatus() + }) + await act(async () => {}) + expect(transport.settle).not.toHaveBeenCalled() + expect(relay.live('prompt')).toHaveLength(1) }) - expect(relay.relayEvents).toMatchObject([ - { type: 'notification', notificationId: agentSessionPromptAttentionKey(SCOPE, SESSION, 'B') } - ]) - expect(relay.liveDeliveries()).toHaveLength(1) - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') -}) -it('captured read cursors are cloned and a late older read cannot lower their frontier', async () => { - const relay = await mountPausedRelay({ away: true }) - const olderRead = captureAgentSubjectReads([SUBJECT]) - act(() => { - addPrompt('A') - publishView() + it('never settles from a status the desktop lost contact with', async () => { + // The mirror still holds the row from before A, which reads no prompt; it goes unverifiable. + const relay = await mountRemoteRelay({ promptStatus: false }) + act(() => endStatusStream()) + await act(async () => {}) + expect(transport.settle).not.toHaveBeenCalled() + expect(relay.live('prompt')).toHaveLength(1) }) - await act(async () => {}) - const currentRead = captureAgentSubjectReads([SUBJECT]) - expect(currentRead[0].structured?.observedCursor).toEqual({ epoch: 'journal-a', sequence: 3 }) - await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT], currentRead)) - const structured = currentRead[0].structured - if (!structured) { - throw new Error('accepted read owner not captured') - } - structured.observedCursor.sequence = 0 - await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT], olderRead)) - await act(async () => relay.release()) - expect(relay.relayEvents).toEqual([]) - act(() => addPrompt('B')) - await act(async () => relay.release()) - expect(relay.relayEvents).toMatchObject([ - { type: 'notification', notificationId: agentSessionPromptAttentionKey(SCOPE, SESSION, 'B') } - ]) -}) -it('accepting a transcript while away does not fabricate a read frontier', async () => { - const relay = await mountPausedRelay({ away: true }) - act(() => { - addPrompt('A') - publishView() + it('settles once per relayed prompt, not on every later status update', async () => { + await mountRemoteRelay() + act(() => { + revise(resolveA('resolved')) + fixture.hostFeed.observe(SESSION) + publishStatus() + }) + await waitFor(() => expect(transport.settle).toHaveBeenCalledOnce()) + act(() => { + revise((item) => item) + publishStatus() + }) + await act(async () => {}) + expect(transport.settle).toHaveBeenCalledOnce() }) - await act(async () => {}) - expect(readCalls()).toBe(0) - expect(dismissIds()).toEqual([]) - await act(async () => relay.release()) - expect(relay.relayEvents).toMatchObject([{ type: 'notification' }]) - expect(relay.liveDeliveries()).toHaveLength(1) -}) -it('a genuine read during unread writes prevents the final relay delivery', async () => { - const relay = await mountPausedRelay({ viewed: false, away: true }) - act(() => { - useAppStore.setState({ activeWorktreeId: 'elsewhere' }) - addPrompt('A') - publishView() + // Status and edges ride separate sockets: the pre-prompt row can still be draining after the edge. + it('keeps the pending prompt alert when the edge outruns its status and another frame lands', async () => { + const relay = await mountRemoteRelay({ promptStatus: false }) + act(() => otherSessionStatus()) + await act(async () => {}) + act(() => publishStatus()) + await act(async () => {}) + expect(transport.settle).not.toHaveBeenCalled() + expect(relay.live('prompt')).toHaveLength(1) }) - expect(readCalls()).toBe(0) - let readDuringUnread = false - const stop = useAppStore.subscribe((state) => { - if (!readDuringUnread && state.unreadAgentCompletionPanes[SUBJECT]) { - readDuringUnread = true - state.acknowledgeAgents([SUBJECT]) - } + + it('keeps it when a queued frame lands in the same task as the edge', async () => { + const relay = await mountRemoteRelay({ promptStatus: false }) + act(() => { + addPrompt('B') + otherSessionStatus() + }) + await act(async () => {}) + expect(transport.settle).not.toHaveBeenCalled() + expect(relay.live('prompt')).toHaveLength(2) }) - try { - await act(async () => relay.release()) - expect(readDuringUnread).toBe(true) - await waitFor(() => expect(dismissIds()).toHaveLength(1)) - expect(relay.relayEvents).toEqual([]) - expect(relay.liveDeliveries()).toEqual([]) - } finally { - stop() - } -}) -it.each(['missing cursor', 'different epoch', 'different target'] as const)( - 'read coverage never spends unproven prompt identity: %s', - async (mode) => { - const relay = await mountPausedRelay() + it('retries a settlement that failed on the next qualifying row', async () => { + const relay = await mountRemoteRelay() + const settle = transport.settle.getMockImplementation() + transport.settle.mockRejectedValueOnce(new Error('main unavailable')) act(() => { - addPrompt('A') - publishView() + revise(resolveA('resolved')) + fixture.hostFeed.observe(SESSION) + publishStatus() }) - await waitFor(() => expect(dismissIds()).toHaveLength(1)) - transport.away.mockResolvedValue(true) - act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) - const edge = relay.edges.at(-1) - if (edge?.type !== 'prompt') { - throw new Error('expected actual prompt frame') - } - if (mode === 'different target') { - act(() => - useAppStore.setState((state) => ({ - unifiedTabsByWorktree: { - [WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE].map((tab) => ({ - ...tab, - executionHostId: 'runtime:another-host' - })) - } - })) - ) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - await act(async () => - fixture.completion?.({ - id: 'rebound', - ok: true, - _meta: { runtimeId: 'attention-host' }, - result: edge - }) - ) - } else { - const { journalCursor, ...oldPrompt } = edge.prompt - expect(journalCursor).toEqual({ epoch: 'journal-a', sequence: 3 }) - await act(async () => - relay.release({ - type: 'prompt', - prompt: { - ...oldPrompt, - ...(mode === 'different epoch' - ? { journalCursor: { epoch: 'journal-b', sequence: 3 } } - : {}) - } - }) - ) - } - expect(relay.relayEvents).toMatchObject([{ type: 'notification' }]) - expect(relay.liveDeliveries()).toHaveLength(1) - } -) + await waitFor(() => expect(transport.settle).toHaveBeenCalledOnce()) + expect(relay.live('prompt')).toHaveLength(1) + transport.settle.mockImplementation(settle ?? (async () => {})) + act(() => { + revise((item) => item) + publishStatus() + }) + await waitFor(() => expect(relay.live('prompt')).toEqual([])) + expect(transport.settle).toHaveBeenCalledTimes(2) + }) -it.each(['success', 'failure'] as const)( - 'prompt read coverage leaves independent %s completion delivery unchanged', - async (outcome) => { - const relay = await mountPausedRelay() + it('settles a prompt answered while the previous settle call was still out', async () => { + const relay = await mountRemoteRelay() + const reconcile = transport.settle.getMockImplementation() + const replies: (() => void)[] = [] + // Main reconciles on arrival; only the reply is late. + transport.settle.mockImplementation((scope: AgentSessionExecutionLocation, id: string) => { + void reconcile?.(scope, id) + return new Promise((resolve) => replies.push(resolve)) + }) act(() => { - addPrompt('A') - publishView() + revise(resolveA('resolved')) + fixture.hostFeed.observe(SESSION) + publishStatus() }) - await waitFor(() => expect(dismissIds()).toHaveLength(1)) + await waitFor(() => expect(replies).toHaveLength(1)) act(() => { - fixture.items = fixture.items.map((item): AgentJournalRenderItem => { - if (item.body.kind === 'turn') { - return { - ...item, - revision: item.revision + 1, - body: { ...item.body, state: 'completed', outcome } - } - } - if (outcome === 'success' && item.body.kind === 'approval') { - return { - ...item, - revision: item.revision + 1, - body: { ...item.body, resolution: { ...item.body.resolution, state: 'resolved' } } - } - } - return item - }) - fixture.sequence += 1 + addPrompt('B') + publishStatus() + }) + await waitFor(() => expect(relay.live('prompt')).toHaveLength(1)) + act(() => { + revise(resolvePrompt('B', 'resolved')) fixture.hostFeed.observe(SESSION) - publishView() + publishStatus() }) await act(async () => {}) - expect(relay.edges.at(-1)).toMatchObject({ type: 'completion', completion: { outcome } }) - await act(async () => relay.release()) - expect(relay.relayEvents).toMatchObject([ - { - type: 'notification', - notificationId: `${agentSessionAttentionSubjectPrefix(SCOPE, SESSION)}turn:turn-1` - } - ]) - expect(relay.liveDeliveries()).toHaveLength(1) - } -) + expect(replies).toHaveLength(1) + // The host goes quiet; the reply alone must re-judge the mirror. + await act(async () => replies[0]?.()) + await waitFor(() => expect(relay.live('prompt')).toEqual([])) + }) + + it('settles a prompt whose edge arrives after its own resolution row', async () => { + const relay = await mountRemoteRelay({ raise: false }) + const deliver = fixture.completion + const held: Parameters>[0][] = [] + fixture.completion = (response) => held.push(response) + act(() => { + addPrompt('A') + publishStatus() + revise(resolveA('resolved')) + fixture.hostFeed.observe(SESSION) + publishStatus() + }) + fixture.completion = deliver + act(() => held.forEach((response) => deliver?.(response))) + await waitFor(() => + expect( + transport.dispatch.mock.calls.some( + ([request]: [NotificationDispatchRequest]) => + request.structuredOrigin?.cause.kind === 'prompt' + ) + ).toBe(true) + ) + await waitFor(() => expect(relay.live('prompt')).toEqual([])) + expect(transport.settle).toHaveBeenCalledOnce() + }) +}) diff --git a/tests/e2e/structured-attention-read-owner-lifecycle.unit.test.ts b/tests/e2e/structured-attention-read-owner-lifecycle.unit.test.ts deleted file mode 100644 index 616edb5ed221..000000000000 --- a/tests/e2e/structured-attention-read-owner-lifecycle.unit.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -// @vitest-environment happy-dom -// Register preload/store seams before importing their consumers. -import { - fixture, - WORKSPACE, - SUBJECT, - SCOPE, - addPrompt, - publishView, - AttentionPolicy, - readCalls, - dismissIds -} from './structured-attention-read-retirement.test-fixture' -import { Fragment, StrictMode, createElement } from 'react' -import { act, render, waitFor } from '@testing-library/react' -import { expect, it } from 'vitest' -import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' -import { agentSessionPromptAttentionKey } from '../../src/shared/agent-session-attention' -import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture' -import { useAppStore } from '@/store' -import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge' -import { findStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner' -import { useStructuredAgentSessionRead } from '@/components/native-chat/use-structured-agent-session-read' - -const PROMPT_A = agentSessionPromptAttentionKey(SCOPE, SESSION, 'A') -// Memoized like the pane's own target, so a rerender keeps the owner it already holds. -const LOCAL: RuntimeClientTarget = { kind: 'local' } - -/** The chat pane's read: shown means visible and viewed, as the transport derives both. */ -function Pane({ shown, target }: { shown: boolean; target: RuntimeClientTarget }): null { - useStructuredAgentSessionRead({ sessionId: SESSION, target, isVisible: shown, isViewed: shown }) - return null -} - -function tree(options: { - shown: boolean - paneKey?: string - strict?: boolean - target?: RuntimeClientTarget -}) { - const inner = createElement( - Fragment, - null, - createElement(StructuredAgentSessionAttentionBridge), - createElement(AttentionPolicy), - createElement(Pane, { - key: options.paneKey ?? 'pane', - shown: options.shown, - target: options.target ?? LOCAL - }) - ) - return options.strict ? createElement(StrictMode, null, inner) : inner -} - -async function settle(): Promise { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 30)) - }) -} - -/** A prompt raised while the user is elsewhere and the pane is hidden. */ -async function promptWhileAway(options: { strict?: boolean } = {}) { - useAppStore.setState({ activeWorktreeId: 'elsewhere' }) - const screen = render(tree({ shown: false, strict: options.strict })) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - act(() => addPrompt('A')) - await settle() - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') - return screen -} - -/** The user opens the chat: the pane reads its history and views it. */ -async function show(rerender: () => void): Promise { - fixture.hydrate = undefined - act(() => useAppStore.getState().setActiveWorktree(WORKSPACE)) - rerender() - await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) - await act(async () => fixture.hydrate?.()) - await settle() -} - -function expectViewReadWithdraws(): void { - expect(findStructuredAgentSessionReadOwner(SESSION, LOCAL)).toBeDefined() - expect(readCalls()).toBeGreaterThan(0) - expect(dismissIds()).toContain(PROMPT_A) - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() -} - -it('a view read under StrictMode still finds its owner and withdraws the alert', async () => { - const screen = await promptWhileAway({ strict: true }) - await show(() => screen.rerender(tree({ shown: true, strict: true }))) - expectViewReadWithdraws() -}) - -it('a pane hidden by a worktree switch and shown again withdraws on view', async () => { - const screen = await promptWhileAway() - await show(() => screen.rerender(tree({ shown: true }))) - expect(dismissIds()).toContain(PROMPT_A) - act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) - screen.rerender(tree({ shown: false })) - await settle() - act(() => addPrompt('B')) - await settle() - // An owner that already holds history resumes its journal stream instead of re-reading it. - act(() => useAppStore.getState().setActiveWorktree(WORKSPACE)) - screen.rerender(tree({ shown: true })) - await settle() - act(() => publishView()) - await settle() - expectViewReadWithdraws() - expect(dismissIds()).toContain(agentSessionPromptAttentionKey(SCOPE, SESSION, 'B')) -}) - -it('a pane remounted in one commit (a tab move) keeps its owner findable', async () => { - const screen = await promptWhileAway() - // The new instance reads the still-held owner before the old one's cleanup releases it. - screen.rerender(tree({ shown: false, paneKey: 'moved' })) - await settle() - await show(() => screen.rerender(tree({ shown: true, paneKey: 'moved' }))) - expectViewReadWithdraws() -}) - -it('a new target object for the same host keeps the owner findable', async () => { - const screen = await promptWhileAway() - screen.rerender(tree({ shown: false, target: { kind: 'local' } })) - await settle() - await show(() => screen.rerender(tree({ shown: true, target: { kind: 'local' } }))) - expectViewReadWithdraws() -}) diff --git a/tests/e2e/structured-attention-read-retirement.unit.test.ts b/tests/e2e/structured-attention-read-retirement.unit.test.ts index 22a1a80fc567..1acdbebebb21 100644 --- a/tests/e2e/structured-attention-read-retirement.unit.test.ts +++ b/tests/e2e/structured-attention-read-retirement.unit.test.ts @@ -4,250 +4,465 @@ import { fixture, transport, WORKSPACE, - TAB, SUBJECT, SCOPE, + NOTIFICATION_SETTINGS, addPrompt, - publishView, - ReadSurface, AttentionPolicy, + dismissIds, + publishView, readCalls, - dismissIds + TAB, + ReadSurface } from './structured-attention-read-retirement.test-fixture' -import { Fragment, createElement } from 'react' +import { Fragment, createElement, StrictMode } from 'react' import { act, render, waitFor } from '@testing-library/react' -import { expect, it } from 'vitest' +import { expect, it, describe } from 'vitest' +import type { AgentJournalRenderItem } from '../../src/shared/agent-session-journal-types' +import { + agentSessionAttentionSubjectPrefix, + agentSessionPromptAttentionKey +} from '../../src/shared/agent-session-attention' +import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture' +import { + projectStructuredAgentSessionStatusState, + structuredAgentSessionPaneKey +} from '../../src/shared/structured-agent-session-projection' +import { StructuredAgentSessionTurnCompletionFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed' +import { createStructuredAttentionMobileDelivery } from '../../src/main/runtime/structured-agent-session-mobile-attention' +import { makeUnifiedTab } from '@/store/slices/store-test-helpers' +import { useAppStore } from '@/store' +import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge' +import { useStructuredAgentSessionRead } from '@/components/native-chat/use-structured-agent-session-read' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { findStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { StructuredNotificationRead } from '../../src/shared/notification-settings-types' -import type { AgentJournalRenderItem } from '../../src/shared/agent-session-journal-types' -import { agentSessionPromptAttentionKey } from '../../src/shared/agent-session-attention' import { RuntimeMobileNotificationController } from '../../src/main/runtime/runtime-mobile-notification-controller' -import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture' -import { useAppStore } from '@/store' -import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge' -it('reads the first accepted history even after local unread and clock targets were cleared', async () => { - addPrompt('A') - render( - createElement( +describe('Explicit desktop read acknowledgements', () => { + const TARGET = { kind: 'local' } as const + + function Transcript({ visible }: { visible: boolean }): null { + useStructuredAgentSessionRead({ + sessionId: SESSION, + target: TARGET, + isVisible: visible, + isViewed: visible + }) + return null + } + + function bridge(visible?: boolean) { + return createElement( Fragment, null, createElement(StructuredAgentSessionAttentionBridge), createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: true }) + ...(visible === undefined ? [] : [createElement(Transcript, { visible })]) ) - ) - await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) - expect(readCalls()).toBe(0) - expect(useAppStore.getState().unreadAgentCompletionPanes).toEqual({}) - await act(async () => fixture.hydrate?.()) - await waitFor(() => - expect(dismissIds()).toEqual([agentSessionPromptAttentionKey(SCOPE, SESSION, 'A')]) - ) - expect(readCalls()).toBe(1) -}) + } -it('reads B on return while A keeps the already-read row clock unchanged', async () => { - useAppStore.setState({ activeWorktreeId: 'elsewhere' }) - useAppStore - .getState() - .setAgentStatus( - SUBJECT, - { state: 'blocked', prompt: 'Work', agentType: 'claude' }, - 'Chat', - { updatedAt: 1000, stateStartedAt: 1000 }, - { tabId: TAB, worktreeId: WORKSPACE } - ) - const screen = render( - createElement( - Fragment, - null, - createElement(StructuredAgentSessionAttentionBridge), - createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: false }) + function settleTurn(): void { + fixture.items = fixture.items.map((item): AgentJournalRenderItem => + item.body.kind === 'turn' + ? { + ...item, + revision: item.revision + 1, + body: { ...item.body, state: 'completed', outcome: 'success' } + } + : item ) - ) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - act(() => addPrompt('A')) - await act(async () => fixture.hydrate?.()) - await waitFor(() => expect(fixture.journal).toBeTypeOf('function')) - act(() => useAppStore.getState().acknowledgeAgents([SUBJECT])) - await waitFor(() => expect(dismissIds()).toHaveLength(1)) - const stamp = useAppStore.getState().acknowledgedAgentsByPaneKey[SUBJECT] - act(() => { - addPrompt('B') - publishView() + fixture.sequence += 1 + fixture.hostFeed.observe(SESSION) + } + + async function flush(): Promise { + for (let i = 0; i < 5; i += 1) { + await act(async () => {}) + } + } + + const promptKey = (id: string) => agentSessionPromptAttentionKey(SCOPE, SESSION, id) + + it('Mark read on a chat never opened withdraws the prompt alert it surfaced', async () => { + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + render(bridge()) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + act(() => addPrompt('A')) + await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1)) + act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit')) + await flush() + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() + expect(dismissIds()).toEqual([promptKey('A')]) }) - expect(dismissIds()).toHaveLength(1) - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') - act(() => useAppStore.setState({ activeWorktreeId: WORKSPACE })) - screen.rerender( - createElement( - Fragment, - null, - createElement(StructuredAgentSessionAttentionBridge), - createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: true }) - ) - ) - await waitFor(() => - expect(dismissIds()).toEqual( - ['A', 'B'].map((id) => agentSessionPromptAttentionKey(SCOPE, SESSION, id)) - ) - ) - expect(useAppStore.getState().acknowledgedAgentsByPaneKey[SUBJECT]).toBe(stamp) - expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() - expect(readCalls()).toBe(2) -}) -it('reads a newly accepted visible prompt without sending an RPC for text-only updates', async () => { - addPrompt('A') - render( - createElement( - Fragment, - null, - createElement(StructuredAgentSessionAttentionBridge), - createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: true }) - ) - ) - await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) - await act(async () => fixture.hydrate?.()) - await waitFor(() => expect(readCalls()).toBe(1)) - act(() => { - addPrompt('B') - publishView() + it('Mark read on a chat never opened withdraws its completion alert, as before prompt alerts', async () => { + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + render(bridge()) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + act(() => settleTurn()) + await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1)) + act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit')) + await flush() + expect(dismissIds()).toEqual([ + `${agentSessionAttentionSubjectPrefix(SCOPE, SESSION)}turn:turn-1` + ]) }) - await waitFor(() => expect(dismissIds()).toHaveLength(2)) - const calls = readCalls() - act(() => { - fixture.items = [ - ...fixture.items, - { - itemId: 'text', - revision: 1, - sequence: ++fixture.sequence, - observedAt: fixture.sequence, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'More output' }] + + it('Mark read on a chat hidden since an earlier view covers the newer prompt it surfaced', async () => { + const screen = render(bridge(true)) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + act(() => addPrompt('A')) + await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + await act(async () => fixture.hydrate?.()) + await waitFor(() => expect(dismissIds()).toHaveLength(1)) + act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) + screen.rerender(bridge(false)) + await flush() + transport.dispatch.mockClear() + act(() => addPrompt('B')) + await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1)) + act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit')) + await flush() + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() + expect(dismissIds()).toEqual([promptKey('A'), promptKey('B')]) + }) + + it('Mark read never covers a prompt raised after the click', async () => { + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + render(bridge()) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + act(() => addPrompt('A')) + await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1)) + act(() => { + useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit') + addPrompt('B') + }) + await flush() + await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(2)) + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') + expect(dismissIds()).toEqual([promptKey('A')]) + }) + + it('Mark all read covers every chat it marks, viewed earlier or never opened', async () => { + const OTHER = 'session-beta' + const otherSubject = structuredAgentSessionPaneKey('chat-2', OTHER) + useAppStore.setState({ + unifiedTabsByWorktree: { + [WORKSPACE]: [ + ...(useAppStore.getState().unifiedTabsByWorktree[WORKSPACE] ?? []), + makeUnifiedTab({ + id: 'chat-2', + worktreeId: WORKSPACE, + groupId: 'group', + contentType: 'agent-session', + entityId: OTHER, + agentSessionAgent: 'claude' + }) + ] + } + }) + // The second chat's host side: its own journal, the same phone delivery and renderer stream. + let otherItems = fixture.items.map((item) => ({ ...item })) + let otherSequence = fixture.sequence + const otherFeed = new StructuredAgentSessionTurnCompletionFeed({ + sessions: new Map([ + [ + OTHER, + { + journal: { cursor: () => ({ epoch: 'journal-b', sequence: otherSequence }) }, + params: { location: SCOPE } + } + ] + ]), + readStatusState: () => projectStructuredAgentSessionStatusState(otherItems), + now: () => 42 + }) + const delivery = createStructuredAttentionMobileDelivery({ + readNotificationSettings: () => ({ ...NOTIFICATION_SETTINGS, suppressWhenFocused: false }), + readWorkspaceLabels: () => ({}), + dispatch: (event) => fixture.controller.dispatch(event), + reconcile: (state) => fixture.controller.reconcileStructuredPromptAttention(state), + now: () => 42 + }) + otherFeed.subscribe({ + id: 'other', + includePrompts: true, + emit: (event) => { + if (event.type !== 'end') { + delivery.deliver(event, undefined) + fixture.completion?.({ + id: 'completion', + ok: true, + _meta: { runtimeId: 'h' }, + result: event + }) } } - ] - publishView() + }) + otherFeed.observe(OTHER) + const screen = render(bridge(true)) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + act(() => addPrompt('A')) + await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + await act(async () => fixture.hydrate?.()) + await waitFor(() => expect(dismissIds()).toHaveLength(1)) + act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) + screen.rerender(bridge(false)) + await flush() + transport.dispatch.mockClear() + act(() => { + addPrompt('B') + const promptB = fixture.items.find((item) => item.itemId === 'B') + if (!promptB) { + throw new Error('prompt B missing') + } + otherItems = [...otherItems, { ...promptB, itemId: 'C', sequence: ++otherSequence }] + otherFeed.observe(OTHER) + }) + await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(2)) + act(() => + useAppStore.getState().acknowledgeAgents([SUBJECT, otherSubject], undefined, 'explicit') + ) + await flush() + expect(dismissIds()).toEqual([ + promptKey('A'), + promptKey('B'), + agentSessionPromptAttentionKey(SCOPE, OTHER, 'C') + ]) }) - await act(async () => {}) - expect(readCalls()).toBe(calls) }) -it('keeps hydration unread while away and retries the read on presence return', async () => { - transport.away.mockResolvedValue(true) - addPrompt('A') - render( - createElement( +describe('Viewed structured session owner lifecycle', () => { + const PROMPT_A = agentSessionPromptAttentionKey(SCOPE, SESSION, 'A') + // Memoized like the pane's own target, so a rerender keeps the owner it already holds. + const LOCAL: RuntimeClientTarget = { kind: 'local' } + + /** The chat pane's read: shown means visible and viewed, as the transport derives both. */ + function Pane({ shown, target }: { shown: boolean; target: RuntimeClientTarget }): null { + useStructuredAgentSessionRead({ sessionId: SESSION, target, isVisible: shown, isViewed: shown }) + return null + } + + function tree(options: { + shown: boolean + paneKey?: string + strict?: boolean + target?: RuntimeClientTarget + }) { + const inner = createElement( Fragment, null, createElement(StructuredAgentSessionAttentionBridge), createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: true }) + createElement(Pane, { + key: options.paneKey ?? 'pane', + shown: options.shown, + target: options.target ?? LOCAL + }) ) - ) - await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) - await act(async () => fixture.hydrate?.()) - expect(readCalls()).toBe(0) - transport.away.mockResolvedValue(false) - await act(async () => window.dispatchEvent(new Event('focus'))) - await waitFor(() => expect(dismissIds()).toHaveLength(1)) -}) + return options.strict ? createElement(StrictMode, null, inner) : inner + } -it.each(['transport-error', 'false-result'] as const)( - 'retries a remote %s on a later read with the existing presence gate', - async (failure) => { - const target = { kind: 'environment', environmentId: 'retry-host' } as const - const tab = useAppStore.getState().unifiedTabsByWorktree[WORKSPACE]?.[0] - if (!tab) { - throw new Error('chat tab missing') - } - useAppStore.setState({ - unifiedTabsByWorktree: { [WORKSPACE]: [{ ...tab, executionHostId: 'runtime:retry-host' }] } + async function settle(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)) }) + } + + /** A prompt raised while the user is elsewhere and the pane is hidden. */ + async function promptWhileAway(options: { strict?: boolean } = {}) { + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + const screen = render(tree({ shown: false, strict: options.strict })) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + act(() => addPrompt('A')) + await settle() + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') + return screen + } + + /** The user opens the chat: the pane reads its history and views it. */ + async function show(rerender: () => void): Promise { + fixture.hydrate = undefined + act(() => useAppStore.getState().setActiveWorktree(WORKSPACE)) + rerender() + await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + await act(async () => fixture.hydrate?.()) + await settle() + } + + function expectViewReadWithdraws(): void { + expect(findStructuredAgentSessionReadOwner(SESSION, LOCAL)).toBeDefined() + expect(readCalls()).toBeGreaterThan(0) + expect(dismissIds()).toContain(PROMPT_A) + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() + } + + it('a view read under StrictMode still finds its owner and withdraws the alert', async () => { + const screen = await promptWhileAway({ strict: true }) + await show(() => screen.rerender(tree({ shown: true, strict: true }))) + expectViewReadWithdraws() + }) + + it('a pane hidden by a worktree switch and shown again withdraws on view', async () => { + const screen = await promptWhileAway() + await show(() => screen.rerender(tree({ shown: true }))) + expect(dismissIds()).toContain(PROMPT_A) + act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' })) + screen.rerender(tree({ shown: false })) + await settle() + act(() => addPrompt('B')) + await settle() + // An owner that already holds history resumes its journal stream instead of re-reading it. + act(() => useAppStore.getState().setActiveWorktree(WORKSPACE)) + screen.rerender(tree({ shown: true })) + await settle() + act(() => publishView()) + await settle() + expectViewReadWithdraws() + expect(dismissIds()).toContain(agentSessionPromptAttentionKey(SCOPE, SESSION, 'B')) + }) + + it('a pane remounted in one commit (a tab move) keeps its owner findable', async () => { + const screen = await promptWhileAway() + // The new instance reads the still-held owner before the old one's cleanup releases it. + screen.rerender(tree({ shown: false, paneKey: 'moved' })) + await settle() + await show(() => screen.rerender(tree({ shown: true, paneKey: 'moved' }))) + expectViewReadWithdraws() + }) + + it('a new target object for the same host keeps the owner findable', async () => { + const screen = await promptWhileAway() + screen.rerender(tree({ shown: false, target: { kind: 'local' } })) + await settle() + await show(() => screen.rerender(tree({ shown: true, target: { kind: 'local' } }))) + expectViewReadWithdraws() + }) +}) + +describe('Accepted structured history read retirement', () => { + it('reads the first accepted history even after local unread and clock targets were cleared', async () => { addPrompt('A') - const original = transport.call.getMockImplementation() - let failed = false - transport.call.mockImplementation(async (...args) => { - if (args[1] === 'agentSession.acknowledgeAttention' && !failed) { - failed = true - if (failure === 'transport-error') { - throw new Error('scripted transient transport failure') - } - return { acknowledged: false } - } - return original?.(...args) - }) render( createElement( Fragment, null, createElement(StructuredAgentSessionAttentionBridge), createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: true, target: target }) + createElement(ReadSurface, { viewed: true }) ) ) await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + expect(readCalls()).toBe(0) + expect(useAppStore.getState().unreadAgentCompletionPanes).toEqual({}) await act(async () => fixture.hydrate?.()) - expect(readCalls()).toBe(1) - expect(dismissIds()).toEqual([]) - await act(async () => {}) - expect(readCalls()).toBe(1) - if (failure === 'transport-error') { - await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT])) - } else { - transport.away.mockResolvedValue(true) - await act(async () => window.dispatchEvent(new Event('focus'))) - expect(readCalls()).toBe(1) - transport.away.mockResolvedValue(false) - await act(async () => window.dispatchEvent(new Event('focus'))) - } await waitFor(() => expect(dismissIds()).toEqual([agentSessionPromptAttentionKey(SCOPE, SESSION, 'A')]) ) + expect(readCalls()).toBe(1) + }) + + it('reads B on return while A keeps the already-read row clock unchanged', async () => { + useAppStore.setState({ activeWorktreeId: 'elsewhere' }) + useAppStore + .getState() + .setAgentStatus( + SUBJECT, + { state: 'blocked', prompt: 'Work', agentType: 'claude' }, + 'Chat', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: TAB, worktreeId: WORKSPACE } + ) + const screen = render( + createElement( + Fragment, + null, + createElement(StructuredAgentSessionAttentionBridge), + createElement(AttentionPolicy), + createElement(ReadSurface, { viewed: false }) + ) + ) + await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) + act(() => addPrompt('A')) + await act(async () => fixture.hydrate?.()) + await waitFor(() => expect(fixture.journal).toBeTypeOf('function')) + act(() => useAppStore.getState().acknowledgeAgents([SUBJECT])) + await waitFor(() => expect(dismissIds()).toHaveLength(1)) + const stamp = useAppStore.getState().acknowledgedAgentsByPaneKey[SUBJECT] + act(() => { + addPrompt('B') + publishView() + }) + expect(dismissIds()).toHaveLength(1) + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') + act(() => useAppStore.setState({ activeWorktreeId: WORKSPACE })) + screen.rerender( + createElement( + Fragment, + null, + createElement(StructuredAgentSessionAttentionBridge), + createElement(AttentionPolicy), + createElement(ReadSurface, { viewed: true }) + ) + ) + await waitFor(() => + expect(dismissIds()).toEqual( + ['A', 'B'].map((id) => agentSessionPromptAttentionKey(SCOPE, SESSION, id)) + ) + ) + expect(useAppStore.getState().acknowledgedAgentsByPaneKey[SUBJECT]).toBe(stamp) + expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() expect(readCalls()).toBe(2) - expect( - transport.call.mock.calls - .filter(([, method]) => method === 'agentSession.acknowledgeAttention') - .map(([owner]) => owner) - ).toEqual([target, target]) - } -) + }) -it('retries a failed local desktop relay withdrawal on a later explicit read', async () => { - const relayDirectory = mkdtempSync(join(tmpdir(), 'orca-relay-read-retry-')) - try { + it('reads a newly accepted visible prompt without sending an RPC for text-only updates', async () => { addPrompt('A') - const sent = fixture.events.find((event) => event.type === 'notification') - if (!sent?.notificationId || sent.type !== 'notification') { - throw new Error('prompt not sent') - } - const relay = new RuntimeMobileNotificationController() - relay.configureDismissalStore(relayDirectory) - relay.dispatch(sent) - const withdrawals: string[] = [] - relay.onDispatched((event) => { - if (event.type === 'dismiss') { - withdrawals.push(event.notificationId) - } + render( + createElement( + Fragment, + null, + createElement(StructuredAgentSessionAttentionBridge), + createElement(AttentionPolicy), + createElement(ReadSurface, { viewed: true }) + ) + ) + await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + await act(async () => fixture.hydrate?.()) + await waitFor(() => expect(readCalls()).toBe(1)) + act(() => { + addPrompt('B') + publishView() }) - transport.dismiss - .mockImplementation(async (_ids, _panes, reads?: StructuredNotificationRead[]) => { - for (const read of reads ?? []) { - relay.retireStructuredAttention(read) + await waitFor(() => expect(dismissIds()).toHaveLength(2)) + const calls = readCalls() + act(() => { + fixture.items = [ + ...fixture.items, + { + itemId: 'text', + revision: 1, + sequence: ++fixture.sequence, + observedAt: fixture.sequence, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'More output' }] + } } - return { dismissed: 0 } - }) - .mockRejectedValueOnce(new Error('scripted local retirement failure')) + ] + publishView() + }) + await act(async () => {}) + expect(readCalls()).toBe(calls) + }) + + it('keeps hydration unread while away and retries the read on presence return', async () => { + transport.away.mockResolvedValue(true) + addPrompt('A') render( createElement( Fragment, @@ -259,69 +474,172 @@ it('retries a failed local desktop relay withdrawal on a later explicit read', a ) await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) await act(async () => fixture.hydrate?.()) - expect(dismissIds()).toHaveLength(1) - expect(withdrawals).toEqual([]) - await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT])) - await waitFor(() => expect(withdrawals).toEqual([sent.notificationId])) - expect(transport.dismiss.mock.calls.filter(([, , reads]) => Array.isArray(reads))).toHaveLength( - 2 - ) - } finally { - rmSync(relayDirectory, { recursive: true, force: true }) - } -}) + expect(readCalls()).toBe(0) + transport.away.mockResolvedValue(false) + await act(async () => window.dispatchEvent(new Event('focus'))) + await waitFor(() => expect(dismissIds()).toHaveLength(1)) + }) -it('an older failed attempt cannot erase a newer success when the observation returns to A', async () => { - addPrompt('A') - const original = transport.call.getMockImplementation() - let release: (() => void) | undefined - let first = true - transport.call.mockImplementation(async (...args) => { - if (args[1] === 'agentSession.acknowledgeAttention' && first) { - first = false - return await new Promise((resolve) => { - release = () => resolve({ acknowledged: false }) + it.each(['transport-error', 'false-result'] as const)( + 'retries a remote %s on a later read with the existing presence gate', + async (failure) => { + const target = { kind: 'environment', environmentId: 'retry-host' } as const + const tab = useAppStore.getState().unifiedTabsByWorktree[WORKSPACE]?.[0] + if (!tab) { + throw new Error('chat tab missing') + } + useAppStore.setState({ + unifiedTabsByWorktree: { [WORKSPACE]: [{ ...tab, executionHostId: 'runtime:retry-host' }] } }) + addPrompt('A') + const original = transport.call.getMockImplementation() + let failed = false + transport.call.mockImplementation(async (...args) => { + if (args[1] === 'agentSession.acknowledgeAttention' && !failed) { + failed = true + if (failure === 'transport-error') { + throw new Error('scripted transient transport failure') + } + return { acknowledged: false } + } + return original?.(...args) + }) + render( + createElement( + Fragment, + null, + createElement(StructuredAgentSessionAttentionBridge), + createElement(AttentionPolicy), + createElement(ReadSurface, { viewed: true, target: target }) + ) + ) + await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + await act(async () => fixture.hydrate?.()) + expect(readCalls()).toBe(1) + expect(dismissIds()).toEqual([]) + await act(async () => {}) + expect(readCalls()).toBe(1) + if (failure === 'transport-error') { + await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT])) + } else { + transport.away.mockResolvedValue(true) + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(readCalls()).toBe(1) + transport.away.mockResolvedValue(false) + await act(async () => window.dispatchEvent(new Event('focus'))) + } + await waitFor(() => + expect(dismissIds()).toEqual([agentSessionPromptAttentionKey(SCOPE, SESSION, 'A')]) + ) + expect(readCalls()).toBe(2) + expect( + transport.call.mock.calls + .filter(([, method]) => method === 'agentSession.acknowledgeAttention') + .map(([owner]) => owner) + ).toEqual([target, target]) } - return original?.(...args) - }) - render( - createElement( - Fragment, - null, - createElement(StructuredAgentSessionAttentionBridge), - createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: true }) - ) ) - await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) - await act(async () => fixture.hydrate?.()) - expect(readCalls()).toBe(1) - act(() => useAppStore.getState().acknowledgeAgents([SUBJECT])) - expect(readCalls()).toBe(1) - act(() => { - addPrompt('B') - publishView() - }) - await waitFor(() => expect(readCalls()).toBe(2)) - await act(async () => {}) - act(() => { - fixture.items = fixture.items.map((item): AgentJournalRenderItem => - item.itemId === 'B' && item.body.kind === 'approval' - ? { - ...item, - revision: item.revision + 1, - body: { ...item.body, resolution: { ...item.body.resolution, state: 'resolved' } } + + it('retries a failed local desktop relay withdrawal on a later explicit read', async () => { + const relayDirectory = mkdtempSync(join(tmpdir(), 'orca-relay-read-retry-')) + try { + addPrompt('A') + const sent = fixture.events.find((event) => event.type === 'notification') + if (!sent?.notificationId || sent.type !== 'notification') { + throw new Error('prompt not sent') + } + const relay = new RuntimeMobileNotificationController() + relay.configureDismissalStore(relayDirectory) + relay.dispatch(sent) + const withdrawals: string[] = [] + relay.onDispatched((event) => { + if (event.type === 'dismiss') { + withdrawals.push(event.notificationId) + } + }) + transport.dismiss + .mockImplementation(async (_ids, _panes, reads?: StructuredNotificationRead[]) => { + for (const read of reads ?? []) { + relay.retireStructuredAttention(read) } - : item + return { dismissed: 0 } + }) + .mockRejectedValueOnce(new Error('scripted local retirement failure')) + render( + createElement( + Fragment, + null, + createElement(StructuredAgentSessionAttentionBridge), + createElement(AttentionPolicy), + createElement(ReadSurface, { viewed: true }) + ) + ) + await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + await act(async () => fixture.hydrate?.()) + expect(dismissIds()).toHaveLength(1) + expect(withdrawals).toEqual([]) + await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT])) + await waitFor(() => expect(withdrawals).toEqual([sent.notificationId])) + expect( + transport.dismiss.mock.calls.filter(([, , reads]) => Array.isArray(reads)) + ).toHaveLength(2) + } finally { + rmSync(relayDirectory, { recursive: true, force: true }) + } + }) + + it('an older failed attempt cannot erase a newer success when the observation returns to A', async () => { + addPrompt('A') + const original = transport.call.getMockImplementation() + let release: (() => void) | undefined + let first = true + transport.call.mockImplementation(async (...args) => { + if (args[1] === 'agentSession.acknowledgeAttention' && first) { + first = false + return await new Promise((resolve) => { + release = () => resolve({ acknowledged: false }) + }) + } + return original?.(...args) + }) + render( + createElement( + Fragment, + null, + createElement(StructuredAgentSessionAttentionBridge), + createElement(AttentionPolicy), + createElement(ReadSurface, { viewed: true }) + ) ) - fixture.sequence += 1 - fixture.hostFeed.observe(SESSION) - publishView() + await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function')) + await act(async () => fixture.hydrate?.()) + expect(readCalls()).toBe(1) + act(() => useAppStore.getState().acknowledgeAgents([SUBJECT])) + expect(readCalls()).toBe(1) + act(() => { + addPrompt('B') + publishView() + }) + await waitFor(() => expect(readCalls()).toBe(2)) + await act(async () => {}) + act(() => { + fixture.items = fixture.items.map((item): AgentJournalRenderItem => + item.itemId === 'B' && item.body.kind === 'approval' + ? { + ...item, + revision: item.revision + 1, + body: { ...item.body, resolution: { ...item.body.resolution, state: 'resolved' } } + } + : item + ) + fixture.sequence += 1 + fixture.hostFeed.observe(SESSION) + publishView() + }) + await waitFor(() => expect(readCalls()).toBe(3)) + await act(async () => release?.()) + await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT])) + expect(readCalls()).toBe(3) + expect(dismissIds()).toHaveLength(2) }) - await waitFor(() => expect(readCalls()).toBe(3)) - await act(async () => release?.()) - await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT])) - expect(readCalls()).toBe(3) - expect(dismissIds()).toHaveLength(2) }) diff --git a/tests/e2e/structured-attention-relay-settlement.unit.test.ts b/tests/e2e/structured-attention-relay-settlement.unit.test.ts deleted file mode 100644 index 9230707900c0..000000000000 --- a/tests/e2e/structured-attention-relay-settlement.unit.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -// @vitest-environment happy-dom -// Register preload/store seams before importing their consumers. -import { - fixture, - REMOTE_TARGET, - NOTIFICATION_SETTINGS, - transport, - WORKSPACE, - TAB, - addPrompt, - publishStatus, - publishView, - ReadSurface, - AttentionPolicy, - readCalls -} from './structured-attention-read-retirement.test-fixture' -import { Fragment, createElement } from 'react' -import { act, render, waitFor } from '@testing-library/react' -import { expect, it } from 'vitest' -import { join } from 'node:path' -import type { NotificationDispatchRequest } from '../../src/shared/notification-settings-types' -import type { AgentJournalRenderItem } from '../../src/shared/agent-session-journal-types' -import type { AgentSessionExecutionLocation } from '../../src/shared/agent-session-record' -import { RuntimeMobileNotificationController } from '../../src/main/runtime/runtime-mobile-notification-controller' -import { MobileNotificationDismissalStore } from '../../src/main/runtime/mobile-notification-dismissal-store' -import { createNotificationDeliveryService } from '../../src/main/notifications/notification-delivery-service' -import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture' -import { makeUnifiedTab, makeWorktree } from '@/store/slices/store-test-helpers' -import { useAppStore } from '@/store' -import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge' - -/** A remote-host chat this desktop relays to its own phones while the user is away. */ -async function mountRemoteRelay(options?: { promptStatus?: false; raise?: false }) { - const relayDirectory = join(fixture.directory, 'relay') - const relay = new RuntimeMobileNotificationController() - relay.configureDismissalStore(relayDirectory) - const delivery = createNotificationDeliveryService({ - readNotificationSettings: () => NOTIFICATION_SETTINGS, - findActiveWindow: () => null, - isWindowVisible: () => false, - setTrayAttention: () => {}, - isNotificationSupported: () => false, - dispatchMobileNotification: relay.dispatch.bind(relay), - readAuthorizationStatus: async () => 'authorized', - recordDeliveryOutcome: () => {}, - deliverNative: () => ({ delivered: true }), - platform: 'linux', - now: () => 20_000 - }) - transport.dispatch.mockImplementation(async (request: NotificationDispatchRequest) => - delivery.dispatch(request) - ) - // What the notifications:settleStructuredPrompts handler does with it. - transport.settle.mockImplementation(async (scope: AgentSessionExecutionLocation, id: string) => - relay.reconcileStructuredPromptAttention({ scope, sessionId: id, pendingPromptIds: [] }) - ) - transport.away.mockResolvedValue(true) - useAppStore.setState({ - activeWorktreeId: 'elsewhere', - activeWorkspaceExecutionHostId: 'runtime:relay-host', - worktreesByRepo: { - repo1: [ - makeWorktree({ - id: WORKSPACE, - repoId: 'repo1', - hostId: 'runtime:relay-host', - runtimeOwnerEnvironmentId: 'relay-host' - }) - ] - }, - unifiedTabsByWorktree: { - [WORKSPACE]: [ - makeUnifiedTab({ - id: TAB, - worktreeId: WORKSPACE, - groupId: 'group', - contentType: 'agent-session', - entityId: SESSION, - agentSessionAgent: 'claude', - executionHostId: 'runtime:relay-host' - }) - ] - }, - runtimeEnvironments: [ - { - id: 'relay-host', - name: 'Remote', - createdAt: 1, - updatedAt: 1, - lastUsedAt: null, - runtimeId: null, - endpoints: [], - preferredEndpointId: 'endpoint' - } - ] - }) - render( - createElement( - Fragment, - null, - createElement(StructuredAgentSessionAttentionBridge), - createElement(AttentionPolicy), - createElement(ReadSurface, { viewed: false, target: REMOTE_TARGET }) - ) - ) - await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) - await waitFor(() => expect(fixture.status).toBeTypeOf('function')) - act(() => publishStatus()) - const live = (kind: 'prompt' | 'completion') => - new MobileNotificationDismissalStore(relayDirectory) - .liveDeliveries() - .filter((entry) => entry.structuredOrigin?.cause.kind === kind) - if (options?.raise === false) { - return { live } - } - act(() => { - addPrompt('A') - if (options?.promptStatus !== false) { - publishStatus() - } - publishView() - }) - await waitFor(() => expect(live('prompt')).toHaveLength(1)) - return { live } -} - -function revise(update: (item: AgentJournalRenderItem) => AgentJournalRenderItem): void { - fixture.sequence += 1 - fixture.items = fixture.items.map((item) => { - const next = update(item) - return next === item - ? item - : { ...next, revision: item.revision + 1, sequence: fixture.sequence } - }) -} - -const resolvePrompt = - (id: string, state: 'resolved' | 'cancelled') => - (item: AgentJournalRenderItem): AgentJournalRenderItem => - item.itemId === id && item.body.kind === 'approval' - ? { - ...item, - body: { - ...item.body, - resolution: { - state, - selectedOptionId: state === 'resolved' ? 'yes' : null, - resolvedBy: state === 'resolved' ? 'second-client' : null, - resolvedAt: fixture.sequence - } - } - } - : item - -const resolveA = (state: 'resolved' | 'cancelled') => resolvePrompt('A', state) - -const interrupt = (item: AgentJournalRenderItem): AgentJournalRenderItem => - item.body.kind === 'turn' - ? { ...item, body: { ...item.body, state: 'interrupted', outcome: 'cancellation' } } - : item - -/** A frame the status socket queued before A's commit: another session's row. */ -function otherSessionStatus(): void { - fixture.status?.({ - id: 'status', - ok: true, - _meta: { runtimeId: 'h' }, - result: { - type: 'status', - session: { - sessionId: 'other-session', - workspaceId: WORKSPACE, - agent: 'claude', - status: 'working', - latestPrompt: '', - updatedAt: 1 - } - } - }) -} - -function endStatusStream(): void { - fixture.status?.({ id: 'status', ok: true, _meta: { runtimeId: 'h' }, result: { type: 'end' } }) -} - -it.each([ - ['answered from another client', () => revise(resolveA('resolved'))], - ['turn cancelled', () => revise((item) => interrupt(resolveA('cancelled')(item)))] -] as const)( - 'withdraws the relayed prompt alert once the remote host reports none pending: %s', - async (_mode, resolve) => { - const relay = await mountRemoteRelay() - act(() => { - resolve() - fixture.hostFeed.observe(SESSION) - publishStatus() - }) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) - expect(readCalls()).toBe(0) - expect(transport.settle).toHaveBeenCalledOnce() - // Prompt settlement never touches a completion alert. - const relayedCompletions = transport.dispatch.mock.calls.filter( - ([request]: [NotificationDispatchRequest]) => - request.structuredOrigin?.cause.kind === 'completion' - ) - expect(relay.live('completion')).toHaveLength(relayedCompletions.length) - } -) - -it('withdraws it when a restarted remote host comes back with the prompt cancelled', async () => { - const relay = await mountRemoteRelay() - const before = fixture.status - act(() => endStatusStream()) - revise((item) => interrupt(resolveA('cancelled')(item))) - await waitFor(() => expect(fixture.status).not.toBe(before)) - act(() => publishStatus()) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) -}) - -it('keeps the relayed prompt alert while the host still reports a prompt pending', async () => { - const relay = await mountRemoteRelay() - act(() => { - revise((item) => item) - publishStatus() - }) - await act(async () => {}) - expect(transport.settle).not.toHaveBeenCalled() - expect(relay.live('prompt')).toHaveLength(1) -}) - -it('never settles from a status the desktop lost contact with', async () => { - // The mirror still holds the row from before A, which reads no prompt; it goes unverifiable. - const relay = await mountRemoteRelay({ promptStatus: false }) - act(() => endStatusStream()) - await act(async () => {}) - expect(transport.settle).not.toHaveBeenCalled() - expect(relay.live('prompt')).toHaveLength(1) -}) - -it('settles once per relayed prompt, not on every later status update', async () => { - await mountRemoteRelay() - act(() => { - revise(resolveA('resolved')) - fixture.hostFeed.observe(SESSION) - publishStatus() - }) - await waitFor(() => expect(transport.settle).toHaveBeenCalledOnce()) - act(() => { - revise((item) => item) - publishStatus() - }) - await act(async () => {}) - expect(transport.settle).toHaveBeenCalledOnce() -}) - -// Status and edges ride separate sockets: the pre-prompt row can still be draining after the edge. -it('keeps the pending prompt alert when the edge outruns its status and another frame lands', async () => { - const relay = await mountRemoteRelay({ promptStatus: false }) - act(() => otherSessionStatus()) - await act(async () => {}) - act(() => publishStatus()) - await act(async () => {}) - expect(transport.settle).not.toHaveBeenCalled() - expect(relay.live('prompt')).toHaveLength(1) -}) - -it('keeps it when a queued frame lands in the same task as the edge', async () => { - const relay = await mountRemoteRelay({ promptStatus: false }) - act(() => { - addPrompt('B') - otherSessionStatus() - }) - await act(async () => {}) - expect(transport.settle).not.toHaveBeenCalled() - expect(relay.live('prompt')).toHaveLength(2) -}) - -it('retries a settlement that failed on the next qualifying row', async () => { - const relay = await mountRemoteRelay() - const settle = transport.settle.getMockImplementation() - transport.settle.mockRejectedValueOnce(new Error('main unavailable')) - act(() => { - revise(resolveA('resolved')) - fixture.hostFeed.observe(SESSION) - publishStatus() - }) - await waitFor(() => expect(transport.settle).toHaveBeenCalledOnce()) - expect(relay.live('prompt')).toHaveLength(1) - transport.settle.mockImplementation(settle ?? (async () => {})) - act(() => { - revise((item) => item) - publishStatus() - }) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) - expect(transport.settle).toHaveBeenCalledTimes(2) -}) - -it('settles a prompt answered while the previous settle call was still out', async () => { - const relay = await mountRemoteRelay() - const reconcile = transport.settle.getMockImplementation() - const replies: (() => void)[] = [] - // Main reconciles on arrival; only the reply is late. - transport.settle.mockImplementation((scope: AgentSessionExecutionLocation, id: string) => { - void reconcile?.(scope, id) - return new Promise((resolve) => replies.push(resolve)) - }) - act(() => { - revise(resolveA('resolved')) - fixture.hostFeed.observe(SESSION) - publishStatus() - }) - await waitFor(() => expect(replies).toHaveLength(1)) - act(() => { - addPrompt('B') - publishStatus() - }) - await waitFor(() => expect(relay.live('prompt')).toHaveLength(1)) - act(() => { - revise(resolvePrompt('B', 'resolved')) - fixture.hostFeed.observe(SESSION) - publishStatus() - }) - await act(async () => {}) - expect(replies).toHaveLength(1) - // The host goes quiet; the reply alone must re-judge the mirror. - await act(async () => replies[0]?.()) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) -}) - -it('settles a prompt whose edge arrives after its own resolution row', async () => { - const relay = await mountRemoteRelay({ raise: false }) - const deliver = fixture.completion - const held: Parameters>[0][] = [] - fixture.completion = (response) => held.push(response) - act(() => { - addPrompt('A') - publishStatus() - revise(resolveA('resolved')) - fixture.hostFeed.observe(SESSION) - publishStatus() - }) - fixture.completion = deliver - act(() => held.forEach((response) => deliver?.(response))) - await waitFor(() => - expect( - transport.dispatch.mock.calls.some( - ([request]: [NotificationDispatchRequest]) => - request.structuredOrigin?.cause.kind === 'prompt' - ) - ).toBe(true) - ) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) - expect(transport.settle).toHaveBeenCalledOnce() -}) From eaf5dbed9e5fec6046eb8b4835303c40125b8012 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:16:18 -0700 Subject: [PATCH 016/110] fix(cli): preserve alias recovery while guarding destructive suggestions (#26934) Co-authored-by: kambarakun --- src/cli/command-suggestion-budget.test.ts | 7 + ...nd-suggestion-destructive-families.test.ts | 151 ++++++++++++++++++ src/cli/command-suggestion.ts | 37 +++-- src/cli/index.test.ts | 33 ++++ src/cli/specs/browser-basic.ts | 1 + src/cli/specs/emulator.ts | 2 + src/cli/specs/linear-mcp.ts | 1 + src/cli/specs/managed-server.ts | 1 + src/cli/specs/orchestration-worker-specs.ts | 2 + src/cli/specs/orchestration.ts | 4 +- 10 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 src/cli/command-suggestion-destructive-families.test.ts diff --git a/src/cli/command-suggestion-budget.test.ts b/src/cli/command-suggestion-budget.test.ts index 7902ad7fdba7..d2d1d7256f58 100644 --- a/src/cli/command-suggestion-budget.test.ts +++ b/src/cli/command-suggestion-budget.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as distance from '../shared/edit-distance' import { suggestCommands, unknownFlagData } from './command-suggestion' import type { CommandSpec } from './command-spec' +import { COMMAND_SPECS } from './specs' const specs: CommandSpec[] = [ { path: ['list'], summary: '', usage: '', allowedFlags: [] }, @@ -17,6 +18,12 @@ describe('suggestion distance work', () => { expect(spy).not.toHaveBeenCalled() }) + it('keeps the live destructive alias registry out of long-input distance work', () => { + const spy = vi.spyOn(distance, 'levenshtein') + expect(suggestCommands(COMMAND_SPECS, ['emulator', 'x'.repeat(32_768)])).toEqual([]) + expect(spy).not.toHaveBeenCalled() + }) + it('does no distance calculations for a long flag but still lists valid flags', () => { const spy = vi.spyOn(distance, 'levenshtein') expect(unknownFlagData('x'.repeat(32_768), ['worktree', 'json'])).toEqual({ diff --git a/src/cli/command-suggestion-destructive-families.test.ts b/src/cli/command-suggestion-destructive-families.test.ts new file mode 100644 index 000000000000..110d2490b56f --- /dev/null +++ b/src/cli/command-suggestion-destructive-families.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import { + findCommandSpec, + normalizeCommandPositionals, + parseArgs, + validateCommandAndFlags +} from './args' +import { specPaths, type CommandSpec } from './command-spec' +import { suggestCommands, unknownCommandData } from './command-suggestion' +import { COMMAND_SPECS } from './specs' + +const blocked = [ + ['orchestration resume', 'orchestration reset'], + ['orchestration rerun', 'orchestration reset'], + ['orchestration repl', 'orchestration reset'], + ['artifacts deset', 'artifacts delete'], + ['emulator ball', 'emulator kill'], + ['emulator showdown', 'emulator shutdown'], + ['orchestration worker-showw', 'orchestration worker-stop'], + ['orchestration worker-state', 'orchestration worker-stop'], + ['orchestration worker-relay', 'orchestration worker-release'], + ['orchestration worker-reuse', 'orchestration worker-release'], + ['tab clues', 'tab close'], + ['linear relation move', 'linear relation remove'], + ['environment rollbook', 'environment rollback'] +] + +const recovery = [ + ['orchestration rese', 'orchestration reset'], + ['orchestration rest', 'orchestration reset'], + ['emulator kil', 'emulator kill'], + ['emulator shutdow', 'emulator shutdown'], + ['orchestration worker-stp', 'orchestration worker-stop'], + ['orchestration worker-releasee', 'orchestration worker-release'], + ['tab clos', 'tab close'], + ['tab clone', 'tab close'], + ['environment rollbac', 'environment rollback'], + ['environment remov', 'environment rm'], + ['artifacts delet', 'artifacts delete'], + ['worktree remov', 'worktree remove'], + ['worktree delet', 'worktree delete'], + ['orchestration chek', 'orchestration check'], + ['linear relation remov', 'linear relation remove'], + ['linear label remov', 'linear label remove'], + ['capture stp', 'capture stop'], + ['clea', 'clear'] +] + +describe('live destructive command recovery', () => { + it.each(blocked)('does not turn %s into %s', (input, destructive) => { + const path = input.split(' ') + const data = unknownCommandData(COMMAND_SPECS, path) + expect(data.suggestions).not.toContain(destructive) + expect(data.nextSteps.join(' ')).not.toContain(`orca ${destructive}`) + const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(path)) + expect(() => validateCommandAndFlags(COMMAND_SPECS, parsed)).toThrow('Unknown command') + }) + + it.each(recovery)('still recovers %s as %s', (input, expected) => { + const data = unknownCommandData(COMMAND_SPECS, input.split(' ')) + expect(data.suggestions).toContain(expected) + expect(data.nextSteps.join(' ')).toContain(`orca ${expected}`) + }) + + it('retains the complete legitimate environment removal ranking', () => { + expect(suggestCommands(COMMAND_SPECS, ['environment', 'remov'])).toEqual([ + 'environment recover', + 'environment rm' + ]) + }) + + it('keeps every declared alias accepted and normalized to its canonical command', () => { + for (const spec of COMMAND_SPECS) { + for (const path of specPaths(spec)) { + expect(findCommandSpec(COMMAND_SPECS, path)).toBe(spec) + const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(path)) + expect(parsed.commandPath).toEqual(spec.path) + } + } + }) +}) + +function destructiveSpec(path: string[], aliases?: string[][]): CommandSpec { + return { path, aliases, destructive: true, summary: '', usage: '', allowedFlags: [] } +} + +describe('declared destructive alias families', () => { + const deletion = destructiveSpec( + ['worktree', 'rm'], + [ + ['worktree', 'remove'], + ['worktree', 'delete'] + ] + ) + const reset = destructiveSpec(['orchestration', 'reset']) + + it('does not let intent for reset unlock a deletion candidate', () => { + const specs = [deletion, reset, destructiveSpec(['artifacts', 'delete'])] + expect(suggestCommands(specs, ['artifacts', 'deset'])).toEqual([]) + }) + + it('shares only declared equivalent verbs with another command', () => { + const specs = [deletion, reset, destructiveSpec(['environment', 'rm'])] + expect(suggestCommands(specs, ['environment', 'remov'])).toEqual(['environment rm']) + expect(suggestCommands(specs, ['environment', 'rese'])).toEqual([]) + }) + + it('merges overlapping declarations transitively regardless of registry order', () => { + const specs = [ + destructiveSpec(['one', 'read'], [['one', 'redo']]), + destructiveSpec(['two', 'redo'], [['two', 'undo']]), + destructiveSpec(['three', 'undo']) + ] + expect(suggestCommands(specs, ['three', 'red'])).toEqual(['three undo']) + expect(suggestCommands(specs.toReversed(), ['three', 'red'])).toEqual(['three undo']) + }) + + it('does not derive destructive equivalence from ordinary command aliases', () => { + const specs: CommandSpec[] = [ + destructiveSpec(['state', 'undo']), + { + path: ['inspect', 'read'], + aliases: [['inspect', 'undo']], + summary: '', + usage: '', + allowedFlags: [] + } + ] + expect(suggestCommands(specs, ['state', 'red'])).toEqual([]) + }) + + it('keeps ranking, deduplication, depth, and the three-result limit', () => { + const specs = [ + destructiveSpec(['state', 'reset']), + ...['rest', 'resee', 'reses', 'resets'].map((verb) => ({ + path: ['state', verb], + summary: '', + usage: '', + allowedFlags: [] + })), + { path: ['state', 'rest'], summary: '', usage: '', allowedFlags: [] }, + { ...destructiveSpec(['state', 'rese']), hidden: true } + ] + expect(suggestCommands(specs, ['state', 'rese'])).toEqual([ + 'state resee', + 'state reses', + 'state reset' + ]) + expect(suggestCommands(specs, ['state', 'rese', 'extra'])).toEqual([]) + }) +}) diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index dcdbed780ab6..47c78d74119a 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -15,26 +15,24 @@ function finalToken(path: string[]): string { return path.at(-1) ?? '' } -// Why: destructiveness is declared on the spec (single source of truth); the -// intent verbs are the final tokens of every destructive path/alias so the guard -// tracks the registry instead of a hand-maintained list. -function destructiveVerbs(specs: CommandSpec[]): Set { - const verbs = new Set() +// Why: declared aliases connect equivalent destructive verbs without unlocking unrelated actions. +function destructiveVerbFamilies(specs: CommandSpec[]): Map> { + const families = new Map>() for (const spec of specs) { - if (spec.destructive) { - for (const path of specPaths(spec)) { - verbs.add(finalToken(path)) - } + if (!spec.destructive) { + continue + } + const verbs = specPaths(spec).map(finalToken) + const family = new Set(verbs.flatMap((verb) => [...(families.get(verb) ?? [verb])])) + for (const verb of family) { + families.set(verb, family) } } - return verbs + return families } -// Why: deletion is irreversible and suggestions flow into agents' recovery -// channel (--json nextSteps), so only unlock destructive candidates when the -// input token is itself a near-miss of a destructive verb. #6303 -function intendsDestruction(inputToken: string, verbs: Set): boolean { - for (const verb of verbs) { +function intendsDestruction(inputToken: string, verbs: Set | undefined): boolean { + for (const verb of verbs ?? []) { if ( Math.abs(inputToken.length - verb.length) <= DESTRUCTIVE_INTENT_THRESHOLD && levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD @@ -62,16 +60,17 @@ function rankByDistance(scored: { label: string; distance: number }[]): string[] // Why: same-depth matching avoids suggesting parent groups or unrelated commands. export function suggestCommands(specs: CommandSpec[], commandPath: string[]): string[] { const input = commandPath.join(' ') - // Why: only surface destructive commands when the user actually reached for one; - // otherwise a benign typo could recover into an irreversible action. #6303 - const allowDestructive = intendsDestruction(finalToken(commandPath), destructiveVerbs(specs)) + const families = destructiveVerbFamilies(specs) const seen = new Set() const scored: { label: string; distance: number }[] = [] for (const spec of specs) { if (spec.hidden) { continue } - if (spec.destructive && !allowDestructive) { + if ( + spec.destructive && + !intendsDestruction(finalToken(commandPath), families.get(finalToken(spec.path))) + ) { continue } const candidates = specPaths(spec).map((path) => diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 259fce08ccab..e0ec54b26434 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -310,6 +310,39 @@ describe('unknown command surfaces a suggestion', () => { expect(stderr).toContain('orca worktree') }) + it.each([ + ['orchestration resume', 'orchestration reset'], + ['artifacts deset', 'artifacts delete'], + ['emulator ball', 'emulator kill'], + ['emulator showdown', 'emulator shutdown'], + ['orchestration worker-showw', 'orchestration worker-stop'], + ['orchestration worker-relay', 'orchestration worker-release'], + ['tab clues', 'tab close'], + ['environment rollbook', 'environment rollback'], + ['linear relation move', 'linear relation remove'] + ])('keeps %s recovery away from %s before runtime lookup', async (input, forbidden) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + runtimeClientConstructorMock.mockClear() + try { + await main([...input.split(' '), '--json'], '/tmp/repo') + expect(process.exitCode).toBe(1) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('invalid_argument') + expect(output).toContain('"runtimeId": null') + expect(output).not.toContain(`orca ${forbidden}`) + expect(output).not.toContain(`"${forbidden}"`) + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + } finally { + logSpy.mockRestore() + } + + await main(input.split(' '), '/tmp/repo') + expect(errorSpy.mock.calls.flat().join('\n')).not.toContain(`orca ${forbidden}`) + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + }) + it('reports a mistyped pre-command flag without swallowing the command', async () => { await main(['--jso', 'worktree', 'list'], '/tmp/repo') diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 3125f2f38644..d03d8e5fe6a7 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -245,6 +245,7 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['tab', 'close'], + destructive: true, summary: 'Close a browser tab', usage: 'orca tab close [--index ] [--worktree ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'index', 'worktree'] diff --git a/src/cli/specs/emulator.ts b/src/cli/specs/emulator.ts index 42f1087d4086..5b8ac913e48e 100644 --- a/src/cli/specs/emulator.ts +++ b/src/cli/specs/emulator.ts @@ -65,12 +65,14 @@ export const EMULATOR_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['emulator', 'kill'], + destructive: true, summary: 'Stop helper for device', usage: 'orca emulator kill [--device ] [--worktree ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'device', 'emulator', 'worktree'] }, { path: ['emulator', 'shutdown'], + destructive: true, summary: 'Stop helper and shut down the simulator device', usage: 'orca emulator shutdown [--device ] [--emulator ] [--worktree ] [--json]', diff --git a/src/cli/specs/linear-mcp.ts b/src/cli/specs/linear-mcp.ts index af7f654e0a32..531a41813781 100644 --- a/src/cli/specs/linear-mcp.ts +++ b/src/cli/specs/linear-mcp.ts @@ -92,6 +92,7 @@ export const LINEAR_MCP_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['linear', 'relation', 'remove'], + destructive: true, aliases: [['linear', 'relation', 'rm']], summary: 'Remove a Linear issue relation', usage: diff --git a/src/cli/specs/managed-server.ts b/src/cli/specs/managed-server.ts index 675f40709c94..9a73b0896660 100644 --- a/src/cli/specs/managed-server.ts +++ b/src/cli/specs/managed-server.ts @@ -29,6 +29,7 @@ export const MANAGED_SERVER_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['environment', 'rollback'], + destructive: true, summary: 'Roll a managed Orca server back to its previous version', usage: 'orca environment rollback --environment [--json]', allowedFlags: [...GLOBAL_FLAGS], diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 43674b9d7578..9123009f9de0 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -72,6 +72,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'worker-stop'], + destructive: true, summary: 'Fence one Dispatch and stop its supervised agent terminal', usage: 'orca orchestration worker-stop --dispatch [--retry-request ] [--json]', @@ -91,6 +92,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'worker-release'], + destructive: true, summary: 'Release the terminal of one settled supervised worker', usage: 'orca orchestration worker-release --dispatch [--retry-request ] [--json]', diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 5d9e621467bf..b879de5d5935 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -1,5 +1,4 @@ -import type { CommandSpec } from '../args' -import { GLOBAL_FLAGS } from '../args' +import { GLOBAL_FLAGS, type CommandSpec } from '../args' import { ORCHESTRATION_WORKER_COMMAND_SPECS } from './orchestration-worker-specs' export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ @@ -293,6 +292,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'reset'], + destructive: true, summary: 'Reset one explicit orchestration state scope', usage: 'orca orchestration reset (--all | --tasks | --messages) [--retry-request ] [--json]', From 68519e63b9e3a6cf4fbcdc2e8fc2992b1e88c329 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:32:21 -0700 Subject: [PATCH 017/110] fix(composer): preserve explicit workspace names after selecting tasks (#26937) Preserve names entered after selecting a work item in desktop, mobile and folder creation. Reuse the existing source and last-generated-name state, and avoid repeating source naming when a delayed base lookup finishes. Verified the current-main merge composition: 70 focused tests and renderer typecheck pass; newer execution-host routing and folder launch checks remain intact. The reviewed PR head has no failed or pending CI checks and no unresolved review threads. --- mobile/src/tasks/composer-linked-work-item.ts | 6 +- ...use-mobile-composer-name-ownership.test.ts | 123 ++++++++++ mobile/src/tasks/use-mobile-composer-name.ts | 27 +++ .../src/tasks/use-mobile-composer-source.ts | 28 +-- .../folder-workspace-composer-submit.test.ts | 30 +++ .../folder-workspace-composer-submit.ts | 8 +- .../branch-start-point-actions.ts | 15 +- .../composer-state/composer-decisions.ts | 14 +- .../composer-deferred-source-name.test.ts | 181 +++++++++++++++ .../composer-state/composer-external-sync.ts | 1 + .../composer-name-ownership.test.ts | 212 ++++++++++++++++++ .../composer-name-source-selection.test.ts | 51 +++++ .../composer-state/composer-source-model.ts | 6 +- .../composer-state/composer-source-state.ts | 2 + .../github-provider-selection.ts | 9 +- .../github-source-application.ts | 6 +- .../gitlab-provider-selection.ts | 9 +- .../composer-state/issue-source-actions.ts | 15 +- .../quick-submit-source-preparation.ts | 3 +- .../composer-state/source-identity-actions.ts | 4 +- .../new-workspace/workspace-source.test.ts | 18 ++ src/shared/new-workspace/workspace-source.ts | 4 +- 22 files changed, 729 insertions(+), 43 deletions(-) create mode 100644 mobile/src/tasks/use-mobile-composer-name-ownership.test.ts create mode 100644 mobile/src/tasks/use-mobile-composer-name.ts create mode 100644 src/renderer/src/hooks/composer-state/composer-deferred-source-name.test.ts create mode 100644 src/renderer/src/hooks/composer-state/composer-name-ownership.test.ts diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts index 13d5c845b375..8a3621be8f8b 100644 --- a/mobile/src/tasks/composer-linked-work-item.ts +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -50,7 +50,11 @@ export function buildLinearLinkedWorkItem(issue: { // Faithful port of desktop applyLinkedWorkItem's name gate: the derived name // replaces the current field only when it's empty, still the last auto-name, or // a lookup query — never a name the user deliberately typed. -export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean { +export function shouldApplyAutoName(args: { + currentName: string + lastAutoName: string + lookupTextIsQuery?: boolean +}): boolean { return shouldApplyWorkspaceSourceAutoName(args) } diff --git a/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts b/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts new file mode 100644 index 000000000000..270a0439eba8 --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-name-ownership.test.ts @@ -0,0 +1,123 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types' +import type { LinearIssue } from '../../../src/shared/linear/issue-types' +import { useMobileComposerSource } from './use-mobile-composer-source' +import { buildTaskWorkspaceCreateParams } from './workspace-create-params' + +const mounted: ReactTestRenderer[] = [] +afterEach(() => { + act(() => { + for (const renderer of mounted.splice(0)) { + renderer.unmount() + } + }) +}) + +function renderMobileComposerSource() { + const holder: { value?: ReturnType } = {} + function Probe() { + holder.value = useMobileComposerSource({ client: null, selectedRepoId: 'fixture-repo' }) + return null + } + act(() => { + mounted.push(create(createElement(Probe))) + }) + return { + get current() { + if (!holder.value) { + throw new Error('Composer source did not render') + } + return holder.value + } + } +} + +function issue(number: number): GitHubWorkItem { + return { + id: `issue-${number}`, + type: 'issue', + number, + title: 'Fix export', + state: 'open', + url: `https://github.com/fixture/repo/issues/${number}`, + repoId: 'fixture-repo', + labels: [], + updatedAt: '', + author: null + } +} + +describe('mobile workspace name ownership', () => { + const linearIssue: LinearIssue = { + id: 'linear-9', + identifier: 'ENG-9', + title: 'Fix export', + url: 'https://linear.app/fixture/issue/ENG-9', + state: { name: 'Open', type: 'unstarted', color: '' }, + team: { id: 'engineering', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 0, + updatedAt: '' + } + + it('keeps a manual Linear identifier name when its source refreshes', () => { + const result = renderMobileComposerSource() + act(() => result.current.handleSmartLinearIssueSelect(linearIssue)) + act(() => result.current.setName('ENG-9')) + act(() => + result.current.handleSmartLinearIssueSelect({ ...linearIssue, title: 'Fix refreshed export' }) + ) + + expect(result.current.name).toBe('ENG-9') + expect(result.current.isNameAutoManaged).toBe(false) + }) + + it('still replaces an unselected Linear identifier query', () => { + const result = renderMobileComposerSource() + act(() => result.current.setName('ENG-9')) + act(() => result.current.handleSmartLinearIssueSelect(linearIssue)) + + expect(result.current.name).toBe('eng-9-fix-export') + expect(result.current.isNameAutoManaged).toBe(true) + }) + + it.each([2, 347])('keeps an edited name when issue #%s replaces the source', (number) => { + const result = renderMobileComposerSource() + act(() => result.current.handleSmartGitHubItemSelect(issue(2))) + act(() => result.current.setName('002')) + act(() => result.current.handleSmartGitHubItemSelect(issue(number))) + + expect(result.current.name).toBe('002') + expect(result.current.isNameAutoManaged).toBe(false) + expect( + buildTaskWorkspaceCreateParams({ + item: { + provider: 'github', + source: { ...issue(number), type: 'issue', repoId: 'fixture-repo' } + }, + targetRepoId: 'fixture-repo', + setupDecision: 'skip', + agent: 'blank', + workspaceName: result.current.name, + nameIsAutoManaged: result.current.isNameAutoManaged + }) + ).toMatchObject({ + name: '002', + displayName: '002', + displayNameKind: 'user', + linkedIssue: number + }) + }) + + it.each(['002', '347', '#347'])('still generates a name when picking query %s', (query) => { + const result = renderMobileComposerSource() + act(() => result.current.setName(query)) + act(() => result.current.handleSmartGitHubItemSelect(issue(query === '002' ? 2 : 347))) + + expect(result.current.name).toBe('fix-export') + expect(result.current.isNameAutoManaged).toBe(true) + }) +}) diff --git a/mobile/src/tasks/use-mobile-composer-name.ts b/mobile/src/tasks/use-mobile-composer-name.ts new file mode 100644 index 000000000000..26dfe4872e4c --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-name.ts @@ -0,0 +1,27 @@ +import { useCallback, useRef, useState } from 'react' +import { shouldApplyAutoName } from './composer-linked-work-item' +import type { MobileLinkedWorkItem } from './mobile-composer-source-types' + +export function useMobileComposerName(linkedWorkItem: MobileLinkedWorkItem | null) { + const [name, setNameState] = useState('') + const lastAutoNameRef = useRef('') + const setName = useCallback((value: string) => setNameState(value), []) + const applyAutoName = useCallback( + (suggested: string, currentName: string) => { + if ( + suggested && + shouldApplyAutoName({ + currentName, + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem + }) + ) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + }, + [linkedWorkItem] + ) + const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current + return { name, setName, setNameState, lastAutoNameRef, applyAutoName, isNameAutoManaged } +} diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts index 5d6c373d454c..e3bb9c02518b 100644 --- a/mobile/src/tasks/use-mobile-composer-source.ts +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -28,6 +28,7 @@ import type { MobileLinkedWorkItem, SmartNameSelection } from './mobile-composer-source-types' +import { useMobileComposerName } from './use-mobile-composer-name' const EMPTY_BASE: ComposerBaseState = {} export type UseMobileComposerSourceArgs = { @@ -39,7 +40,6 @@ export type UseMobileComposerSourceArgs = { export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { const { client, selectedRepoId, worktreeBranches = [], onError } = args - const [name, setNameState] = useState('') const [linkedWorkItem, setLinkedWorkItem] = useState(null) const [base, setBase] = useState(EMPTY_BASE) const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) @@ -50,19 +50,12 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { // may contain slashes) is kept verbatim as the git branch (folder is sanitized). const [branchCreateIntent, setBranchCreateIntent] = useState(false) - const lastAutoNameRef = useRef('') const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null) // Guards async base resolution: only the latest selection applies its result. const resolveTokenRef = useRef(0) - const setName = useCallback((value: string) => setNameState(value), []) - - const applyAutoName = useCallback((suggested: string, currentName: string) => { - if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) { - setNameState(suggested) - lastAutoNameRef.current = suggested - } - }, []) + const { name, setName, setNameState, lastAutoNameRef, applyAutoName, isNameAutoManaged } = + useMobileComposerName(linkedWorkItem) const clearBaseAndBranch = useCallback(() => { branchSelectionRef.current = null @@ -201,18 +194,23 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { resolveTokenRef.current += 1 setLinkedWorkItem(buildLinearLinkedWorkItem(issue)) const suggested = resolveLinearAutoName(issue) - const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase() + const identifierTyped = + !linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase() if ( suggested && (identifierTyped || - shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current })) + shouldApplyAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem + })) ) { setNameState(suggested) lastAutoNameRef.current = suggested } clearBaseAndBranch() }, - [clearBaseAndBranch, name] + [clearBaseAndBranch, linkedWorkItem, name] ) const handleSmartBranchSelect = useCallback( @@ -302,10 +300,6 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { [base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch] ) - // Auto-managed until the user edits the name away from the last derived value; - // desktop suppresses the workspace displayName once the name is user-edited. - const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current - return { name, setName, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index ae4e8df209a3..85d5f2020be0 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -65,6 +65,36 @@ function makeFolderWorkspace(overrides: Partial = {}): FolderWo } describe('submitFolderWorkspaceCreate', () => { + it.each(['347', '002'])('keeps manual folder name %s with its linked source', async (name) => { + const linkedWorkItem = { + provider: 'github' as const, + type: 'issue' as const, + number: Number(name), + title: 'Fix export', + url: `https://github.com/fixture/repo/issues/${Number(name)}` + } + const createFolderWorkspace = vi.fn(async () => null) + await submitFolderWorkspaceCreate({ + projectGroup: makeProjectGroup(), + name, + lastAutoName: 'Fix export', + linkedWorkItem, + note: '', + quickAgent: null, + autoRenameBranchFromWork: false, + agentCmdOverrides: {}, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + expect(createFolderWorkspace).toHaveBeenCalledWith({ + projectGroupId: 'group-1', + name, + connectionId: null, + linkedTask: linkedWorkItem + }) + }) + beforeEach(() => { mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index dc3afb246ff7..0dde03495e3a 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -4,7 +4,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation' -import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import { shouldApplyWorkspaceSourceAutoName } from '../../../../shared/new-workspace/workspace-source' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../../shared/project-group-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -82,7 +82,11 @@ export async function submitFolderWorkspaceCreate({ onOpenChange }: SubmitFolderWorkspaceCreateParams): Promise { const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null - const nameIsAutoManaged = !name.trim() || name === lastAutoName || isWorkItemLookupText(name) + const nameIsAutoManaged = shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName, + lookupTextIsQuery: !linkedWorkItem + }) const workspaceName = nameIsAutoManaged && linkedName ? linkedName diff --git a/src/renderer/src/hooks/composer-state/branch-start-point-actions.ts b/src/renderer/src/hooks/composer-state/branch-start-point-actions.ts index 6a5a7b0af8c4..caa7f5e4706c 100644 --- a/src/renderer/src/hooks/composer-state/branch-start-point-actions.ts +++ b/src/renderer/src/hooks/composer-state/branch-start-point-actions.ts @@ -135,7 +135,8 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) item: GitHubWorkItem, nextPushTarget?: GitPushTarget, nextBranchNameOverride?: string, - nextCompareBaseRef?: string + nextCompareBaseRef?: string, + options: { sourceAlreadyLinked?: boolean } = {} ): void => { setBaseBranch(nextBaseBranch) setBaseBranchNamesWorkspace(true) @@ -146,7 +147,10 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) branchAutoNameRef.current = '' setStartFromResetHint(null) // Why: a Start-from PR pick is also a linkedWorkItem assignment; reuse applyLinkedWorkItem so auto-name and linkedPR stay one code path. - applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) }) + // Provider picks already linked the source; a delayed base result must not replay its name. + if (!options.sourceAlreadyLinked) { + applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) }) + } // Why: prefill the note from the PR (only when empty or still an auto-fill) so the sidebar surfaces it without clobbering user text. const identity = resolveGitHubWorkItemIdentity(item) if (identity.type === 'pr') { @@ -180,7 +184,8 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) nextBaseBranch: string, item: GitLabWorkItem, nextPushTarget?: GitPushTarget, - nextCompareBaseRef?: string + nextCompareBaseRef?: string, + options: { sourceAlreadyLinked?: boolean } = {} ): void => { setBaseBranch(nextBaseBranch) setBaseBranchNamesWorkspace(true) @@ -189,7 +194,9 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) setBranchNameOverride(undefined) branchAutoNameRef.current = '' setStartFromResetHint(null) - applyLinkedGitLabWorkItem(item) + if (!options.sourceAlreadyLinked) { + applyLinkedGitLabWorkItem(item) + } if (item.type === 'mr') { const suggestedNote = `MR !${item.number} — ${item.title}` const currentNote = noteRef.current diff --git a/src/renderer/src/hooks/composer-state/composer-decisions.ts b/src/renderer/src/hooks/composer-state/composer-decisions.ts index ce852acc7c55..58651fe83b48 100644 --- a/src/renderer/src/hooks/composer-state/composer-decisions.ts +++ b/src/renderer/src/hooks/composer-state/composer-decisions.ts @@ -3,7 +3,7 @@ import { getLinkedWorkItemWorkspaceName } from '../../../../shared/workspace-name' import { normalizeExecutionHostId } from '../../../../shared/execution-host' -import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import { shouldApplyWorkspaceSourceAutoName } from '../../../../shared/new-workspace/workspace-source' import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' import { getGitHubLinkedWorkItemIdentity } from './source-selection-decisions' @@ -30,7 +30,11 @@ export type ComposerDecisions = { item: LinkedWorkItemSummary | null | undefined, context: TaskSourceContext | null | undefined ) => TaskSourceContext | null - isExplicitWorkspaceNameInput: (input: { name: string; lastAutoName: string }) => boolean + isExplicitWorkspaceNameInput: (input: { + name: string + lastAutoName: string + lookupTextIsQuery?: boolean + }) => boolean resolveInitialWorkspaceRunSeed: (input: { draftProjectId?: string | null draftHostId?: string | null @@ -67,13 +71,15 @@ export function canResolveFolderSmartGitHubSubmit({ export function isExplicitWorkspaceNameInput({ name, - lastAutoName + lastAutoName, + lookupTextIsQuery }: { name: string lastAutoName: string + lookupTextIsQuery?: boolean }): boolean { // Why: a user-authored name must win over linked-item and first-message AI naming. - return Boolean(name.trim()) && name !== lastAutoName && !isWorkItemLookupText(name) + return !shouldApplyWorkspaceSourceAutoName({ currentName: name, lastAutoName, lookupTextIsQuery }) } export function resolveSmartGitHubCreateNames({ diff --git a/src/renderer/src/hooks/composer-state/composer-deferred-source-name.test.ts b/src/renderer/src/hooks/composer-state/composer-deferred-source-name.test.ts new file mode 100644 index 000000000000..7dce9f21b521 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-deferred-source-name.test.ts @@ -0,0 +1,181 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { useRef, useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' +import type { GitHubPrStartPoint } from '../../../../shared/worktree/types' +import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions' +import { useGitHubSourceApplication } from './github-source-application' +import { useSourceIdentityActions } from './source-identity-actions' +import { useBranchStartPointActions } from './branch-start-point-actions' +import { useGitHubProviderSelection } from './github-provider-selection' +import { useGitLabProviderSelection } from './gitlab-provider-selection' + +const repo = { + id: 'fixture-repo', + path: '/repos/fixture', + displayName: 'Fixture', + badgeColor: '', + addedAt: 0 +} +const pr: GitHubWorkItem = { + id: 'pr-2', + type: 'pr', + number: 2, + title: 'Fix export', + state: 'open', + url: 'https://github.com/fixture/repo/pull/2', + labels: [], + updatedAt: '', + author: null, + repoId: repo.id +} +const mr: GitLabWorkItem = { + id: 'mr-2', + number: 2, + title: 'Fix export', + labels: [], + updatedAt: '', + author: null, + repoId: repo.id, + type: 'mr', + state: 'opened', + url: 'https://gitlab.com/fixture/repo/-/merge_requests/2' +} +let originalApi: PropertyDescriptor | undefined +const resolvePrBase = vi.fn() +const resolveMrBase = vi.fn() + +beforeEach(() => { + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { worktrees: { resolvePrBase, resolveMrBase } } + }) +}) +afterEach(() => { + vi.resetAllMocks() + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +function deferred() { + let resolve!: (value: GitHubPrStartPoint) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function useDeferredSource() { + const [name, setName] = useState('') + const [linkedWorkItem, setLinkedWorkItem] = useState(null) + const [baseBranch, setBaseBranch] = useState() + const common = { + name, + setName, + linkedWorkItem, + setLinkedWorkItem, + baseBranch, + setBaseBranch, + lastAutoNameRef: useRef(''), + branchAutoNameRef: useRef(''), + smartGitHubPrStartPointSelectionRef: useRef(null), + lastAutoNoteRef: useRef(''), + noteRef: useRef(''), + initialProjectGroupAppliedRef: useRef(false), + selectedRepoGitHubSourceContext: null, + branchNameOverride: undefined, + branchNameOverridePreservesNameEdits: false, + forkPushWarning: null, + pushTarget: undefined, + baseBranchNamesWorkspace: false, + isProjectGroupTarget: false, + settings: null, + eligibleRepos: [repo], + selectedRepo: repo, + setBranchNameOverride: vi.fn(), + setBranchNameOverridePreservesNameEdits: vi.fn(), + setCreateError: vi.fn(), + setForkPushWarning: vi.fn(), + setLinkDebouncedQuery: vi.fn(), + setLinkDirectItem: vi.fn(), + setLinkPopoverOpen: vi.fn(), + setLinkQuery: vi.fn(), + setLinkedGitLabIssue: vi.fn(), + setLinkedGitLabMR: vi.fn(), + setLinkedIssue: vi.fn(), + setLinkedPR: vi.fn(), + setLinkedTaskSourceContext: vi.fn(), + setPushTarget: vi.fn(), + setReuseEligibleBranch: vi.fn(), + setReuseSelectedBranch: vi.fn(), + setCompareBaseRef: vi.fn(), + setStartFromResetHint: vi.fn(), + setBaseBranchNamesWorkspace: vi.fn(), + setNote: vi.fn(), + setProjectError: vi.fn(), + setSelectedProjectGroupId: vi.fn(), + setSparseDirectories: vi.fn(), + setSparseEnabled: vi.fn(), + setSparseSelectedPresetId: vi.fn(), + handleRepoChange: vi.fn() + } + const application = useGitHubSourceApplication(common) + const identity = useSourceIdentityActions({ ...common, ...application }) + const branch = useBranchStartPointActions({ ...common, ...application, ...identity }) + const github = useGitHubProviderSelection({ ...common, ...application, ...branch }) + const gitlab = useGitLabProviderSelection({ ...common, ...identity, ...branch }) + return { ...github, ...gitlab, ...identity, ...branch, name, linkedWorkItem, baseBranch } +} + +describe('name ownership while resolving review bases', () => { + it.each(['github', 'gitlab'])( + 'keeps an Advanced name while %s resolves its base', + async (provider) => { + const pending = deferred() + resolvePrBase.mockReturnValue(pending.promise) + resolveMrBase.mockReturnValue(pending.promise) + const { result } = renderHook(useDeferredSource) + act(() => { + if (provider === 'github') { + result.current.handleSmartGitHubItemSelect(pr) + } else { + result.current.handleSmartGitLabItemSelect(mr) + } + }) + act(() => result.current.handleNameValueChange('002')) + await act(async () => { + pending.resolve({ baseBranch: 'feature/export' }) + await pending.promise + }) + + expect(result.current.name).toBe('002') + expect(result.current.linkedWorkItem).toMatchObject({ provider, number: 2 }) + expect(result.current.baseBranch).toBe('feature/export') + } + ) + + it.each(['github', 'gitlab'])( + 'still links and auto-names a direct %s Start-from selection', + (provider) => { + const { result } = renderHook(useDeferredSource) + act(() => { + if (provider === 'github') { + result.current.handleBaseBranchPrSelect('feature/export', pr) + } else { + result.current.handleBaseBranchMrSelect('feature/export', mr) + } + }) + + expect(result.current.name).toBe('fix-export') + expect(result.current.linkedWorkItem).toMatchObject({ provider, number: 2 }) + } + ) +}) diff --git a/src/renderer/src/hooks/composer-state/composer-external-sync.ts b/src/renderer/src/hooks/composer-state/composer-external-sync.ts index f7efc502f889..66801c987ece 100644 --- a/src/renderer/src/hooks/composer-state/composer-external-sync.ts +++ b/src/renderer/src/hooks/composer-state/composer-external-sync.ts @@ -56,6 +56,7 @@ export function useComposerExternalSync(target: ComposerTargetState): ComposerEx const githubSourceApplication = useGitHubSourceApplication({ branchAutoNameRef: target.asyncComposerState.branchAutoNameRef, lastAutoNameRef: target.asyncComposerState.lastAutoNameRef, + linkedWorkItem: target.sourceContextState.linkedWorkItem, name: target.sourceContextState.name, selectedRepoGitHubSourceContext: target.sourceContextState.selectedRepoGitHubSourceContext, setBranchNameOverride: target.workspaceIdentityState.setBranchNameOverride, diff --git a/src/renderer/src/hooks/composer-state/composer-name-ownership.test.ts b/src/renderer/src/hooks/composer-state/composer-name-ownership.test.ts new file mode 100644 index 000000000000..db3277864fa8 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-name-ownership.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { useRef, useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' +import { getSmartGitHubSubmitResolution } from '@/lib/smart-github-submit' +import { useGitHubSourceApplication } from './github-source-application' +import { useSourceIdentityActions } from './source-identity-actions' +import { useQuickSubmitSourcePreparation } from './quick-submit-source-preparation' +import * as decisions from './composer-decisions' +import type { + PendingSmartGitHubSubmitResolution, + SmartGitHubPrStartPointSelection +} from './source-selection-decisions' + +function issue(number: number): GitHubWorkItem { + return { + id: `issue-${number}`, + type: 'issue', + number, + title: 'Fix export', + state: 'open', + url: `https://github.com/fixture/repo/issues/${number}`, + labels: [], + updatedAt: '', + author: null, + repoId: 'fixture-repo' + } +} + +function useNameOwnership(draft?: { name: string; linkedWorkItem: LinkedWorkItemSummary }) { + const [name, setName] = useState(draft?.name ?? '') + const [linkedWorkItem, setLinkedWorkItem] = useState( + draft?.linkedWorkItem ?? null + ) + const lastAutoNameRef = useRef( + decisions.getInitialAutoManagedWorkspaceName({ + initialName: '', + draftName: draft?.name, + draftLinkedWorkItem: draft?.linkedWorkItem + }) + ) + const common = { + name, + setName, + linkedWorkItem, + setLinkedWorkItem, + lastAutoNameRef, + branchAutoNameRef: useRef(''), + smartGitHubPrStartPointSelectionRef: useRef(null), + selectedRepoGitHubSourceContext: null, + branchNameOverride: undefined, + branchNameOverridePreservesNameEdits: false, + forkPushWarning: null, + pushTarget: undefined, + setBranchNameOverride: vi.fn(), + setBranchNameOverridePreservesNameEdits: vi.fn(), + setCreateError: vi.fn(), + setForkPushWarning: vi.fn(), + setLinkDebouncedQuery: vi.fn(), + setLinkDirectItem: vi.fn(), + setLinkPopoverOpen: vi.fn(), + setLinkQuery: vi.fn(), + setLinkedGitLabIssue: vi.fn(), + setLinkedGitLabMR: vi.fn(), + setLinkedIssue: vi.fn(), + setLinkedPR: vi.fn(), + setLinkedTaskSourceContext: vi.fn(), + setPushTarget: vi.fn(), + setReuseEligibleBranch: vi.fn(), + setReuseSelectedBranch: vi.fn() + } + const application = useGitHubSourceApplication(common) + const identity = useSourceIdentityActions({ ...common, ...application }) + const source = useQuickSubmitSourcePreparation({ + ...common, + baseBranch: 'main', + compareBaseRef: undefined, + disabledTuiAgents: [], + effectiveLinkedPR: null, + decisions, + fallbackCreatureName: 'otter', + linkedGitLabMR: null, + parsedLinkedIssueNumber: linkedWorkItem?.number ?? null + }) + return { + ...application, + ...identity, + name, + linkedWorkItem, + prepare: (resolution: PendingSmartGitHubSubmitResolution = { kind: 'none' }) => + source.prepareQuickSubmitSource(resolution, null, name || 'otter') + } +} + +describe('workspace name ownership after linking a source', () => { + it.each(['347', '002'])('submits Advanced name %s as user input', (name) => { + const { result } = renderHook(() => useNameOwnership()) + act(() => result.current.applyLinkedWorkItem(issue(Number(name)))) + act(() => result.current.handleNameValueChange(name)) + + expect(result.current.prepare()).toMatchObject({ + workspaceName: name, + nameIsAutoManaged: false, + submitLinkedWorkItem: { number: Number(name), url: issue(Number(name)).url } + }) + }) + + it.each([2, 347])('preserves an edited name when source #%s replaces the source', (number) => { + const { result } = renderHook(() => useNameOwnership()) + act(() => result.current.applyLinkedWorkItem(issue(2))) + act(() => result.current.handleNameValueChange('002')) + act(() => result.current.applyLinkedWorkItem(issue(number))) + + expect(result.current.name).toBe('002') + expect(result.current.prepare()).toMatchObject({ + workspaceName: '002', + nameIsAutoManaged: false, + submitLinkedWorkItem: { number } + }) + }) + + it('restores manual ownership from an existing draft without a new persisted flag', () => { + const { result } = renderHook(() => useNameOwnership({ name: '002', linkedWorkItem: issue(2) })) + + expect(result.current.prepare()).toMatchObject({ + workspaceName: '002', + nameIsAutoManaged: false + }) + }) + + it('keeps manual ownership when the linked source is removed', () => { + const { result } = renderHook(() => useNameOwnership()) + act(() => result.current.applyLinkedWorkItem(issue(2))) + act(() => result.current.handleNameValueChange('002')) + act(() => result.current.handleRemoveLinkedWorkItem()) + + expect(result.current.prepare()).toMatchObject({ + workspaceName: '002', + nameIsAutoManaged: false, + submitLinkedWorkItem: null + }) + }) + + it.each(['347', '002', '#347'])('still auto-names a source picked from query %s', (query) => { + const { result } = renderHook(() => useNameOwnership()) + act(() => result.current.handleNameValueChange(query)) + act(() => result.current.applyLinkedWorkItem(issue(query === '002' ? 2 : 347))) + + expect(result.current.prepare()).toMatchObject({ + workspaceName: 'fix-export', + nameIsAutoManaged: true + }) + }) + + it('restores automatic naming when the Advanced name is cleared', () => { + const { result } = renderHook(() => useNameOwnership()) + act(() => result.current.applyLinkedWorkItem(issue(2))) + act(() => result.current.handleNameValueChange('002')) + act(() => result.current.handleNameValueChange('')) + + expect(result.current.prepare()).toMatchObject({ + workspaceName: 'fix-export', + nameIsAutoManaged: true + }) + }) + + it('keeps automatic ownership when a generated name is restored or refreshed', () => { + const { result } = renderHook(() => + useNameOwnership({ name: 'fix-export', linkedWorkItem: issue(2) }) + ) + act(() => result.current.applyLinkedWorkItem({ ...issue(2), title: 'Fix refreshed export' })) + + expect(result.current.prepare()).toMatchObject({ + workspaceName: 'fix-refreshed-export', + nameIsAutoManaged: true + }) + }) + + it('still resolves a pasted lookup at submit time as an automatic name', () => { + const { result } = renderHook(() => useNameOwnership()) + act(() => result.current.handleNameValueChange(issue(2).url)) + + expect( + result.current.prepare({ + ...getSmartGitHubSubmitResolution(issue(2)), + kind: 'metadata-only' + }) + ).toMatchObject({ workspaceName: 'fix-export', nameIsAutoManaged: true }) + }) + + it('keeps an Advanced name when the selected pull request resolves its base at submit time', () => { + const pr: GitHubWorkItem = { + ...issue(2), + type: 'pr', + url: 'https://github.com/fixture/repo/pull/2' + } + const { result } = renderHook(() => useNameOwnership()) + act(() => result.current.applyLinkedWorkItem(pr)) + act(() => result.current.handleNameValueChange('002')) + + expect( + result.current.prepare({ + ...getSmartGitHubSubmitResolution(pr), + kind: 'pr-start-point', + baseBranch: 'feature/export' + }) + ).toMatchObject({ workspaceName: '002', nameIsAutoManaged: false, submitLinkedPR: 2 }) + }) +}) diff --git a/src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts b/src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts index 3de3c57099d1..5aa4dec0efcb 100644 --- a/src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts +++ b/src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts @@ -5,9 +5,23 @@ import { describe, expect, it, vi } from 'vitest' import { useIssueSourceActions } from './issue-source-actions' import { resolveDraftBaseBranchNamesWorkspace } from './workspace-identity-state' import { useGitHubProviderSelection } from './github-provider-selection' +import type { LinearIssue } from '../../../../shared/linear/issue-types' type Input = Parameters[0] +const linearIssue: LinearIssue = { + id: 'linear-9', + identifier: 'ENG-9', + title: 'Fix refreshed export', + url: 'https://linear.app/fixture/issue/ENG-9', + state: { name: 'Open', type: 'unstarted', color: '' }, + team: { id: 'engineering', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 0, + updatedAt: '' +} + function createInput(overrides: Partial = {}): Input { return { baseBranch: undefined, @@ -42,6 +56,43 @@ function createInput(overrides: Partial = {}): Input { } describe('composer name source selection', () => { + it.each([false, true])( + 'keeps an Advanced Linear identifier name on source refresh (folder %s)', + (isProjectGroupTarget) => { + const input = createInput({ + isProjectGroupTarget, + name: 'ENG-9', + lastAutoNameRef: { current: 'eng-9-fix-export' }, + linkedWorkItem: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Fix export', + url: linearIssue.url, + linearIdentifier: 'ENG-9' + } + }) + const { result } = renderHook(() => useIssueSourceActions(input)) + act(() => result.current.handleSmartLinearIssueSelect(linearIssue)) + + expect(input.setName).not.toHaveBeenCalled() + expect(input.setLinkedWorkItem).toHaveBeenCalledWith( + expect.objectContaining({ linearIdentifier: 'ENG-9' }) + ) + } + ) + + it.each([false, true])( + 'still replaces an unselected Linear identifier query (folder %s)', + (isProjectGroupTarget) => { + const input = createInput({ isProjectGroupTarget, name: 'ENG-9' }) + const { result } = renderHook(() => useIssueSourceActions(input)) + act(() => result.current.handleSmartLinearIssueSelect(linearIssue)) + + expect(input.setName).toHaveBeenCalledTimes(1) + } + ) + it.each([false, true])('clears only source-owned bases when selecting Jira (%s)', (owned) => { const input = createInput({ baseBranch: 'release/1.2', baseBranchNamesWorkspace: owned }) const { result } = renderHook(() => useIssueSourceActions(input)) diff --git a/src/renderer/src/hooks/composer-state/composer-source-model.ts b/src/renderer/src/hooks/composer-state/composer-source-model.ts index f2af568f1230..a73377928c8c 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-model.ts @@ -25,14 +25,16 @@ export type ComposerSourceModel = { nextBaseBranch: string, item: GitLabWorkItem, nextPushTarget?: GitPushTarget | undefined, - nextCompareBaseRef?: string | undefined + nextCompareBaseRef?: string | undefined, + options?: { sourceAlreadyLinked?: boolean } ) => void handleBaseBranchPrSelect: ( nextBaseBranch: string, item: GitHubWorkItem, nextPushTarget?: GitPushTarget | undefined, nextBranchNameOverride?: string | undefined, - nextCompareBaseRef?: string | undefined + nextCompareBaseRef?: string | undefined, + options?: { sourceAlreadyLinked?: boolean } ) => void handleBranchNameOverrideChange: (value: string | undefined) => void handleClearSmartNameSelection: () => void diff --git a/src/renderer/src/hooks/composer-state/composer-source-state.ts b/src/renderer/src/hooks/composer-state/composer-source-state.ts index cb5052336d6a..94516fd22e28 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-state.ts @@ -159,6 +159,7 @@ export function useComposerSourceState( handleBaseBranchPrSelect: branchStartPointActions.handleBaseBranchPrSelect, isProjectGroupTarget: target.runtimeTargetSelection.isProjectGroupTarget, lastAutoNameRef: target.asyncComposerState.lastAutoNameRef, + linkedWorkItem: target.sourceContextState.linkedWorkItem, name: target.sourceContextState.name, selectedRepo: target.runtimeTargetSelection.selectedRepo, selectedRepoGitHubSourceContext: target.sourceContextState.selectedRepoGitHubSourceContext, @@ -188,6 +189,7 @@ export function useComposerSourceState( handleBaseBranchMrSelect: branchStartPointActions.handleBaseBranchMrSelect, isProjectGroupTarget: target.runtimeTargetSelection.isProjectGroupTarget, lastAutoNameRef: target.asyncComposerState.lastAutoNameRef, + linkedWorkItem: target.sourceContextState.linkedWorkItem, name: target.sourceContextState.name, selectedRepo: target.runtimeTargetSelection.selectedRepo, setBaseBranch: target.workspaceIdentityState.setBaseBranch, diff --git a/src/renderer/src/hooks/composer-state/github-provider-selection.ts b/src/renderer/src/hooks/composer-state/github-provider-selection.ts index 0c72a6725865..5a40353f4362 100644 --- a/src/renderer/src/hooks/composer-state/github-provider-selection.ts +++ b/src/renderer/src/hooks/composer-state/github-provider-selection.ts @@ -9,6 +9,7 @@ type GitHubProviderSelectionInput = Pick< | 'handleBaseBranchPrSelect' | 'isProjectGroupTarget' | 'lastAutoNameRef' + | 'linkedWorkItem' | 'name' | 'selectedRepo' | 'selectedRepoGitHubSourceContext' @@ -55,6 +56,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) handleBaseBranchPrSelect, isProjectGroupTarget, lastAutoNameRef, + linkedWorkItem, name, selectedRepo, selectedRepoGitHubSourceContext, @@ -96,7 +98,8 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) nextName && shouldApplyWorkspaceSourceAutoName({ currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem }) ) { setName(nextName) @@ -150,7 +153,8 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) normalizedItem, result.pushTarget, result.branchNameOverride, - result.compareBaseRef + result.compareBaseRef, + { sourceAlreadyLinked: true } ) // Why: a fork PR push lands on the contributor's fork; without maintainer-edits allowed GitHub rejects it, so warn up front. setForkPushWarning(getForkPushWarning(result)) @@ -176,6 +180,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) handleBaseBranchPrSelect, isProjectGroupTarget, name, + linkedWorkItem, selectedRepo, selectedRepoGitHubSourceContext, branchAutoNameRef, diff --git a/src/renderer/src/hooks/composer-state/github-source-application.ts b/src/renderer/src/hooks/composer-state/github-source-application.ts index 55476a90ea14..bda7d7668811 100644 --- a/src/renderer/src/hooks/composer-state/github-source-application.ts +++ b/src/renderer/src/hooks/composer-state/github-source-application.ts @@ -4,6 +4,7 @@ type GitHubSourceApplicationInput = Pick< ComposerModel, | 'branchAutoNameRef' | 'lastAutoNameRef' + | 'linkedWorkItem' | 'name' | 'selectedRepoGitHubSourceContext' | 'setBranchNameOverride' @@ -27,6 +28,7 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput) const { branchAutoNameRef, lastAutoNameRef, + linkedWorkItem, name, selectedRepoGitHubSourceContext, setBranchNameOverride, @@ -73,7 +75,8 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput) suggestedName && shouldApplyWorkspaceSourceAutoName({ currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem }) ) { setName(suggestedName) @@ -87,6 +90,7 @@ export function useGitHubSourceApplication(input: GitHubSourceApplicationInput) }, [ name, + linkedWorkItem, selectedRepoGitHubSourceContext, branchAutoNameRef, lastAutoNameRef, diff --git a/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts b/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts index e322d84156c4..1dfc9ac7ee67 100644 --- a/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts +++ b/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts @@ -8,6 +8,7 @@ type GitLabProviderSelectionInput = Pick< | 'handleBaseBranchMrSelect' | 'isProjectGroupTarget' | 'lastAutoNameRef' + | 'linkedWorkItem' | 'name' | 'selectedRepo' | 'setBaseBranch' @@ -47,6 +48,7 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) handleBaseBranchMrSelect, isProjectGroupTarget, lastAutoNameRef, + linkedWorkItem, name, selectedRepo, setBaseBranch, @@ -81,7 +83,8 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) nextName && shouldApplyWorkspaceSourceAutoName({ currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem }) ) { setName(nextName) @@ -149,7 +152,8 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) result.baseBranch, item, result.pushTarget, - result.compareBaseRef + result.compareBaseRef, + { sourceAlreadyLinked: true } ) }) .catch((error: unknown) => { @@ -169,6 +173,7 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) handleBaseBranchMrSelect, isProjectGroupTarget, name, + linkedWorkItem, selectedRepo, branchAutoNameRef, lastAutoNameRef, diff --git a/src/renderer/src/hooks/composer-state/issue-source-actions.ts b/src/renderer/src/hooks/composer-state/issue-source-actions.ts index 58df16c9cc03..e65aadd35e8f 100644 --- a/src/renderer/src/hooks/composer-state/issue-source-actions.ts +++ b/src/renderer/src/hooks/composer-state/issue-source-actions.ts @@ -103,9 +103,10 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { if ( shouldApplyWorkspaceSourceAutoName({ currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem }) || - name.trim().toLowerCase() === issue.identifier.toLowerCase() + (!linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase()) ) { setName(suggestedName) lastAutoNameRef.current = suggestedName @@ -124,9 +125,10 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { if ( shouldApplyWorkspaceSourceAutoName({ currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem }) || - name.trim().toLowerCase() === issue.identifier.toLowerCase() + (!linkedWorkItem && name.trim().toLowerCase() === issue.identifier.toLowerCase()) ) { setName(suggestedName) lastAutoNameRef.current = suggestedName @@ -141,6 +143,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { [ isProjectGroupTarget, name, + linkedWorkItem, branchAutoNameRef, lastAutoNameRef, setBranchNameOverride, @@ -182,7 +185,8 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { suggestedName && shouldApplyWorkspaceSourceAutoName({ currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem }) ) { setName(suggestedName) @@ -191,6 +195,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { }, [ name, + linkedWorkItem, baseBranchNamesWorkspace, branchAutoNameRef, lastAutoNameRef, diff --git a/src/renderer/src/hooks/composer-state/quick-submit-source-preparation.ts b/src/renderer/src/hooks/composer-state/quick-submit-source-preparation.ts index 4cf534953c34..407bfb9e658c 100644 --- a/src/renderer/src/hooks/composer-state/quick-submit-source-preparation.ts +++ b/src/renderer/src/hooks/composer-state/quick-submit-source-preparation.ts @@ -71,7 +71,8 @@ export function useQuickSubmitSourcePreparation(input: QuickSubmitSourcePreparat const nameIsAutoManaged = !isExplicitWorkspaceNameInput({ name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem && smartGitHubResolution.kind !== 'none' }) const smartGitHubCreateNames = diff --git a/src/renderer/src/hooks/composer-state/source-identity-actions.ts b/src/renderer/src/hooks/composer-state/source-identity-actions.ts index 1120737e48a5..f41fe696e2fc 100644 --- a/src/renderer/src/hooks/composer-state/source-identity-actions.ts +++ b/src/renderer/src/hooks/composer-state/source-identity-actions.ts @@ -111,7 +111,8 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { nextName && shouldApplyWorkspaceSourceAutoName({ currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + lookupTextIsQuery: !linkedWorkItem }) ) { setName(nextName) @@ -123,6 +124,7 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { }, [ name, + linkedWorkItem, branchAutoNameRef, lastAutoNameRef, setBranchNameOverride, diff --git a/src/shared/new-workspace/workspace-source.test.ts b/src/shared/new-workspace/workspace-source.test.ts index 35c7ffd76a38..2fc35e1c34cb 100644 --- a/src/shared/new-workspace/workspace-source.test.ts +++ b/src/shared/new-workspace/workspace-source.test.ts @@ -10,6 +10,24 @@ import { } from './workspace-source' describe('workspace source policy', () => { + it.each(['347', '002', '#347', 'https://github.com/fixture/repo/issues/347'])( + 'keeps manual name %s after the query field becomes a selected source', + (currentName) => { + expect( + shouldApplyWorkspaceSourceAutoName({ + currentName, + lastAutoName: 'fix-export', + lookupTextIsQuery: false + }) + ).toBe(false) + expect( + shouldApplyWorkspaceSourceAutoName({ + currentName, + lastAutoName: 'fix-export' + }) + ).toBe(true) + } + ) const linear = buildLinearWorkspaceSource({ identifier: 'ENG-42', title: 'Ship mobile parity', diff --git a/src/shared/new-workspace/workspace-source.ts b/src/shared/new-workspace/workspace-source.ts index 42af08049153..d7764ad0e784 100644 --- a/src/shared/new-workspace/workspace-source.ts +++ b/src/shared/new-workspace/workspace-source.ts @@ -160,11 +160,13 @@ export function buildJiraWorkspaceSource( export function shouldApplyWorkspaceSourceAutoName(args: { currentName: string lastAutoName: string + lookupTextIsQuery?: boolean }): boolean { return ( !args.currentName.trim() || args.currentName === args.lastAutoName || - isWorkItemLookupText(args.currentName) + // A selected source exposes a name field; its manual edits are no longer lookup queries. + (args.lookupTextIsQuery !== false && isWorkItemLookupText(args.currentName)) ) } From e7ee93e265127d5d12913466aa77df2d97be4d00 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 15:53:43 -0700 Subject: [PATCH 018/110] docs(rpc): a paired client runs commands as you; say so when pairing (#27317) * docs(rpc): paired workspace access runs commands as the user; pairing dialogs say so * docs(rpc): mobile is gated by its allowlist, not by tier * docs(rpc): wrap comment --------- Co-authored-by: m4air --- src/main/runtime/rpc/rpc-caller-scope.ts | 4 +++- src/main/runtime/rpc/rpc-method-permission.ts | 11 ++++++++++- .../settings/RuntimePairingGeneratorForm.tsx | 8 +++++++- .../sidebar/AddRemoteHostServerFormPanel.tsx | 7 +++++++ src/renderer/src/i18n/locales/en.json | 2 ++ src/renderer/src/i18n/locales/es.json | 2 ++ src/renderer/src/i18n/locales/fr.json | 2 ++ src/renderer/src/i18n/locales/ja.json | 2 ++ src/renderer/src/i18n/locales/ko.json | 2 ++ src/renderer/src/i18n/locales/zh.json | 2 ++ 10 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/rpc-caller-scope.ts b/src/main/runtime/rpc/rpc-caller-scope.ts index b44b8d8c2a26..ecb1a0fb4308 100644 --- a/src/main/runtime/rpc/rpc-caller-scope.ts +++ b/src/main/runtime/rpc/rpc-caller-scope.ts @@ -29,7 +29,9 @@ export const OWNER_RPC_CALLER_SCOPE: RpcCallerScope = { kind: 'owner' } // Why: a paired desktop, web client or `--host runtime:` CLI is this runtime's full remote UI, so its // standard set covers what that UI drives here (settings, accounts, skills, SSH and server updates). -// Everything else (desktop control, pairing and push admin) is refused unless granted at pairing. +// None of these exceeds `workspace`, which already runs commands as the user (see +// rpc-method-permission.ts). Desktop control needs a grant at pairing; pairing admin is never +// granted to this row. const RUNTIME_PAIRED_STANDARD: ReadonlySet = new Set([ 'workspace', 'settings-write', diff --git a/src/main/runtime/rpc/rpc-method-permission.ts b/src/main/runtime/rpc/rpc-method-permission.ts index 43d06460f62d..bc4ab5270f0b 100644 --- a/src/main/runtime/rpc/rpc-method-permission.ts +++ b/src/main/runtime/rpc/rpc-method-permission.ts @@ -3,9 +3,18 @@ * * The dispatcher grants each caller kind a set of these (rpc-caller-scope.ts), so a new method * cannot ship without someone deciding which callers may reach it. + * + * `workspace` means the caller can run code as this host's user: it opens terminals and starts + * agents here. Anything a terminal command could do anyway (browsing directories, installing + * skills, changing launch env or SSH targets) is therefore not a boundary for a `workspace` + * caller. Those stay separate tiers so a narrower row (an SSH host's CLI) can refuse them; mobile + * is gated by its own method-name allowlist instead. For a paired runtime client the real + * boundaries are `desktop-control` (granted per device at pairing) and `pairing-admin` (never + * granted to it). */ export type RpcMethodPermission = - /** Projects, worktrees, terminals, files, git, browser, orchestration and integrations. */ + /** Projects, worktrees, terminals, files, git, browser, orchestration and integrations; equals + * running commands as this host's user. */ | 'workspace' /** Drives this machine's own desktop: clicks, keys and app state outside Orca. */ | 'desktop-control' diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx index 1c5176a999c4..8fed6b1f1d11 100644 --- a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx @@ -305,7 +305,13 @@ export function RuntimePairingGeneratorForm({ )}
) : null} -
+
+

+ {translate( + 'auto.components.settings.RuntimePairingUrlGenerator.pairedRunsAsYou', + 'A paired client can run commands as you on this computer.' + )} +

+ + + {choice ? ( + + ) : null} +
+ {choice ? ( + + {translate( + 'auto.components.settings.ZcodeCliSetupSection.loading', + 'Loading sign-in terminal…' + )} +

+ } + > + +
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx index 973396b9f885..c49f383f32c3 100644 --- a/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx +++ b/src/renderer/src/components/settings/ZcodePlanAccountsSection.tsx @@ -18,6 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { SearchableSetting } from './SearchableSetting' import { collectZcodeUsageWindows, ZcodeUsageWindowView } from './zcode-plan-usage-windows' import { useZcodePlanCredentials } from './use-zcode-plan-credentials' +import { ZcodeCliSetupSection } from './ZcodeCliSetupSection' import { UnsealedCredentialNotice } from './UnsealedCredentialNotice' const SEARCH_KEYWORDS = [ @@ -330,6 +331,8 @@ export function ZcodePlanAccountsSection(): React.JSX.Element {

+ {credentialEditable ? : null} + {usageWindows.length > 0 ? ( { + await waitForSessionReady(orcaPage) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'accounts-zcode' }) + state?.openSettingsPage() + }) + const section = orcaPage.locator('#accounts-zcode') + await expect(section).toBeVisible({ timeout: 30_000 }) + await section.screenshot({ path: testInfo.outputPath('01-setup-buttons.png') }) + for (const [label, command] of [ + ['Z.AI browser sign-in', 'zcode login zai --no-browser'], + ['BigModel browser sign-in', 'zcode login bigmodel --no-browser'], + ['ZCode API-key setup', 'zcode'] + ] as const) { + await section.getByRole('button', { name: label, exact: true }).click() + const close = section.getByRole('button', { name: 'Close setup terminal', exact: true }) + await expect(close).toBeVisible({ timeout: 15_000 }) + const tabId = await section + .locator('[data-terminal-tab-id]') + .first() + .getAttribute('data-terminal-tab-id') + if (!tabId) { + throw new Error('Setup terminal did not mount') + } + await expectTerminalAccessibilityText(orcaPage, tabId, command) + await expect(section.getByRole('button', { name: label, exact: true })).toBeDisabled() + await section.screenshot({ path: testInfo.outputPath(`${label}.png`) }) + await close.click() + await expect(close).toHaveCount(0) + await expect(section.locator('[data-terminal-tab-id]')).toHaveCount(0) + await expect(section.getByRole('button', { name: label, exact: true })).toBeEnabled() + } +}) From fda01facc21ddd92e95fa0b8f90e9c7f40b1bc18 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:11:59 -0400 Subject: [PATCH 025/110] feat(relay): the director that booked a join writes its row about a second later (off) (#27048) Rebased onto main as one commit after its parent squash-merged. --- .../relay/src/floating-promise-census.test.ts | 1 + cloud/apps/relay/src/relay-server.ts | 73 ++++++ .../apps/relay/src/reserve-assignment.test.ts | 83 ++++++- cloud/apps/relay/src/reserve-assignment.ts | 94 +++++++- .../reserve-ledger-writer-postgres.test.ts | 61 +++++ .../relay/src/reserve-ledger-writer.test.ts | 127 ++++++++++ cloud/apps/relay/src/reserve-ledger-writer.ts | 225 ++++++++++++++++++ cloud/apps/relay/src/shadow-seat-directory.ts | 9 + 8 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 cloud/apps/relay/src/reserve-ledger-writer-postgres.test.ts create mode 100644 cloud/apps/relay/src/reserve-ledger-writer.test.ts create mode 100644 cloud/apps/relay/src/reserve-ledger-writer.ts diff --git a/cloud/apps/relay/src/floating-promise-census.test.ts b/cloud/apps/relay/src/floating-promise-census.test.ts index 19fd403a8dbd..fcc056a54462 100644 --- a/cloud/apps/relay/src/floating-promise-census.test.ts +++ b/cloud/apps/relay/src/floating-promise-census.test.ts @@ -14,6 +14,7 @@ const NEVER_REJECTS = new Map([ ['cell-heartbeat-client.ts#send', 'one try/catch around the whole send'], ['cell-reserve-client.ts#send', 'one try/catch around the request; resolves every item'], ['cell-reserve-client.ts#demote', 'one try/catch around the request'], + ['reserve-ledger-writer.ts#flush', 'one try/catch around the write; retries with backoff'], ['control-renewal-batch.ts#flush', 'rejects the waiters, never itself'], ['regional-rehome-worker.ts#run', 'one try/catch around the whole poll'], ['relay-control-flag-channel.ts#poll', 'one catch on the whole read'] diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index d2f8e71ab565..e2066bfd91e8 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -51,6 +51,11 @@ import { CellReserveClient } from './cell-reserve-client.js' import { googleMetadataIdentityToken, reusedIdentityToken } from './google-metadata-identity-token.js' import { ReserveAssignment } from './reserve-assignment.js' import { ReservePlacer } from './reserve-placement.js' +import { + reconcileReserveLedger, + RESERVE_LEDGER_RECONCILE_MS, + ReserveLedgerWriter +} from './reserve-ledger-writer.js' import { ProcessQueuedByteBudget } from './splice-forwarder.js' // A malformed percent-escape in the request target must be a client error, never a URIError @@ -185,6 +190,11 @@ export function createRelayServer( // Step 5 on directors: needs the map and the rehome credential the cells verify. const directorId = randomUUID() const rehomeAudience = config.rehomeAudience + // Only a director that books writes rows, and only for the joins it booked. + const reserveLedger = + config.role === 'director' && shadowSeatPoller && config.reservePlacement === 'on' + ? new ReserveLedgerWriter(observedDatabase, { now: options.now }) + : undefined const reservePlacement = config.role === 'director' && shadowSeatPoller && @@ -204,6 +214,7 @@ export function createRelayServer( ) }), readRow: async (identity) => await assignments.hostWhereabouts(identity), + databaseBusy: () => readRelayDatabasePoolPressure(database).databasePoolWaiting > 0, now: options.now, random: options.random }) @@ -228,6 +239,68 @@ export function createRelayServer( }, 60_000) : null offWithReserveTimer?.unref() + if (reserveLedger && shadowSeatPoller) { + const directory = shadowSeatPoller.directory + directory.onBookedJoin = (join) => { + if (join.reservedBy !== directorId) return + reserveLedger.enqueue({ + userId: join.userId, + relayHostId: join.relayHostId, + cellId: join.cellId, + epoch: join.epoch + }) + } + reserveLedger.start() + const reconcile = (): void => { + const now = (options.now ?? Date.now)() + void reconcileReserveLedger({ + writer: reserveLedger, + seats: () => + directory + .cellIds() + .filter((cellId) => directory.admitModeOf(cellId) === 'reserve') + .flatMap((cellId) => + directory.seatsOnCell(cellId).map(({ userId, relayHostId, seat }) => ({ + userId, + relayHostId, + cellId, + epoch: seat.epoch, + joinedAt: seat.joinedAt + })) + ), + demote: (seat, row) => { + reservePlacement?.demoteSeat(seat) + // The re-assign mints above the row, so the mirror can take it. + reservePlacement?.raiseEpochFloor(seat, row.epoch) + console.warn( + JSON.stringify({ + event: 'orca_relay_reserve_ledger_seat_behind_row', + cellId: seat.cellId, + epoch: seat.epoch, + rowEpoch: row.epoch + }) + ) + }, + now + }) + .catch(() => console.warn('[orca-relay] reserve ledger reconcile deferred')) + .finally(schedule) + } + // Jittered, so five directors do not reconcile together. + const schedule = (): void => { + const random = options.random ?? Math.random + setTimeout(reconcile, RESERVE_LEDGER_RECONCILE_MS + random() * 60_000).unref?.() + } + // A restart loses the queue: the first run goes as soon as the map is complete. + const clock = options.now ?? Date.now + const bootedAt = clock() + const firstRun = setInterval(() => { + if (!directory.isComplete() && clock() - bootedAt < RESERVE_LEDGER_RECONCILE_MS) return + clearInterval(firstRun) + reconcile() + }, 1_000) + firstRun.unref?.() + } const configuredConnectionLimits = config.connectionHardCap === undefined ? null diff --git a/cloud/apps/relay/src/reserve-assignment.test.ts b/cloud/apps/relay/src/reserve-assignment.test.ts index c48d8f3a4ac4..7af627d7aa89 100644 --- a/cloud/apps/relay/src/reserve-assignment.test.ts +++ b/cloud/apps/relay/src/reserve-assignment.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import type { CellReserveClient } from './cell-reserve-client.js' -import { ReserveAssignment, ReservePaceError, RESERVE_STARTUP_GATE_MS } from './reserve-assignment.js' +import { + ReserveAssignment, + ReservePaceError, + RESERVE_ROW_FLOOR_TTL_MS, + RESERVE_WRONG_CELL_ROW_READ_MS, + RESERVE_STARTUP_GATE_MS +} from './reserve-assignment.js' import { ReservePlacer, type ReserveAttempt } from './reserve-placement.js' import { ShadowSeatDirectory, type SeatFeedCell, type SeatFeedResponse } from './shadow-seat-directory.js' @@ -43,6 +49,7 @@ function setup( feeds?: SeatFeedResponse[] reserve?: (cellId: string, sticky: boolean) => ReserveAttempt row?: (identity: { userId: string; relayHostId: string }) => Promise<{ cellId: string; assignmentEpoch: number } | null> + databaseBusy?: () => boolean mode?: 'off' | 'dry-run' | 'on' startedAt?: number } = {} @@ -78,6 +85,7 @@ function setup( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above. client: client as unknown as CellReserveClient, readRow: options.row ?? (async () => null), + ...(options.databaseBusy ? { databaseBusy: options.databaseBusy } : {}), now: () => now.value, random: () => 0.25, log: () => undefined @@ -88,7 +96,7 @@ function setup( for (const response of options.feeds ?? cells.map((cell) => feed(cell.cellId))) { directory.apply(response.cellId, { ...response, full: response.full ?? [] }, now.value - 1, now.value - 1) } - return { assignment, directory, reserved, demoted, now } + return { assignment, directory, reserved, demoted, now, placer } } function seated(epoch: number, joinedAt = 0) { @@ -162,6 +170,21 @@ describe('reserve assignment on a director', () => { expect(plan).toMatchObject({ kind: 'answer', lane: 'sticky', assignment: { cellId: 'c1', assignmentEpoch: 7 } }) }) + it('after the reconcile demotes a seat behind its row, mints above the row and never answers that seat', async () => { + const { assignment, directory, reserved, now } = setup({ feeds: [feed('c1', { full: [seated(7)] }), feed('c2')] }) + // The row names another cell at epoch 8; the mirror would refuse a re-assign at 8. + assignment.raiseEpochFloor(HOST, 8) + const plan = await assignment.plan(HOST, { reconnect: true, region: US }) + expect(plan).toMatchObject({ kind: 'answer', lane: 'placement', assignment: { assignmentEpoch: 9 } }) + expect(reserved.every((call) => !call.sticky && call.epoch === 9)).toBe(true) + // The floor lapses, so a host it no longer protects answers from memory again. + now.value += RESERVE_ROW_FLOOR_TTL_MS + 1 + for (const response of [feed('c1', { seq: 2, full: [seated(7)] }), feed('c2', { seq: 2 })]) { + directory.apply(response.cellId, response, now.value, now.value) + } + expect(await assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ lane: 'sticky' }) + }) + it('never sends a drained or demoted host back, and books it elsewhere above every known epoch', async () => { const { assignment, directory, reserved, now } = setup() directory.apply( @@ -231,6 +254,62 @@ describe('reserve assignment on a director', () => { }) }) + it('after a WRONG_CELL leave, any director reads the row and mints above it', async () => { + const leftWrongCell = ( + row: () => Promise<{ cellId: string; assignmentEpoch: number } | null>, + databaseBusy?: () => boolean + ) => { + const harness = setup({ row, ...(databaseBusy ? { databaseBusy } : {}) }) + // Another director demoted the seat: this one only sees the leave in c1's feed. + harness.directory.apply( + 'c1', + feed('c1', { + full: undefined, + seq: 3, + changes: [ + { seq: 2, kind: 'join', ...HOST, epoch: 7, generation: 1, at: 1 }, + { seq: 3, kind: 'leave', ...HOST, epoch: 7, generation: 1, closeCode: 4409, at: 2 } + ] + }), + harness.now.value, + harness.now.value + ) + return harness + } + const row = vi.fn(async () => ({ cellId: 'c2', assignmentEpoch: 8 })) + const read = leftWrongCell(row) + expect(await read.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ + kind: 'answer', + lane: 'placement', + assignment: { assignmentEpoch: 9 } + }) + expect(row).toHaveBeenCalledTimes(1) + // The database down: placed as before the read existed, above what the map knows. + const down = leftWrongCell(async () => Promise.reject(new Error('timeout'))) + expect(await down.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ + kind: 'answer', + assignment: { assignmentEpoch: 8 } + }) + // Pool waiters: the read is skipped outright, so a stall cannot stack timed-out reads. + const busyRow = vi.fn(async () => ({ cellId: 'c2', assignmentEpoch: 8 })) + const busy = leftWrongCell(busyRow, () => true) + expect(await busy.assignment.plan(HOST, { reconnect: true, region: US })).toMatchObject({ + kind: 'answer', + assignment: { assignmentEpoch: 8 } + }) + expect(busyRow).not.toHaveBeenCalled() + // A stalled database: the read gives up after its bound and places as before. + vi.useFakeTimers() + try { + const stalled = leftWrongCell(() => new Promise(() => undefined)) + const plan = stalled.assignment.plan(HOST, { reconnect: true, region: US }) + await vi.advanceTimersByTimeAsync(RESERVE_WRONG_CELL_ROW_READ_MS) + expect(await plan).toMatchObject({ kind: 'answer', assignment: { assignmentEpoch: 8 } }) + } finally { + vi.useRealTimers() + } + }) + it('paces with Retry-After when every reserve cell is out of budget and no database cell exists', async () => { const { assignment } = setup({ reserve: () => ({ outcome: 'intake' }) }) await expect(assignment.plan(HOST, { reconnect: false, region: US })).rejects.toBeInstanceOf( diff --git a/cloud/apps/relay/src/reserve-assignment.ts b/cloud/apps/relay/src/reserve-assignment.ts index 701bdc1e0d40..2fa46f230bfa 100644 --- a/cloud/apps/relay/src/reserve-assignment.ts +++ b/cloud/apps/relay/src/reserve-assignment.ts @@ -49,9 +49,32 @@ export type ReservePlan = placeFresh?: () => Promise } +// A host whose seat the ledger reconcile demoted mints above its row for this long, so the +// re-assign does not collide with the row again (the mirror refuses an equal epoch). +export const RESERVE_ROW_FLOOR_TTL_MS = 2 * 60 * 60_000 +const RESERVE_ROW_FLOOR_MAX = 100_000 + +// The WRONG_CELL row read's bound: well under a database stall (5-7 s). +export const RESERVE_WRONG_CELL_ROW_READ_MS = 1_000 + +// Rejects after `ms`; the read itself still settles later, and its failure is swallowed. +async function withinMs(read: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('row_read_timeout')), ms) + }) + read.catch(() => undefined) + try { + return await Promise.race([read, timeout]) + } finally { + clearTimeout(timer) + } +} + export class ReserveAssignment { private summary: PlacementSummary = emptySummary() private summaryAt: number + private readonly rowFloors = new Map() constructor( private readonly input: { @@ -63,6 +86,9 @@ export class ReserveAssignment { client: CellReserveClient // One primary-key read of the host's row: only for a host the map has never seen. readRow: (identity: Identity) => Promise<{ cellId: string; assignmentEpoch: number } | null> + // True while the pool has waiters: the optional WRONG_CELL read is skipped, so timed-out + // reads cannot stack up behind a stall. + databaseBusy?: () => boolean now?: () => number random?: () => number log?: (line: string) => void @@ -130,19 +156,34 @@ export class ReserveAssignment { .filter((entry) => now - entry.at <= RESERVE_STICKY_RECENT_MS) .at(-1) const booking = directory.bookingOf(identity.userId, identity.relayHostId) + const rowFloor = this.rowFloor(identity, now) const known = [ ...seats.map((seat) => seat.epoch), ...(left ? [left.epoch] : []), - ...(booking ? [booking.epoch] : []) + ...(booking ? [booking.epoch] : []), + ...(rowFloor === undefined ? [] : [rowFloor]) ] let row: { cellId: string; assignmentEpoch: number } | null = null - if (known.length === 0) { + // A seat closed with WRONG_CELL was demoted (or failed to re-register) because its row is + // ahead, which every director sees in that cell's feed: read the row, so the re-assign mints + // above it on any director, not just the one that demoted it. + const leftWrongCell = + left?.closeCode === RELAY_CLOSE_CODE.WRONG_CELL && !(this.input.databaseBusy?.() ?? false) + if (known.length === 0 || leftWrongCell) { try { - row = await this.input.readRow(identity) + // Only the never-seen host needs the row; for the WRONG_CELL one it is an optimisation, + // bounded so a database stall cannot hold the reconnect (the reconcile repairs a miss). + row = + known.length === 0 + ? await this.input.readRow(identity) + : await withinMs(this.input.readRow(identity), RESERVE_WRONG_CELL_ROW_READ_MS) } catch { // A host the map never saw, with the database down: its epoch cannot be minted safely. - this.summary.retries.database += 1 - return { kind: 'retry', retryAfterSeconds: 2, reason: 'database' } + // One the map knows places as it did before this read existed. + if (known.length === 0) { + this.summary.retries.database += 1 + return { kind: 'retry', retryAfterSeconds: 2, reason: 'database' } + } } if (row) known.push(row.assignmentEpoch) } @@ -158,7 +199,7 @@ export class ReserveAssignment { } if (request.reconnect) { - const sticky = await this.stickyFromMemory(identity, seats, left, now) + const sticky = await this.stickyFromMemory(identity, seats, left, now, rowFloor ?? 0) if (sticky) { this.summary.sticky += 1 return { kind: 'answer', assignment: sticky, lane: 'sticky' } @@ -183,11 +224,14 @@ export class ReserveAssignment { identity: Identity, seats: ShadowSeat[], left: { cellId: string; epoch: number; incarnation?: string; closeCode?: number } | undefined, - now: number + now: number, + rowFloor: number ): Promise { // Highest epoch first, then the newest join: only the newest grant may be answered from // memory, so a stale seat (or a leaver at a lower epoch) is never sent back. const newest = Math.max(0, ...seats.map((seat) => seat.epoch), left?.epoch ?? 0) + // A seat at or behind a row the reconcile demoted it for is never answered again. + if (newest <= rowFloor) return null const candidates: Array<{ cellId: string epoch: number @@ -310,6 +354,42 @@ export class ReserveAssignment { return null } + // The ledger reconcile demoted this host's seat behind (or level with) a row at `epoch`. + raiseEpochFloor(identity: Identity, epoch: number): void { + const key = `${identity.userId}\u0000${identity.relayHostId}` + const current = this.rowFloors.get(key) + this.rowFloors.delete(key) + this.rowFloors.set(key, { + epoch: Math.max(epoch, current?.epoch ?? 0), + expiresAt: this.now() + RESERVE_ROW_FLOOR_TTL_MS + }) + if (this.rowFloors.size > RESERVE_ROW_FLOOR_MAX) { + this.rowFloors.delete(this.rowFloors.keys().next().value!) + } + } + + private rowFloor(identity: Identity, now: number): number | undefined { + const key = `${identity.userId}\u0000${identity.relayHostId}` + const floor = this.rowFloors.get(key) + if (!floor) return undefined + if (floor.expiresAt > now) return floor.epoch + this.rowFloors.delete(key) + return undefined + } + + // Fire-and-forget; the cell checks the seat's epoch and join itself. + demoteSeat(seat: { userId: string; relayHostId: string; cellId: string; epoch: number; joinedAt: number }): void { + const cell = this.input.cells().find((entry) => entry.cellId === seat.cellId) + if (!cell) return + void this.input.client.demote(cell, { + v: 1, + userId: seat.userId, + relayHostId: seat.relayHostId, + epoch: seat.epoch, + joinedAt: seat.joinedAt + }) + } + // Supersede at placement: the old seats this director's map holds on reserve cells are told // to go. Each cell checks the seat's epoch and join itself, so a stale map only misses. private supersede(identity: Identity, seats: readonly ShadowSeat[], cellId: string, epoch: number): void { diff --git a/cloud/apps/relay/src/reserve-ledger-writer-postgres.test.ts b/cloud/apps/relay/src/reserve-ledger-writer-postgres.test.ts new file mode 100644 index 000000000000..2276ed767d9d --- /dev/null +++ b/cloud/apps/relay/src/reserve-ledger-writer-postgres.test.ts @@ -0,0 +1,61 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openRelayDatabase, type RelayDatabase } from './database.js' +import { reconcileReserveLedger, ReserveLedgerWriter } from './reserve-ledger-writer.js' + +// The ledger's statements on real PostgreSQL: the guarded upsert, its counter reset on a +// move, and the batched row read. + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip +const USER = `ledger-pg-${randomUUID().slice(0, 8)}` + +describePostgres('reserve ledger writer on PostgreSQL', () => { + let database: RelayDatabase + + beforeAll(async () => { + database = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + }) + + afterAll(async () => { + await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [USER]) + await database.close() + }) + + const row = async (relayHostId: string) => + ( + await database.query( + `SELECT cell_id, assignment_epoch, reserved_controls FROM relay_assignments + WHERE user_id = ? AND relay_host_id = ?`, + [USER, relayHostId] + ) + )[0] + + it('writes only a higher epoch, drops a moved row’s counters, and reconciles from the map', async () => { + const writer = new ReserveLedgerWriter(database, { log: () => undefined }) + const host = 'ledgerpghost0001' + writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c1', epoch: 5 }) + await writer.flush() + expect(await row(host)).toMatchObject({ cell_id: 'c1', assignment_epoch: '5' }) + await database.query(`UPDATE relay_assignments SET reserved_controls = 2 WHERE user_id = ?`, [USER]) + writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c2', epoch: 5 }) + await writer.flush() + expect(await row(host)).toMatchObject({ cell_id: 'c1', assignment_epoch: '5', reserved_controls: '2' }) + writer.enqueue({ userId: USER, relayHostId: host, cellId: 'c2', epoch: 6 }) + await writer.flush() + expect(await row(host)).toMatchObject({ cell_id: 'c2', assignment_epoch: '6', reserved_controls: '0' }) + const other = 'ledgerpghost0002' + const result = await reconcileReserveLedger({ + writer, + seats: () => [ + { userId: USER, relayHostId: host, cellId: 'c1', epoch: 4, joinedAt: 0 }, + { userId: USER, relayHostId: other, cellId: 'c1', epoch: 2, joinedAt: 0 } + ], + now: 1_000_000 + }) + await writer.flush() + expect(result).toEqual({ upserted: 1, demoted: 0 }) + expect(await row(host)).toMatchObject({ cell_id: 'c2', assignment_epoch: '6' }) + expect(await row(other)).toMatchObject({ cell_id: 'c1', assignment_epoch: '2' }) + }) +}) diff --git a/cloud/apps/relay/src/reserve-ledger-writer.test.ts b/cloud/apps/relay/src/reserve-ledger-writer.test.ts new file mode 100644 index 000000000000..ca51c6709c78 --- /dev/null +++ b/cloud/apps/relay/src/reserve-ledger-writer.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' +import { reconcileReserveLedger, ReserveLedgerWriter } from './reserve-ledger-writer.js' + +const HOST = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + +describe('reserve ledger writer', () => { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + async function setup(now = () => 1_000) { + database = await openInMemoryRelayDatabase() + return { database, writer: new ReserveLedgerWriter(database, { now, log: () => undefined }) } + } + + const row = async (db: RelayDatabase, relayHostId = HOST.relayHostId) => + ( + await db.query( + `SELECT cell_id, assignment_epoch, reserved_controls FROM relay_assignments + WHERE user_id = ? AND relay_host_id = ?`, + [HOST.userId, relayHostId] + ) + )[0] + + it('writes a booked join, and only a higher epoch replaces it', async () => { + const { database: db, writer } = await setup() + writer.enqueue({ ...HOST, cellId: 'c1', epoch: 5 }) + await writer.flush() + expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 }) + writer.enqueue({ ...HOST, cellId: 'c2', epoch: 4 }) + writer.enqueue({ ...HOST, cellId: 'c3', epoch: 5 }) + await writer.flush() + expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 }) + writer.enqueue({ ...HOST, cellId: 'c2', epoch: 6 }) + await writer.flush() + expect(await row(db)).toMatchObject({ cell_id: 'c2', assignment_epoch: 6 }) + }) + + it('drops a moved row’s counters but keeps a same-cell row’s', async () => { + const { database: db, writer } = await setup() + await db.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at, + reserved_controls, reserved_splices, reserved_invites, pending_installs, + pending_confirmations, migration_leases) + VALUES (?, ?, 'c1', 3, 0, 0, 2, 0, 0, 0, 0, 0), (?, ?, 'c1', 3, 0, 0, 2, 0, 0, 0, 0, 0)`, + [HOST.userId, HOST.relayHostId, HOST.userId, 'bbbbbbbbbbbbbbbb'] + ) + writer.enqueue({ ...HOST, cellId: 'c9', epoch: 4 }) + writer.enqueue({ userId: HOST.userId, relayHostId: 'bbbbbbbbbbbbbbbb', cellId: 'c1', epoch: 4 }) + await writer.flush() + expect(await row(db)).toMatchObject({ cell_id: 'c9', reserved_controls: 0 }) + expect(await row(db, 'bbbbbbbbbbbbbbbb')).toMatchObject({ cell_id: 'c1', reserved_controls: 2 }) + }) + + it('keeps its queue through a database stall and drops the oldest past the bound', async () => { + let now = 1_000 + const { database: db, writer } = await setup(() => now) + const query = vi.spyOn(db, 'query').mockRejectedValueOnce(new Error('timeout')) + writer.enqueue({ ...HOST, cellId: 'c1', epoch: 5 }) + await writer.flush() + expect(writer.pending()).toBe(1) + // Backoff: no retry before it is due. + await writer.flush() + expect(query).toHaveBeenCalledTimes(1) + now += 1_000 + await writer.flush() + expect(writer.pending()).toBe(0) + expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 }) + + const bounded = new ReserveLedgerWriter(db, { queueMax: 2, log: () => undefined }) + for (const epoch of [1, 2, 3]) bounded.enqueue({ ...HOST, cellId: 'c1', epoch }) + expect(bounded.pending()).toBe(2) + }) + + it('reconciles the map’s seats and never lowers an epoch', async () => { + const { database: db, writer } = await setup() + // Another path wrote a higher epoch on c7: the mirror leaves it, whatever the map says. + writer.enqueue({ ...HOST, cellId: 'c7', epoch: 9 }) + await writer.flush() + writer.enqueue({ ...HOST, cellId: 'c1', epoch: 9 }) + await writer.flush() + const other = { userId: 'user-2', relayHostId: 'cccccccccccccccc' } + const demoted: unknown[] = [] + const result = await reconcileReserveLedger({ + writer, + seats: () => [ + { ...HOST, cellId: 'c1', epoch: 8, joinedAt: 0 }, + { ...other, cellId: 'c1', epoch: 2, joinedAt: 0 }, + // Too young: it may still be superseded. + { userId: 'user-3', relayHostId: 'dddddddddddddddd', cellId: 'c1', epoch: 2, joinedAt: 990_000 } + ], + demote: (seat) => demoted.push(seat), + now: 1_000_000 + }) + await writer.flush() + // The row is ahead of the c1 seat: the row stays, and that seat is told to go. + expect(result).toEqual({ upserted: 1, demoted: 1 }) + expect(demoted).toEqual([{ ...HOST, cellId: 'c1', epoch: 8, joinedAt: 0 }]) + expect(await row(db)).toMatchObject({ cell_id: 'c7', assignment_epoch: 9 }) + const written = await db.query(`SELECT cell_id FROM relay_assignments WHERE user_id = 'user-2'`) + expect(written).toEqual([{ cell_id: 'c1' }]) + }) + + it('demotes a seat level with its row on another cell, which the mirror would never replace', async () => { + const { database: db, writer } = await setup() + writer.enqueue({ ...HOST, cellId: 'c7', epoch: 9 }) + await writer.flush() + const demoted: unknown[] = [] + const result = await reconcileReserveLedger({ + writer, + seats: () => [ + { ...HOST, cellId: 'c1', epoch: 9, joinedAt: 0 }, + // Level with its own row: nothing to do. + { ...HOST, relayHostId: 'eeeeeeeeeeeeeeee', cellId: 'c7', epoch: 9, joinedAt: 0 } + ], + demote: (seat, found) => demoted.push({ seat: seat.cellId, row: found }), + now: 1_000_000 + }) + expect(result).toEqual({ upserted: 1, demoted: 1 }) + expect(demoted).toEqual([{ seat: 'c1', row: { cellId: 'c7', epoch: 9 } }]) + }) +}) diff --git a/cloud/apps/relay/src/reserve-ledger-writer.ts b/cloud/apps/relay/src/reserve-ledger-writer.ts new file mode 100644 index 000000000000..d10d5d954cf9 --- /dev/null +++ b/cloud/apps/relay/src/reserve-ledger-writer.ts @@ -0,0 +1,225 @@ +import type { RelayDatabase } from './database.js' + +// Step 5's after-the-fact record: a reserve-mode cell admits a booked host without reading +// Postgres, and the director that booked it writes the host's relay_assignments row about a +// second later. Phones still resolve through that row until step 6. +// +// A guarded mirror, which nothing reads for a desktop's correctness: only a higher epoch +// replaces a row, and nothing ever lowers one. No relay_cells row and no activity lease is +// touched. A row whose cell changes drops its counters: the leases that back them keep +// their own cell, and unbacked units stay counted on the old cell until its next reconcile, +// which only makes that cell look fuller. + +export const RESERVE_LEDGER_FLUSH_MS = 250 +export const RESERVE_LEDGER_QUEUE_MAX = 200_000 +const RESERVE_LEDGER_BATCH = 500 +const RETRY_BASE_MS = 250 +const RETRY_MAX_MS = 5_000 +const SUMMARY_INTERVAL_MS = 60_000 + +export type LedgerEntry = { + userId: string + relayHostId: string + cellId: string + epoch: number +} + +export class ReserveLedgerWriter { + private queue: LedgerEntry[] = [] + private flushing = false + private failures = 0 + private retryAt = 0 + private timer: ReturnType | null = null + private dropped = 0 + private written = 0 + private summaryAt: number + + constructor( + private readonly database: RelayDatabase, + private readonly input: { + now?: () => number + queueMax?: number + log?: (line: string) => void + } = {} + ) { + this.summaryAt = this.now() + } + + private now(): number { + return (this.input.now ?? Date.now)() + } + + start(): void { + this.timer ??= setInterval(() => void this.flush(), RESERVE_LEDGER_FLUSH_MS) + this.timer.unref?.() + } + + stop(): void { + if (this.timer) clearInterval(this.timer) + this.timer = null + } + + // During a database stall the queue grows; past its bound the oldest entries go first, + // since the 10-minute reconcile rewrites every seat anyway. + enqueue(entry: LedgerEntry): void { + this.queue.push(entry) + const max = this.input.queueMax ?? RESERVE_LEDGER_QUEUE_MAX + if (this.queue.length > max) { + const excess = this.queue.length - max + this.queue.splice(0, excess) + this.dropped += excess + } + } + + pending(): number { + return this.queue.length + } + + // Single-flight; resolves once the queued rows are written or the attempt failed. + async flush(): Promise { + if (this.flushing || this.queue.length === 0 || this.now() < this.retryAt) { + this.logSummary() + return + } + this.flushing = true + const batch = this.queue.slice(0, RESERVE_LEDGER_BATCH) + try { + await this.write(batch) + this.queue.splice(0, batch.length) + this.written += batch.length + this.failures = 0 + this.retryAt = 0 + } catch { + this.failures += 1 + this.retryAt = this.now() + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (this.failures - 1)) + } finally { + this.flushing = false + this.logSummary() + } + } + + private async write(batch: LedgerEntry[]): Promise { + // The newest entry per host wins inside one batch. + const latest = new Map() + for (const entry of batch) { + const key = `${entry.userId}\u0000${entry.relayHostId}` + const known = latest.get(key) + if (!known || entry.epoch > known.epoch) latest.set(key, entry) + } + await this.upsert([...latest.values()]) + } + + private async upsert(entries: LedgerEntry[]): Promise { + const now = this.now() + const rows = entries.map(() => '(?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0)').join(', ') + const zeroOnMove = (column: string) => + `${column} = CASE WHEN relay_assignments.cell_id = excluded.cell_id + THEN relay_assignments.${column} ELSE 0 END` + await this.database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at, + reserved_controls, reserved_splices, reserved_invites, pending_installs, + pending_confirmations, migration_leases) + VALUES ${rows} + ON CONFLICT (user_id, relay_host_id) DO UPDATE SET + ${[ + 'reserved_controls', + 'reserved_splices', + 'reserved_invites', + 'pending_installs', + 'pending_confirmations', + 'migration_leases' + ] + .map(zeroOnMove) + .join(',\n ')}, + cell_id = excluded.cell_id, + assignment_epoch = excluded.assignment_epoch, + last_activity_at = excluded.last_activity_at + WHERE relay_assignments.assignment_epoch < excluded.assignment_epoch`, + entries.flatMap((entry) => [ + entry.userId, + entry.relayHostId, + entry.cellId, + entry.epoch, + now, + now + ]) + ) + } + + // Rows for these hosts, in one read per batch. + async readRows( + hosts: ReadonlyArray<{ userId: string; relayHostId: string }> + ): Promise> { + const rows = new Map() + for (let start = 0; start < hosts.length; start += RESERVE_LEDGER_BATCH) { + const chunk = hosts.slice(start, start + RESERVE_LEDGER_BATCH) + const found = await this.database.query( + `SELECT user_id, relay_host_id, cell_id, assignment_epoch FROM relay_assignments + WHERE ${chunk.map(() => '(user_id = ? AND relay_host_id = ?)').join(' OR ')}`, + chunk.flatMap((host) => [host.userId, host.relayHostId]) + ) + for (const row of found) { + rows.set(`${String(row.user_id)}\u0000${String(row.relay_host_id)}`, { + cellId: String(row.cell_id), + epoch: Number(row.assignment_epoch) + }) + } + } + return rows + } + + private logSummary(): void { + const now = this.now() + if (now - this.summaryAt < SUMMARY_INTERVAL_MS) return + this.summaryAt = now + ;(this.input.log ?? ((line: string) => console.log(line)))( + JSON.stringify({ + event: 'orca_relay_reserve_ledger_summary', + written: this.written, + pending: this.queue.length, + dropped: this.dropped, + failures: this.failures + }) + ) + this.written = 0 + this.dropped = 0 + } +} + +export const RESERVE_LEDGER_RECONCILE_MS = 10 * 60_000 +// A seat this young may still be superseded; the next reconcile takes it. +const RECONCILE_SETTLE_MS = 30_000 + +type ReconcileSeat = { userId: string; relayHostId: string; cellId: string; epoch: number; joinedAt: number } + +// Every director upserts the map's seats on reserve-mode cells: guarded and idempotent, so +// concurrent runs are harmless. A row ahead of the seat (today's path answered a newer epoch, +// and the desktop was then answered the old seat from memory) is never rewritten: that seat is +// demoted (epoch-checked, closes with 4409), so the desktop re-assigns to what the row names. +// The demoting director then mints above the row's epoch for that host (see raiseEpochFloor). +export async function reconcileReserveLedger(input: { + writer: ReserveLedgerWriter + seats: () => ReconcileSeat[] + demote?: (seat: ReconcileSeat, row: { cellId: string; epoch: number }) => void + now: number +}): Promise<{ upserted: number; demoted: number }> { + const seats = input.seats().filter((seat) => input.now - seat.joinedAt >= RECONCILE_SETTLE_MS) + const rows = await input.writer.readRows(seats) + let upserted = 0 + let demoted = 0 + for (const seat of seats) { + const row = rows.get(`${seat.userId}\u0000${seat.relayHostId}`) + // Level with the row on another cell is the same divergence: the mirror never replaces it. + const behindRow = row && (row.epoch > seat.epoch || (row.epoch === seat.epoch && row.cellId !== seat.cellId)) + if (row && behindRow && input.demote) { + input.demote(seat, row) + demoted += 1 + continue + } + if (row && row.epoch >= seat.epoch) continue + input.writer.enqueue(seat) + upserted += 1 + } + return { upserted, demoted } +} diff --git a/cloud/apps/relay/src/shadow-seat-directory.ts b/cloud/apps/relay/src/shadow-seat-directory.ts index 302bbd2b7774..7255284cdaca 100644 --- a/cloud/apps/relay/src/shadow-seat-directory.ts +++ b/cloud/apps/relay/src/shadow-seat-directory.ts @@ -284,6 +284,15 @@ export class ShadowSeatDirectory { return [...this.cells.keys()] } + // One cell's seats as the map holds them. + seatsOnCell(cellId: string): Array<{ userId: string; relayHostId: string; seat: ShadowSeat }> { + const cursor = this.cells.get(cellId) + if (!cursor) return [] + return [...cursor.seats].map(([key, seat]) => { + const separator = key.indexOf('\u0000') + return { userId: key.slice(0, separator), relayHostId: key.slice(separator + 1), seat } + }) + } // An old cell (404) has no feed; its last known seats stay, marked by status. markNoFeed(cellId: string, now: number, runtimeIncarnation?: string): void { From a09dc9aa7143e70300b012fe92486dc7478bb4d1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:12:08 -0700 Subject: [PATCH 026/110] fix: honor inactive Claude account retry deadlines (#26936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatic inactive Claude account refreshes now reuse each account’s existing retry deadline before sending requests. Healthy accounts keep refreshing, and manual forced refresh retains its current behavior. Partially addresses #26588. Co-authored-by: Tauri-EPO --- ...de-managed-usage-credential-source.test.ts | 93 ++++ .../service-inactive-claude-cooldown.test.ts | 440 ++++++++++++++++++ .../service/service-inactive-accounts.ts | 4 +- 3 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 src/main/rate-limits/claude-managed-usage-credential-source.test.ts create mode 100644 src/main/rate-limits/service-inactive-claude-cooldown.test.ts diff --git a/src/main/rate-limits/claude-managed-usage-credential-source.test.ts b/src/main/rate-limits/claude-managed-usage-credential-source.test.ts new file mode 100644 index 000000000000..ecdc6cea2d7a --- /dev/null +++ b/src/main/rate-limits/claude-managed-usage-credential-source.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type * as ClaudeOAuthCredentials from './claude-oauth-credentials' + +type CredentialSourceFixture = { + source: ClaudeOAuthCredentials.ClaudeOAuthCredentialSource + readCredentials: ReturnType + fetchUsage: ReturnType + hostPreparation: ReturnType + wslPreparation: ReturnType +} + +const fixture = vi.hoisted((): CredentialSourceFixture => ({ + source: 'credentials-file', + readCredentials: vi.fn(), + fetchUsage: vi.fn(), + hostPreparation: vi.fn(), + wslPreparation: vi.fn() +})) + +vi.mock('./claude-oauth-credentials', async (importOriginal) => ({ + ...(await importOriginal()), + readClaudeOAuthCredentials: fixture.readCredentials +})) +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + getClaudeProfileRouter: () => ({ accountUsagePreparation: fixture.hostPreparation }), + getClaudeWslProfileRouter: () => ({ accountUsagePreparation: fixture.wslPreparation }) +})) +vi.mock('../wsl-running-path-filter', () => ({ + filterPathsToRunningWslDistrosAsync: async (paths: readonly string[]) => [...paths] +})) +vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: fixture.fetchUsage })) + +import { fetchInactiveClaudeAccountUsage } from './claude-managed-account-usage' +import { OAuthUsageError } from './claude-oauth-usage-error' + +const configDir = join(tmpdir(), 'synthetic-claude-profile-A') +const now = 1800000000000 + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(Date, 'now').mockReturnValue(now) + fixture.readCredentials.mockImplementation(async () => ({ + token: 'synthetic-token', + hasRefreshableCredentials: false, + source: fixture.source + })) + fixture.fetchUsage.mockRejectedValue(new OAuthUsageError('Synthetic wait', 429, true, 3600000)) +}) + +afterEach(() => vi.restoreAllMocks()) + +it.each([ + ['host', 'scoped-keychain'], + ['host', 'credentials-file'], + ['wsl', 'credentials-file'] +] as const)('keeps %s profile 429 metadata from %s', async (runtime, source) => { + fixture.source = source + const provenance = runtime === 'wsl' ? 'wsl:SyntheticDistro:profile:A' : 'profile:A' + const preparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + provenance + } + fixture.hostPreparation.mockReturnValue(preparation) + fixture.wslPreparation.mockResolvedValue(preparation) + const result = await fetchInactiveClaudeAccountUsage({ + id: 'A', + managedAuthRuntime: runtime, + wslDistro: runtime === 'wsl' ? 'SyntheticDistro' : null + }) + + expect(result.usageMetadata).toMatchObject({ + credentialSource: source, + authProvenance: provenance, + failureKind: 'rate-limited', + retryAtMs: now + 3600000 + }) + expect(fixture.readCredentials).toHaveBeenCalledWith({ + credentialsFileConfigDir: configDir, + keychainConfigDir: configDir, + unsuffixedKeychainFallback: false + }) + expect(fixture.fetchUsage).toHaveBeenCalledWith('synthetic-token', undefined) + if (runtime === 'wsl') { + expect(fixture.wslPreparation).toHaveBeenCalledWith('SyntheticDistro', 'A') + expect(fixture.hostPreparation).not.toHaveBeenCalled() + } else { + expect(fixture.hostPreparation).toHaveBeenCalledWith('A') + expect(fixture.wslPreparation).not.toHaveBeenCalled() + } +}) diff --git a/src/main/rate-limits/service-inactive-claude-cooldown.test.ts b/src/main/rate-limits/service-inactive-claude-cooldown.test.ts new file mode 100644 index 000000000000..1e8c44963e58 --- /dev/null +++ b/src/main/rate-limits/service-inactive-claude-cooldown.test.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createServer, type ServerResponse } from 'node:http' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +type CooldownFixture = { + userData: string + origin: string + now: number + status: number + retryAfter: string | null + statusB: number + retryAfterB: string | null + hold: boolean + pending: ServerResponse | null + arrived: () => void + requests: string[] +} + +const fixture = vi.hoisted((): CooldownFixture => ({ + userData: '', + origin: '', + now: 1800000000000, + status: 200, + retryAfter: '3600', + statusB: 200, + retryAfterB: '7200', + hold: false, + pending: null, + arrived: () => {}, + requests: [] +})) + +vi.mock('electron', () => ({ + app: { getPath: () => fixture.userData }, + session: { defaultSession: {} }, + net: { + fetch: (url: string, options: RequestInit) => { + if (url !== 'https://api.anthropic.com/api/oauth/usage') { + throw new Error('Unexpected endpoint in local usage fixture') + } + return globalThis.fetch(fixture.origin, options) + } + } +})) + +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn(async () => {}) +})) +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn(async () => { + throw new Error('Usage fixture must not launch Claude') + }) +})) +vi.mock('../claude-accounts/keychain', () => ({ + readActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir: string) => { + const { readFile } = await import('node:fs/promises') + const { join } = await import('node:path') + if (!configDir.startsWith(fixture.userData)) { + throw new Error('Usage fixture must read its own account folder') + } + return readFile(join(configDir, '.credentials.json'), 'utf8') + }) +})) +vi.mock('./grok-auth', () => ({ readGrokAuthSession: () => ({ status: 'missing' }) })) +vi.mock('./cursor-auth', () => ({ readCursorAuthSession: async () => ({ status: 'missing' }) })) +vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: () => false })) +vi.mock('../minimax/minimax-api-key-store', () => ({ hasMiniMaxApiKey: () => false })) +vi.mock('../zcode/zcode-plan-api-key-store', () => ({ hasZcodePlanApiKey: () => false })) +vi.mock('./codex-fetcher', () => ({ + fetchCodexRateLimits: async () => ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }), + consumeCodexRateLimitResetCredit: vi.fn() +})) +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: async () => ({ + provider: 'gemini', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: async () => ({ + provider: 'kimi', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: async () => ({ + provider: 'opencode-go', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./minimax/minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: async () => ({ + provider: 'minimax', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: async () => ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: async () => ({ + provider: 'cursor', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: async () => ({ + provider: 'zcode', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: async () => ({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: fixture.now, + error: null, + status: 'unavailable' + }) +})) + +import { RateLimitService } from './service' +import { ClaudeProfileRouter } from '../claude-accounts/claude-profile-router' +import { installClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' + +let activeId = 'A' +let inactiveIds = ['A', 'B'] +let service: RateLimitService +let restoreClock: () => void +let router: ClaudeProfileRouter + +function account(id: string) { + return { id } +} + +function preparation(id: string) { + return router.accountUsagePreparation(id) +} + +function reply(response: ServerResponse, status: number, retryAfter = fixture.retryAfter) { + response.statusCode = status + response.setHeader('Content-Type', 'application/json') + if (status === 429 && retryAfter) { + response.setHeader('Retry-After', retryAfter) + } + response.end( + JSON.stringify( + status === 200 + ? { + five_hour: { utilization: 21, resets_at: '2027-01-16T14:00:00Z' }, + seven_day: { utilization: 11, resets_at: '2027-01-20T14:00:00Z' } + } + : { error: { message: 'Synthetic usage failure' } } + ) + ) +} + +const server = createServer((request, response) => { + const id = + request.headers.authorization === 'Bearer synthetic-A' + ? 'A' + : request.headers.authorization === 'Bearer synthetic-B' + ? 'B' + : null + if (!id) { + response.writeHead(403).end() + return + } + fixture.requests.push(id) + if (id === 'A' && fixture.hold) { + fixture.pending = response + fixture.arrived() + return + } + reply( + response, + id === 'A' ? fixture.status : fixture.statusB, + id === 'A' ? fixture.retryAfter : fixture.retryAfterB + ) +}) + +function count(id: string) { + return fixture.requests.filter((requestId) => requestId === id).length +} + +function cached(id: string) { + return service.getState().inactiveClaudeAccounts.find((row) => row.accountId === id)?.rateLimits +} + +beforeEach(async () => { + fixture.userData = mkdtempSync(join(tmpdir(), 'orca-inactive-claude-429-')) + fixture.now = 1800000000000 + fixture.status = 200 + fixture.retryAfter = '3600' + fixture.statusB = 200 + fixture.retryAfterB = '7200' + fixture.hold = false + fixture.pending = null + fixture.requests = [] + activeId = 'A' + inactiveIds = ['A', 'B'] + for (const id of inactiveIds) { + const path = join(fixture.userData, 'claude-profiles', id, 'home') + mkdirSync(path, { recursive: true }) + writeFileSync( + join(path, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { accessToken: `synthetic-${id}`, expiresAt: 1900000000000 } + }) + ) + } + router = new ClaudeProfileRouter({ + dataRoot: fixture.userData, + userHome: join(fixture.userData, 'user-home'), + env: { CLAUDE_CONFIG_DIR: join(fixture.userData, 'system-default') }, + getSettings: () => ({ + claudeManagedAccounts: [], + activeClaudeManagedAccountId: activeId, + activeClaudeManagedAccountIdsByRuntime: { host: activeId, wsl: {} }, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + }), + runSetup: async () => { + throw new Error('Usage fixture must not set up an account') + } + }) + installClaudeProfileRouter(router) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected local listener') + } + fixture.origin = `http://127.0.0.1:${address.port}` + const clock = vi.spyOn(Date, 'now').mockImplementation(() => fixture.now) + restoreClock = () => clock.mockRestore() + service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => preparation(activeId)) + service.setInactiveClaudeAccountsResolver(() => inactiveIds.map(account)) +}) + +afterEach(async () => { + service?.stop() + installClaudeProfileRouter(undefined) + restoreClock?.() + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(fixture.userData, { recursive: true, force: true }) +}) + +it.each(['seconds', 'HTTP-date'] as const)( + 'keeps A last-good usage and refreshes healthy B until the exact %s deadline', + async (format) => { + await service.fetchInactiveClaudeAccountsOnOpen() + const originalAt = cached('A')?.updatedAt + fixture.now += 61000 + fixture.status = 429 + fixture.retryAfter = + format === 'seconds' ? '3600' : new Date(fixture.now + 3600000).toUTCString() + await service.fetchInactiveClaudeAccountsOnOpen() + const retryAt = fixture.now + 3600000 + expect(cached('A')).toMatchObject({ + status: 'error', + updatedAt: originalAt, + session: { usedPercent: 21 }, + usageMetadata: { + failureKind: 'rate-limited', + retryAtMs: retryAt, + authProvenance: 'profile:A', + credentialSource: process.platform === 'darwin' ? 'scoped-keychain' : 'credentials-file' + } + }) + fixture.now += 61000 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(2) + expect(count('B')).toBe(3) + expect(cached('A')?.updatedAt).toBe(originalAt) + expect(service.getState().inactiveClaudeAccounts.every((row) => !row.isFetching)).toBe(true) + fixture.now = retryAt + fixture.status = 200 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(3) + expect(count('B')).toBe(4) + expect(cached('A')).toMatchObject({ status: 'ok', updatedAt: retryAt }) + } +) + +it('keeps two server waits independent when all inactive accounts are blocked', async () => { + fixture.status = 429 + fixture.statusB = 429 + await service.fetchInactiveClaudeAccountsOnOpen() + const retryAtA = fixture.now + 3600000 + const retryAtB = fixture.now + 7200000 + expect(cached('A')?.usageMetadata?.retryAtMs).toBe(retryAtA) + expect(cached('B')?.usageMetadata?.retryAtMs).toBe(retryAtB) + fixture.now += 61000 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(fixture.requests).toEqual(['A', 'B']) + expect(service.getState().inactiveClaudeAccounts.every((row) => !row.isFetching)).toBe(true) + fixture.now = retryAtA + fixture.status = 200 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(fixture.requests).toEqual(['A', 'B', 'A']) + expect(cached('A')?.status).toBe('ok') + expect(cached('B')?.usageMetadata?.retryAtMs).toBe(retryAtB) + fixture.now = retryAtB + fixture.statusB = 200 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(fixture.requests).toEqual(['A', 'B', 'A', 'A', 'B']) + expect(cached('B')?.status).toBe('ok') +}) + +it('honors a deadline seeded from live active usage and preserves forced account-change retries', async () => { + inactiveIds = ['B'] + await service.refreshClaudeForTarget() + fixture.now += 61000 + fixture.status = 429 + await service.refreshClaudeForTarget() + service.ingestLiveClaudeRateLimits({ + configDir: preparation('A').configDir, + fiveHour: { utilization: 23, resets_at: '2027-01-16T14:00:00Z' }, + sevenDay: null + }) + activeId = 'B' + inactiveIds = ['A'] + await service.refreshForClaudeAccountChange('A') + expect(cached('A')).toMatchObject({ + status: 'ok', + usageMetadata: { source: 'live-session', retryAtMs: fixture.now + 3600000 } + }) + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(2) + expect(count('B')).toBe(1) + expect(service.getState().inactiveClaudeAccounts[0]?.isFetching).toBe(false) + activeId = 'A' + inactiveIds = ['B'] + await service.refreshForClaudeAccountChange('B') + await service.refresh() + expect(count('A')).toBe(4) +}) + +it.each([401, 503])( + 'preserves current profile failure metadata without a wait for HTTP %s', + async (status) => { + await service.fetchInactiveClaudeAccountsOnOpen() + const originalAt = cached('A')?.updatedAt + fixture.now += 61000 + fixture.status = status + await service.fetchInactiveClaudeAccountsOnOpen() + expect(cached('A')).toMatchObject({ + status: 'error', + updatedAt: originalAt, + session: { usedPercent: 21 }, + usageMetadata: { failureKind: status === 401 ? 'stale-token' : 'server' } + }) + expect(cached('A')?.usageMetadata?.retryAtMs).toBeUndefined() + fixture.now += 61000 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(3) + } +) + +it.each([null, '0', '-5', 'soon'])( + 'does not invent a wait for Retry-After %s', + async (retryAfter) => { + fixture.status = 429 + fixture.retryAfter = retryAfter + await service.fetchInactiveClaudeAccountsOnOpen() + expect(cached('A')).toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'rate-limited' } + }) + expect(cached('A')?.usageMetadata?.retryAtMs).toBeUndefined() + fixture.now += 61000 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(count('A')).toBe(2) + } +) + +it.each(['remove', 'stop'])('discards a late 429 after account %s', async (action) => { + fixture.hold = true + const arrived = new Promise((resolve) => { + fixture.arrived = resolve + }) + const pending = service.fetchInactiveClaudeAccountsOnOpen() + await arrived + if (action === 'stop') { + service.stop() + } else { + inactiveIds = ['B'] + service.evictInactiveClaudeCache('A') + } + if (!fixture.pending) { + throw new Error('Expected pending local response') + } + reply(fixture.pending, 429) + await pending + expect(cached('A')).toBeUndefined() + expect(service.getState().inactiveClaudeAccounts.every((row) => !row.isFetching)).toBe(true) +}) diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts index e0d388639c86..70f06c093f7f 100644 --- a/src/main/rate-limits/service/service-inactive-accounts.ts +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -13,7 +13,9 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP if (this.inactiveClaudeFetching.size > 0) { return } - const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] + const accounts = (this.inactiveClaudeAccountsResolver?.() ?? []).filter( + (account) => !this.isRetryAfterActive(this.inactiveClaudeCache.get(account.id) ?? null) + ) if (accounts.length === 0) { return } From a18883130ffe2303eee6055696622636c119b0fe Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:22:11 -0400 Subject: [PATCH 027/110] fix(relay): database sweeps and ops reads skip reserve-mode cells (step-5 census, inert) (#27046) Rebased onto main as one commit after its parent squash-merged. --- .../relay/src/admin-token-verifier.test.ts | 1 + cloud/apps/relay/src/app.ts | 49 ++- .../assignment-reserve-mode-census.test.ts | 387 ++++++++++++++++++ .../assignment-reserve-mode-postgres.test.ts | 251 ++++++++++++ .../src/assignment-store-lock-order.test.ts | 5 +- cloud/apps/relay/src/assignment-store.ts | 337 ++++++++++++++- .../src/cell-admit-effective-writer.test.ts | 88 ++++ .../relay/src/cell-admit-effective-writer.ts | 63 +++ .../cell-reserve-rollback.blackbox.test.ts | 31 +- cloud/apps/relay/src/cell-seat-feed-route.ts | 3 + cloud/apps/relay/src/database.test.ts | 2 + cloud/apps/relay/src/database.ts | 16 + cloud/apps/relay/src/relay-server.ts | 13 + .../apps/relay/src/reserve-assignment.test.ts | 11 + cloud/apps/relay/src/reserve-assignment.ts | 6 +- .../relay/src/shadow-seat-directory.test.ts | 28 ++ cloud/apps/relay/src/shadow-seat-directory.ts | 12 + .../src/test-fixtures/reserve-mode-cell.ts | 18 +- 18 files changed, 1301 insertions(+), 20 deletions(-) create mode 100644 cloud/apps/relay/src/assignment-reserve-mode-census.test.ts create mode 100644 cloud/apps/relay/src/assignment-reserve-mode-postgres.test.ts create mode 100644 cloud/apps/relay/src/cell-admit-effective-writer.test.ts create mode 100644 cloud/apps/relay/src/cell-admit-effective-writer.ts diff --git a/cloud/apps/relay/src/admin-token-verifier.test.ts b/cloud/apps/relay/src/admin-token-verifier.test.ts index 342144c4bff3..628e31f7b1a2 100644 --- a/cloud/apps/relay/src/admin-token-verifier.test.ts +++ b/cloud/apps/relay/src/admin-token-verifier.test.ts @@ -17,6 +17,7 @@ const mutationRoutes = [ '/v1/admin/admission-selector/apply', '/v1/admin/admission-selector/add-migration-cells', '/v1/admin/cell-state', + '/v1/admin/cell-admit-mode', '/v1/admin/cell-fence-adopt-legacy', '/v1/admin/cell-fence-commit-legacy-adoption', '/v1/admin/cell-fence-attest', diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index db8586719d31..42bcb311b312 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -29,6 +29,7 @@ import { } from './admin-token-verifier.js' import { RelayAssignmentRowBusyError, + RelayCellInReserveModeError, RelayHomeCellUnavailableError, type CellFenceAttemptEvidence, type RelayAssignment, @@ -135,6 +136,7 @@ export function createRelayApp( cellDemote?: (request: DemoteRequest) => string cellReserverPoll?: () => void cellAdmitModeEffective?: () => 'db' | 'reserve' + cellAdmitModeRaw?: () => 'db' | 'reserve' cellReserveCounts?: () => { bookings: number intake: { perSec: number; burst: number; tokens: number } @@ -298,7 +300,9 @@ export function createRelayApp( error: unknown, status: 404 | 409 ): Response => { - if (!isRelayDatabaseTransientError(error)) { + // The reserve-mode set could not be read: retry, as for a database blip. + const unknownReserveSet = error instanceof RelayCellInReserveModeError && error.cellId === null + if (!isRelayDatabaseTransientError(error) && !unknownReserveSet) { return context.json({ error: operationError(error) }, status) } context.header('Retry-After', String(config.publicAssignmentRetryAfterSeconds)) @@ -350,6 +354,7 @@ export function createRelayApp( cellFlags: operations.cellFlags, onReserverPoll: operations.cellReserverPoll, admitModeEffective: operations.cellAdmitModeEffective, + admitModeRaw: operations.cellAdmitModeRaw, reserveCounts: operations.cellReserveCounts, placementCeiling: operations.cellPlacementCeiling }) @@ -1222,6 +1227,44 @@ export function createRelayApp( return rejectAdminOperation(context, error, 409) } }) + // Step 5: the flag workflow's record of a cell's admitMode, which every sweep reads. Without + // `admitMode` it only reads; reserve is refused while a drain, migration or rehome is open. + app.post('/v1/admin/cell-admit-mode', async (context) => { + const bearer = readBearer(context.req.header('authorization')) + if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) + if (!bearer || !(await verifyAdminToken(bearer))) { + return context.json({ error: 'invalid_token' }, 401) + } + if (requestTooLarge(context.req.header('content-length'))) { + return context.json({ error: 'request_too_large' }, 413) + } + const body = AdminCellAdmitModeSchema.safeParse(await context.req.json().catch(() => null)) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + try { + if (body.data.admitMode) { + const before = await operations.assignments.cellAdmitMode(body.data.cellId) + await operations.assignments.setCellAdmitMode(body.data.cellId, body.data.admitMode) + // The sweeps' switch: every write is on the record, the break-glass flip included. + console.warn( + JSON.stringify({ + event: 'orca_relay_cell_admit_mode_recorded', + cellId: body.data.cellId, + from: before.admitMode, + to: body.data.admitMode + }) + ) + } + return context.json({ + v: 1, + cellId: body.data.cellId, + ...(await operations.assignments.cellAdmitMode(body.data.cellId)), + // The break-glass flip refuses a cell that is still heartbeating. + heartbeatFresh: await operations.assignments.cellHeartbeatFresh(body.data.cellId) + }) + } catch (error) { + return rejectAdminOperation(context, error, 409) + } + }) app.post('/v1/admin/cell-fence-adopt-legacy', async (context) => { const bearer = readBearer(context.req.header('authorization')) if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) @@ -2044,6 +2087,10 @@ const AdminAdmissionSelectorAddMigrationCellsSchema = z new Set(cells.map(({ cellUrl }) => cellUrl)).size === cells.length ) +const AdminCellAdmitModeSchema = z + .object({ v: z.literal(1), cellId: CellIdSchema, admitMode: z.enum(['db', 'reserve']).optional() }) + .strict() + const AdminCellStateSchema = z.union([ z.object({ v: z.literal(1), cellId: CellIdSchema, enabled: z.boolean() }).strict(), z.object({ v: z.literal(1), cellId: CellIdSchema, state: CellAdmissionStateSchema }).strict() diff --git a/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts b/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts new file mode 100644 index 000000000000..a10b49940132 --- /dev/null +++ b/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts @@ -0,0 +1,387 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' +import ts from 'typescript' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore, RelayCellInReserveModeError } from './assignment-store.js' +import type { RelayCellConfig } from './config.js' +import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' + +// Step 5 census (design §6.3): a reserve-mode cell keeps its hosts in memory, so Postgres's +// per-host state for it is stale. Every store method is classified below; a new one fails +// this test until someone decides how it treats a reserve-mode cell. +const RESERVE_MODE_CENSUS: Record = { + // One host's connect, resolve or lease: a reserve-mode cell answers these from memory, and + // its ledger row is written after the fact. + acquireActivity: 'connect-path', + activateControl: 'connect-path', + // A flip back's re-registration: the cell is leaving reserve mode, so its rows are rebuilt. + activateControlDeferringCell: 'connect-path', + assign: 'connect-path', + changeActivity: 'connect-path', + exchangeRegionCorrection: 'connect-path', + hostWhereabouts: 'connect-path', + markMigrationTargetRegistered: 'connect-path', + releaseActivity: 'connect-path', + renewControlActivities: 'connect-path', + renewControlActivity: 'connect-path', + resolve: 'connect-path', + verifyCellAssignment: 'connect-path', + // Sweeps and per-cell host readers: skip reserve-mode cells, or refuse them. + cellDeploymentStatus: 'refuses-reserve-cells', + cellEvacuationCapacity: 'refuses-reserve-cells', + cellEvacuationStatus: 'refuses-reserve-cells', + evacuateDeadCells: 'skips-reserve-cells', + prepareCellDrainAttempt: 'refuses-reserve-cells', + rebalanceDormant: 'refuses-reserve-cells', + releaseExpiredActivity: 'skips-reserve-cells', + releaseExpiredActivityLeases: 'skips-reserve-cells', + selectIdleRegionalRehomeCandidates: 'skips-reserve-cells', + startActiveCellEvacuations: 'refuses-reserve-cells', + startEvacuation: 'refuses-reserve-cells', + // Steps of a drain, migration or rehome that only exist once a refused start above began one. + abortExpiredEvacuations: 'follows-a-refused-start', + abortExpiredRegionalRehomes: 'follows-a-refused-start', + abortUnarrivedRegionalRehomes: 'follows-a-refused-start', + beginCellDrainSend: 'follows-a-refused-start', + commitIdleRegionalRehome: 'follows-a-refused-start', + // A reserve source keeps no activity rows, so its "no source units" check proves nothing. + completeEvacuation: 'refuses-reserve-cells', + completeEvacuationFromDeadSource: 'refuses-reserve-cells', + completeReadyEvacuations: 'follows-a-refused-start', + completeReadyRegionalRehomes: 'follows-a-refused-start', + prepareCellDrainRecovery: 'follows-a-refused-start', + proveCellDrainNotDelivered: 'follows-a-refused-start', + reapRegionalRehomeAttempts: 'follows-a-refused-start', + reconcileIdleRegionalRehome: 'follows-a-refused-start', + recordCellDrainApplicationReceipt: 'follows-a-refused-start', + refreshRegionalRehomeLeases: 'follows-a-refused-start', + supersedeRegisteredCellEvacuations: 'follows-a-refused-start', + supersedeRegisteredEvacuation: 'follows-a-refused-start', + // Cell-level state, fences and controls: no per-host rows. + abortCellFenceAttempt: 'cell-level', + addMigrationCells: 'cell-level', + adoptLegacyCellFence: 'cell-level', + applyCellAdmissionSelector: 'cell-level', + applyRegionalRehomeControl: 'cell-level', + cellAdmitMode: 'cell-level', + cellHeartbeatFresh: 'cell-level', + recordCellAdmitEffective: 'cell-level', + commitCellReservationDelta: 'cell-level', + attestCellFence: 'cell-level', + attestCellFenceAttempt: 'cell-level', + bindCellFencePlanGeneration: 'cell-level', + cellFenceAttempt: 'cell-level', + commitLegacyCellFenceAdoption: 'cell-level', + configureCell: 'cell-level', + disableRegionalRehomeControl: 'cell-level', + inspectCellAdmissionSelector: 'cell-level', + inspectRegionalRehomeControl: 'cell-level', + prepareCellFenceAttempt: 'cell-level', + previewRegionCorrection: 'cell-level', + previewRegionalRehomeEligibility: 'cell-level', + pruneReleasedControlReservations: 'cell-level', + reconcileCells: 'cell-level', + reconcileCellsAtStartup: 'cell-level', + reserveModeCells: 'cell-level', + recordCellFenceOperation: 'cell-level', + recordCellHeartbeat: 'cell-level', + recordCellRegionalRehomeStatus: 'cell-level', + regionCatalog: 'cell-level', + regionCorrectionOutcomes: 'cell-level', + regionalRehomeFleetSafety: 'cell-level', + releaseExpiredRegionPreferences: 'cell-level', + seatFeedCells: 'cell-level', + setCellAdmissionState: 'cell-level', + setCellAdmitMode: 'cell-level', + setCellEnabled: 'cell-level', + startCellFenceApply: 'cell-level' +} + +const CELLS: RelayCellConfig[] = [ + { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, + { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } +] + +describe('reserve-mode census', () => { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + it('classifies every public store method for reserve-mode cells', () => { + const methods = publicStoreMethods() + expect(methods.filter((name) => !(name in RESERVE_MODE_CENSUS))).toEqual([]) + expect(Object.keys(RESERVE_MODE_CENSUS).filter((name) => !methods.includes(name))).toEqual([]) + }) + + async function setup(now: () => number, reserve: string[] = []) { + database = await openInMemoryRelayDatabase() + const store = new RelayAssignmentStore(database, now) + await store.reconcileCells(CELLS) + await setReserve(store, reserve) + return store + } + + async function setReserve(store: RelayAssignmentStore, reserve: string[]) { + for (const cell of CELLS) { + await store.setCellAdmitMode(cell.id, reserve.includes(cell.id) ? 'reserve' : 'db') + } + } + + // A store that has never read the set: the table is unreadable. + async function unknownSet(now: () => number) { + const unknown = new RelayAssignmentStore(database!, now) + await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone') + return { + unknown, + restore: async () => + await database!.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes') + } + } + + it('lets no expiry sweep touch a reserve-mode cell, and runs none while the set is unknown', async () => { + let now = 1_000 + const store = await setup(() => now) + const onA = { userId: 'user-a', relayHostId: 'host000000000001' } + expect((await store.assign(onA)).cellId).toBe('cell-a') + await store.acquireActivity(onA, { activityId: 'invite:one', kind: 'invite', cellId: 'cell-a' }) + await setReserve(store, ['cell-a']) + now += ASSIGNMENT_LIMITS.activityLeaseMs + 1 + expect(await store.releaseExpiredActivityLeases()).toBe(0) + expect(await store.releaseExpiredActivity()).toBe(0) + const { unknown, restore } = await unknownSet(() => now) + expect(await unknown.reserveModeCells()).toBeNull() + expect(await unknown.releaseExpiredActivityLeases()).toBe(0) + expect(await unknown.evacuateDeadCells()).toBe(0) + await restore() + await setReserve(store, []) + expect(await store.releaseExpiredActivityLeases()).toBeGreaterThan(0) + }) + + it('logs an unreadable reserve-mode set at most once a minute', async () => { + let now = 1_000 + await setup(() => now) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const clock = vi.spyOn(performance, 'now') + let at = 1_000_000 + clock.mockImplementation(() => at) + const { unknown, restore } = await unknownSet(() => now) + try { + for (let read = 0; read < 3; read += 1) { + expect(await unknown.reserveModeCells()).toBeNull() + at += 10_000 + } + at += 60_000 + expect(await unknown.reserveModeCells()).toBeNull() + const lines = warn.mock.calls.filter(([line]) => String(line).includes('orca_relay_reserve_mode_read_failed')) + expect(lines).toHaveLength(2) + } finally { + await restore() + clock.mockRestore() + warn.mockRestore() + } + }) + + it('tells break glass whether a cell is still heartbeating', async () => { + let now = 1_000 + const store = await setup(() => now) + expect(await store.cellHeartbeatFresh('cell-a')).toBe(false) + await store.reconcileCells([{ ...CELLS[0]!, connectionHardCap: 600, connectionUnobservedBound: 50 }, CELLS[1]!]) + await store.recordCellHeartbeat({ + cellId: 'cell-a', + cellUrl: CELLS[0]!.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: 50, + ready: false, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: 600, + connectionUnobservedBound: 50 + }) + // Not ready is still alive: break glass is for a cell that has gone silent. + expect(await store.cellHeartbeatFresh('cell-a')).toBe(true) + now += 45_001 + expect(await store.cellHeartbeatFresh('cell-a')).toBe(false) + }) + + it('keeps an idle host sticky on a reserve-mode cell where today it would be re-placed', async () => { + let now = 1_000 + const store = await setup(() => now) + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + const first = await store.assign(identity) + await store.changeActivity(identity, 'control', -1) + now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1 + await setReserve(store, [first.cellId]) + expect(await store.assign(identity)).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + // Not knowing which cells are switched on is treated the same way. + const { unknown, restore } = await unknownSet(() => now) + expect((await unknown.assign(identity)).assignmentEpoch).toBe(first.assignmentEpoch) + await restore() + }) + + // Only the first reserve-set read is held; it is released (or failed) by the test. + function holdFirstRead(options: { fail?: boolean } = {}) { + const query = database!.query.bind(database!) + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + const counter = { reads: 0 } + database!.query = async (sql, params) => { + if (sql.includes('FROM relay_cell_admit_modes m')) { + counter.reads += 1 + if (counter.reads === 1) { + await released + if (options.fail) throw new Error('canceling statement due to statement timeout') + } + } + return await query(sql, params) + } + return { counter, release, restore: () => (database!.query = query) } + } + + it('reads afresh after an admit-mode write instead of joining a read begun before it', async () => { + const store = await setup(() => 1_000) + const held = holdFirstRead() + try { + const before = store.reserveModeCells() + await store.setCellAdmitMode('cell-a', 'reserve') + const after = await Promise.race([ + store.reserveModeCells(), + new Promise<'joined'>((resolve) => setTimeout(() => resolve('joined'), 500)) + ]) + expect(after === 'joined' ? after : [...(after ?? [])]).toEqual(['cell-a']) + held.release() + // The pre-write read lands after, and is not kept. + await before + expect([...((await store.reserveModeCells()) ?? [])]).toEqual(['cell-a']) + } finally { + held.release() + held.restore() + } + }) + + it('does not back off for a pre-write read that fails after the write', async () => { + const store = await setup(() => 1_000) + const held = holdFirstRead({ fail: true }) + try { + const before = store.reserveModeCells() + await store.setCellAdmitMode('cell-a', 'reserve') + const reads = held.counter.reads + held.release() + await before + // A write cleared the cache: the next caller reads now, not after a 5 s backoff. + expect([...((await store.reserveModeCells()) ?? [])]).toEqual(['cell-a']) + expect(held.counter.reads).toBeGreaterThan(reads) + } finally { + held.release() + held.restore() + } + }) + + // A table unreadable for over a minute leaves a stale set, not none: a host on a cell last + // known to be in database mode is placed as today, not pinned as if its cell might be reserve. + it('checks pins and dormancy against the last-known set once the read goes stale', async () => { + let now = 1_000 + const clock = vi.spyOn(performance, 'now') + let at = 5_000_000 + clock.mockImplementation(() => at) + const store = await setup(() => now) + try { + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + const first = await store.assign(identity) + await store.changeActivity(identity, 'control', -1) + now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1 + expect([...((await store.reserveModeCells()) ?? [])]).toEqual([]) + await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone') + at += 61_000 + expect(await store.reserveModeCells()).toBeNull() + expect((await store.assign(identity)).assignmentEpoch).toBeGreaterThan(first.assignmentEpoch) + } finally { + await database!.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes').catch(() => undefined) + clock.mockRestore() + } + }) + + it('never places a fresh host on a reserve-mode cell through the database', async () => { + const store = await setup(() => 1_000, ['cell-a']) + for (let index = 0; index < 4; index += 1) { + const placed = await store.assign({ + userId: `user-${index}`, + relayHostId: `host${String(index).padStart(12, '0')}` + }) + expect(placed.cellId).toBe('cell-b') + } + }) + + it('refuses drains, evacuations and status reads that would treat a reserve-mode cell as empty', async () => { + const store = await setup(() => 1_000, ['cell-a']) + await expect(store.cellDeploymentStatus('cell-a')).rejects.toBeInstanceOf( + RelayCellInReserveModeError + ) + await expect(store.cellEvacuationCapacity('cell-a', 'cell-b')).rejects.toThrow( + 'cell_in_reserve_mode' + ) + await expect(store.startActiveCellEvacuations('cell-b', 'cell-a', 10)).rejects.toThrow( + 'cell_in_reserve_mode' + ) + await expect( + store.prepareCellDrainAttempt({ + attemptId: '22222222-2222-4222-8222-222222222222', + cellId: 'cell-a', + cellIncarnation: '11111111-1111-4111-8111-111111111111', + traceValue: 'trace', + plannedGraceMs: 1_000 + }) + ).rejects.toThrow('cell_in_reserve_mode') + expect((await store.cellDeploymentStatus('cell-b')).cellId).toBe('cell-b') + // Unknown refuses too, with a retryable error. + const { unknown, restore } = await unknownSet(() => 1_000) + await expect(unknown.cellDeploymentStatus('cell-b')).rejects.toThrow('reserve_mode_unknown') + await restore() + }) + + it('refuses the flip to reserve while a migration names the cell', async () => { + const store = await setup(() => 1_000) + await database!.query( + `INSERT INTO relay_assignment_migrations + (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch, + source_request_units, target_reserved_units, expires_at, created_at, updated_at) + VALUES ('user-a', 'host000000000001', 'cell-b', 'cell-a', 1, 2, 0, 1, 9999, 1000, 1000)` + ) + await expect(store.setCellAdmitMode('cell-a', 'reserve')).rejects.toThrow('cell_has_open_migration') + expect(await store.cellAdmitMode('cell-a')).toMatchObject({ admitMode: 'db' }) + await store.setCellAdmitMode('cell-a', 'db') + await database!.query(`UPDATE relay_assignment_migrations SET aborted_at = 1001`) + await store.setCellAdmitMode('cell-a', 'reserve') + expect(await store.cellAdmitMode('cell-a')).toMatchObject({ admitMode: 'reserve', updatedAt: 1_000 }) + }) +}) + +function publicStoreMethods(): string[] { + const path = fileURLToPath(new URL('./assignment-store.ts', import.meta.url)) + const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.ES2022, true) + const names = new Set() + source.forEachChild((node) => { + if (!ts.isClassDeclaration(node) || node.name?.text !== 'RelayAssignmentStore') return + for (const member of node.members) { + if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue + const hidden = ts + .getModifiers(member) + ?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PrivateKeyword || + modifier.kind === ts.SyntaxKind.ProtectedKeyword + ) + if (!hidden) names.add(member.name.text) + } + }) + return [...names].sort() +} diff --git a/cloud/apps/relay/src/assignment-reserve-mode-postgres.test.ts b/cloud/apps/relay/src/assignment-reserve-mode-postgres.test.ts new file mode 100644 index 000000000000..442f63744c70 --- /dev/null +++ b/cloud/apps/relay/src/assignment-reserve-mode-postgres.test.ts @@ -0,0 +1,251 @@ +import { randomUUID } from 'node:crypto' +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { CELL_ADMIT_EFFECTIVE_FRESH_MS, RelayAssignmentStore } from './assignment-store.js' +import type { RelayCellConfig } from './config.js' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +// The step-5 census on real PostgreSQL: every new statement (the admit-mode table, the +// open-flow check under FOR UPDATE, the live-reserve pin) must run there, not only on SQLite. + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +const run = randomUUID().slice(0, 8) +const cellA: RelayCellConfig = { id: `census-pg-a-${run}`, url: `https://census-a-${run}.example.com`, capacityRequests: 10, connectionHardCap: 600, connectionUnobservedBound: 50 } +const cellB: RelayCellConfig = { id: `census-pg-b-${run}`, url: `https://census-b-${run}.example.com`, capacityRequests: 10, connectionHardCap: 600, connectionUnobservedBound: 50 } +const USER = `census-pg-${run}` + +describePostgres('reserve-mode census on PostgreSQL', () => { + let database: RelayDatabase + + beforeAll(async () => { + database = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + }) + + afterAll(async () => { + for (const table of ['relay_assignment_activity_leases', 'relay_assignment_migrations', 'relay_assignments']) { + await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [USER]) + } + for (const cell of [cellA, cellB]) { + for (const table of ['relay_cell_admit_effective', 'relay_cell_admit_modes', 'relay_cell_admission', 'relay_cell_regions', 'relay_cell_runtime', 'relay_cells']) { + await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id]) + } + } + await database.close() + }) + + const heartbeat = async (store: RelayAssignmentStore, cell: RelayCellConfig, ready = true) => + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: 50, + ready, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: 600, + connectionUnobservedBound: 50 + }) + + it('flips a cell to reserve and back, and refuses reserve while a migration names it', async () => { + const store = new RelayAssignmentStore(database, () => 1_000) + await store.reconcileCells([cellA, cellB], false) + await store.setCellAdmitMode(cellA.id, 'reserve') + expect(await store.cellAdmitMode(cellA.id)).toEqual({ admitMode: 'reserve', updatedAt: 1_000 }) + expect([...((await store.reserveModeCells()) ?? [])]).toContain(cellA.id) + await store.setCellAdmitMode(cellA.id, 'db') + expect(await store.cellAdmitMode(cellA.id)).toMatchObject({ admitMode: 'db' }) + await database.query( + `INSERT INTO relay_assignment_migrations + (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch, + source_request_units, target_reserved_units, expires_at, created_at, updated_at) + VALUES (?, 'host000000000009', ?, ?, 1, 2, 0, 1, 9999, 1000, 1000)`, + [USER, cellB.id, cellA.id] + ) + await expect(store.setCellAdmitMode(cellA.id, 'reserve')).rejects.toThrow('cell_has_open_migration') + await database.query(`DELETE FROM relay_assignment_migrations WHERE user_id = ?`, [USER]) + await store.setCellAdmitMode(cellA.id, 'reserve') + await store.setCellAdmitMode(cellA.id, 'db') + }) + + it('pins a host on a live reserve cell without writing its rows, and re-places it once that cell is dead', async () => { + let now = 100_000 + const store = new RelayAssignmentStore(database, () => now, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) + await store.reconcileCells([cellA, cellB], false) + await heartbeat(store, cellA) + await heartbeat(store, cellB) + expect(await store.cellHeartbeatFresh(cellA.id)).toBe(true) + const identity = { userId: USER, relayHostId: 'host000000000001' } + await database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at, + reserved_controls, reserved_splices, reserved_invites, pending_installs, + pending_confirmations, migration_leases) + VALUES (?, ?, ?, 4, ?, ?, 0, 0, 0, 0, 0, 0)`, + [USER, identity.relayHostId, cellA.id, now, now] + ) + await store.setCellAdmitMode(cellA.id, 'reserve') + now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1 + await heartbeat(store, cellA) + await heartbeat(store, cellB) + const reservedBefore = await database.query(`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, [cellA.id]) + expect(await store.assign(identity)).toMatchObject({ cellId: cellA.id, assignmentEpoch: 4 }) + expect(await database.query(`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, [cellA.id])).toEqual( + reservedBefore + ) + expect( + await database.query(`SELECT activity_id FROM relay_assignment_activity_leases WHERE user_id = ?`, [USER]) + ).toEqual([]) + // cellA stops heartbeating: a dead cell holds no duplicate, so the host is re-placed. + now += 60_000 + await heartbeat(store, cellB) + expect(await store.cellHeartbeatFresh(cellA.id)).toBe(false) + expect((await store.assign(identity)).cellId).toBe(cellB.id) + await store.setCellAdmitMode(cellA.id, 'db') + }) + + // A database brownout drops ready, not the socket: a host on a reserve cell that still + // heartbeats is not re-placed (that would seat it twice); its home just reads unavailable. + it('does not re-place a host off a reserve cell that heartbeats while not ready', async () => { + let now = 400_000 + const store = new RelayAssignmentStore(database, () => now, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) + await store.reconcileCells([cellA, cellB], false) + const identity = { userId: USER, relayHostId: 'host000000000005' } + await database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at, + reserved_controls, reserved_splices, reserved_invites, pending_installs, + pending_confirmations, migration_leases) + VALUES (?, ?, ?, 7, ?, ?, 0, 0, 0, 0, 0, 0)`, + [USER, identity.relayHostId, cellA.id, now, now] + ) + await store.setCellAdmitMode(cellA.id, 'reserve') + now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1 + await heartbeat(store, cellA, false) + await heartbeat(store, cellB) + await expect(store.assign(identity)).rejects.toThrow('relay_home_cell_unavailable') + expect( + await database.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, [ + USER, + identity.relayHostId + ]) + ).toEqual([{ cell_id: cellA.id }]) + await store.setCellAdmitMode(cellA.id, 'db') + }) + + // A fleet-wide dead-man trip leaves every cell reserve in PG, and no director writes db back. + // The database path places on a reserve cell only by that cell's own fresh row saying db. + it('places a fresh host on a reserve cell only while its own current-process row says db', async () => { + let now = 200_000 + const fresh = () => new RelayAssignmentStore(database, () => now) + const setup = fresh() + await setup.reconcileCells([cellA, cellB], false) + await heartbeat(setup, cellA) + await heartbeat(setup, cellB) + await setup.setCellAdmitMode(cellA.id, 'reserve') + await setup.setCellAdmitMode(cellB.id, 'reserve') + // An older cell image writes no row: never admitted. + await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000002' })).rejects.toThrow( + 'relay_capacity_exhausted' + ) + // A row from another process (a restarted cell's predecessor) does not count either. + await setup.recordCellAdmitEffective({ cellId: cellB.id, cellIncarnation: '22222222-2222-4222-8222-222222222222', mode: 'db' }) + await setup.recordCellAdmitEffective({ cellId: cellA.id, cellIncarnation: '11111111-1111-4111-8111-111111111111', mode: 'reserve' }) + await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000002' })).rejects.toThrow( + 'relay_capacity_exhausted' + ) + await setup.recordCellAdmitEffective({ cellId: cellA.id, cellIncarnation: '11111111-1111-4111-8111-111111111111', mode: 'db' }) + const store = fresh() + expect((await store.assign({ userId: USER, relayHostId: 'host000000000003' })).cellId).toBe(cellA.id) + // Census sweeps still treat it as reserve. + expect([...((await store.reserveModeCells()) ?? [])]).toEqual(expect.arrayContaining([cellA.id, cellB.id])) + // Three missed 15 s refreshes and the row counts for nothing. + now += CELL_ADMIT_EFFECTIVE_FRESH_MS + 1 + await heartbeat(setup, cellA) + await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000006' })).rejects.toThrow( + 'relay_capacity_exhausted' + ) + await setup.setCellAdmitMode(cellA.id, 'db') + await setup.setCellAdmitMode(cellB.id, 'db') + }) + + // A timed-out read inside BEGIN aborts the transaction (25P02 on the next statement), so the + // reserve set is read before BEGIN and the transaction answers from that read. + it('assigns while the admit-mode read times out (57014), without aborting the transaction', async () => { + const timed = await openRelayDatabase({ databaseUrl, dataDir: '/tmp', statementTimeoutMs: 300 }) + const holder = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + let held!: () => void + const holding = new Promise((resolve) => (held = resolve)) + const lock = holder.transaction(async (transaction) => { + await transaction.query(`LOCK TABLE relay_cell_admit_modes IN ACCESS EXCLUSIVE MODE`) + held() + await released + }) + try { + await holding + const store = new RelayAssignmentStore(timed, () => 300_000) + await store.reconcileCells([cellA, cellB], false) + await heartbeat(store, cellA) + await heartbeat(store, cellB) + expect(await store.reserveModeCells()).toBeNull() + const placed = await store.assign({ userId: USER, relayHostId: 'host000000000004' }) + expect([cellA.id, cellB.id]).toContain(placed.cellId) + } finally { + release() + await lock + await timed.close() + await holder.close() + } + }, 20_000) + + // A stall must not double connection demand: concurrent assigns share one reserve-set read, + // and after it fails the last answer stands for the backoff without another query. + it('reads the reserve set once for concurrent assigns during a stall, and not again in the backoff', async () => { + const timed = await openRelayDatabase({ databaseUrl, dataDir: '/tmp', statementTimeoutMs: 300 }) + const holder = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + let held!: () => void + const holding = new Promise((resolve) => (held = resolve)) + const lock = holder.transaction(async (transaction) => { + await transaction.query(`LOCK TABLE relay_cell_admit_modes IN ACCESS EXCLUSIVE MODE`) + held() + await released + }) + try { + await holding + const store = new RelayAssignmentStore(timed, () => 500_000) + await store.reconcileCells([cellA, cellB], false) + await heartbeat(store, cellA) + await heartbeat(store, cellB) + const query = timed.query.bind(timed) + let reads = 0 + timed.query = async (sql, params) => { + if (sql.includes('FROM relay_cell_admit_modes m')) reads += 1 + return await query(sql, params) + } + const placed = await Promise.all( + Array.from({ length: 6 }, async (_, index) => + await store.assign({ userId: USER, relayHostId: `hoststall0000${String(index).padStart(3, '0')}` }) + ) + ) + expect(placed).toHaveLength(6) + expect(reads).toBe(1) + await store.assign({ userId: USER, relayHostId: 'hoststall0000100' }) + expect(await store.reserveModeCells()).toBeNull() + expect(reads).toBe(1) + } finally { + release() + await lock + await timed.close() + await holder.close() + } + }, 20_000) +}) + diff --git a/cloud/apps/relay/src/assignment-store-lock-order.test.ts b/cloud/apps/relay/src/assignment-store-lock-order.test.ts index e6ca46baf710..352b42305b7c 100644 --- a/cloud/apps/relay/src/assignment-store-lock-order.test.ts +++ b/cloud/apps/relay/src/assignment-store-lock-order.test.ts @@ -16,6 +16,8 @@ class LockOrderDatabase implements RelayDatabase { ) {} async query(sql: string): Promise { + // No cell is in reserve mode. + if (sql.includes('FROM relay_cell_admit_modes')) return [] if (sql.includes('SELECT user_id, relay_host_id, activity_id')) { return this.cleanupCandidate ? [ @@ -149,7 +151,8 @@ class ReassignmentLockOrderDatabase implements RelayDatabase { class AggregateCleanupDatabase implements RelayDatabase { failIfUnavailable: boolean | null = null - async query(): Promise { + async query(sql: string): Promise { + if (sql.includes('FROM relay_cell_admit_modes')) return [] return [{ changes: 1 }] } diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 16a39f691846..e70e5dc7d10f 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -140,6 +140,25 @@ type RelayAssignmentStoreOptions = { export type { ControlRenewalOutcome, ControlRenewalRequest } +// `cellId` null: the reserve-mode set could not be read, so no cell is known to be in db mode. +export class RelayCellInReserveModeError extends Error { + constructor(readonly cellId: string | null) { + super(cellId === null ? 'reserve_mode_unknown' : 'cell_in_reserve_mode') + } +} + +// Step 5: the flag workflow records each cell's admitMode here (reserve before the cell's +// switch file, db only after the cell has re-registered its controls). Every per-host sweep +// and reader skips reserve cells, and does nothing while the set is unknown. +const RESERVE_MODE_CACHE_MS = 5_000 +const RESERVE_MODE_STALE_MS = 60_000 +// After a failed read, the last good one answers this long without a query: during a stall +// every assign would otherwise spend a read's timeout before its own transaction. +const RESERVE_MODE_FAILURE_BACKOFF_MS = 5_000 +// A cell refreshes its admit-effective row every 15 s; three missed refreshes and it counts for nothing. +export const CELL_ADMIT_EFFECTIVE_FRESH_MS = 45_000 +const RESERVE_MODE_FAILURE_LOG_MS = 60_000 + type ControlActivationInput = { cellId: string assignmentEpoch: number @@ -579,6 +598,11 @@ const ABORTABLE_EXPIRED_MIGRATION = `( OR ${ABANDONED_REGISTERED_MIGRATION} )` +// `cell_id NOT IN (...)` for the reserve-mode cells, or nothing when there are none. +function excludeCellsSql(column: string, cellIds: readonly string[]): string { + return cellIds.length === 0 ? '' : ` AND ${column} NOT IN (${cellIds.map(() => '?').join(', ')})` +} + export class RelayAssignmentStore { private readonly regionalRehomeCohortPercent: number private readonly regionalRehomeDirectorRegion?: RelayRegion @@ -593,6 +617,17 @@ export class RelayAssignmentStore { { failures: number; until: number } >() private readonly recordControlRenewal?: RelayAssignmentStoreOptions['recordControlRenewal'] + private reserveModeRead: { + cells: ReadonlySet + admitsDatabase: ReadonlySet + readAt: number + } | null = null + private reserveModeFailureLoggedAt = Number.NEGATIVE_INFINITY + private reserveModeFailedAt = Number.NEGATIVE_INFINITY + // One read at a time: concurrent callers share it. + private reserveModeInFlight: Promise | null = null + // Bumped by an admit-mode write, so a read begun before it cannot land after it. + private reserveModeGeneration = 0 private readonly placementLoadBand?: PlacementLoadBand private readonly admissionSelector: RelayCellAdmissionSelector private readonly migrationCellRegistrar: RelayMigrationCellRegistrar @@ -625,6 +660,205 @@ export class RelayAssignmentStore { this.migrationCellRegistrar = new RelayMigrationCellRegistrar(database, now) } + // Null when unreadable for a minute: callers then treat every cell as possibly reserve. + // Inside a transaction (any `database` but the pool) it answers from the last read and never + // runs a statement: a failed read there (57014) would abort the transaction, and the next + // statement's 25P02 is not retried. Callers read once before they begin. + async reserveModeCells( + database: Pick = this.database + ): Promise | null> { + const at = performance.now() + const cached = this.reserveModeRead + const answer = (): ReadonlySet | null => { + const read = this.reserveModeRead + return read && performance.now() - read.readAt <= RESERVE_MODE_STALE_MS ? read.cells : null + } + if ( + database !== this.database || + (cached && at - cached.readAt < RESERVE_MODE_CACHE_MS) || + at - this.reserveModeFailedAt < RESERVE_MODE_FAILURE_BACKOFF_MS + ) { + return answer() + } + if (!this.reserveModeInFlight) { + const read: Promise = this.readReserveModes(database).finally(() => { + if (this.reserveModeInFlight === read) this.reserveModeInFlight = null + }) + this.reserveModeInFlight = read + } + await this.reserveModeInFlight + return answer() + } + + private async readReserveModes(database: Pick): Promise { + const at = performance.now() + const generation = this.reserveModeGeneration + try { + // With each reserve cell, whether its own current process says it admits through the + // database (a tripped dead-man): the one read the database path may place on it by. + const rows = await database.query( + `SELECT m.cell_id, e.cell_id AS admits_database + FROM relay_cell_admit_modes m + LEFT JOIN relay_cell_admit_effective e + ON e.cell_id = m.cell_id + AND e.mode = 'db' + AND e.updated_at > ? + AND e.cell_incarnation = + (SELECT r.cell_incarnation FROM relay_cell_runtime r WHERE r.cell_id = m.cell_id) + WHERE m.admit_mode <> 'db'`, + [this.now() - CELL_ADMIT_EFFECTIVE_FRESH_MS] + ) + if (generation !== this.reserveModeGeneration) return + this.reserveModeRead = { + cells: new Set(rows.map((row) => text(row, 'cell_id'))), + admitsDatabase: new Set(rows.filter((row) => row.admits_database != null).map((row) => text(row, 'cell_id'))), + readAt: at + } + } catch (error) { + // A read begun before an admit-mode write says nothing about the table after it. + if (generation === this.reserveModeGeneration) this.reserveModeFailedAt = performance.now() + // Keep the last read until it is stale. Logged at most once a minute: while it fails, + // sweeps soon stop and admin operations are refused, so the cause must be on the record. + if (at - this.reserveModeFailureLoggedAt >= RESERVE_MODE_FAILURE_LOG_MS) { + this.reserveModeFailureLoggedAt = at + console.warn( + JSON.stringify({ + event: 'orca_relay_reserve_mode_read_failed', + reason: error instanceof Error ? error.message : 'unknown', + lastReadAgeMs: this.reserveModeRead ? Math.round(at - this.reserveModeRead.readAt) : null + }) + ) + } + } + } + + // Only from a recent read: a cell re-armed since may be booking from memory again. + private reserveCellAdmitsDatabase(cellId: string): boolean { + const read = this.reserveModeRead + return read !== null && performance.now() - read.readAt <= 2 * RESERVE_MODE_CACHE_MS && read.admitsDatabase.has(cellId) + } + + // Written by the cell itself, best-effort, on every change of its raw mode and every 15 s. + async recordCellAdmitEffective(input: { + cellId: string + cellIncarnation: string + mode: 'db' | 'reserve' + }): Promise { + await this.database.query( + `INSERT INTO relay_cell_admit_effective (cell_id, cell_incarnation, mode, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (cell_id) DO UPDATE SET + cell_incarnation = excluded.cell_incarnation, + mode = excluded.mode, + updated_at = excluded.updated_at`, + [input.cellId, input.cellIncarnation, input.mode, this.now()] + ) + } + + // Break glass flips only a cell that has stopped heartbeating, ready or not. + async cellHeartbeatFresh(cellId: string): Promise { + return await this.heartbeatFresh(this.database, cellId, this.now()) + } + + private async heartbeatFresh(database: Pick, cellId: string, now: number): Promise { + const rows = await database.query( + `SELECT cell_id FROM relay_cell_runtime WHERE cell_id = ? AND last_heartbeat_at > ?`, + [cellId, now - this.heartbeatTtlMs] + ) + return rows.length === 1 + } + + // A reserve cell still heartbeating holds its hosts in memory, ready or not (a database + // brownout drops ready): the same test as break glass, so neither re-places open sockets. + private async liveReserveCell( + cellId: string, + database: RelayDatabase, + now: number + ): Promise { + if (!(await this.mayBeReserveCell(cellId, database))) return false + return !this.requireLiveCells || (await this.heartbeatFresh(database, cellId, now)) + } + + // A stale set beats none: an unreadable table for a minute must not pin every host. Only a + // store that has never read the set treats every cell as possibly reserve. + private async mayBeReserveCell( + cellId: string, + database: Pick = this.database + ): Promise { + const cells = (await this.reserveModeCells(database)) ?? this.reserveModeRead?.cells ?? null + return cells === null || cells.has(cellId) + } + + // Ops on a reserve cell would act on rows its hosts no longer keep; unknown refuses too. + private async refuseReserveCell( + cellId: string, + database: Pick = this.database + ): Promise { + const cells = await this.reserveModeCells(database) + if (cells === null) throw new RelayCellInReserveModeError(null) + if (cells.has(cellId)) throw new RelayCellInReserveModeError(cellId) + } + + async cellAdmitMode(cellId: string): Promise<{ admitMode: 'db' | 'reserve'; updatedAt: number | null }> { + const row = ( + await this.database.query( + `SELECT admit_mode, updated_at FROM relay_cell_admit_modes WHERE cell_id = ?`, + [cellId] + ) + )[0] + return row + ? { admitMode: text(row, 'admit_mode') === 'reserve' ? 'reserve' : 'db', updatedAt: integer(row, 'updated_at') } + : { admitMode: 'db', updatedAt: null } + } + + // Reserve is refused while anything that moves this cell's rows is open: those flows read + // and write per-host rows a reserve cell no longer keeps. + async setCellAdmitMode(cellId: string, admitMode: 'db' | 'reserve'): Promise { + const now = this.now() + await this.database.transaction(async (transaction) => { + if (admitMode === 'reserve') { + // One locked read per table: Postgres refuses FOR UPDATE on a UNION. + const openFlows: Array<[string, string, unknown[]]> = [ + [ + 'migration', + `SELECT user_id FROM relay_assignment_migrations + WHERE (source_cell_id = ? OR target_cell_id = ?) + AND completed_at IS NULL AND aborted_at IS NULL LIMIT 1`, + [cellId, cellId] + ], + [ + 'rehome', + `SELECT attempt_id FROM relay_region_rehome_attempts + WHERE (source_cell_id = ? OR target_cell_id = ?) + AND completed_at IS NULL AND aborted_at IS NULL LIMIT 1`, + [cellId, cellId] + ], + [ + 'drain', + `SELECT attempt_id FROM relay_cell_drain_attempt_states + WHERE cell_id = ? AND state <> 'proven-not-delivered' + AND cell_incarnation = (SELECT cell_incarnation FROM relay_cell_runtime WHERE cell_id = ?) + LIMIT 1`, + [cellId, cellId] + ] + ] + for (const [kind, sql, params] of openFlows) { + if ((await transaction.queryLocked(sql, params))[0]) throw new Error(`cell_has_open_${kind}`) + } + } + await transaction.query( + `INSERT INTO relay_cell_admit_modes (cell_id, admit_mode, updated_at) VALUES (?, ?, ?) + ON CONFLICT (cell_id) DO UPDATE SET admit_mode = excluded.admit_mode, updated_at = excluded.updated_at`, + [cellId, admitMode, now] + ) + }) + this.reserveModeRead = null + this.reserveModeFailedAt = Number.NEGATIVE_INFINITY + this.reserveModeGeneration += 1 + // The next caller reads afresh rather than joining a read from before this write. + this.reserveModeInFlight = null + } + async reconcileCells(cells: RelayCellConfig[], disableMissing = true): Promise { await this.reconcileCellsWithOptions(cells, disableMissing) } @@ -849,6 +1083,7 @@ export class RelayAssignmentStore { preferredRegion?: RelayRegion ): Promise { const now = this.now() + await this.reserveModeCells() return await this.database.transaction(async (transaction) => { // Why: the retry exists to take a cell row before the assignment row, the // order placement uses. It only ever needs the one cell this host is @@ -884,7 +1119,13 @@ export class RelayAssignmentStore { if (!existing) return null const activityLeases = await this.lockAssignmentActivities(transaction, identity, true) await this.recordRegionPreference(transaction, identity, preferredRegion, now) - if (mayNormallyReassign(activity(existing), now)) return null + // A live reserve cell's hosts hold no activity here, so idle is not evidence they left. + if ( + !(await this.liveReserveCell(text(existing, 'cell_id'), transaction, now)) && + mayNormallyReassign(activity(existing), now) + ) { + return null + } const pinnedAdmission = text(existing, 'cell_id') === pinnedCellId ? unlockedAdmission @@ -956,6 +1197,17 @@ export class RelayAssignmentStore { ) { return null } + const leaseExpiresAt = now + ASSIGNMENT_LIMITS.activityLeaseMs + // A reserve cell counts its own controls: the pin is returned with no row written, + // since the sweeps that would release those units skip this cell. + if (!hadControl && (await this.mayBeReserveCell(currentCellId, transaction))) { + return this.result( + identity, + existing, + cell(currentRow, await this.cellRegion(transaction, currentCellId)), + leaseExpiresAt + ) + } if ( !hadControl && !(await this.cellHasConnectionHeadroom(transaction, currentCellId)) @@ -964,7 +1216,6 @@ export class RelayAssignmentStore { throw new Error('relay_connection_headroom_exhausted') } - const leaseExpiresAt = now + ASSIGNMENT_LIMITS.activityLeaseMs if (hadControl) { await this.touchAssignment(transaction, identity, leaseExpiresAt, now) } else { @@ -1059,6 +1310,7 @@ export class RelayAssignmentStore { // so a lock timeout there must retry the same tier, not fall to 'all'. let retryScope: RetriedAssignmentInventoryScope = inventoryScope === 'none' ? 'general' : inventoryScope + await this.reserveModeCells() // Why the events ride back out rather than being written where they are // decided: everything below runs in one transaction, and a reservation or // lease write that fails after the decision rolls the placement back. A @@ -1093,7 +1345,12 @@ export class RelayAssignmentStore { const existing = await this.assignmentRowOrBusy(transaction, identity) // A dormant host holds no units, so its placement never writes its old // cell and needs only the rows it could land on. - const dormant = !existing || mayNormallyReassign(activity(existing), now) + // A live reserve cell's hosts hold no activity rows here: idle there is not dormant. A + // dead one holds no duplicate, so its hosts are re-placed as any dormant host is. + const dormant = + !existing || + (mayNormallyReassign(activity(existing), now) && + !(await this.liveReserveCell(text(existing, 'cell_id'), transaction, now))) retryScope = dormant ? 'general' : isolatedScope ? inventoryScope : 'all' if ( (inventoryScope === 'general' && !dormant) || @@ -1213,6 +1470,19 @@ export class RelayAssignmentStore { current.cellId, integer(existing, 'assignment_epoch') ) + // A reserve cell counts its own controls: the pin is returned with no row written, + // since the sweeps that would release those units skip this cell. + if (!hadControl && (await this.mayBeReserveCell(current.cellId, transaction))) { + return { + assignment: this.result( + identity, + existing, + current, + now + ASSIGNMENT_LIMITS.activityLeaseMs + ), + events + } + } const hasConnectionHeadroom = hadControl || (await this.cellHasConnectionHeadroom(transaction, current.cellId)) @@ -2347,6 +2617,8 @@ export class RelayAssignmentStore { traceValue: string plannedGraceMs: number }): Promise { + // Rolling a switched-on cell needs a flip back first (design default 4). + await this.refuseReserveCell(input.cellId) const now = this.now() return await this.database.transaction(async (transaction) => { await this.assertDrainCellGeneration( @@ -3022,7 +3294,13 @@ export class RelayAssignmentStore { async evacuateDeadCells(limit = 100): Promise { if (!this.requireLiveCells) return 0 const cutoff = this.now() - this.heartbeatTtlMs - const cellIds = await this.deadCellEvacuationCandidates(cutoff) + const reserveCells = await this.reserveModeCells() + if (reserveCells === null) return 0 + // A reserve-mode cell that stops heartbeating is unverifiable, never gone: its hosts + // re-place themselves when their control drops. + const cellIds = (await this.deadCellEvacuationCandidates(cutoff)).filter( + (cellId) => !reserveCells.has(cellId) + ) // Why: without a candidate cell the host query below walks every assignment by primary key to // return nothing (574 ms per call in production, from stale existing-only cells it can never act on). if (cellIds.length === 0) return 0 @@ -3242,6 +3520,8 @@ export class RelayAssignmentStore { targetCellId: string, limit: number ): Promise { + await this.refuseReserveCell(sourceCellId) + await this.refuseReserveCell(targetCellId) const rows = await this.database.query( `SELECT user_id, relay_host_id FROM relay_assignments WHERE cell_id = ? AND @@ -3266,6 +3546,8 @@ export class RelayAssignmentStore { sourceCellId: string, targetCellId: string ): Promise { + await this.refuseReserveCell(sourceCellId) + await this.refuseReserveCell(targetCellId) const cells = await this.database.query( `SELECT * FROM relay_cells WHERE cell_id IN (?, ?) ORDER BY cell_id`, [sourceCellId, targetCellId] @@ -3308,6 +3590,8 @@ export class RelayAssignmentStore { targetCellId: string, completeReady: boolean ): Promise { + await this.refuseReserveCell(sourceCellId) + await this.refuseReserveCell(targetCellId) let completed = 0 let blocked = 0 const sourceFenced = await this.cellHasActiveFence(sourceCellId) @@ -3496,7 +3780,9 @@ export class RelayAssignmentStore { return completed } + // Its host counts here are stale for a reserve-mode cell, so tooling must not read it as empty. async cellDeploymentStatus(cellId: string): Promise { + await this.refuseReserveCell(cellId) const cellRow = ( await this.database.query( `WITH activity AS ( @@ -3870,7 +4156,13 @@ export class RelayAssignmentStore { selectionMs: performance.now() - startedAt, skippedOffRegionSourceCells: selection.skippedOffRegionSourceCells }) - return selection.candidates + // Neither side of an idle rehome may be a reserve-mode cell: the commit moves rows here. + const reserveCells = await this.reserveModeCells() + if (reserveCells === null) return [] + return selection.candidates.filter( + (candidate) => + !reserveCells.has(candidate.sourceCellId) && !reserveCells.has(candidate.targetCellId) + ) } async commitIdleRegionalRehome( @@ -4474,6 +4766,7 @@ export class RelayAssignmentStore { expectedSourceCellId?: string ): Promise { const now = this.now() + await this.reserveModeCells() return await this.database.transaction(async (transaction) => { const assignment = await this.assignmentRow(transaction, identity) if (!assignment) throw new Error('assignment_not_found') @@ -4497,6 +4790,8 @@ export class RelayAssignmentStore { return migration(identity, existing) } if (sourceCellId === targetCellId) throw new Error('target_matches_source') + await this.refuseReserveCell(sourceCellId, transaction) + await this.refuseReserveCell(targetCellId, transaction) await this.lockAssignmentActivities(transaction, identity) await this.lockControlConnectionReservations(transaction, identity) const cells = await this.lockCellInventory(transaction, 'request') @@ -4702,7 +4997,12 @@ export class RelayAssignmentStore { }, inventoryFirst: boolean ): Promise { + // A heartbeating reserve source keeps no activity rows, so its hosts are not provably off + // it. A dead one holds no duplicate. const now = this.now() + if (await this.cellIsLive(this.database, input.sourceCellId, now)) { + await this.refuseReserveCell(input.sourceCellId) + } return await this.database.transaction(async (transaction) => { await this.lockControlConnectionReservations(transaction, identity) let lockedCells: SqlRow[] | undefined @@ -5582,6 +5882,7 @@ export class RelayAssignmentStore { assignmentEpoch: number ): Promise { const now = this.now() + await this.reserveModeCells() await this.database.transaction(async (transaction) => { const assignment = await this.assignmentRow(transaction, identity) const row = ( @@ -5608,6 +5909,8 @@ export class RelayAssignmentStore { const activityLeases = await this.lockAssignmentActivities(transaction, identity) const sourceUnits = activityUnitsForCell(activityLeases, sourceCellId) if (sourceUnits > 0) throw new Error('migration_source_still_active') + // A reserve source keeps no activity rows, so "no source units" proves nothing there. + await this.refuseReserveCell(sourceCellId, transaction) let cellsLocked = false let targetStartedAt = 0 if (this.requireLiveCells) { @@ -5684,12 +5987,15 @@ export class RelayAssignmentStore { targetCellId: string ): Promise { const now = this.now() + await this.reserveModeCells() return await this.database.transaction(async (transaction) => { const assignment = await this.assignmentRow(transaction, identity) if (!assignment) throw new Error('assignment_not_found') if (!mayNormallyReassign(activity(assignment), now)) throw new Error('assignment_active') const sourceCellId = text(assignment, 'cell_id') if (sourceCellId === targetCellId) throw new Error('target_matches_source') + await this.refuseReserveCell(sourceCellId, transaction) + await this.refuseReserveCell(targetCellId, transaction) await this.lockAssignmentActivities(transaction, identity) await this.lockControlConnectionReservations(transaction, identity) const cells = await this.lockCellInventory(transaction, 'request') @@ -7387,6 +7693,8 @@ export class RelayAssignmentStore { } async releaseExpiredActivityLeases(): Promise { + const reserveCells = await this.reserveModeCells() + if (reserveCells === null) return 0 const now = this.now() await this.database.query( `UPDATE relay_control_connection_reservations @@ -7405,7 +7713,7 @@ export class RelayAssignmentStore { const candidates = await this.database.query( `SELECT user_id, relay_host_id, activity_id FROM relay_assignment_activity_leases lease - WHERE expires_at <= ? + WHERE expires_at <= ?${excludeCellsSql('lease.cell_id', [...reserveCells])} AND NOT EXISTS ( SELECT 1 FROM relay_region_rehome_attempts rehome WHERE rehome.user_id = lease.user_id @@ -7431,7 +7739,7 @@ export class RelayAssignmentStore { 'migration:' || CAST(pin.assignment_epoch AS TEXT) ) )`, - [now] + [now, ...reserveCells] ) let released = 0 for (const candidate of candidates) { @@ -7466,13 +7774,18 @@ export class RelayAssignmentStore { } async releaseExpiredActivity(): Promise { + const reserveCells = await this.reserveModeCells() + if (reserveCells === null) return 0 const now = this.now() try { return await this.database.transaction(async (transaction) => { // Only counters no lease backs. A host's leases can sit on more than // one cell, and the lease sweep takes each one's units off its own. const expired = await transaction.queryLocked( - `SELECT * FROM relay_assignments WHERE lease_expires_at <= ? AND + `SELECT * FROM relay_assignments WHERE lease_expires_at <= ?${excludeCellsSql( + 'relay_assignments.cell_id', + [...reserveCells] + )} AND (reserved_controls > 0 OR reserved_splices > 0 OR reserved_invites > 0 OR pending_installs > 0 OR pending_confirmations > 0 OR migration_leases > 0) AND NOT EXISTS ( @@ -7498,7 +7811,7 @@ export class RelayAssignmentStore { AND migration.aborted_at IS NULL ) ORDER BY user_id, relay_host_id`, - [now], + [now, ...reserveCells], { failIfUnavailable: true } ) if (expired.length > 0) await this.lockCellInventory(transaction, 'nowait') @@ -7858,9 +8171,15 @@ export class RelayAssignmentStore { ) : null const connectionHeadroom = await this.connectionHeadroomByCell(database) + // The database pool: a reserve-mode cell takes only the bookings it admits itself. Unknown + // keeps the last set read (stale), not none: placement must not stop with a slow read. + const reserveCells = (await this.reserveModeCells(database)) ?? this.reserveModeRead?.cells ?? new Set() const candidates = rows.filter((row) => { const cellId = text(row, 'cell_id') return ( + // A reserve cell whose own feed says it admits through the database again (its dead-man + // tripped, or a flip back finished) takes placements; the census sweeps still skip it. + (!reserveCells.has(cellId) || this.reserveCellAdmitsDatabase(cellId)) && admission.get(cellId) === 'general' && integer(row, 'reserved_requests') < integer(row, 'capacity_requests') && (!runtimeLoad || runtimeLoad.has(cellId)) && diff --git a/cloud/apps/relay/src/cell-admit-effective-writer.test.ts b/cloud/apps/relay/src/cell-admit-effective-writer.test.ts new file mode 100644 index 000000000000..2b345766759d --- /dev/null +++ b/cloud/apps/relay/src/cell-admit-effective-writer.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { CELL_ADMIT_EFFECTIVE_REFRESH_MS, CellAdmitEffectiveWriter } from './cell-admit-effective-writer.js' + +function setup(options: { fail?: boolean; busy?: boolean } = {}) { + const clock = { value: 1_000 } + const writes: Array<'db' | 'reserve'> = [] + const pending: Array<() => void> = [] + const logs: string[] = [] + const writer = new CellAdmitEffectiveWriter({ + write: async (mode) => { + writes.push(mode) + await new Promise((resolve) => pending.push(resolve)) + if (options.fail) throw new Error('connection refused') + }, + databaseBusy: () => options.busy === true, + now: () => clock.value, + log: (line) => logs.push(line) + }) + const settle = async () => { + while (pending.length > 0) pending.shift()!() + await new Promise((resolve) => setTimeout(resolve, 0)) + } + return { clock, writes, logs, writer, settle } +} + +describe('CellAdmitEffectiveWriter', () => { + it('writes on the first tick, on every change, and every 15 s, one write at a time', async () => { + const { clock, writes, writer, settle } = setup() + writer.tick('reserve', true) + writer.tick('db', true) + expect(writes).toEqual(['reserve']) + await settle() + // Tripped: the switch still says reserve, the raw mode db. + writer.tick('db', true) + await settle() + writer.tick('db', true) + expect(writes).toEqual(['reserve', 'db']) + clock.value += CELL_ADMIT_EFFECTIVE_REFRESH_MS + writer.tick('db', true) + expect(writes).toEqual(['reserve', 'db', 'db']) + }) + + // Step 5 off: a db switch never writes, so the dark roll adds no database write. + it('writes nothing while the switch has never said reserve, and one db write on the way out', async () => { + const { clock, writes, writer, settle } = setup() + writer.tick('db', false) + clock.value += 10 * CELL_ADMIT_EFFECTIVE_REFRESH_MS + writer.tick('db', false) + expect(writes).toEqual([]) + writer.tick('reserve', true) + await settle() + writer.tick('reserve', false) + await settle() + expect(writes).toEqual(['reserve', 'db']) + clock.value += 10 * CELL_ADMIT_EFFECTIVE_REFRESH_MS + writer.tick('db', false) + expect(writes).toEqual(['reserve', 'db']) + }) + + it('owes nothing more once a tripped cell already wrote db', async () => { + const { clock, writes, writer, settle } = setup() + writer.tick('db', true) + await settle() + clock.value += CELL_ADMIT_EFFECTIVE_REFRESH_MS + writer.tick('db', false) + writer.tick('db', false) + expect(writes).toEqual(['db']) + }) + + it('takes no connection while requests wait for the pool', () => { + const { writes, writer } = setup({ busy: true }) + writer.tick('db', true) + expect(writes).toEqual([]) + }) + + // Best-effort: a failed write never throws into the cell, logs at most once a minute, and retries. + it('retries a failed write on the next tick and rate-limits its log line', async () => { + const { clock, writes, logs, writer, settle } = setup({ fail: true }) + writer.tick('db', true) + await settle() + clock.value += 1_000 + writer.tick('db', true) + await settle() + expect(writes).toEqual(['db', 'db']) + expect(logs).toHaveLength(1) + expect(JSON.parse(logs[0]!)).toMatchObject({ event: 'orca_relay_cell_admit_effective_write_failed', mode: 'db' }) + }) +}) diff --git a/cloud/apps/relay/src/cell-admit-effective-writer.ts b/cloud/apps/relay/src/cell-admit-effective-writer.ts new file mode 100644 index 000000000000..1250f1770aaf --- /dev/null +++ b/cloud/apps/relay/src/cell-admit-effective-writer.ts @@ -0,0 +1,63 @@ +// The cell's raw admit mode in Postgres (relay_cell_admit_effective), so the database path can +// place on a tripped reserve cell even when no director's feed poll reaches it. Only the database +// path's PG-reserve cells need a row, so a cell writes only while its switch says reserve, plus +// one db write on the way out: with step 5 off, no cell writes anything. +export const CELL_ADMIT_EFFECTIVE_REFRESH_MS = 15_000 +const CELL_ADMIT_EFFECTIVE_FAILURE_LOG_MS = 60_000 + +export class CellAdmitEffectiveWriter { + private written: { mode: 'db' | 'reserve'; at: number } | null = null + private inFlight = false + // A reserve row may stand: the db write that replaces it is still owed. + private owed = false + private failureLoggedAt = Number.NEGATIVE_INFINITY + + constructor( + private readonly input: { + write: (mode: 'db' | 'reserve') => Promise + // Never takes a connection a waiting request needs; the next tick tries again. + databaseBusy: () => boolean + now: () => number + log?: (line: string) => void + } + ) {} + + // Best-effort: never awaited, never throws, at most one write in flight. `switchReserve` is the + // applied switch file's admitMode (a tripped dead-man keeps it reserve, with `mode` db). + tick(rawMode: 'db' | 'reserve', switchReserve: boolean): void { + if (switchReserve) this.owed = true + else if (!this.owed) return + else if (this.written?.mode === 'db' && !this.inFlight) { + this.owed = false + return + } + const mode = switchReserve ? rawMode : 'db' + const at = this.input.now() + const due = + this.written === null || + this.written.mode !== mode || + at - this.written.at >= CELL_ADMIT_EFFECTIVE_REFRESH_MS + if (!due || this.inFlight || this.input.databaseBusy()) return + this.inFlight = true + void this.input + .write(mode) + .then(() => { + this.written = { mode, at } + if (!switchReserve) this.owed = false + }) + .catch((error: unknown) => { + if (at - this.failureLoggedAt < CELL_ADMIT_EFFECTIVE_FAILURE_LOG_MS) return + this.failureLoggedAt = at + ;(this.input.log ?? ((line: string) => console.warn(line)))( + JSON.stringify({ + event: 'orca_relay_cell_admit_effective_write_failed', + mode, + reason: error instanceof Error ? error.message : 'unknown' + }) + ) + }) + .finally(() => { + this.inFlight = false + }) + } +} diff --git a/cloud/apps/relay/src/cell-reserve-rollback.blackbox.test.ts b/cloud/apps/relay/src/cell-reserve-rollback.blackbox.test.ts index 2b039af92f23..71500d481762 100644 --- a/cloud/apps/relay/src/cell-reserve-rollback.blackbox.test.ts +++ b/cloud/apps/relay/src/cell-reserve-rollback.blackbox.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' +import { CELL_ADMIT_EFFECTIVE_REFRESH_MS } from './cell-admit-effective-writer.js' import { RESERVE_DEAD_MAN_MS, reserveDeadManWindowMs } from './cell-reserve-dead-man.js' import { openRelayDatabase } from './database.js' import { @@ -34,7 +35,7 @@ describe('flipping a reserve-mode cell back to the database', () => { vi.restoreAllMocks() }) - async function cell(now?: () => number) { + async function cell(now?: () => number, options: { flagsUnread?: boolean } = {}) { vi.spyOn(console, 'log').mockImplementation(() => undefined) vi.spyOn(console, 'warn').mockImplementation(() => undefined) vi.spyOn(console, 'error').mockImplementation(() => undefined) @@ -42,7 +43,7 @@ describe('flipping a reserve-mode cell back to the database', () => { cleanup.push(() => rmSync(dataDir, { recursive: true, force: true })) const database = await openRelayDatabase({ dataDir }) cleanup.push(() => database.close()) - return await startReserveModeCell({ database, dataDir, cleanup, ...(now ? { now } : {}) }) + return await startReserveModeCell({ database, dataDir, cleanup, ...options, ...(now ? { now } : {}) }) } it('registers every memory-admitted control without disconnecting any, matching the seat report', async () => { @@ -228,11 +229,33 @@ describe('flipping a reserve-mode cell back to the database', () => { expect(reply.socket.readyState).toBe(WebSocket.OPEN) }, 30_000) + // Step 5 off stays write-free: a db switch (read, generation > 0) writes no row, nor does a + // booted cell before its first flag read. Leaving reserve writes db once, then nothing. + it('writes an admit-effective row only while its switch says reserve, plus one db write on the way out', async () => { + let now = Date.now() + const booting = await cell(() => now, { flagsUnread: true }) + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect(await booting.admitEffectiveRow()).toBeUndefined() + booting.setAdmitMode('db') + now += 3 * CELL_ADMIT_EFFECTIVE_REFRESH_MS + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect(await booting.admitEffectiveRow()).toBeUndefined() + booting.setAdmitMode('reserve') + await until(async () => (await booting.admitEffectiveRow())?.mode === 'reserve') + booting.setAdmitMode('db') + await until(async () => (await booting.admitEffectiveRow())?.mode === 'db') + const last = (await booting.admitEffectiveRow())!.updated_at + now += 3 * CELL_ADMIT_EFFECTIVE_REFRESH_MS + await new Promise((resolve) => setTimeout(resolve, 2_500)) + expect((await booting.admitEffectiveRow())!.updated_at).toEqual(last) + }, 30_000) + it('flips itself back to db when no placing director has been in touch for the dead-man window', async () => { let now = Date.now() const reserve = await cell(() => now) const seated = await reserve.bookedHost(3) expect(reserve.relay.sessions.inReserveMode()).toBe(true) + await until(async () => (await reserve.admitEffectiveRow())?.mode === 'reserve') const windowMs = reserveDeadManWindowMs(reserve.config.cellId) expect(windowMs).toBeGreaterThanOrEqual(RESERVE_DEAD_MAN_MS) expect(windowMs).toBeLessThanOrEqual(2 * RESERVE_DEAD_MAN_MS) @@ -252,6 +275,10 @@ describe('flipping a reserve-mode cell back to the database', () => { .mock.calls.map(([line]) => String(line)) .filter((line) => line.includes('orca_relay_cell_reserve_dead_man_tripped')) expect(tripped.map((line) => JSON.parse(line).cellId)).toEqual([reserve.config.cellId]) + // The cell's own row says db at once, while Postgres still records reserve: the database + // path places on it by this row, whether or not any director's feed poll gets through. + await until(async () => (await reserve.admitEffectiveRow())?.mode === 'db') + expect((await reserve.admitEffectiveRow())?.cell_incarnation).toBe(reserve.relay.cellIncarnation) await until(async () => (await reserve.controlLeases()).length === 2) expect(seated.socket.readyState).toBe(WebSocket.OPEN) // Latched for this switch generation: contact again does not bring reserve back. diff --git a/cloud/apps/relay/src/cell-seat-feed-route.ts b/cloud/apps/relay/src/cell-seat-feed-route.ts index 422437bbb214..59cc6f51365c 100644 --- a/cloud/apps/relay/src/cell-seat-feed-route.ts +++ b/cloud/apps/relay/src/cell-seat-feed-route.ts @@ -22,6 +22,8 @@ export function registerCellSeatFeedRoute( // A placing director's poll (reserver=1) feeds the cell's dead-man. onReserverPoll?: () => void admitModeEffective?: () => 'db' | 'reserve' + // Switch file and dead-man only: db the moment the cell trips, while re-registration runs. + admitModeRaw?: () => 'db' | 'reserve' // Step 5: what a director needs to estimate a seat and a booking here. reserveCounts?: () => { bookings: number @@ -74,6 +76,7 @@ export function registerCellSeatFeedRoute( // Applied, never desired: generation 0 means no object has been read since boot. ...(input.cellFlags ? { flagsApplied: input.cellFlags() } : {}), ...(input.admitModeEffective ? { admitModeEffective: input.admitModeEffective() } : {}), + ...(input.admitModeRaw ? { admitModeRaw: input.admitModeRaw() } : {}), ...page }) }) diff --git a/cloud/apps/relay/src/database.test.ts b/cloud/apps/relay/src/database.test.ts index bfeff59c69f6..b4aa9c13a23b 100644 --- a/cloud/apps/relay/src/database.test.ts +++ b/cloud/apps/relay/src/database.test.ts @@ -62,6 +62,8 @@ describe('relay database', () => { 'relay_assignments', 'relay_audit_events', 'relay_cell_admission', + 'relay_cell_admit_effective', + 'relay_cell_admit_modes', 'relay_cell_capabilities', 'relay_cell_committed_fences', 'relay_cell_connection_limits', diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index fa1ac57acadd..8569fcca53c9 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -359,6 +359,22 @@ CREATE TABLE IF NOT EXISTS relay_cell_admission ( roll_isolated_at BIGINT ); +-- Step 5: written by the flag workflow. A cell with no row is in database mode. +CREATE TABLE IF NOT EXISTS relay_cell_admit_modes ( + cell_id TEXT PRIMARY KEY, + admit_mode TEXT NOT NULL CHECK (admit_mode IN ('db', 'reserve')), + updated_at BIGINT NOT NULL +); + +-- Step 5: written by each cell, its raw mode (switch file and dead-man latch). The database +-- path places on a reserve cell only while this row, from its current process, says db. +CREATE TABLE IF NOT EXISTS relay_cell_admit_effective ( + cell_id TEXT PRIMARY KEY, + cell_incarnation TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('db', 'reserve')), + updated_at BIGINT NOT NULL +); + CREATE TABLE IF NOT EXISTS relay_admission_selectors ( selector_id TEXT PRIMARY KEY, generation BIGINT NOT NULL, diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index e2066bfd91e8..44d871072fd1 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -19,6 +19,7 @@ import { createRemoteJWKSet } from 'jose' import { WebSocketServer } from 'ws' import type WebSocket from 'ws' import type { RawData } from 'ws' +import { CellAdmitEffectiveWriter } from './cell-admit-effective-writer.js' import { ReserveDeadMan, reserveDeadManWindowMs } from './cell-reserve-dead-man.js' import { createRelayApp } from './app.js' import { classifyAssignmentLease } from './assignment-lease.js' @@ -382,6 +383,15 @@ export function createRelayServer( // Expired bookings give their units back even while no director is reserving. A flip out of // reserve mode voids the bookings and registers every control admitted from memory. let appliedAdmitMode = effectiveAdmitMode() + const admitEffective = + config.role === 'cell' && reserveBook + ? new CellAdmitEffectiveWriter({ + write: async (mode) => + await assignments.recordCellAdmitEffective({ cellId: config.cellId, cellIncarnation, mode }), + databaseBusy: () => readRelayDatabasePoolPressure(database).databasePoolWaiting > 0, + now: options.now ?? Date.now + }) + : undefined const reserveSweepTimer = reserveBook ? setInterval(() => { reserveBook.sweep() @@ -391,6 +401,8 @@ export function createRelayServer( sessions.reregisterMemoryControls() } appliedAdmitMode = admitMode + // A booted cell runs db defaults until its first flag read, so it writes nothing until then. + admitEffective?.tick(admitMode, options.cellFlags?.().flags.admitMode === 'reserve') }, RESERVE_MODE_WATCH_MS) : null reserveSweepTimer?.unref() @@ -422,6 +434,7 @@ export function createRelayServer( cellReserverPoll: () => reserveDeadMan?.contact(), // Reserve until every control it admitted from memory holds a lease again: the flag // workflow records db in Postgres (and sweeps resume) only after this says db. + cellAdmitModeRaw: effectiveAdmitMode, cellAdmitModeEffective: (): 'db' | 'reserve' => effectiveAdmitMode() === 'reserve' || sessions.reregistrationPending() > 0 ? 'reserve' diff --git a/cloud/apps/relay/src/reserve-assignment.test.ts b/cloud/apps/relay/src/reserve-assignment.test.ts index 7af627d7aa89..56c465fd0d53 100644 --- a/cloud/apps/relay/src/reserve-assignment.test.ts +++ b/cloud/apps/relay/src/reserve-assignment.test.ts @@ -121,6 +121,17 @@ describe('reserve assignment on a director', () => { expect(await tripped.assignment.plan(HOST, { reconnect: false, region: US })).toEqual({ kind: 'database' }) const leasing = setup({ feeds: [feed('c1', { admitMode: 'db', admitModeEffective: 'reserve' }), feed('c2')] }) expect(leasing.directory.admitModeOf('c1')).toBe('reserve') + // A tripped cell still re-registering reports reserve as effective but db as raw: placement + // treats it as a database cell at once, books nothing there and paces nobody for it. + const reregistering = setup({ + feeds: [ + feed('c1', { admitModeEffective: 'reserve', admitModeRaw: 'db' }), + feed('c2', { admitModeEffective: 'reserve', admitModeRaw: 'db' }) + ] + }) + expect(await reregistering.assignment.plan(HOST, { reconnect: false, region: US })).toEqual({ kind: 'database' }) + expect(reregistering.reserved).toEqual([]) + expect(reregistering.directory.admitModeOf('c1')).toBe('reserve') }) it('before every live cell has answered, holds back only hosts that may be on an unheard cell', async () => { diff --git a/cloud/apps/relay/src/reserve-assignment.ts b/cloud/apps/relay/src/reserve-assignment.ts index 2fa46f230bfa..47fe8d668830 100644 --- a/cloud/apps/relay/src/reserve-assignment.ts +++ b/cloud/apps/relay/src/reserve-assignment.ts @@ -115,7 +115,7 @@ export class ReserveAssignment { return this.input.cells().flatMap((cell) => { const state = directory.cellState(cell.cellId) if (!state) return [] - const reserve = directory.admitModeOf(cell.cellId) === 'reserve' + const reserve = directory.placementModeOf(cell.cellId) === 'reserve' return [ { cellId: cell.cellId, @@ -136,7 +136,7 @@ export class ReserveAssignment { } private hasReserveCells(): boolean { - return this.input.directory.cellIds().some((cellId) => this.input.directory.admitModeOf(cellId) === 'reserve') + return this.input.directory.cellIds().some((cellId) => this.input.directory.placementModeOf(cellId) === 'reserve') } async plan(identity: Identity, request: { reconnect: boolean; region: RelayRegion }): Promise { @@ -209,7 +209,7 @@ export class ReserveAssignment { await this.placeFresh(identity, request.region, epochFloor, seats) // Where the host was last seen decides who owns it: a database-mode cell keeps today's // path, sticky included; only a fresh placement may be booked on a reserve-mode cell. - const lastOnReserve = lastCellId !== undefined && directory.admitModeOf(lastCellId) === 'reserve' + const lastOnReserve = lastCellId !== undefined && directory.placementModeOf(lastCellId) === 'reserve' if (!lastOnReserve && (seats.length > 0 || left !== undefined || row !== null)) { return { kind: 'database', epochFloor, placeFresh } } diff --git a/cloud/apps/relay/src/shadow-seat-directory.test.ts b/cloud/apps/relay/src/shadow-seat-directory.test.ts index 0ed43722f217..e57428a6b032 100644 --- a/cloud/apps/relay/src/shadow-seat-directory.test.ts +++ b/cloud/apps/relay/src/shadow-seat-directory.test.ts @@ -528,3 +528,31 @@ describe('RelayAssignmentStore.seatFeedCells', () => { ]) }) }) + +describe('ShadowSeatDirectory.placementModeOf', () => { + const response = ( + modes: { admitModeEffective?: 'db' | 'reserve'; admitModeRaw?: 'db' | 'reserve' } = {} + ): SeatFeedResponse => ({ + v: 1, + cellId: 'cell-a', + incarnation: INCARNATION, + seq: 1, + at: 0, + counts: { seats: 0, controls: 0, bookings: 0, units: 0, ceiling: 500 }, + intake: { perSec: 100, burst: 20, tokens: 20 }, + flagsApplied: { generation: 3, flags: { admitMode: 'reserve' } }, + full: [], + ...modes + }) + + // A tripped cell re-registering still reports reserve as effective; placement reads raw db. + it('reads the raw mode when the cell sends it, and the effective mode otherwise', () => { + const directory = new ShadowSeatDirectory() + directory.setCells(['cell-a']) + directory.apply('cell-a', response({ admitModeEffective: 'reserve', admitModeRaw: 'db' }), 1_000, 1_000) + expect(directory.placementModeOf('cell-a')).toBe('db') + expect(directory.admitModeOf('cell-a')).toBe('reserve') + directory.apply('cell-a', response({ admitModeEffective: 'reserve' }), 2_000, 2_000) + expect(directory.placementModeOf('cell-a')).toBe('reserve') + }) +}) diff --git a/cloud/apps/relay/src/shadow-seat-directory.ts b/cloud/apps/relay/src/shadow-seat-directory.ts index 7255284cdaca..6a491123e718 100644 --- a/cloud/apps/relay/src/shadow-seat-directory.ts +++ b/cloud/apps/relay/src/shadow-seat-directory.ts @@ -78,6 +78,8 @@ const SeatFeedSchema = z.object({ // What the cell acts on: reserve also while a flip back is still leasing its controls, or // db once its dead-man has flipped it back. Absent from older images. admitModeEffective: z.enum(['db', 'reserve']).optional(), + // Switch file and dead-man only, without the flip back: db the moment the cell trips. + admitModeRaw: z.enum(['db', 'reserve']).optional(), changes: z.array(SeatChangeSchema).optional(), // The cell cut the page (2,000 changes); poll again at once. more: z.boolean().optional(), @@ -142,6 +144,7 @@ export type SeatFeedCellState = { // The incarnation whose feed reported `flagsApplied`. flagsIncarnation?: string admitModeEffective?: 'db' | 'reserve' + admitModeRaw?: 'db' | 'reserve' // Step 5: when the answered poll was sent, and the cell's own reserve numbers. polledAt?: number bookings?: number @@ -261,6 +264,7 @@ export class ShadowSeatDirectory { cursor.flagsApplied = response.flagsApplied cursor.flagsIncarnation = response.incarnation cursor.admitModeEffective = response.admitModeEffective + cursor.admitModeRaw = response.admitModeRaw cursor.polledAt = polledAt cursor.bookings = response.counts?.bookings cursor.units = response.counts?.units @@ -280,6 +284,13 @@ export class ShadowSeatDirectory { return cursor?.flagsApplied?.flags.admitMode === 'reserve' ? 'reserve' : 'db' } + // What placement treats the cell as: a tripped cell is a database cell at once, so nothing + // books into it or paces for it while it re-registers. Older images report no raw mode. + placementModeOf(cellId: string): 'db' | 'reserve' { + const raw = this.cells.get(cellId)?.admitModeRaw + return raw ?? this.admitModeOf(cellId) + } + cellIds(): string[] { return [...this.cells.keys()] } @@ -317,6 +328,7 @@ export class ShadowSeatDirectory { cursor.flagsApplied = undefined cursor.flagsIncarnation = undefined cursor.admitModeEffective = undefined + cursor.admitModeRaw = undefined } } diff --git a/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts b/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts index aff1f7e1ea31..0242f499a631 100644 --- a/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts +++ b/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts @@ -48,6 +48,8 @@ export async function startReserveModeCell(input: { cleanup: Array<() => Promise | void> databasePoolMax?: number now?: () => number + // Booted, no flag object read yet (generation 0, defaults). + flagsUnread?: boolean }) { const keys = await generateKeyPair('ES256') const publicJwk = await exportJWK(keys.publicKey) @@ -90,10 +92,9 @@ export async function startReserveModeCell(input: { publicAssignmentRetryAfterSeconds: 5, dataDir: input.dataDir } satisfies RelayConfig - let applied: AppliedControlFlags = { - generation: 1, - flags: { ...CELL_FLAG_DEFAULTS, admitMode: 'reserve' } - } + let applied: AppliedControlFlags = input.flagsUnread + ? { generation: 0, flags: CELL_FLAG_DEFAULTS } + : { generation: 1, flags: { ...CELL_FLAG_DEFAULTS, admitMode: 'reserve' } } const relay = createRelayServer(config, input.database, { cellFlags: () => applied, ...(input.now ? { now: input.now } : {}) @@ -204,6 +205,14 @@ export async function startReserveModeCell(input: { WHERE activity_kind = 'control' ORDER BY relay_host_id` ) + const admitEffectiveRow = async () => + ( + await input.database.query( + `SELECT cell_incarnation, mode, updated_at FROM relay_cell_admit_effective WHERE cell_id = ?`, + [config.cellId] + ) + )[0] + return { relay, config, @@ -212,6 +221,7 @@ export async function startReserveModeCell(input: { insertRow, bookedHost, controlLeases, + admitEffectiveRow, setAdmitMode: (admitMode: 'db' | 'reserve', flags: Partial = {}) => { applied = { generation: applied.generation + 1, flags: { ...CELL_FLAG_DEFAULTS, ...flags, admitMode } } } From 9029e84883daeaca7efaed49bfcf98b720f4f710 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:22:21 -0400 Subject: [PATCH 028/110] feat(relay): log metric and console alert for unreadable cell switch files (evidence code) (#27024) --- .../terraform-root-partition/families.json | 2 + .../relay-control-flag-alerts.test.mjs | 37 ++++++++++ cloud/infra/terraform/relay-observability.tf | 68 +++++++++++++++++++ cloud/package.json | 2 +- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 cloud/dev/scripts/relay-control-flag-alerts.test.mjs diff --git a/cloud/dev/fixtures/terraform-root-partition/families.json b/cloud/dev/fixtures/terraform-root-partition/families.json index 200de64d77a1..0fa56d63cd86 100644 --- a/cloud/dev/fixtures/terraform-root-partition/families.json +++ b/cloud/dev/fixtures/terraform-root-partition/families.json @@ -137,6 +137,7 @@ "google_logging_metric.relay_assignment_lease_shadow", "google_logging_metric.relay_cell_fix_level", "google_logging_metric.relay_cell_pool_herd", + "google_logging_metric.relay_control_flags_unreadable", "google_logging_metric.relay_incident", "google_logging_metric.relay_long_cell_inventory_hold", "google_logging_metric.relay_snapshot", @@ -152,6 +153,7 @@ "google_monitoring_alert_policy.relay_cloud_sql_backends", "google_monitoring_alert_policy.relay_cloud_sql_checkpoint_loop", "google_monitoring_alert_policy.relay_cloud_sql_disk", + "google_monitoring_alert_policy.relay_control_flags_unreadable", "google_monitoring_alert_policy.relay_custom", "google_monitoring_alert_policy.relay_database_rejection_fenced", "google_monitoring_alert_policy.relay_director_cell_inventory_hold", diff --git a/cloud/dev/scripts/relay-control-flag-alerts.test.mjs b/cloud/dev/scripts/relay-control-flag-alerts.test.mjs new file mode 100644 index 000000000000..95604f29d6e5 --- /dev/null +++ b/cloud/dev/scripts/relay-control-flag-alerts.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +// Why: the metric keys on the event name and fields the switch-file reader writes. A rename on +// either side silences the alert without failing anything. + +const read = (relative) => readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8') +const terraform = read('../../infra/terraform/relay-observability.tf') +const channel = read('../../apps/relay/src/relay-control-flag-channel.ts') +const cellFlags = read('../../apps/relay/src/cell-flags.ts') + +const block = (kind, name) => { + const body = new RegExp(`resource "${kind}" "${name}" \\{([\\s\\S]*?)\\n\\}`).exec(terraform)?.[1] + assert.ok(body, `${kind}.${name} not found in relay-observability.tf`) + return body +} + +test('the metric counts the event and fields the reader writes', () => { + const unreadableLine = /event: 'orca_relay_control_flags_unreadable',\s*object: input\.objectName,\s*failure,/ + assert.match(channel, unreadableLine) + assert.match(cellFlags, /return `cells\/\$\{cellId\}\.json`/) + const metric = block('google_logging_metric', 'relay_control_flags_unreadable') + assert.ok(metric.includes('jsonPayload.event=\\"orca_relay_control_flags_unreadable\\"')) + assert.ok(metric.includes('REGEXP_EXTRACT(jsonPayload.object, \\"cells/([^.]+)\\")')) + assert.ok(metric.includes('failure = "EXTRACT(jsonPayload.failure)"')) +}) + +test('the alert fires on one line per cell and stays in the console', () => { + const alert = block('google_monitoring_alert_policy', 'relay_control_flags_unreadable') + // The reader logs once per change of cause, so a stuck cell writes one line; a higher bar misses it. + assert.match(alert, /threshold_value = 0\n/) + assert.match(alert, /notification_channels = \[\]/) + assert.ok(alert.includes('metric.type=\\"logging.googleapis.com/user/orca_relay_control_flags_unreadable\\"')) + assert.ok(alert.includes('group_by_fields = ["metric.label.\\"cell_id\\""]')) +}) diff --git a/cloud/infra/terraform/relay-observability.tf b/cloud/infra/terraform/relay-observability.tf index a048a31667ff..d5bdf31b44ae 100644 --- a/cloud/infra/terraform/relay-observability.tf +++ b/cloud/infra/terraform/relay-observability.tf @@ -1539,3 +1539,71 @@ resource "google_monitoring_alert_policy" "relay_assignment_lease_bad_signature" depends_on = [google_logging_metric.relay_assignment_lease_shadow] } + +# relay-control-flag-channel.ts logs one line per change of cause, not per failed poll, and nothing +# on recovery: a cell stuck unreadable writes exactly one line, so any count is the signal. +resource "google_logging_metric" "relay_control_flags_unreadable" { + project = var.project_id + name = "orca_relay_control_flags_unreadable" + description = "Cells that started failing to read their switch file (cells/.json in the relay-control bucket). The cell keeps its last applied switches." + filter = "resource.type=\"gce_instance\" AND jsonPayload.event=\"orca_relay_control_flags_unreadable\"" + label_extractors = { + cell_id = "REGEXP_EXTRACT(jsonPayload.object, \"cells/([^.]+)\")" + failure = "EXTRACT(jsonPayload.failure)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier." + } + labels { + key = "failure" + value_type = "STRING" + description = "metadata, not-found, http, network, malformed, or void (the object failed the parser)." + } + } +} + +# Console only until the step-3/5 roll finishes: a single storage 5xx on one cell also writes a line. +resource "google_monitoring_alert_policy" "relay_control_flags_unreadable" { + project = var.project_id + display_name = "Orca Relay: cell cannot read its switch file" + combiner = "OR" + enabled = true + notification_channels = [] + + conditions { + display_name = "Switch-file read failures on a cell" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_control_flags_unreadable\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A cell logged `orca_relay_control_flags_unreadable`: its read of `cells/.json` in `-relay-control` failed, and it keeps its last applied switches (defaults, all off, if it never read one). The line is written once per change of cause and nothing marks recovery, so this incident closing does not mean the cell recovered: compare the object's generation with the cell's last `orca_relay_cell_flags_applied` line. `failure` says why: `not-found` (no object for this cell), `metadata` or `http` (token, IAM, or storage), `network`, `malformed` or `void` (the object is unparseable or failed the schema; fix the object). Many cells at once points to the bucket or its IAM. A one-off `http` or `network` that the next poll cleared needs nothing." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_control_flags_unreadable] +} diff --git a/cloud/package.json b/cloud/package.json index 9ec7b478c912..1b5b928ce4c4 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -21,7 +21,7 @@ "load:relay:model": "node dev/scripts/run-relay-load-model.mjs", "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", - "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", + "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-control-flag-alerts.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, From c33fa877d442040f3cf3f98728ec14f520929cbb Mon Sep 17 00:00:00 2001 From: Luchong Date: Sat, 3 Oct 2026 14:00:43 +0800 Subject: [PATCH 029/110] fix(accounts): remove a rejected GLM key when restore fails A failed plaintext replacement can publish the new key before file restriction is confirmed. If restoring the previous key fails before it replaces that file, delete only the rejected envelope so a completed restore is left intact. --- .../zcode/zcode-plan-api-key-store.test.ts | 37 +++++++++++++++++++ src/main/zcode/zcode-plan-api-key-store.ts | 24 ++++++++---- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts index ed53cd28e7b3..75736af5f591 100644 --- a/src/main/zcode/zcode-plan-api-key-store.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -170,6 +170,43 @@ describe('zcode-plan-api-key-store', () => { warn.mockRestore() }) + it('removes a rejected plaintext replacement when restoring the previous key fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const previous = Buffer.from(envelope('encrypted', 'old-key')) + const attempted = envelope('plaintext', 'new-key') + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValueOnce(previous).mockReturnValueOnce(Buffer.from(attempted)) + writeSecureFileMock.mockReturnValueOnce(false).mockImplementationOnce(() => { + throw new Error('restore failed before publish') + }) + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('new-key')).toThrow( + 'could not be stored securely on this device' + ) + expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true }) + warn.mockRestore() + }) + + it('keeps a restored key when rollback fails after the previous envelope is published', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const previous = Buffer.from(envelope('encrypted', 'old-key')) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(previous) + writeSecureFileMock.mockReturnValueOnce(false).mockImplementationOnce(() => { + throw new Error('restore failed after publish') + }) + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('new-key')).toThrow( + 'could not be stored securely on this device' + ) + expect(rmSyncMock).not.toHaveBeenCalledWith(storePath, expect.anything()) + warn.mockRestore() + }) + it('refuses to decrypt an encrypted envelope once safeStorage becomes unavailable', async () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) existsSyncMock.mockReturnValue(true) diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts index 45c4ad315ac6..cdf775875eec 100644 --- a/src/main/zcode/zcode-plan-api-key-store.ts +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -84,6 +84,19 @@ export function getZcodePlanApiKeyProtection(): SecretAtRestProtection | null { } } +function removeRejectedPlaintextEnvelope(keyPath: string, attemptedEnvelope: string): void { + let published: string + try { + published = readFileSync(keyPath).toString('utf8') + } catch { + return + } + // Why: only the rejected replacement is removed; a restore that already landed must stay. + if (published === attemptedEnvelope) { + rmSync(keyPath, { force: true }) + } +} + export function saveZcodePlanApiKey(key: string): void { const trimmed = key.trim() if (!trimmed) { @@ -115,10 +128,8 @@ export function saveZcodePlanApiKey(key: string): void { previousEnvelope = null } } - const wroteRestricted = writeSecureFile( - keyPath, - encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) - ) + const attemptedEnvelope = encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) + const wroteRestricted = writeSecureFile(keyPath, attemptedEnvelope) // Why: an unrestricted plaintext credential must never be reported as saved. if (!wroteRestricted) { if (!previousEnvelope) { @@ -127,9 +138,8 @@ export function saveZcodePlanApiKey(key: string): void { try { writeSecureFile(keyPath, previousEnvelope.toString('utf8')) } catch { - // Why: restriction is failing device-wide; the restored bytes keep the - // previous credential available instead of deleting it, and the thrown - // save error still tells the user the store is not secure. + // Why: a restore that fails before replacing the file leaves the rejected key published. + removeRejectedPlaintextEnvelope(keyPath, attemptedEnvelope) } } throw new Error('GLM Coding Plan API key could not be stored securely on this device') From ae573a90634343a679ad1c3ce2a7f5d4e015c467 Mon Sep 17 00:00:00 2001 From: Luchong Date: Sat, 3 Oct 2026 14:26:55 +0800 Subject: [PATCH 030/110] fix(accounts): keep an identical plaintext key after a failed restore A restored plaintext envelope can match the rejected replacement byte for byte. Do not delete that file, since it is the previous key rather than a new secret. --- .../zcode/zcode-plan-api-key-store.test.ts | 18 ++++++++++++++++++ src/main/zcode/zcode-plan-api-key-store.ts | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts index 75736af5f591..864ef384582e 100644 --- a/src/main/zcode/zcode-plan-api-key-store.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -207,6 +207,24 @@ describe('zcode-plan-api-key-store', () => { warn.mockRestore() }) + it('keeps an identical plaintext key when restore fails after publishing it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const sameKey = Buffer.from(envelope('plaintext', 'same-key')) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(sameKey) + writeSecureFileMock.mockReturnValueOnce(false).mockImplementationOnce(() => { + throw new Error('restore failed after publish') + }) + const store = await loadStore() + + expect(() => store.saveZcodePlanApiKey('same-key')).toThrow( + 'could not be stored securely on this device' + ) + expect(rmSyncMock).not.toHaveBeenCalledWith(storePath, expect.anything()) + warn.mockRestore() + }) + it('refuses to decrypt an encrypted envelope once safeStorage becomes unavailable', async () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) existsSyncMock.mockReturnValue(true) diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts index cdf775875eec..00ee40efc64d 100644 --- a/src/main/zcode/zcode-plan-api-key-store.ts +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -138,8 +138,10 @@ export function saveZcodePlanApiKey(key: string): void { try { writeSecureFile(keyPath, previousEnvelope.toString('utf8')) } catch { - // Why: a restore that fails before replacing the file leaves the rejected key published. - removeRejectedPlaintextEnvelope(keyPath, attemptedEnvelope) + // Why: identical bytes are the previous key, not a new rejected secret. + if (previousEnvelope.toString('utf8') !== attemptedEnvelope) { + removeRejectedPlaintextEnvelope(keyPath, attemptedEnvelope) + } } } throw new Error('GLM Coding Plan API key could not be stored securely on this device') From 1f6ab26ec6ee783ed365e2339d7fed799785a1f6 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 22:04:42 -0700 Subject: [PATCH 031/110] fix(accounts): preserve competing keys during failed GLM saves --- ...n-api-key-store-plaintext-rollback.test.ts | 342 ++++++++++++++++++ ...lan-api-key-store-publication-race.test.ts | 160 ++++++++ .../zcode/zcode-plan-api-key-store.test.ts | 17 +- src/main/zcode/zcode-plan-api-key-store.ts | 47 ++- src/shared/secure-file.ts | 9 +- 5 files changed, 550 insertions(+), 25 deletions(-) create mode 100644 src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts create mode 100644 src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts diff --git a/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts b/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts new file mode 100644 index 000000000000..5784b8a0a6d6 --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts @@ -0,0 +1,342 @@ +import type * as fs from 'node:fs' +import * as os from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SecureFile from '../../shared/secure-file' +import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store' + +const mocks = vi.hoisted(() => ({ + home: '', + write: vi.fn(), + rename: vi.fn(), + restrict: vi.fn((_path: string, _directory: boolean) => false) +})) + +vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => mocks.home +})) +vi.mock('node:fs', async (original) => ({ + ...(await original()), + writeFileSync: mocks.write, + renameSync: mocks.rename +})) +vi.mock('../../shared/secure-path-windows-acl', () => ({ + restrictWindowsPathSync: mocks.restrict, + bestEffortRestrictWindowsPath: ( + _path: string, + _directory: boolean, + settled: (restricted: boolean) => void + ) => settled(false), + resetSecureFileWindowsUserSidForTests: vi.fn() +})) + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! +const realFs = await vi.importActual('node:fs') +const envelope = (value: string): string => + `orca-zcode-plan-api-key:v1:plaintext:${Buffer.from(value).toString('base64')}` +let store: typeof ZcodePlanApiKeyStore +let writer: typeof SecureFile +let keyPath: string + +beforeEach(async () => { + mocks.home = realFs.mkdtempSync(join(os.tmpdir(), 'orca-glm-plaintext-rollback-')) + keyPath = join(mocks.home, '.orca', 'zcode-plan-api-key.enc') + realFs.mkdirSync(join(mocks.home, '.orca')) + mocks.write.mockReset().mockImplementation(realFs.writeFileSync) + mocks.rename.mockReset().mockImplementation(realFs.renameSync) + mocks.restrict.mockReset().mockReturnValue(false) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + vi.resetModules() + store = await import('./zcode-plan-api-key-store') + writer = await import('../../shared/secure-file') +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', platformDescriptor) + vi.restoreAllMocks() + realFs.rmSync(mocks.home, { recursive: true, force: true }) +}) + +function rejectReplacement(): void { + expect(() => store.saveZcodePlanApiKey('new-key')).toThrow( + 'could not be stored securely on this device' + ) +} + +describe('plaintext rollback through the secure-file writer', () => { + it('removes only the rejected replacement when rollback fails before publication', () => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + mocks.write.mockImplementationOnce(realFs.writeFileSync).mockImplementationOnce(() => { + throw new Error('Synthetic rollback staging failure') + }) + + rejectReplacement() + + expect(mocks.write).toHaveBeenCalledTimes(2) + expect(realFs.existsSync(keyPath)).toBe(false) + expect(store.readZcodePlanApiKey()).toBeNull() + expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual([]) + }) + + it.each(['restriction-false', 'restriction-throws'])( + 'keeps the previous envelope if rollback publishes before %s', + (failure) => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + mocks.restrict.mockImplementation((path) => { + if ( + failure === 'restriction-throws' && + path === keyPath && + mocks.write.mock.calls.length === 2 + ) { + throw new Error('Synthetic restored-path restriction failure') + } + return false + }) + + rejectReplacement() + + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('previous-key')) + expect(store.readZcodePlanApiKey()).toBe('previous-key') + } + ) + + it.each([ + { phase: 'before-rollback', previous: true }, + { phase: 'before-rollback', previous: false }, + { phase: 'during-rollback', previous: true } + ])( + 'preserves a different envelope written $phase with previous key $previous', + ({ phase, previous }) => { + if (previous) { + realFs.writeFileSync(keyPath, envelope('previous-key')) + } + if (phase === 'before-rollback') { + mocks.restrict.mockImplementation((path) => { + if (path === keyPath && mocks.write.mock.calls.length === 1) { + realFs.writeFileSync(keyPath, envelope('other-writer-key')) + } + return false + }) + } else { + mocks.write.mockImplementationOnce(realFs.writeFileSync).mockImplementationOnce(() => { + realFs.writeFileSync(keyPath, envelope('other-writer-key')) + throw new Error('Synthetic competing rollback writer') + }) + } + + rejectReplacement() + + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('other-writer-key')) + expect(store.readZcodePlanApiKey()).toBe('other-writer-key') + } + ) + + it('retains an identical previous plaintext envelope when retry rollback cannot publish', () => { + realFs.writeFileSync(keyPath, envelope('new-key')) + mocks.write.mockImplementationOnce(realFs.writeFileSync).mockImplementationOnce(() => { + throw new Error('Synthetic same-key rollback staging failure') + }) + + rejectReplacement() + + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('new-key')) + expect(store.readZcodePlanApiKey()).toBe('new-key') + }) + + it('removes a new unrestricted envelope without caching it when no previous key exists', () => { + rejectReplacement() + + expect(realFs.existsSync(keyPath)).toBe(false) + expect(store.readZcodePlanApiKey()).toBeNull() + }) + + it('keeps the cached previous key after a rejected replacement', () => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + expect(store.readZcodePlanApiKey()).toBe('previous-key') + mocks.write.mockImplementationOnce(realFs.writeFileSync).mockImplementationOnce(() => { + throw new Error('Synthetic rollback staging failure') + }) + + rejectReplacement() + + expect(store.readZcodePlanApiKey()).toBe('previous-key') + }) +}) + +describe('conditional rollback publication through the real writer', () => { + it.each([false, true])( + 'preserves a competing envelope during rollback staging with restriction %s', + (restricted) => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + mocks.restrict.mockImplementation((path) => { + if (path.endsWith('.tmp') && mocks.write.mock.calls.length === 2) { + realFs.writeFileSync(keyPath, envelope('other-writer-key')) + return restricted + } + return false + }) + + rejectReplacement() + + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('other-writer-key')) + expect(store.readZcodePlanApiKey()).toBe('other-writer-key') + expect(mocks.rename).toHaveBeenCalledTimes(1) + expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual(['zcode-plan-api-key.enc']) + } + ) + + it('keeps the cached previous key when a competing staged rollback cancels publication', () => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + expect(store.readZcodePlanApiKey()).toBe('previous-key') + mocks.restrict.mockImplementation((path) => { + if (path.endsWith('.tmp') && mocks.write.mock.calls.length === 2) { + realFs.writeFileSync(keyPath, envelope('other-writer-key')) + } + return false + }) + + rejectReplacement() + + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('other-writer-key')) + expect(store.readZcodePlanApiKey()).toBe('previous-key') + expect(mocks.rename).toHaveBeenCalledTimes(1) + }) + + it('preserves an unowned envelope if rollback staging restriction throws', () => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + mocks.restrict.mockImplementation((path) => { + if (path.endsWith('.tmp') && mocks.write.mock.calls.length === 2) { + realFs.writeFileSync(keyPath, envelope('other-writer-key')) + throw new Error('Synthetic staged restriction failure') + } + return false + }) + + rejectReplacement() + + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('other-writer-key')) + expect(store.readZcodePlanApiKey()).toBe('other-writer-key') + expect(mocks.rename).toHaveBeenCalledTimes(1) + expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual(['zcode-plan-api-key.enc']) + }) + + it('removes only the owned rejected plaintext when rollback rename fails', () => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + mocks.rename.mockImplementationOnce(realFs.renameSync).mockImplementationOnce(() => { + throw new Error('Synthetic rollback rename failure') + }) + + rejectReplacement() + + expect(realFs.existsSync(keyPath)).toBe(false) + expect(store.readZcodePlanApiKey()).toBeNull() + expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual([]) + }) + + it.each([false, true])( + 'cancels guarded durable publication without changing a previously present target: %s', + (previous) => { + if (previous) { + realFs.writeFileSync(keyPath, envelope('previous-key')) + } + const shouldPublish = vi.fn(() => false) + + expect( + writer.writeSecureFile(keyPath, envelope('candidate-key'), { + durable: true, + shouldPublish + }) + ).toBe(false) + + expect(shouldPublish).toHaveBeenCalledTimes(1) + expect(mocks.rename).not.toHaveBeenCalled() + expect(realFs.existsSync(keyPath)).toBe(previous) + expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual( + previous ? ['zcode-plan-api-key.enc'] : [] + ) + if (previous) { + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('previous-key')) + } + } + ) + + it('cleans staged bytes when the publication predicate throws', () => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + + expect(() => + writer.writeSecureFile(keyPath, envelope('candidate-key'), { + shouldPublish: () => { + throw new Error('Synthetic ownership read failure') + } + }) + ).toThrow('Synthetic ownership read failure') + + expect(mocks.rename).not.toHaveBeenCalled() + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('previous-key')) + expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual(['zcode-plan-api-key.enc']) + }) + + it('retains best-effort restriction outcomes when guarded publication is allowed', () => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + const shouldPublish = vi.fn(() => true) + + expect( + writer.writeSecureFile(keyPath, envelope('candidate-key'), { + durable: true, + shouldPublish + }) + ).toBe(false) + + expect(shouldPublish).toHaveBeenCalledTimes(1) + expect(mocks.rename).toHaveBeenCalledTimes(1) + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('candidate-key')) + expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual(['zcode-plan-api-key.enc']) + }) + + it('checks publication immediately after staged hardening and before rename', () => { + const operations: string[] = [] + mocks.restrict.mockImplementation((path) => { + operations.push(path.endsWith('.tmp') ? 'staged restriction' : 'published restriction') + return true + }) + mocks.rename.mockImplementation((from, to) => { + operations.push('rename') + realFs.renameSync(from, to) + }) + + expect( + writer.writeSecureFile(keyPath, envelope('candidate-key'), { + shouldPublish: () => { + operations.push('ownership check') + return true + } + }) + ).toBe(true) + + expect(operations).toEqual([ + 'staged restriction', + 'ownership check', + 'rename', + 'published restriction' + ]) + }) + + it('cleans guarded staging bytes if rename throws without deleting the original target', () => { + realFs.writeFileSync(keyPath, envelope('previous-key')) + mocks.rename.mockImplementation(() => { + throw new Error('Synthetic guarded rename failure') + }) + + expect(() => + writer.writeSecureFile(keyPath, envelope('candidate-key'), { + shouldPublish: () => true + }) + ).toThrow('Synthetic guarded rename failure') + + expect(realFs.readFileSync(keyPath, 'utf8')).toBe(envelope('previous-key')) + expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual(['zcode-plan-api-key.enc']) + }) +}) diff --git a/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts b/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts new file mode 100644 index 000000000000..dca5e914eb0c --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts @@ -0,0 +1,160 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SecureFile from '../../shared/secure-file' +import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store' + +const restriction = vi.hoisted(() => vi.fn((_path: string, _directory: boolean) => false)) + +vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } })) +vi.mock('../../shared/secure-path-windows-acl', () => ({ + restrictWindowsPathSync: restriction, + bestEffortRestrictWindowsPath: ( + _path: string, + _directory: boolean, + settled: (restricted: boolean) => void + ) => settled(false), + resetSecureFileWindowsUserSidForTests: vi.fn() +})) + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! +const envelope = (key: string): string => + `orca-zcode-plan-api-key:v1:plaintext:${Buffer.from(key).toString('base64')}` +const previousEnvelope = envelope('previous-key') +const competingEnvelope = envelope('competing-key') +let caseHome: string +let keyPath: string +let store: typeof ZcodePlanApiKeyStore +let writer: typeof SecureFile + +beforeEach(async () => { + caseHome = mkdtempSync(join(tmpdir(), 'orca-rollback-publication-')) + vi.stubEnv('HOME', caseHome) + vi.stubEnv('USERPROFILE', caseHome) + keyPath = join(caseHome, '.orca', 'zcode-plan-api-key.enc') + mkdirSync(join(caseHome, '.orca')) + restriction.mockReset().mockReturnValue(false) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + vi.resetModules() + expect(homedir()).toBe(caseHome) + store = await import('./zcode-plan-api-key-store') + writer = await import('../../shared/secure-file') + expect(vi.isMockFunction(writer.writeSecureFile)).toBe(false) + expect(vi.isMockFunction(writeFileSync)).toBe(false) + expect(vi.isMockFunction(renameSync)).toBe(false) +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', platformDescriptor) + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(caseHome, { recursive: true, force: true }) +}) + +function publishDuringRollbackStaging(outcome: boolean | 'throws'): void { + let stagedRestrictions = 0 + restriction.mockImplementation((targetPath) => { + if (targetPath.endsWith('.tmp') && ++stagedRestrictions === 2) { + const competingPath = join(caseHome, '.orca', 'competing-envelope') + writeFileSync(competingPath, competingEnvelope) + renameSync(competingPath, keyPath) + if (outcome === 'throws') { + throw new Error('Synthetic rollback staged ACL failure') + } + return outcome + } + return false + }) +} + +function refuseReplacement(): void { + expect(() => store.saveZcodePlanApiKey('attempted-key')).toThrow( + 'could not be stored securely on this device' + ) +} + +describe('rollback publication with real filesystem writes and renames', () => { + it.each([false, true, 'throws'] as const)( + 'preserves the competing publication when the second staged ACL call returns %s', + (outcome) => { + writeFileSync(keyPath, previousEnvelope) + publishDuringRollbackStaging(outcome) + + refuseReplacement() + + expect(readFileSync(keyPath, 'utf8')).toBe(competingEnvelope) + expect(readFileSync(keyPath, 'utf8')).not.toBe(envelope('attempted-key')) + expect(store.readZcodePlanApiKey()).toBe('competing-key') + expect(readdirSync(join(caseHome, '.orca'))).toEqual(['zcode-plan-api-key.enc']) + } + ) + + it('restores the previous envelope when the attempted publication is still owned', () => { + writeFileSync(keyPath, previousEnvelope) + + refuseReplacement() + + expect(readFileSync(keyPath, 'utf8')).toBe(previousEnvelope) + expect(store.readZcodePlanApiKey()).toBe('previous-key') + expect(readdirSync(join(caseHome, '.orca'))).toEqual(['zcode-plan-api-key.enc']) + }) + + it('removes the owned rejected plaintext when no previous envelope exists', () => { + refuseReplacement() + + expect(existsSync(keyPath)).toBe(false) + expect(store.readZcodePlanApiKey()).toBeNull() + expect(readdirSync(join(caseHome, '.orca'))).toEqual([]) + }) + + it.each([false, true])( + 'retains the prior target state when initial staged hardening throws: previous %s', + (previous) => { + if (previous) { + writeFileSync(keyPath, previousEnvelope) + } + restriction.mockImplementation((targetPath) => { + if (targetPath.endsWith('.tmp')) { + throw new Error('Synthetic initial staged ACL failure') + } + return false + }) + + expect(() => store.saveZcodePlanApiKey('attempted-key')).toThrow( + 'Synthetic initial staged ACL failure' + ) + + expect(existsSync(keyPath)).toBe(previous) + expect(store.readZcodePlanApiKey()).toBe(previous ? 'previous-key' : null) + expect(readdirSync(join(caseHome, '.orca'))).toEqual( + previous ? ['zcode-plan-api-key.enc'] : [] + ) + if (previous) { + expect(readFileSync(keyPath, 'utf8')).toBe(previousEnvelope) + } + } + ) + + it('keeps the cached prior key after rejecting a replacement during competing publication', () => { + writeFileSync(keyPath, previousEnvelope) + expect(store.readZcodePlanApiKey()).toBe('previous-key') + publishDuringRollbackStaging(false) + + refuseReplacement() + + expect(readFileSync(keyPath, 'utf8')).toBe(competingEnvelope) + expect(store.readZcodePlanApiKey()).toBe('previous-key') + expect(readdirSync(join(caseHome, '.orca'))).toEqual(['zcode-plan-api-key.enc']) + }) +}) diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts index 864ef384582e..7e9f6f6b7b69 100644 --- a/src/main/zcode/zcode-plan-api-key-store.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -141,6 +141,7 @@ describe('zcode-plan-api-key-store', () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) writeSecureFileMock.mockReturnValue(false) existsSyncMock.mockReturnValue(false) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', 'glm-secret'))) const store = await loadStore() expect(() => store.saveZcodePlanApiKey('glm-secret')).toThrow( @@ -155,7 +156,10 @@ describe('zcode-plan-api-key-store', () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) const previous = Buffer.from(envelope('encrypted', 'old-key')) existsSyncMock.mockReturnValue(true) - readFileSyncMock.mockReturnValue(previous) + readFileSyncMock + .mockReturnValueOnce(previous) + .mockReturnValueOnce(Buffer.from(envelope('plaintext', 'new-key'))) + .mockReturnValue(previous) writeSecureFileMock.mockReturnValueOnce(false).mockReturnValueOnce(true) const store = await loadStore() @@ -165,7 +169,9 @@ describe('zcode-plan-api-key-store', () => { // First write publishes the unrestricted replacement; the second restores // the previous envelope so the user's old key survives the failed replace. expect(writeSecureFileMock).toHaveBeenCalledTimes(2) - expect(writeSecureFileMock).toHaveBeenLastCalledWith(storePath, previous.toString('utf8')) + expect(writeSecureFileMock).toHaveBeenLastCalledWith(storePath, previous.toString('utf8'), { + shouldPublish: expect.any(Function) + }) expect(rmSyncMock).not.toHaveBeenCalledWith(storePath, expect.anything()) warn.mockRestore() }) @@ -176,7 +182,7 @@ describe('zcode-plan-api-key-store', () => { const previous = Buffer.from(envelope('encrypted', 'old-key')) const attempted = envelope('plaintext', 'new-key') existsSyncMock.mockReturnValue(true) - readFileSyncMock.mockReturnValueOnce(previous).mockReturnValueOnce(Buffer.from(attempted)) + readFileSyncMock.mockReturnValueOnce(previous).mockReturnValue(Buffer.from(attempted)) writeSecureFileMock.mockReturnValueOnce(false).mockImplementationOnce(() => { throw new Error('restore failed before publish') }) @@ -194,7 +200,10 @@ describe('zcode-plan-api-key-store', () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) const previous = Buffer.from(envelope('encrypted', 'old-key')) existsSyncMock.mockReturnValue(true) - readFileSyncMock.mockReturnValue(previous) + readFileSyncMock + .mockReturnValueOnce(previous) + .mockReturnValueOnce(Buffer.from(envelope('plaintext', 'new-key'))) + .mockReturnValue(previous) writeSecureFileMock.mockReturnValueOnce(false).mockImplementationOnce(() => { throw new Error('restore failed after publish') }) diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts index 00ee40efc64d..7aad68366610 100644 --- a/src/main/zcode/zcode-plan-api-key-store.ts +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -84,16 +84,34 @@ export function getZcodePlanApiKeyProtection(): SecretAtRestProtection | null { } } -function removeRejectedPlaintextEnvelope(keyPath: string, attemptedEnvelope: string): void { - let published: string +function rollbackPlaintextEnvelope( + keyPath: string, + attemptedEnvelope: string, + previousEnvelope: Buffer | null +): void { + const attempted = Buffer.from(attemptedEnvelope, 'utf8') try { - published = readFileSync(keyPath).toString('utf8') + if (!readFileSync(keyPath).equals(attempted)) { + return + } + if (previousEnvelope) { + try { + writeSecureFile(keyPath, previousEnvelope.toString('utf8'), { + shouldPublish: () => readFileSync(keyPath).equals(attempted) + }) + } catch { + // A failed restore may already have published the previous envelope. + } + } + const currentEnvelope = readFileSync(keyPath) + if ( + currentEnvelope.equals(attempted) && + (!previousEnvelope || !currentEnvelope.equals(previousEnvelope)) + ) { + rmSync(keyPath, { force: true }) + } } catch { - return - } - // Why: only the rejected replacement is removed; a restore that already landed must stay. - if (published === attemptedEnvelope) { - rmSync(keyPath, { force: true }) + // Unreadable current bytes do not establish ownership of the rejected write. } } @@ -132,18 +150,7 @@ export function saveZcodePlanApiKey(key: string): void { const wroteRestricted = writeSecureFile(keyPath, attemptedEnvelope) // Why: an unrestricted plaintext credential must never be reported as saved. if (!wroteRestricted) { - if (!previousEnvelope) { - rmSync(keyPath, { force: true }) - } else { - try { - writeSecureFile(keyPath, previousEnvelope.toString('utf8')) - } catch { - // Why: identical bytes are the previous key, not a new rejected secret. - if (previousEnvelope.toString('utf8') !== attemptedEnvelope) { - removeRejectedPlaintextEnvelope(keyPath, attemptedEnvelope) - } - } - } + rollbackPlaintextEnvelope(keyPath, attemptedEnvelope, previousEnvelope) throw new Error('GLM Coding Plan API key could not be stored securely on this device') } cachedZcodePlanApiKey = trimmed diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts index c04ea9afe5b0..172436dcc5b1 100644 --- a/src/shared/secure-file.ts +++ b/src/shared/secure-file.ts @@ -116,6 +116,8 @@ export function writeDurableSecureJsonFile(targetPath: string, value: unknown): * legitimately on FAT32, network paths and restricted tokens, and must not break a write — but * the outcome is now reported rather than assumed, so a caller storing a credential can react. * + * An optional publication predicate runs after staged hardening; false discards the staged file. + * * The return value covers the *file* only. The parent directory is hardened fire-and-forget — on * Windows that lane is async and answers `pending` regardless — so a `true` here says nothing * about the directory's ACL. @@ -123,7 +125,7 @@ export function writeDurableSecureJsonFile(targetPath: string, value: unknown): export function writeSecureFile( targetPath: string, contents: string, - options: { durable?: boolean } = {} + options: { durable?: boolean; shouldPublish?: () => boolean } = {} ): boolean { const dir = dirname(targetPath) if (!existsSync(dir)) { @@ -143,6 +145,11 @@ export function writeSecureFile( } // Why: writeFileSync mode is a no-op on Windows, so restrict the credential's ACL synchronously before the rename publishes it under inherited ACLs. const stagedOutcome = applySecurePathRestriction(tmpFile, false, process.platform, true) + // Recheck after hardening; the check and rename are separate, non-atomic operations. + if (options.shouldPublish && !options.shouldPublish()) { + rmSync(tmpFile, { force: true }) + return false + } renameSync(tmpFile, targetPath) // Why: these hold auth credentials, so the published path must stay current-user only; cache only on confirmed success so failures retry. // The staged file's protected DACL survives the rename, so this pass usually just verifies it. From 6de900fcc653eadda591d043edcc143049720bc8 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:26:35 -0700 Subject: [PATCH 032/110] test(zcode): verify rollback with real Windows file permissions --- .../workflows/zcode-credential-contract.yml | 34 +++++ ...-plan-api-key-store-native-windows.test.ts | 141 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 .github/workflows/zcode-credential-contract.yml create mode 100644 src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts diff --git a/.github/workflows/zcode-credential-contract.yml b/.github/workflows/zcode-credential-contract.yml new file mode 100644 index 000000000000..80fcc911f9ea --- /dev/null +++ b/.github/workflows/zcode-credential-contract.yml @@ -0,0 +1,34 @@ +name: Coding Plan credential contract +on: + pull_request: + paths: + - 'src/main/zcode/**' + - 'src/shared/secure-file*' + - 'src/shared/secure-path-windows-acl*' + - 'src/shared/windows-security-descriptor*' + - '.github/workflows/zcode-credential-contract.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: coding-plan-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + credential-files: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Verify rollback and native Windows permissions + run: pnpm test src/main/zcode/zcode-plan-api-key-store.test.ts src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts src/shared/secure-file.test.ts diff --git a/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts b/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts new file mode 100644 index 000000000000..7e069327e896 --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts @@ -0,0 +1,141 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import { join } from 'node:path' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import type * as WindowsAcl from '../../shared/secure-path-windows-acl' +import type * as Store from './zcode-plan-api-key-store' + +type NativeCase = { + home: string + fault: 'none' | 'before-restore' | 'after-restore' | 'competing' + reject: boolean + writes: number + renames: number + stages: number + nativeOutcomes: boolean[] + directories: boolean[] +} +const state = vi.hoisted((): NativeCase => ({ + home: '', + fault: 'none', + reject: false, + writes: 0, + renames: 0, + stages: 0, + nativeOutcomes: [], + directories: [] +})) +vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => state.home +})) +vi.mock('node:fs', async (original) => { + const real = await original() + return { + ...real, + writeFileSync: (...args: Parameters) => { + if (++state.writes === 2 && state.fault === 'before-restore') { + throw new Error('Synthetic restore staging failure') + } + return real.writeFileSync(...args) + }, + renameSync: (...args: Parameters) => { + real.renameSync(...args) + if (++state.renames === 2 && state.fault === 'after-restore') { + throw new Error('Synthetic failure after restore publication') + } + } + } +}) +vi.mock('../../shared/secure-path-windows-acl', async (original) => { + const real = await original() + const files = await vi.importActual('node:fs') + return { + ...real, + restrictWindowsPathSync: (...args: Parameters) => { + const outcome = real.restrictWindowsPathSync(...args) + state.nativeOutcomes.push(outcome) + if (state.fault === 'competing' && args[0].endsWith('.tmp') && ++state.stages === 2) { + const competing = join(state.home, '.orca', 'competing-key') + files.writeFileSync(competing, envelope('synthetic-competing')) + expect(real.restrictWindowsPathSync(competing, false)).toBe(true) + files.renameSync(competing, join(state.home, '.orca', 'zcode-plan-api-key.enc')) + } + return state.reject ? false : outcome + }, + bestEffortRestrictWindowsPath: ( + path: string, + directory: boolean, + settled?: (restricted: boolean) => void + ) => + real.bestEffortRestrictWindowsPath(path, directory, (restricted) => { + state.directories.push(restricted) + settled?.(restricted) + }) + } +}) + +const envelope = (key: string) => + `orca-zcode-plan-api-key:v1:plaintext:${Buffer.from(key).toString('base64')}` +let store: typeof Store +let keyPath: string + +describe.skipIf(process.platform !== 'win32')('native Windows Coding Plan rollback', () => { + beforeEach(async () => { + state.home = fs.mkdtempSync(join(os.tmpdir(), 'orca-native-plan-')) + state.fault = 'none' + state.reject = false + state.writes = 0 + state.renames = 0 + state.stages = 0 + state.nativeOutcomes = [] + state.directories = [] + keyPath = join(state.home, '.orca', 'zcode-plan-api-key.enc') + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.resetModules() + store = await import('./zcode-plan-api-key-store') + store.saveZcodePlanApiKey('synthetic-previous') + expect(fs.readFileSync(keyPath, 'utf8')).toBe(envelope('synthetic-previous')) + expect(state.nativeOutcomes.length).toBeGreaterThan(0) + expect(state.nativeOutcomes.every(Boolean)).toBe(true) + await vi.waitFor( + () => { + expect(state.directories.length).toBeGreaterThan(0) + expect(state.directories.every(Boolean)).toBe(true) + }, + { timeout: 15_000 } + ) + state.writes = 0 + state.renames = 0 + state.stages = 0 + state.reject = true + }) + + afterEach(() => { + vi.restoreAllMocks() + fs.rmSync(state.home, { recursive: true, force: true }) + }) + + it.each(['before-restore', 'after-restore', 'competing'] as const)( + 'preserves the file contract after %s with real file and directory ACLs', + (fault) => { + state.fault = fault + expect(() => store.saveZcodePlanApiKey('synthetic-rejected')).toThrow( + 'could not be stored securely' + ) + if (fault === 'before-restore') { + expect(fs.existsSync(keyPath)).toBe(false) + } else { + expect(fs.readFileSync(keyPath, 'utf8')).toBe( + envelope(fault === 'competing' ? 'synthetic-competing' : 'synthetic-previous') + ) + } + expect(fs.readdirSync(join(state.home, '.orca')).some((name) => name.endsWith('.tmp'))).toBe( + false + ) + expect(state.nativeOutcomes.every(Boolean)).toBe(true) + }, + 30_000 + ) +}) From 70f0b1adec023b577aae7b8021631a49a47f0c46 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:29:22 -0700 Subject: [PATCH 033/110] ci(zcode): install the pinned test runner before native contracts --- .github/workflows/zcode-credential-contract.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/zcode-credential-contract.yml b/.github/workflows/zcode-credential-contract.yml index 80fcc911f9ea..3e991b22a4e2 100644 --- a/.github/workflows/zcode-credential-contract.yml +++ b/.github/workflows/zcode-credential-contract.yml @@ -30,5 +30,8 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: config/.bun-version - name: Verify rollback and native Windows permissions run: pnpm test src/main/zcode/zcode-plan-api-key-store.test.ts src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts src/shared/secure-file.test.ts From 33a1ddfc26067109b573e39544e715703bb445d0 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 16:06:01 -0700 Subject: [PATCH 034/110] ci(zcode): register native credential tests in the Windows lane --- .github/workflows/pr.yml | 1 + config/scripts/pr-code-change-scope.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b9575f96dbf4..3751f5f2b289 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1213,6 +1213,7 @@ jobs: - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts + src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts config/scripts/rebuild-native-deps.test.mjs config/scripts/rebuild-native-deps-windows-process-tree.test.mjs config/scripts/rebuild-native-deps-node-pty.test.mjs diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index ddc84b4efb62..1d1d784db863 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -360,6 +360,7 @@ const LINUX_PACKAGE_TESTS = [ ] const WINDOWS_PACKAGE_TESTS = [ + 'src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts', ...LINUX_PACKAGE_TESTS, 'config/scripts/rebuild-native-deps.test.mjs', 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', From a90e5d9de6540863f5b6e0d5fdeb6445a9c441b3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:28:50 -0400 Subject: [PATCH 035/110] ci(e2e): run the layout oracle on PRs that change layout writers (#27103) * ci(e2e): run the layout oracle on PRs that change layout writers Co-Authored-By: Claude * ci(e2e): narrow the layout oracle triggers to the modules that write layout Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../pr-e2e-layout-oracle-routing.test.mjs | 89 +++++++++++++++++++ config/scripts/pr-e2e-source-routing.mjs | 28 ++++++ 2 files changed, 117 insertions(+) create mode 100644 config/scripts/pr-e2e-layout-oracle-routing.test.mjs diff --git a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs new file mode 100644 index 000000000000..cd6563d80b0e --- /dev/null +++ b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { classifyE2eJobs, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs' +import { + hasSshSourceChange, + PR_E2E_SOURCE_ROUTES, + selectPrE2eSpecs, + shouldRunReusablePrE2e +} from './pr-e2e-source-routing.mjs' + +const projectDir = resolve(import.meta.dirname, '../..') + +const ORACLE_ROUTE = PR_E2E_SOURCE_ROUTES.find((route) => route.id === 'workspace-layout.oracle') +const TAB_STORE_ROUTE = PR_E2E_SOURCE_ROUTES.find( + (route) => route.id === 'workspace-layout.oracle-tab-store' +) + +/** One product file per layout writer family; each alone must run the oracle. */ +const LAYOUT_AUTHORITIES = [ + 'src/shared/workspace-layout/workspace-layout-pane-commands.ts', + 'src/main/persistence/terminal-topology/terminal-topology-commit.ts', + 'src/main/persistence/restoring-sessions/session-owner-fields.ts', + 'src/main/persistence/loading-store/pty-binding-persistence.ts', + 'src/main/runtime/orca-runtime-split-pty-backed-terminal.ts', + 'src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts', + 'src/main/runtime/orca-runtime-create-terminal.ts', + 'src/renderer/src/runtime/mobile-session-tab-close.ts', + 'src/renderer/src/runtime/runtime-layout-client.ts', + 'tests/e2e/workspace-layout-oracle-known-on-main.ts', + 'tests/e2e/helpers/workspace-layout-oracle-compare.ts' +] + +describe('workspace layout oracle PR E2E routing', () => { + it('routes each layout authority on its own', () => { + expect(ORACLE_ROUTE, 'the oracle route was renamed or removed').toBeDefined() + for (const file of LAYOUT_AUTHORITIES) { + expect(selectPrE2eSpecs([file]), file).toEqual(ORACLE_ROUTE.specs.toSorted()) + expect(shouldRunReusablePrE2e([file]), file).toBe(true) + } + }) + + it('runs the window and headless oracles, not the SSH lane, for the tab store', () => { + for (const file of [ + 'src/renderer/src/store/slices/tabs.ts', + 'src/renderer/src/store/slices/tabs/tabs-move-actions.ts' + ]) { + expect(selectPrE2eSpecs([file]), file).toEqual(TAB_STORE_ROUTE.specs.toSorted()) + expect(existsSync(join(projectDir, file)), file).toBe(true) + } + }) + + it('sends each oracle spec to the job that can run it', () => { + const specs = ORACLE_ROUTE.specs + // Window spec runs in the general shards; headless and SSH run in their dedicated jobs. + expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts']) + expect(classifyE2eJobs(JSON.stringify(specs))).toEqual({ + e2e_run_changed: true, + e2e_needs_build: true + }) + // The SSH oracle reaches the Docker lane by spec name, not by claiming SSH source. + expect(hasSshSourceChange(LAYOUT_AUTHORITIES)).toBe(false) + }) + + it('keeps every routed spec and authority a real file', () => { + for (const file of [...ORACLE_ROUTE.specs, ...LAYOUT_AUTHORITIES]) { + expect(existsSync(join(projectDir, file)), file).toBe(true) + } + }) + + it('leaves tests, fixtures and unrelated runtime modules alone', () => { + for (const file of [ + 'src/main/persistence/terminal-topology/terminal-topology-commit.test.ts', + 'src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts', + 'src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts', + 'src/main/runtime/runtime-durable-store-fixture.ts', + 'src/main/runtime/orchestration/db/worker-terminal/worker-terminal-store.ts', + 'src/main/runtime/rpc/methods/terminal/terminal-create.ts', + 'src/main/runtime/runtime-rpc-database.ts', + 'src/main/runtime/orca-runtime-files.ts', + 'src/renderer/src/runtime/sync-runtime-graph.ts', + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', + 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts', + 'tests/e2e/workspace-layout-oracle-latency.spec.ts' + ]) { + expect(ORACLE_ROUTE.matches(file) || TAB_STORE_ROUTE.matches(file), file).toBe(false) + } + }) +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 1db0497bb884..7db5a8cfebe3 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -3,6 +3,8 @@ import { pathToFileURL } from 'node:url' import { isUnitTestSupportSource } from './pr-code-change-scope.mjs' const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) +const isLayoutOracleSource = (file) => + isProductSource(file) && !/\.spec\.ts$|-fixtures?\.ts$/.test(file) // Why config/patches: the xterm fork owns the helper textarea an input method attaches to, so a // patch edit can break composition without touching a file named "ime". @@ -430,6 +432,32 @@ export const PR_E2E_SOURCE_ROUTES = [ /^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test( file ) + }, + { + // Why: the oracle is the only check that reads every view of the stored layout back. Runtime + // matches only the modules that create, move, close, persist or publish layout, not every + // session or terminal file, so the orcad build and Docker SSH lane stay off unrelated work. + id: 'workspace-layout.oracle', + specs: [ + 'tests/e2e/workspace-layout-oracle-headless.spec.ts', + 'tests/e2e/workspace-layout-oracle-ssh.spec.ts', + 'tests/e2e/workspace-layout-oracle.spec.ts' + ], + matches: (file) => + isLayoutOracleSource(file) && + /^(?:src\/shared\/workspace-layout\/|src\/main\/persistence\/(?:terminal-topology\/|restoring-sessions\/|loading-store\/pty-binding-)|src\/(?:main|renderer\/src)\/runtime\/(?:[^/]+\/)*[^/]*(?:pane-admission|workspace-layout|session-tab|headless-[^/]*tab|layout-client|create-terminal|split-pty-backed-terminal|terminal-split-layout|spawn-placement)[^/]*\.ts$|tests\/e2e\/(?:helpers\/)?(?:workspace-layout-oracle|headless-layout-oracle|terminal-layout-journeys))/.test( + file + ) + }, + { + // The window's tab store reaches SSH through the same layout writers, so not the SSH lane. + id: 'workspace-layout.oracle-tab-store', + specs: [ + 'tests/e2e/workspace-layout-oracle-headless.spec.ts', + 'tests/e2e/workspace-layout-oracle.spec.ts' + ], + matches: (file) => + isLayoutOracleSource(file) && file.startsWith('src/renderer/src/store/slices/tabs') } ] From 289586947eecb5d7bb809b8387f1ea5a5b816c34 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:40:31 -0400 Subject: [PATCH 036/110] feat(relay): alert when a reserve-mode cell trips its dead-man (#27096) Rebased onto main as one commit after its parent squash-merged; kept both sides of the add/add list conflicts with #27024. --- .../terraform-root-partition/families.json | 8 + .../relay-reserve-dead-man-alert.test.mjs | 69 +++++ cloud/infra/terraform/relay-observability.tf | 252 ++++++++++++++++++ cloud/package.json | 2 +- 4 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs diff --git a/cloud/dev/fixtures/terraform-root-partition/families.json b/cloud/dev/fixtures/terraform-root-partition/families.json index 0fa56d63cd86..55ef3f894adb 100644 --- a/cloud/dev/fixtures/terraform-root-partition/families.json +++ b/cloud/dev/fixtures/terraform-root-partition/families.json @@ -137,9 +137,13 @@ "google_logging_metric.relay_assignment_lease_shadow", "google_logging_metric.relay_cell_fix_level", "google_logging_metric.relay_cell_pool_herd", + "google_logging_metric.relay_cell_reregistration_gave_up", + "google_logging_metric.relay_cell_reregistration_stalled", + "google_logging_metric.relay_cell_reserve_dead_man_tripped", "google_logging_metric.relay_control_flags_unreadable", "google_logging_metric.relay_incident", "google_logging_metric.relay_long_cell_inventory_hold", + "google_logging_metric.relay_reserve_placement_off_with_reserve_cells", "google_logging_metric.relay_snapshot", "google_monitoring_alert_policy.relay_assignment_5xx", "google_monitoring_alert_policy.relay_assignment_edge_429", @@ -148,6 +152,9 @@ "google_monitoring_alert_policy.relay_cell_control_rtt", "google_monitoring_alert_policy.relay_cell_outdated_fix_level", "google_monitoring_alert_policy.relay_cell_pool_herd", + "google_monitoring_alert_policy.relay_cell_reregistration_gave_up", + "google_monitoring_alert_policy.relay_cell_reregistration_stalled", + "google_monitoring_alert_policy.relay_cell_reserve_dead_man_tripped", "google_monitoring_alert_policy.relay_cell_process_exit", "google_monitoring_alert_policy.relay_cloud_nat_port_drops", "google_monitoring_alert_policy.relay_cloud_sql_backends", @@ -164,6 +171,7 @@ "google_monitoring_alert_policy.relay_postgres_retry_exhausted", "google_monitoring_alert_policy.relay_process_fatal", "google_monitoring_alert_policy.relay_region_hint_skew", + "google_monitoring_alert_policy.relay_reserve_placement_off_with_reserve_cells", "google_monitoring_dashboard.relay_incident", "google_project_iam_custom_role.github_production_relay_capacity_mutation", "google_project_iam_custom_role.github_relay_asia_topology_mutation", diff --git a/cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs b/cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs new file mode 100644 index 000000000000..07636c267689 --- /dev/null +++ b/cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +// Why: each metric keys on the event name and fields the relay writes. A rename on either side +// silences the alert without failing anything. + +const read = (relative) => readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8') +const terraform = read('../../infra/terraform/relay-observability.tf') +const deadMan = read('../../apps/relay/src/cell-reserve-dead-man.ts') +const registry = read('../../apps/relay/src/host-session-registry.ts') +const server = read('../../apps/relay/src/relay-server.ts') + +const block = (kind, name) => { + const body = new RegExp(`resource "${kind}" "${name}" \\{([\\s\\S]*?)\\n\\}`).exec(terraform)?.[1] + assert.ok(body, `${kind}.${name} not found in relay-observability.tf`) + return body +} + +test('the metric counts the event and cell the dead-man writes', () => { + assert.match(deadMan, /event: 'orca_relay_cell_reserve_dead_man_tripped',\s*cellId: this\.cellId,/) + const metric = block('google_logging_metric', 'relay_cell_reserve_dead_man_tripped') + assert.ok(metric.includes('jsonPayload.event=\\"orca_relay_cell_reserve_dead_man_tripped\\"')) + assert.ok(metric.includes('cell_id = "EXTRACT(jsonPayload.cellId)"')) +}) + +test('the alert fires on one trip per cell and notifies', () => { + const alert = block('google_monitoring_alert_policy', 'relay_cell_reserve_dead_man_tripped') + // One line per switch-file generation, so a higher bar misses a trip. + assert.match(alert, /threshold_value = 0\n/) + assert.match(alert, /notification_channels = var\.relay_alert_notification_channels/) + assert.ok(alert.includes('metric.type=\\"logging.googleapis.com/user/orca_relay_cell_reserve_dead_man_tripped\\"')) + assert.ok(alert.includes('group_by_fields = ["metric.label.\\"cell_id\\""]')) +}) + +const notifyingOnAnyLine = (name, event, resource, group) => { + const alert = block('google_monitoring_alert_policy', name) + assert.match(alert, /threshold_value = 0\n/) + assert.match(alert, /notification_channels = var\.relay_alert_notification_channels/) + assert.ok(alert.includes(`resource.type=\\"${resource}\\" AND metric.type=\\"logging.googleapis.com/user/${event}\\"`)) + assert.ok(alert.includes(`group_by_fields = ["metric.label.\\"${group}\\""]`)) +} + +test('the flip-back alerts count the lines the cell writes, per cell', () => { + for (const event of ['orca_relay_cell_reregistration_gave_up', 'orca_relay_cell_reregistration_stalled']) { + // logIdentity() carries cellId; both lines name the reason. + assert.match(registry, new RegExp(`event: '${event}',\\s*\\.\\.\\.this\\.logIdentity\\(\\),[\\s\\S]{0,200}reason:`)) + const name = event.replace('orca_', '') + const metric = block('google_logging_metric', name) + assert.ok(metric.includes(`resource.type=\\"gce_instance\\" AND jsonPayload.event=\\"${event}\\"`)) + assert.ok(metric.includes('cell_id = "EXTRACT(jsonPayload.cellId)"')) + assert.ok(metric.includes('reason = "EXTRACT(jsonPayload.reason)"')) + notifyingOnAnyLine(name, event, 'gce_instance', 'cell_id') + } + assert.match(registry, /cellId: this\.config\.cellId/) +}) + +test('the director alert counts its placement-off line on the director service', () => { + const event = 'orca_relay_reserve_placement_off_with_reserve_cells' + assert.match(server, new RegExp(`event: '${event}',\\s*cells,`)) + const metric = block('google_logging_metric', 'relay_reserve_placement_off_with_reserve_cells') + assert.ok( + metric.includes( + `resource.type=\\"cloud_run_revision\\" AND resource.labels.service_name=\\"\${var.relay_cloud_run_service_name}\\" AND jsonPayload.event=\\"${event}\\"` + ) + ) + notifyingOnAnyLine('relay_reserve_placement_off_with_reserve_cells', event, 'cloud_run_revision', 'revision') +}) diff --git a/cloud/infra/terraform/relay-observability.tf b/cloud/infra/terraform/relay-observability.tf index d5bdf31b44ae..e1f99cef4fc7 100644 --- a/cloud/infra/terraform/relay-observability.tf +++ b/cloud/infra/terraform/relay-observability.tf @@ -1607,3 +1607,255 @@ resource "google_monitoring_alert_policy" "relay_control_flags_unreadable" { depends_on = [google_logging_metric.relay_control_flags_unreadable] } + +# cell-reserve-dead-man.ts logs once per switch-file generation when it trips, so any count is the signal. +resource "google_logging_metric" "relay_cell_reserve_dead_man_tripped" { + project = var.project_id + name = "orca_relay_cell_reserve_dead_man_tripped" + description = "Reserve-mode cells that heard from no placing director for their dead-man window (60-120 s) and went back to admitting through the database." + filter = "resource.type=\"gce_instance\" AND jsonPayload.event=\"orca_relay_cell_reserve_dead_man_tripped\"" + label_extractors = { + cell_id = "EXTRACT(jsonPayload.cellId)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier." + } + } +} + +resource "google_monitoring_alert_policy" "relay_cell_reserve_dead_man_tripped" { + project = var.project_id + display_name = "Orca Relay: reserve cell lost its placing director" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "A reserve-mode cell tripped its dead-man" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cell_reserve_dead_man_tripped\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A cell with `admitMode=reserve` in its switch file heard from no placing director (no booking, no feed poll marked `reserver=1`) for its dead-man window, so it went back to database admission and is re-registering its controls. Nobody is disconnected. Postgres still records the cell as reserve, so sweeps keep skipping it while it is heartbeat-live. First find why placement stopped: a director rollback, placement turned off, or every director failing its feed polls. Then, once the cell's `runtime-status` reports `admitModeEffective=db`, run `cloud-operate-relay-production-cell-flags.yml` with `--set admitMode=db` for that cell: it writes db into the switch file and records db in Postgres, so sweeps resume. **This step is required, within hours.** Until it runs, sweeps skip the cell, so its `reserved_requests` and leases only rise, until the database path treats the cell as full. The trip is latched for this switch-file generation: the cell will not return to reserve until a new reserve write, even if a director comes back. Several cells at once points to the directors, not the cells." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_cell_reserve_dead_man_tripped] +} + +# host-session-registry.ts: a flip back closed a control it could not re-register (4409), after 20 refusals. +resource "google_logging_metric" "relay_cell_reregistration_gave_up" { + project = var.project_id + name = "orca_relay_cell_reregistration_gave_up" + description = "Controls a cell closed with 4409 during a flip back to db, because the database kept refusing their lease." + filter = "resource.type=\"gce_instance\" AND jsonPayload.event=\"orca_relay_cell_reregistration_gave_up\"" + label_extractors = { + cell_id = "EXTRACT(jsonPayload.cellId)" + reason = "EXTRACT(jsonPayload.reason)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier." + } + labels { + key = "reason" + value_type = "STRING" + description = "The refusal: wrong_assignment (the row names another seat) or another database answer." + } + } +} + +resource "google_monitoring_alert_policy" "relay_cell_reregistration_gave_up" { + project = var.project_id + display_name = "Orca Relay: flip back closed a desktop it could not re-register" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "A cell closed a control during its flip back" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cell_reregistration_gave_up\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "During a flip back to db, a cell asked the database for a control's lease 20 times and was refused each time, so it closed that desktop with 4409 and the desktop re-assigned. `reason` says why: `wrong_assignment` means the host's row names another seat (normally the ledger reconcile repairs that first); anything else is a database answer the lease path does not accept, which needs a code look. A handful on one flip is tolerable; a steady stream means the flip back is disconnecting desktops: stop flipping further cells." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_cell_reregistration_gave_up] +} + +# host-session-registry.ts: one line per 20 consecutive retries of a database that did not answer. +resource "google_logging_metric" "relay_cell_reregistration_stalled" { + project = var.project_id + name = "orca_relay_cell_reregistration_stalled" + description = "Controls whose flip-back lease has failed 20 times in a row because the database did not answer." + filter = "resource.type=\"gce_instance\" AND jsonPayload.event=\"orca_relay_cell_reregistration_stalled\"" + label_extractors = { + cell_id = "EXTRACT(jsonPayload.cellId)" + reason = "EXTRACT(jsonPayload.reason)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier." + } + labels { + key = "reason" + value_type = "STRING" + description = "The last database error." + } + } +} + +resource "google_monitoring_alert_policy" "relay_cell_reregistration_stalled" { + project = var.project_id + display_name = "Orca Relay: flip back stalled on the database" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "A cell keeps retrying flip-back leases against an unanswering database" + + condition_threshold { + filter = "resource.type=\"gce_instance\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cell_reregistration_stalled\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A cell flipping back to db has retried a control's lease 20 times in a row because the database did not answer (a stall, dropped connections, a busy lock). Nobody is disconnected: the cell keeps every control and retries for as long as it is open, and it reports `admitModeEffective=reserve` until it is done, so Postgres keeps reserve and sweeps stay off it. Look at Cloud SQL first. The flag workflow's wait may time out meanwhile; re-run it with `--set admitMode=db` once the database recovers." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_cell_reregistration_stalled] +} + +# relay-server.ts (director): placement is off while the map shows reserve cells; logged once a minute. +resource "google_logging_metric" "relay_reserve_placement_off_with_reserve_cells" { + project = var.project_id + name = "orca_relay_reserve_placement_off_with_reserve_cells" + description = "Directors running with reserve placement off while some cells report reserve mode." + filter = "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"${var.relay_cloud_run_service_name}\" AND jsonPayload.event=\"orca_relay_reserve_placement_off_with_reserve_cells\"" + label_extractors = { + revision = "EXTRACT(resource.labels.revision_name)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "revision" + value_type = "STRING" + description = "Cloud Run revision of the director." + } + } +} + +resource "google_monitoring_alert_policy" "relay_reserve_placement_off_with_reserve_cells" { + project = var.project_id + display_name = "Orca Relay: director placement off while cells are in reserve" + combiner = "OR" + enabled = true + notification_channels = var.relay_alert_notification_channels + + conditions { + display_name = "A director cannot place onto reserve cells" + + condition_threshold { + filter = "resource.type=\"cloud_run_revision\" AND metric.type=\"logging.googleapis.com/user/orca_relay_reserve_placement_off_with_reserve_cells\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"revision\""] + } + + trigger { + count = 1 + } + } + } + + documentation { + content = "A director is not placing (`placement` in the line: off, dry-run, or configured on but never built; `configured` says what `ORCA_RELAY_RESERVE_PLACEMENT` asked for) while its map shows cells in reserve mode, so it books nothing there and leaves their hosts to the other directors. A director that predates step 5 logs nothing, so a rollback past it shows only as dead-man trips. If every director has placement off, each reserve cell's dead-man trips within 60-120 s (`Orca Relay: reserve cell lost its placing director`). If only some are (a partial rollback, or a manual traffic split), the rest keep the dead-man from tripping, and this alert is the only sign. Put every director on the step-5 revision with placement on, or flip the reserve cells to db (`cloud-operate-relay-production-cell-flags.yml --set admitMode=db`) before rolling the directors back." + mime_type = "text/markdown" + } + + depends_on = [google_logging_metric.relay_reserve_placement_off_with_reserve_cells] +} diff --git a/cloud/package.json b/cloud/package.json index 1b5b928ce4c4..94bb37609500 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -21,7 +21,7 @@ "load:relay:model": "node dev/scripts/run-relay-load-model.mjs", "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", - "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-control-flag-alerts.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", + "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-control-flag-alerts.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-reserve-dead-man-alert.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, From 3c6c24f8fa9533cb935cf4526ef45163cf081cf0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:41:33 -0400 Subject: [PATCH 037/110] test(relay): deterministic step-5 simulation with invariants 1-8 (#27040) Rebased onto main as one commit after its parent squash-merged. --- .../src/reserve-placement-simulation.test.ts | 340 +++++ .../relay/src/reserve-placement-simulation.ts | 1254 +++++++++++++++++ 2 files changed, 1594 insertions(+) create mode 100644 cloud/apps/relay/src/reserve-placement-simulation.test.ts create mode 100644 cloud/apps/relay/src/reserve-placement-simulation.ts diff --git a/cloud/apps/relay/src/reserve-placement-simulation.test.ts b/cloud/apps/relay/src/reserve-placement-simulation.test.ts new file mode 100644 index 000000000000..692bf1dc6f0f --- /dev/null +++ b/cloud/apps/relay/src/reserve-placement-simulation.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest' +import { + runReservePlacementSimulation, + type SimulationConfig, + type SimulationReport +} from './reserve-placement-simulation.js' + +const SEEDS = [11, 23, 47] +const rtt = (region: string) => (region === 'asia-east2' ? 170 : 10) + +function fleet(): SimulationConfig['cells'] { + const cells: SimulationConfig['cells'] = [] + for (let index = 0; index < 24; index += 1) { + cells.push({ + cellId: `us${String(index).padStart(2, '0')}`, + region: 'us-central1', + hardCap: 600, + ceiling: 480, + intakePerSec: 5, + // A third of the fleet stays in database mode, one cell on an image without step 5. + mode: index % 3 === 0 ? 'db' : 'reserve', + old: index === 0, + preseated: 150 + }) + } + for (let index = 0; index < 6; index += 1) { + cells.push({ + cellId: `asia${index}`, + region: 'asia-east2', + hardCap: 600, + ceiling: 480, + intakePerSec: 2, + mode: index === 0 ? 'db' : 'reserve', + preseated: 200 + }) + } + return cells +} + +function expectInvariants(report: SimulationReport): void { + expect(report.violations).toEqual([]) +} + +describe('step 5 deterministic simulation', () => { + it.each(SEEDS)( + 'holds invariants 1-3 and 5-8 across mixed versions, lagging polls, restarts and database stalls (seed %i)', + async (seed) => { + const report = await runReservePlacementSimulation({ + seed, + durationMs: 25 * 60_000, + directors: 5, + // One director still on the database-only revision. + oldDirectors: 1, + cells: fleet(), + hosts: 6_600, + hostRegion: (index) => (index % 5 === 0 ? 'asia-east2' : 'us-central1'), + arrivalPerSec: 20, + meanSessionMs: 8 * 60_000, + reconnectDelayMs: [500, 5_000], + duplicateAssignShare: 0.05, + halfOpenShare: 0.05, + pollLagMs: (director) => [0, 200, 1_000, 4_000, 10_000][director]!, + rttMs: rtt, + cellRestarts: [{ at: 6 * 60_000, cellId: 'us04' }, { at: 14 * 60_000, cellId: 'asia2' }], + directorRestarts: [{ at: 8 * 60_000, director: 2 }, { at: 16 * 60_000, director: 4 }], + // Drained hosts land elsewhere while their old seat lingers: placement supersedes it. + drains: [{ at: 12 * 60_000, cellId: 'us05', paceMs: 60_000 }], + databaseStalls: Array.from({ length: 3 }, (_, index) => ({ + at: (index * 610 + 120) * 1_000, + durationMs: 7_000 + })) + }) + expectInvariants(report) + expect(report.reservePlacements).toBeGreaterThan(500) + expect(report.stickyAnswers).toBeGreaterThan(500) + expect(report.seatedAtEnd).toBeGreaterThan(4_000) + // The run reached the paths the invariants guard, so a pass is not vacuous. + expect(report.supersedes).toBeGreaterThan(0) + expect(report.rowAheadDemotions).toBeGreaterThan(0) + expect(report.directorRestarts).toBe(2) + expect(report.stickyReserves).toBeGreaterThan(0) + expect(report.databasePlacements).toBeGreaterThan(0) + }, + 120_000 + ) + + it.each(SEEDS)( + 'near full: extra reserve calls stay within 10% and exhausted requests within 1% (seed %i)', + async (seed) => { + const cells = Array.from({ length: 17 }, (_, index) => ({ + cellId: `us${index}`, + region: 'us-central1' as const, + hardCap: 700, + ceiling: 600, + intakePerSec: 50, + mode: 'reserve' as const, + preseated: 582 + })) + const report = await runReservePlacementSimulation({ + seed, + durationMs: 3 * 60_000, + directors: 5, + cells, + hosts: 17 * 582 + 300, + hostRegion: () => 'us-central1', + arrivalPerSec: 10, + meanSessionMs: 0, + reconnectDelayMs: [1_000, 3_000], + duplicateAssignShare: 0, + halfOpenShare: 0, + pollLagMs: () => 0, + rttMs: rtt + }) + expectInvariants(report) + expect(report.reservePlacements).toBeGreaterThan(250) + expect(report.reserveCalls / report.reservePlacements - 1).toBeLessThanOrEqual(0.1) + expect(report.exhausted / report.requests).toBeLessThanOrEqual(0.01) + for (const share of Object.values(report.maxOccupancyShare)) expect(share).toBeLessThanOrEqual(1) + }, + 120_000 + ) + + it.each(SEEDS.flatMap((seed) => [[1_200_000, seed], [300_000, seed]] as const))( + 'a drain onto an empty cell at the %i ms pace spreads within each cell intake budget (seed %i)', + async (paceMs, seed) => { + // The c27 shape: the drained cell's hosts move at the 1,200 s pace onto five cells + // with free seats 2,805 / 841 / 777 / 667 / 26. + const free = [2_805, 841, 777, 667, 26] + const cells: SimulationConfig['cells'] = free.map((seats, index) => ({ + cellId: `asia${index + 1}`, + region: 'asia-east2', + hardCap: 3_000, + ceiling: 2_830, + intakePerSec: 2, + mode: 'reserve', + preseated: 2_830 - seats + })) + cells.unshift({ + cellId: 'asia0', + region: 'asia-east2', + hardCap: 3_000, + ceiling: 2_830, + intakePerSec: 2, + mode: 'reserve', + preseated: 2_770 + }) + const preseated = cells.reduce((sum, cell) => sum + (cell.preseated ?? 0), 0) + const report = await runReservePlacementSimulation({ + seed, + durationMs: 30 * 60_000, + directors: 5, + cells, + hosts: preseated, + hostRegion: () => 'asia-east2', + arrivalPerSec: 1, + meanSessionMs: 0, + reconnectDelayMs: [1_000, 3_000], + duplicateAssignShare: 0.02, + halfOpenShare: 0, + pollLagMs: () => 0, + rttMs: rtt, + drains: [{ at: 10_000, cellId: 'asia0', paceMs }] + }) + expectInvariants(report) + // Below every cell's budget nothing waits; above it the cells' buckets pace the drain. + if (paceMs === 300_000) expect(report.intakeRefusals + report.paced).toBeGreaterThan(0) + const moved = Object.entries(report.arrivalsByCell) + const total = moved.reduce((sum, [, count]) => sum + count, 0) + expect(total).toBeGreaterThan(2_000) + // Spread: the emptiest cell takes a share, not the drain. + expect((report.arrivalsByCell.asia1 ?? 0) / total).toBeLessThan(0.6) + expect(moved.filter(([, count]) => count / total > 0.1).length).toBeGreaterThanOrEqual(3) + }, + 120_000 + ) + + // Each fault breaks one rule; the matching invariant must fire, or its check measures nothing. + const mixed = (faults: SimulationConfig['faults']): SimulationConfig => ({ + seed: 11, + durationMs: 8 * 60_000, + directors: 5, + cells: fleet(), + hosts: 6_600, + hostRegion: (index) => (index % 5 === 0 ? 'asia-east2' : 'us-central1'), + arrivalPerSec: 20, + meanSessionMs: 4 * 60_000, + reconnectDelayMs: [500, 5_000], + duplicateAssignShare: 0.1, + halfOpenShare: 0.1, + pollLagMs: (director) => [0, 200, 1_000, 2_000, 3_000][director]!, + rttMs: rtt, + faults + }) + const nearFull = (faults: SimulationConfig['faults']): SimulationConfig => ({ + ...mixed(faults), + cells: Array.from({ length: 4 }, (_, index) => ({ + cellId: `us${index}`, + region: 'us-central1' as const, + hardCap: 600, + ceiling: 600, + intakePerSec: 500, + mode: 'reserve' as const, + preseated: 590 + })), + hosts: 4 * 590 + 400, + hostRegion: () => 'us-central1', + arrivalPerSec: 400, + meanSessionMs: 0 + }) + const drain = (faults: SimulationConfig['faults']): SimulationConfig => ({ + ...mixed(faults), + cells: [0, 1, 2].map((index) => ({ + cellId: `asia${index}`, + region: 'asia-east2' as const, + hardCap: 3_000, + ceiling: 2_800, + intakePerSec: 2, + mode: 'reserve' as const, + preseated: index === 0 ? 1_000 : 100 + })), + hosts: 1_200, + hostRegion: () => 'asia-east2', + meanSessionMs: 0, + drains: [{ at: 10_000, cellId: 'asia0', paceMs: 60_000 }] + }) + // The database stalls, the queue grows, and the director restarts before it drains. + const drainedWithoutSupersede = (): SimulationConfig => ({ + ...mixed({ skipSupersede: true }), + drains: [{ at: 60_000, cellId: 'us05', paceMs: 30_000 }] + }) + // Long enough for two reconciles: a seat behind its row must not survive the second. + const rowAheadLeftAlone = (): SimulationConfig => ({ + ...mixed({ noRowAheadDemote: true }), + durationMs: 25 * 60_000, + oldDirectors: 1 + }) + // Demote, re-assign, converge: a placement that never mints above the row is demoted again + // and again behind the same row. + const rowAheadNeverRises = (): SimulationConfig => ({ + ...mixed({ mintAtFloor: true }), + durationMs: 25 * 60_000, + oldDirectors: 1 + }) + const restartedMidQueue = (): SimulationConfig => ({ + ...mixed({ noBootReconcile: true }), + durationMs: 2 * 60_000, + databaseStalls: [{ at: 50_000, durationMs: 20_000 }], + directorRestarts: [0, 1, 2, 3, 4].map((director) => ({ at: 65_000, director })) + }) + it.each([ + [1, nearFull({ bookPastCap: true })], + [2, drain({ intakeBurst: 200, directorShare: 0.05 })], + [3, drainedWithoutSupersede()], + [5, mixed({ unguardedLedger: true })], + // A director restart mid-queue loses its booked joins' rows until its boot reconcile. + [5, restartedMidQueue()], + [8, rowAheadLeftAlone()], + [8, rowAheadNeverRises()], + [7, mixed({ noEpochFloor: true })] + ] as const)('invariant %i fires when its rule is broken', async (invariant, config) => { + const report = await runReservePlacementSimulation(config) + expect(report.violations.map((violation) => violation.invariant)).toContain(invariant) + }, 120_000) + + it('repairs a seat behind its row within two demotions, and reports re-seats that miss', async () => { + const config = { ...mixed({}), durationMs: 25 * 60_000, oldDirectors: 1 } + const report = await runReservePlacementSimulation(config) + expect(report.rowAheadDemotions).toBeGreaterThan(0) + expect(report.violations.filter((violation) => violation.invariant === 8)).toEqual([]) + expect(report.rowAheadDemotionsPerHost.max).toBeLessThanOrEqual(2) + // A director whose map has not seen the leave yet may re-place level with the row; the + // WRONG_CELL row read keeps that a small share (the rest are repaired by one more reconcile). + expect(report.badFirstReseats / report.rowAheadDemotions).toBeLessThan(0.1) + const withoutRead = await runReservePlacementSimulation({ ...config, faults: { noWrongCellRowRead: true } }) + process.stderr.write( + `row_ahead_repair ${JSON.stringify({ + withRead: { demotions: report.rowAheadDemotions, badFirstReseats: report.badFirstReseats, perHost: report.rowAheadDemotionsPerHost }, + withoutRead: { demotions: withoutRead.rowAheadDemotions, badFirstReseats: withoutRead.badFirstReseats, perHost: withoutRead.rowAheadDemotionsPerHost } + })}\n` + ) + }, 240_000) + + // Every director misses its feed polls for 150 s: each reserve cell's dead-man trips (60-120 s) + // while Postgres still says reserve, and fresh placement must find the tripped cells after. + const fleetTrip = (faults: SimulationConfig['faults']): SimulationConfig => ({ + ...mixed(faults), + cells: Array.from({ length: 6 }, (_, index) => ({ + cellId: `us${index}`, + region: 'us-central1' as const, + hardCap: 600, + ceiling: 480, + intakePerSec: 5, + mode: 'reserve' as const, + preseated: 100 + })), + hosts: 1_200, + hostRegion: () => 'us-central1', + durationMs: 6 * 60_000, + pollOutages: [{ at: 60_000, durationMs: 150_000 }] + }) + + // Token minting, the cells' token check or a partition: no director's poll gets through again. + // The cells' own rows still say db once they trip, so today's path recovers without the feed; + // before the trips a fully reserve region has no fresh placement (60-120 s). + it('recovers fresh placement while feed polls fail and stay failed, once the cells trip (invariant 9)', async () => { + const stuck = (faults: SimulationConfig['faults']) => ({ + ...fleetTrip(faults), + pollOutages: [{ at: 60_000, durationMs: Number.POSITIVE_INFINITY }] + }) + const report = await runReservePlacementSimulation(stuck({})) + expect(report.deadManTrips).toBe(6) + expect(report.violations.filter((violation) => violation.invariant === 9)).toEqual([]) + expect(report.databasePlacementsWhilePollsFail).toBeGreaterThan(0) + // The first cell trips 60-120 s into the outage; its row is read within a few seconds. + expect(report.pollFailureGapMs).toBeGreaterThanOrEqual(60_000) + expect(report.pollFailureGapMs).toBeLessThanOrEqual(130_000) + const blocked = await runReservePlacementSimulation(stuck({ pgReserveBlocksDatabase: true })) + expect(blocked.violations.map((violation) => violation.invariant)).toContain(9) + }, 240_000) + + // A tripped cell reports reserve while it re-registers, but db as its raw mode at once. + it('books nothing on a tripped cell while it re-registers, by its raw mode', async () => { + const slow = (faults: SimulationConfig['faults']) => ({ ...fleetTrip(faults), reregistrationMs: 120_000 }) + const report = await runReservePlacementSimulation(slow({})) + expect(report.deadManTrips).toBe(6) + expect(report.reserveCallsOnTrippedCells).toBe(0) + const reported = await runReservePlacementSimulation(slow({ noRawFeedMode: true })) + expect(reported.reserveCallsOnTrippedCells).toBeGreaterThan(0) + }, 240_000) + + it('places fresh hosts on the tripped cells once a fleet-wide poll outage ends (invariant 9)', async () => { + const report = await runReservePlacementSimulation(fleetTrip({})) + expect(report.deadManTrips).toBe(6) + expect(report.violations.filter((violation) => violation.invariant === 9)).toEqual([]) + expect(report.databasePlacements).toBeGreaterThan(0) + const blocked = await runReservePlacementSimulation(fleetTrip({ pgReserveBlocksDatabase: true })) + expect(blocked.deadManTrips).toBe(6) + expect(blocked.violations.map((violation) => violation.invariant)).toContain(9) + }, 240_000) +}) diff --git a/cloud/apps/relay/src/reserve-placement-simulation.ts b/cloud/apps/relay/src/reserve-placement-simulation.ts new file mode 100644 index 000000000000..a7de6f08101b --- /dev/null +++ b/cloud/apps/relay/src/reserve-placement-simulation.ts @@ -0,0 +1,1254 @@ +import { createHash } from 'node:crypto' +import type { RelayRegion } from '@orca-cloud/relay-contract' +import { CellReserveBook } from './cell-reserve-book.js' +import { CELL_INTAKE_BURST, CELL_RESERVE_TTL_MS, type ReserveOutcome } from './cell-reserve-contract.js' +import { + mintEpoch, + RESERVE_CELL_FRESH_MS, + RESERVE_MAX_TRIES, + ReservePlacer, + type PlacementCell +} from './reserve-placement.js' + +// A seeded discrete-event model of step 5 that drives the real CellReserveBook and +// ReservePlacer. Everything else (sockets, the database, desktops) is modelled. The checks +// are the design's invariants 1-8; a violation is recorded, never thrown, so one run reports +// every class it hit. + +export function seededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let value = state + value = Math.imul(value ^ (value >>> 15), value | 1) + value ^= value + Math.imul(value ^ (value >>> 7), value | 61) + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +type Event = { at: number; seq: number; run: () => void } + +class Clock { + now = 0 + private seq = 0 + private readonly heap: Event[] = [] + + schedule(delayMs: number, run: () => void): void { + const event = { at: this.now + Math.max(0, Math.round(delayMs)), seq: this.seq++, run } + this.heap.push(event) + let index = this.heap.length - 1 + while (index > 0) { + const parent = (index - 1) >> 1 + if (!before(this.heap[index]!, this.heap[parent]!)) break + ;[this.heap[index], this.heap[parent]] = [this.heap[parent]!, this.heap[index]!] + index = parent + } + } + + async runUntil(until: number, afterEach: () => void): Promise { + while (this.heap.length > 0 && this.heap[0]!.at <= until) { + const event = this.pop() + this.now = event.at + event.run() + // Lets the promise continuations the event released settle before the next event. + for (let tick = 0; tick < 12; tick += 1) await Promise.resolve() + afterEach() + } + this.now = until + } + + private pop(): Event { + const top = this.heap[0]! + const last = this.heap.pop()! + if (this.heap.length > 0) { + this.heap[0] = last + let index = 0 + for (;;) { + const left = index * 2 + 1 + const right = left + 1 + let smallest = index + if (left < this.heap.length && before(this.heap[left]!, this.heap[smallest]!)) smallest = left + if (right < this.heap.length && before(this.heap[right]!, this.heap[smallest]!)) smallest = right + if (smallest === index) break + ;[this.heap[index], this.heap[smallest]] = [this.heap[smallest]!, this.heap[index]!] + index = smallest + } + } + return top + } +} + +function before(left: Event, right: Event): boolean { + return left.at < right.at || (left.at === right.at && left.seq < right.seq) +} + +// `leased`: admitted through the database check, so its control renews a lease there and is +// closed when the row moves on, as today. +type CellSeat = { epoch: number; joinedAt: number; reservedBy?: string; demoted: boolean; leased?: boolean } +type CellLogEntry = + | { kind: 'join'; host: string; epoch: number; joinedAt: number; reservedBy?: string } + | { kind: 'leave'; host: string; epoch: number; at: number; wrongCell: boolean } + | { kind: 'reserve'; host: string; epoch: number } + | { kind: 'demoted'; host: string; epoch: number } + +const RECENT_SEAT_MS = 10 * 60_000 +const DEMOTE_CLOSE_MS = 60_000 +const CONTROL_RENEWAL_MS = 25_000 +// As the director's: a seat this young may still be superseded. +const RECONCILE_SETTLE_MS = 30_000 +// A drained seat lingers this long (its splices finishing) unless superseded first. +const DRAIN_GRACE_MS = 30_000 +// A cell closes a control silent for 75 s, checked once per 25 s ping. +const CONTROL_SILENCE_CLOSE_MS = 100_000 +// Longer than the slowest duplicate: a half-open seat's silence close. +const QUIESCE_MS = 150_000 + +export type SimulationViolation = { invariant: number; at: number; detail: string } + +// Each fault breaks one rule on purpose, so a test can show the matching invariant fires. +export type SimulationFaults = { + bookPastCap?: boolean + intakeBurst?: number + // Each director assumes this share of every cell's intake, instead of a fifth. + directorShare?: number + // Placement does not supersede the old seat: a duplicate then outlives its bound. + skipSupersede?: boolean + // A restarted director forgets its ledger queue and never reconciles. + noBootReconcile?: boolean + // The reconcile leaves a seat whose row is ahead of it alone. + noRowAheadDemote?: boolean + // Directors do not read the row after a WRONG_CELL leave (only the demoting one has a floor). + noWrongCellRowRead?: boolean + // Reserve placement re-uses the floor epoch instead of minting above it. + mintAtFloor?: boolean + // Today's path skips every cell Postgres records as reserve, ignoring the cell's own row. + pgReserveBlocksDatabase?: boolean + // Directors place by the reported mode, which stays reserve while a tripped cell re-registers. + noRawFeedMode?: boolean + unguardedLedger?: boolean + noEpochFloor?: boolean +} + +// relay_cell_admit_effective: each cell writes its raw mode every 15 s (cell-admit-effective-writer.ts), +// and today's path takes a reserve cell whose current-process row says db within 45 s +// (assignment-store.ts CELL_ADMIT_EFFECTIVE_FRESH_MS). +const CELL_ADMIT_EFFECTIVE_REFRESH_MS = 15_000 +const CELL_ADMIT_EFFECTIVE_FRESH_MS = 45_000 +// A tripped cell reports reserve until every control it admitted from memory holds a lease. +const DEFAULT_REREGISTRATION_MS = 20_000 + +// cell-reserve-dead-man.ts's window: 60-120 s, fixed per cell. +function deadManWindowMs(cellId: string): number { + return 60_000 + (createHash('sha256').update(cellId).digest().readUInt32BE(0) % 60_001) +} + +class SimDatabase { + up = true + // relay_cell_admit_modes: what the flag workflow recorded. A trip does not change it. + readonly reserveCells = new Set() + readonly rows = new Map() + // relay_cell_admit_effective: written by each cell itself, never by a director. + readonly admitEffective = new Map() + private readonly counts = new Map() + + set(host: string, row: { cellId: string; epoch: number }): void { + const previous = this.rows.get(host) + if (previous) this.counts.set(previous.cellId, (this.counts.get(previous.cellId) ?? 1) - 1) + this.counts.set(row.cellId, (this.counts.get(row.cellId) ?? 0) + 1) + this.rows.set(host, row) + } + + constructor( + private readonly violate: (invariant: number, detail: string) => void, + private readonly faults: SimulationFaults + ) {} + + // The director's after-the-fact write, a guarded mirror: only a higher epoch replaces a row. + upsert(host: string, cellId: string, epoch: number): void { + const row = this.rows.get(host) + const guarded = !this.faults.unguardedLedger + if (guarded && row && row.epoch >= epoch) return + if (row && epoch < row.epoch) this.violate(5, `ledger epoch fell for ${host}`) + this.set(host, { cellId, epoch }) + } + + // Today's assign writes the row it answers with. + assign(host: string, cellId: string, epoch: number): void { + const row = this.rows.get(host) + if (row && epoch <= row.epoch) this.violate(5, `db mint not above row for ${host}`) + this.set(host, { cellId, epoch }) + } + + countOn(cellId: string): number { + return this.counts.get(cellId) ?? 0 + } +} + +class SimCell { + readonly seats = new Map() + readonly recent = new Map() + readonly log: CellLogEntry[] = [] + // Per incarnation: a restarted cell starts with a full bucket. + readonly okBookingTimes: number[][] = [[]] + incarnation = 1 + draining = false + mode: 'reserve' | 'db' + bookingUnits = 0 + // What relay_cells says to a director that predates step 5: frozen at the flip. + frozenDatabaseCount = 0 + // The dead-man (cell-reserve-dead-man.ts): a reserve cell no placing director has booked on or + // polled with reserver=1 for its window flips itself to db, latched. + lastContactAt = 0 + trippedAt: number | null = null + reregisteredAt = 0 + // Booted and no flag object read yet: its mode is a default, so it writes no admit row. + flagsUnread = false + book: CellReserveBook + + constructor( + readonly cellId: string, + readonly region: RelayRegion, + readonly hardCap: number, + readonly ceiling: number, + readonly intakePerSec: number, + mode: 'reserve' | 'db', + // An image that predates step 5 reports no ceiling and has no reserve route. + readonly old: boolean, + private readonly clock: Clock, + private readonly faults: SimulationFaults, + private readonly violate: (invariant: number, detail: string) => void + ) { + this.mode = mode + this.book = this.newBook() + } + + private newBook(): CellReserveBook { + const limit = () => (this.faults.bookPastCap ? Infinity : this.ceiling) + return new CellReserveBook( + { + canReserve: () => this.seats.size + this.bookingUnits + 1 <= limit(), + tryReserve: () => { + if (this.seats.size + this.bookingUnits + 1 > limit()) return null + this.bookingUnits += 1 + let released = false + return { + release: () => { + if (released) return + released = true + this.bookingUnits -= 1 + } + } + } + }, + () => this.intakePerSec, + () => this.clock.now, + this.faults.intakeBurst + ) + } + + occupancy(): number { + return this.seats.size + this.bookingUnits + } + + contact(): void { + this.lastContactAt = this.clock.now + } + + checkDeadMan(windowMs: number, reregistrationMs: number): void { + if (this.mode !== 'reserve' || this.old || this.clock.now - this.lastContactAt <= windowMs) return + this.mode = 'db' + this.trippedAt = this.clock.now + this.reregisteredAt = this.clock.now + reregistrationMs + } + + // What the feed's admitModeEffective reports: `mode` is the raw one (switch file and dead-man). + effectiveMode(): 'db' | 'reserve' { + return this.mode === 'reserve' || this.clock.now < this.reregisteredAt ? 'reserve' : 'db' + } + + reserve(directorId: string, host: string, epoch: number, sticky: boolean): ReserveOutcome { + if (this.mode === 'reserve') this.contact() + const outcome = this.book.reserve( + directorId, + { userId: host, relayHostId: 'abcdefghijklmnop', epoch, ttlMs: CELL_RESERVE_TTL_MS, sticky }, + { + mode: this.mode, + draining: this.draining, + seatedEpoch: (userId) => this.seats.get(userId)?.epoch + } + ) + if (outcome.outcome === 'ok') { + this.log.push({ kind: 'reserve', host, epoch }) + if (!sticky) this.okBookingTimes.at(-1)!.push(this.clock.now) + } + return outcome + } + + hello(host: string, epoch: number, database: SimDatabase): boolean { + if (this.draining) return false + if (this.mode === 'reserve') { + const booking = this.book.take(host, 'abcdefghijklmnop', epoch) + if (booking) return this.seat(host, epoch, booking.directorId) + const seated = this.seats.get(host) + const recent = this.recent.get(host) + if ( + (seated && seated.epoch === epoch && !seated.demoted) || + (recent && recent.epoch === epoch && this.clock.now - recent.at <= RECENT_SEAT_MS) + ) { + return this.seat(host, epoch) + } + } + if (!database.up) return false + const row = database.rows.get(host) + if (!row || row.cellId !== this.cellId || row.epoch !== epoch) return false + return this.seat(host, epoch, undefined, true) + } + + private seat(host: string, epoch: number, reservedBy?: string, leased = false): boolean { + // A rebind keeps its unit; a new seat needs one under the hard cap. + if (!this.seats.has(host) && this.occupancy() + 1 > this.hardCap) return false + this.seats.set(host, { epoch, joinedAt: this.clock.now, reservedBy, demoted: false, leased }) + this.recent.delete(host) + this.log.push({ kind: 'join', host, epoch, joinedAt: this.clock.now, reservedBy }) + return true + } + + leave(host: string): void { + const seat = this.seats.get(host) + if (!seat) return + this.seats.delete(host) + if (!seat.demoted) this.recent.set(host, { epoch: seat.epoch, at: this.clock.now }) + this.log.push({ kind: 'leave', host, epoch: seat.epoch, at: this.clock.now, wrongCell: seat.demoted }) + } + + // The cell's own check: only the seat named by epoch and join, and only in reserve mode. + demote(host: string, epoch: number, joinedAt: number, onClose: () => void): void { + if (this.mode !== 'reserve') return + const seat = this.seats.get(host) + if (!seat || seat.epoch !== epoch || seat.joinedAt !== joinedAt || seat.demoted) return + seat.demoted = true + this.log.push({ kind: 'demoted', host, epoch }) + this.clock.schedule(DEMOTE_CLOSE_MS, () => { + if (this.seats.get(host) === seat) { + this.leave(host) + onClose() + } + }) + } + + // Seats, bookings and memory are gone; the image boots in database mode until its first flag read. + restart(): string[] { + const hosts = [...this.seats.keys()] + for (const host of hosts) this.leave(host) + this.book.clear() + this.book = this.newBook() + this.recent.clear() + this.incarnation += 1 + this.okBookingTimes.push([]) + const mode = this.mode + this.mode = 'db' + this.flagsUnread = true + this.clock.schedule(5_000, () => { + this.mode = mode + this.flagsUnread = false + }) + return hosts + } +} + +type MapSeat = { epoch: number; joinedAt: number; reservedBy?: string; demoted: boolean } + +type DirectorCellView = { + incarnation: number | null + cursor: number + seats: Map + placement: PlacementCell +} + +type Answer = + | { kind: 'cell'; cellId: string; epoch: number; calls: number } + | { kind: 'retry'; afterMs: number; calls: number } + +export type SimulationConfig = { + seed: number + durationMs: number + directors: number + oldDirectors?: number + cells: Array<{ + cellId: string + region: RelayRegion + hardCap: number + ceiling: number + intakePerSec: number + mode: 'reserve' | 'db' + old?: boolean + preseated?: number + }> + hosts: number + hostRegion: (index: number) => RelayRegion + // Fresh connects arrive at this rate per second until every host has connected once. + arrivalPerSec: number + meanSessionMs: number + reconnectDelayMs: [number, number] + duplicateAssignShare: number + // Share of departures whose old control stays half-open on the cell while the desktop + // connects elsewhere: the real source of a host seated on two cells. + halfOpenShare: number + pollLagMs: (director: number) => number + rttMs: (region: RelayRegion) => number + faults?: SimulationFaults + cellRestarts?: Array<{ at: number; cellId: string }> + // Cold start budget: a restarted director's estimates start empty and refill at its share of + // each cell's intake, so it books nothing on a cell for 1 / (intakePerSec / directors) s and + // paces the rest (Retry-After >= 1 s); the startup gate holds unheard hosts up to 30 s. + directorRestarts?: Array<{ at: number; director: number }> + databaseStalls?: Array<{ at: number; durationMs: number }> + // Every director misses every feed poll for this long (a director-side outage). + pollOutages?: Array<{ at: number; durationMs: number }> + // How long a tripped cell keeps reporting reserve while it leases its memory-admitted controls. + reregistrationMs?: number + drains?: Array<{ at: number; cellId: string; paceMs: number }> +} + +export type SimulationReport = { + violations: SimulationViolation[] + requests: number + placements: number + reserveCalls: number + exhausted: number + paced: number + databasePlacements: number + reservePlacements: number + // Rows that named a placement the desktop never used, rewritten to its real seat. + supersedes: number + directorRestarts: number + rowAheadDemotions: number + // Row-ahead demotions per demoted host: a repair that converges needs one. + rowAheadDemotionsPerHost: { max: number; p99: number } + deadManTrips: number + // Bookings sent to a cell after its dead-man tripped (the cell answers `off`). + reserveCallsOnTrippedCells: number + databasePlacementsWhilePollsFail: number + // From the first poll outage's start to the first fresh database placement inside it. + pollFailureGapMs: number | null + // First seats after a row-ahead demotion that land behind (or level with) the row again. + badFirstReseats: number + intakeRefusals: number + stickyReserves: number + stickyAnswers: number + seatedAtEnd: number + maxOccupancyShare: Record + arrivalsByCell: Record +} + +class SimDirector { + placer!: ReservePlacer + views = new Map() + recentlyLeft = new Map< + string, + { cellId: string; epoch: number; at: number; incarnation: number; wrongCell?: boolean } + >() + reserveEpochs = new Map() + // Row epochs this director demoted a host's seat behind: its next mint for the host clears them. + rowFloors = new Map() + ledgerQueue: Array<{ host: string; cellId: string; epoch: number }> = [] + answeredCells = new Set() + // Set by a restart: the lost ledger queue is rebuilt by one reconcile once the map is complete. + bootReconcilePending = false + restarted = false + readonly id: string + + constructor( + readonly index: number, + readonly old: boolean, + private readonly random: () => number, + private readonly clock: Clock, + private readonly share?: number + ) { + this.id = `d${index}` + this.reset() + } + + reset(): void { + this.placer = new ReservePlacer(() => this.clock.now, this.random, this.share) + this.views = new Map() + this.recentlyLeft = new Map() + this.reserveEpochs = new Map() + this.rowFloors = new Map() + this.answeredCells = new Set() + this.ledgerQueue = [] + } + + complete(cells: readonly SimCell[]): boolean { + return cells.every((cell) => this.answeredCells.has(cell.cellId)) + } + + knownEpochs(host: string): number[] { + const epochs: number[] = [] + for (const view of this.views.values()) { + const seat = view.seats.get(host) + if (seat) epochs.push(seat.epoch) + } + const reserved = this.reserveEpochs.get(host) + if (reserved !== undefined) epochs.push(reserved) + const left = this.recentlyLeft.get(host) + if (left) epochs.push(left.epoch) + return epochs + } +} + +export async function runReservePlacementSimulation( + config: SimulationConfig +): Promise { + const random = seededRandom(config.seed) + const clock = new Clock() + const violations: SimulationViolation[] = [] + const violate = (invariant: number, detail: string): void => { + if (violations.length < 50) violations.push({ invariant, at: clock.now, detail }) + } + const faults = config.faults ?? {} + const database = new SimDatabase(violate, faults) + const cells = config.cells.map( + (spec) => + new SimCell( + spec.cellId, + spec.region, + spec.hardCap, + spec.ceiling, + spec.intakePerSec, + spec.mode, + spec.old ?? false, + clock, + faults, + violate + ) + ) + const cellById = new Map(cells.map((cell) => [cell.cellId, cell])) + for (const cell of cells) if (cell.mode === 'reserve' && !cell.old) database.reserveCells.add(cell.cellId) + const inPollOutage = (at: number): boolean => + (config.pollOutages ?? []).some((outage) => at >= outage.at && at < outage.at + outage.durationMs) + const directors = Array.from( + { length: config.directors }, + (_, index) => + new SimDirector( + index, + index < (config.oldDirectors ?? 0), + random, + clock, + config.faults?.directorShare + ) + ) + const report: SimulationReport = { + violations, + requests: 0, + placements: 0, + reserveCalls: 0, + exhausted: 0, + paced: 0, + databasePlacements: 0, + reservePlacements: 0, + supersedes: 0, + directorRestarts: 0, + rowAheadDemotions: 0, + rowAheadDemotionsPerHost: { max: 0, p99: 0 }, + deadManTrips: 0, + reserveCallsOnTrippedCells: 0, + databasePlacementsWhilePollsFail: 0, + pollFailureGapMs: null, + badFirstReseats: 0, + intakeRefusals: 0, + stickyReserves: 0, + stickyAnswers: 0, + seatedAtEnd: 0, + maxOccupancyShare: {}, + arrivalsByCell: {} + } + const between = ([low, high]: [number, number]) => low + random() * (high - low) + + // Desktops: the cell and epoch each one currently uses. + type Host = { id: string; region: RelayRegion; current: { cellId: string; epoch: number } | null } + const hosts: Host[] = Array.from({ length: config.hosts }, (_, index) => ({ + id: `h${index}`, + region: config.hostRegion(index), + current: null + })) + const hostById = new Map(hosts.map((host) => [host.id, host])) + + // Pre-seated hosts arrive with database rows, as hosts on a cell before its flip. + let nextHost = 0 + for (const cell of cells) { + const spec = config.cells.find((entry) => entry.cellId === cell.cellId)! + for (let index = 0; index < (spec.preseated ?? 0) && nextHost < hosts.length; index += 1) { + const host = hosts[nextHost++]! + database.set(host.id, { cellId: cell.cellId, epoch: 1 }) + cell.seats.set(host.id, { epoch: 1, joinedAt: 0, demoted: false }) + cell.log.push({ kind: 'join', host: host.id, epoch: 1, joinedAt: 0 }) + host.current = { cellId: cell.cellId, epoch: 1 } + scheduleLeave(host) + } + cell.frozenDatabaseCount = cell.seats.size + } + + function scheduleLeave(host: Host): void { + if (config.meanSessionMs <= 0) return + const sessionMs = -Math.log(1 - random()) * config.meanSessionMs + clock.schedule(sessionMs, () => { + const current = host.current + if (!current || quiescing) return + const cell = cellById.get(current.cellId)! + if (cell.seats.get(host.id)?.epoch !== current.epoch) return + // A half-open control: the desktop moves on, the cell still holds the seat until the + // control's silence timeout closes it. + if (random() >= config.halfOpenShare) cell.leave(host.id) + else { + const seat = cell.seats.get(host.id) + clock.schedule(CONTROL_SILENCE_CLOSE_MS, () => { + if (cell.seats.get(host.id) === seat) cell.leave(host.id) + }) + } + host.current = null + clock.schedule(between(config.reconnectDelayMs), () => connect(host, true)) + }) + } + + // --- director polling --- + function poll(director: SimDirector, cell: SimCell): void { + if (inPollOutage(clock.now)) return + // A placing director's poll carries reserver=1: it is the cell's dead-man contact. + if (!director.old && cell.mode === 'reserve') cell.contact() + const requestedAt = clock.now + const view = director.views.get(cell.cellId) + const full = !view || view.incarnation !== cell.incarnation + const seats = full ? new Map([...cell.seats].map(([host, seat]) => [host, { ...seat }])) : null + const changes = full ? [] : cell.log.slice(view.cursor) + const snapshot = { + incarnation: cell.incarnation, + cursor: cell.log.length, + seats: cell.seats.size, + bookings: cell.book.count(), + tokens: cell.book.intakeSnapshot().tokens, + mode: faults.noRawFeedMode ? cell.effectiveMode() : cell.mode, + draining: cell.draining + } + const delay = config.rttMs(cell.region) + config.pollLagMs(director.index) + clock.schedule(delay, () => { + let current = director.views.get(cell.cellId) + if (full) { + if (current) { + for (const [host, seat] of current.seats) { + if (!seats!.has(host)) { + director.recentlyLeft.set(host, { + cellId: cell.cellId, + epoch: seat.epoch, + at: clock.now, + incarnation: current.incarnation ?? 0 + }) + } + } + } + current = { + incarnation: snapshot.incarnation, + cursor: snapshot.cursor, + seats: seats!, + placement: placementCell(cell, snapshot, requestedAt) + } + director.views.set(cell.cellId, current) + } else if (current && current.incarnation === snapshot.incarnation) { + for (const change of changes) applyChange(director, cell, current, change) + current.cursor = snapshot.cursor + current.placement = placementCell(cell, snapshot, requestedAt) + } else { + // A restarted director's (or a stale) delta: the next poll asks for a full snapshot. + return + } + director.answeredCells.add(cell.cellId) + }) + } + + function placementCell( + cell: SimCell, + snapshot: { seats: number; bookings: number; tokens: number; mode: string; draining: boolean }, + polledAt: number + ): PlacementCell { + return { + cellId: cell.cellId, + region: cell.region, + reserve: !cell.old && snapshot.mode === 'reserve', + general: true, + draining: snapshot.draining, + polledAt, + seats: snapshot.seats, + bookings: snapshot.bookings, + ceiling: cell.old ? undefined : cell.ceiling, + intakePerSec: cell.intakePerSec, + intakeTokens: snapshot.tokens + } + } + + function applyChange( + director: SimDirector, + cell: SimCell, + view: DirectorCellView, + change: CellLogEntry + ): void { + if (change.kind === 'reserve') { + director.reserveEpochs.set( + change.host, + Math.max(director.reserveEpochs.get(change.host) ?? 0, change.epoch) + ) + } else if (change.kind === 'join') { + view.seats.set(change.host, { + epoch: change.epoch, + joinedAt: change.joinedAt, + reservedBy: change.reservedBy, + demoted: false + }) + director.recentlyLeft.delete(change.host) + // Exactly one director writes each booked join: the one that booked it. + if (change.reservedBy === director.id) { + director.ledgerQueue.push({ host: change.host, cellId: cell.cellId, epoch: change.epoch }) + } + } else if (change.kind === 'leave') { + const seat = view.seats.get(change.host) + if (seat && seat.epoch === change.epoch) { + view.seats.delete(change.host) + director.recentlyLeft.set(change.host, { + cellId: cell.cellId, + epoch: change.epoch, + at: change.at, + incarnation: view.incarnation ?? 0, + wrongCell: change.wrongCell + }) + } + } else { + const seat = view.seats.get(change.host) + if (seat && seat.epoch === change.epoch) seat.demoted = true + } + } + + // Supersede at placement: the director that books a host on `cellId` tells the old seats its + // map holds on reserve cells to go. Each cell checks epoch and join itself. + function supersede(director: SimDirector, host: string, cellId: string, epoch: number): void { + if (faults.skipSupersede) return + for (const [seatCellId, view] of director.views) { + const seat = view.seats.get(host) + if (!seat || seat.demoted || seatCellId === cellId || seat.epoch >= epoch) continue + if (!view.placement.reserve) continue + const loser = cellById.get(seatCellId)! + report.supersedes += 1 + clock.schedule(config.rttMs(loser.region), () => + loser.demote(host, seat.epoch, seat.joinedAt, () => onSeatClosed(host, seatCellId, seat.epoch)) + ) + } + } + + function onSeatClosed(hostId: string, cellId: string, epoch: number): void { + const host = hostById.get(hostId)! + if (host.current?.cellId === cellId && host.current.epoch === epoch) { + host.current = null + clock.schedule(between(config.reconnectDelayMs), () => connect(host, true)) + } + } + + function flushLedger(director: SimDirector): void { + if (!database.up || director.ledgerQueue.length === 0) return + for (const entry of director.ledgerQueue) { + database.upsert(entry.host, entry.cellId, entry.epoch) + } + director.ledgerQueue = [] + } + + // Invariant 8: a reserve seat behind its row (today's path wrote a newer epoch, and the + // desktop was answered the old seat from memory), or level with it on another cell, is gone + // by the next reconcile, and the director that demoted it re-places the host above the row. + const behindRow = new Map() + const rowAheadDemotionsByHost = new Map() + const demotedBehindRow = new Map() + // A host demoted behind its row: its next answer, from any director, is that row or above it. + const reassignAbove = new Map() + function reconcileLedger(director: SimDirector): void { + if (!database.up || director.old) return + for (const [cellId, view] of director.views) { + if (!view.placement.reserve) continue + for (const [host, seat] of view.seats) { + if (seat.demoted || clock.now - seat.joinedAt < RECONCILE_SETTLE_MS) continue + const row = database.rows.get(host) + const live = cellById.get(cellId)!.seats.get(host) + const diverged = row && (row.epoch > seat.epoch || (row.epoch === seat.epoch && row.cellId !== cellId)) + if (row && diverged && live && live.joinedAt === seat.joinedAt && !live.demoted) { + const key = `${director.id}\u0000${host}\u0000${cellId}\u0000${seat.joinedAt}` + if (behindRow.has(key)) violate(8, `${host}@${seat.epoch} on ${cellId} still behind row @${row.epoch}`) + behindRow.set(key, clock.now) + if (faults.noRowAheadDemote) continue + report.rowAheadDemotions += 1 + director.rowFloors.set(host, Math.max(row.epoch, director.rowFloors.get(host) ?? 0)) + rowAheadDemotionsByHost.set(host, (rowAheadDemotionsByHost.get(host) ?? 0) + 1) + // A director that had not seen the leave may re-place level with the row once; a later + // divergence (a newer row) may demote the host again. One row never takes a third. + const sameRow = `${host}\u0000${row.cellId}\u0000${row.epoch}` + const behindThisRow = (demotedBehindRow.get(sameRow) ?? 0) + 1 + demotedBehindRow.set(sameRow, behindThisRow) + if (behindThisRow > 2) violate(8, `${host} demoted ${behindThisRow} times behind its row ${row.cellId}@${row.epoch}`) + reassignAbove.set(host, { ...row }) + const cell = cellById.get(cellId)! + clock.schedule(config.rttMs(cell.region), () => + cell.demote(host, seat.epoch, seat.joinedAt, () => onSeatClosed(host, cellId, seat.epoch)) + ) + continue + } + database.upsert(host, cellId, seat.epoch) + } + } + } + + // --- assignment --- + function reserveCall( + director: SimDirector, + cell: SimCell, + host: string, + epoch: number, + sticky: boolean + ): Promise { + const rtt = config.rttMs(cell.region) + return new Promise((resolve) => { + clock.schedule(rtt / 2, () => { + const outcome = cell.old ? { outcome: 'off' as const } : cell.reserve(director.id, host, epoch, sticky) + if (outcome.outcome === 'intake') report.intakeRefusals += 1 + if (outcome.outcome === 'off' && cell.trippedAt !== null) report.reserveCallsOnTrippedCells += 1 + clock.schedule(rtt / 2, () => resolve(outcome)) + }) + }) + } + + function databaseAssign(director: SimDirector, host: Host, floor: number): Answer { + if (!database.up) return { kind: 'retry', afterMs: 2_000, calls: 0 } + const row = database.rows.get(host.id) + // Postgres still says reserve after a trip; the cell's own fresh row (db, current process) + // lets today's path back in, with or without any feed poll getting through. + const admitsDatabase = (cell: SimCell): boolean => { + const own = database.admitEffective.get(cell.cellId) + return ( + !faults.pgReserveBlocksDatabase && + own?.mode === 'db' && + own.incarnation === cell.incarnation && + clock.now - own.at <= CELL_ADMIT_EFFECTIVE_FRESH_MS + ) + } + const eligible = cells.filter((cell) => { + if (cell.draining) return false + if (!director.old && database.reserveCells.has(cell.cellId) && !admitsDatabase(cell)) return false + const count = cell.mode === 'reserve' ? cell.frozenDatabaseCount : database.countOn(cell.cellId) + return count < cell.ceiling + }) + const load = (cell: SimCell) => + (cell.mode === 'reserve' ? cell.frozenDatabaseCount : database.countOn(cell.cellId)) / cell.ceiling + const inRegion = eligible.filter((cell) => cell.region === host.region) + const pool = inRegion.length > 0 ? inRegion : eligible + if (pool.length === 0) { + // A cell that tripped 20 s ago (a 1 s write, a 5 s cached read, slack) must be takeable. + const stranded = cells.find( + (cell) => + !director.old && + !cell.old && + !cell.draining && + cell.trippedAt !== null && + cell.mode === 'db' && + clock.now - cell.trippedAt >= 20_000 && + database.countOn(cell.cellId) < cell.ceiling + ) + if (stranded) violate(9, `${host.id}: ${stranded.cellId} tripped but takes no fresh placement`) + return { kind: 'retry', afterMs: 5_000, calls: 0 } + } + const target = pool.reduce((best, cell) => (load(cell) < load(best) ? cell : best)) + const epoch = Math.max(row?.epoch ?? 0, faults.noEpochFloor ? 0 : floor) + 1 + if (!director.old && epoch <= floor) violate(7, `db mint ${epoch} not above map floor ${floor}`) + database.assign(host.id, target.cellId, epoch) + if (target.mode === 'reserve') target.frozenDatabaseCount += 1 + report.databasePlacements += 1 + const outage = (config.pollOutages ?? [])[0] + if (inPollOutage(clock.now)) { + report.databasePlacementsWhilePollsFail += 1 + if (outage && report.pollFailureGapMs === null) report.pollFailureGapMs = clock.now - outage.at + } + return { kind: 'cell', cellId: target.cellId, epoch, calls: 0 } + } + + async function assign(director: SimDirector, host: Host, reconnect: boolean): Promise { + report.requests += 1 + if (director.old) return databaseAssign(director, host, 0) + if (!director.complete(cells)) return { kind: 'retry', afterMs: 1_000, calls: 0 } + const now = clock.now + const rowFloor = director.rowFloors.get(host.id) + const known = [...director.knownEpochs(host.id), ...(rowFloor === undefined ? [] : [rowFloor])] + let floor: number + if (known.length > 0) { + floor = Math.max(...known) + // A WRONG_CELL leave, seen in the cell's feed by every director: read the row too. + if (director.recentlyLeft.get(host.id)?.wrongCell && !faults.noWrongCellRowRead && database.up) { + floor = Math.max(floor, database.rows.get(host.id)?.epoch ?? 0) + } + } else { + if (!database.up) return { kind: 'retry', afterMs: 2_000, calls: 0 } + floor = database.rows.get(host.id)?.epoch ?? 0 + } + if (reconnect) { + const found = stickyFromMemory(director, host, now) + // The seat the reconcile demoted (or one level with its row) is never answered again. + const sticky = found && rowFloor !== undefined && found.epoch <= rowFloor ? null : found + if (sticky) { + const cell = cellById.get(sticky.cellId)! + // The map's own view of the cell's incarnation: a restart not yet polled answers stale, + // and the hello then falls to the database check. + const view = director.views.get(sticky.cellId) + if (sticky.incarnation === view?.incarnation || cell.old) { + report.stickyAnswers += 1 + return { kind: 'cell', cellId: sticky.cellId, epoch: sticky.epoch, calls: 0 } + } + report.stickyReserves += 1 + const outcome = await reserveCall(director, cell, host.id, sticky.epoch, true) + if (outcome.outcome === 'ok') { + report.stickyAnswers += 1 + return { kind: 'cell', cellId: sticky.cellId, epoch: sticky.epoch, calls: 1 } + } + } + } + const placements = [...director.views.values()].map((view) => view.placement) + if (random() >= director.placer.reservePoolShare(placements, host.region)) { + return databaseAssign(director, host, floor) + } + const result = await director.placer.place({ + cells: placements, + region: host.region, + epoch: faults.mintAtFloor ? Math.max(1, floor) : mintEpoch([floor]), + reserve: (cellId, epoch) => reserveCall(director, cellById.get(cellId)!, host.id, epoch, false) + }) + report.reserveCalls += result.calls + if (result.calls > RESERVE_MAX_TRIES) violate(6, `${result.calls} reserve calls in one request`) + if (result.kind === 'placed') { + report.reservePlacements += 1 + supersede(director, host.id, result.cellId, result.epoch) + return { kind: 'cell', cellId: result.cellId, epoch: result.epoch, calls: result.calls } + } + if (result.calls === RESERVE_MAX_TRIES) report.exhausted += 1 + if (result.kind === 'pace') { + report.paced += 1 + return { kind: 'retry', afterMs: result.retryAfterSeconds * 1_000, calls: result.calls } + } + const fallback = databaseAssign(director, host, floor) + return fallback.kind === 'cell' ? { ...fallback, calls: result.calls } : fallback + } + + function stickyFromMemory(director: SimDirector, host: Host, now: number) { + // The newest grant only: highest epoch, then newest join. + const seats = [...director.views] + .map(([cellId, view]) => ({ cellId, view, seat: view.seats.get(host.id) })) + .filter((entry) => entry.seat && !entry.seat.demoted) + .sort((left, right) => right.seat!.epoch - left.seat!.epoch || right.seat!.joinedAt - left.seat!.joinedAt) + const top = seats[0] + if (top && stickyCell(director, top.view, now, true)) { + return { cellId: top.cellId, epoch: top.seat!.epoch, incarnation: top.view.incarnation ?? 0 } + } + if (top) return null + const left = director.recentlyLeft.get(host.id) + // A seat closed WRONG_CELL may not rejoin from memory. + if (left && !left.wrongCell && now - left.at <= RECENT_SEAT_MS) { + const view = director.views.get(left.cellId) + if (view && stickyCell(director, view, now, false)) { + return { cellId: left.cellId, epoch: left.epoch, incarnation: left.incarnation } + } + } + return null + } + + function stickyCell(director: SimDirector, view: DirectorCellView, now: number, seated: boolean): boolean { + const cell = view.placement + if (!cell.reserve || !cell.general || cell.draining || cell.polledAt === null) return false + if (now - cell.polledAt > RESERVE_CELL_FRESH_MS) return false + return seated || director.placer.hasSeat(cell) + } + + // --- desktops --- + let directorCursor = 0 + function pickDirector(): SimDirector { + directorCursor = (directorCursor + 1 + Math.floor(random() * directors.length)) % directors.length + return directors[directorCursor]! + } + + // After the run, desktops stop moving so the maps and the ledger can settle. + let quiescing = false + function connect(host: Host, reconnect: boolean): void { + if (host.current || quiescing) return + const first = pickDirector() + const duplicate = random() < config.duplicateAssignShare ? pickDirector() : null + let used = false + const onAnswer = (answer: Answer, primary: boolean): void => { + if (answer.kind === 'retry') { + if (primary && !used) { + used = true + clock.schedule(answer.afterMs + random() * 500, () => connect(host, reconnect)) + } + return + } + if (used) return + used = true + clock.schedule(50 + random() * 250, () => hello(host, answer.cellId, answer.epoch, reconnect)) + } + // A throw in the model is a broken run, recorded like any violated rule. + const broken = (error: unknown) => violate(0, `model threw: ${String(error)}`) + void assign(first, host, reconnect) + .then((answer) => onAnswer(answer, true)) + .catch(broken) + if (duplicate) { + clock.schedule(random() * 500, () => { + void assign(duplicate, host, reconnect) + .then((answer) => onAnswer(answer, false)) + .catch(broken) + }) + } + } + + function hello(host: Host, cellId: string, epoch: number, reconnect: boolean): void { + const cell = cellById.get(cellId)! + if (host.current) return + const admitted = cell.hello(host.id, epoch, database) + if (!admitted) { + clock.schedule(1_000 + random() * 2_000, () => connect(host, reconnect)) + return + } + report.placements += 1 + report.arrivalsByCell[cellId] = (report.arrivalsByCell[cellId] ?? 0) + 1 + // Reported, not a violation: the first seat after a demotion behind the row lands on that + // row or above it, unless the answering director's map had not yet seen the leave. + const above = reassignAbove.get(host.id) + if (above !== undefined) { + reassignAbove.delete(host.id) + if (epoch < above.epoch || (epoch === above.epoch && cellId !== above.cellId)) report.badFirstReseats += 1 + } + host.current = { cellId, epoch } + scheduleLeave(host) + } + + // --- schedule --- + for (const cell of cells) { + const windowMs = deadManWindowMs(cell.cellId) + const check = (): void => { + const wasReserve = cell.mode === 'reserve' + cell.checkDeadMan(windowMs, config.reregistrationMs ?? DEFAULT_REREGISTRATION_MS) + if (wasReserve && cell.mode === 'db') report.deadManTrips += 1 + // Best-effort through the cell's own pool: nothing to write while the database is down, + // and only while its switch says reserve (a db cell never writes one). + const own = database.admitEffective.get(cell.cellId) + if ( + !cell.old && + database.reserveCells.has(cell.cellId) && + !cell.flagsUnread && + database.up && + (!own || own.mode !== cell.mode || own.incarnation !== cell.incarnation || clock.now - own.at >= CELL_ADMIT_EFFECTIVE_REFRESH_MS) + ) { + database.admitEffective.set(cell.cellId, { mode: cell.mode, incarnation: cell.incarnation, at: clock.now }) + } + clock.schedule(1_000, check) + } + clock.schedule(1_000, check) + } + for (const director of directors) { + const tick = (): void => { + for (const cell of cells) poll(director, cell) + if (director.bootReconcilePending && director.complete(cells)) { + director.bootReconcilePending = false + reconcileLedger(director) + } + clock.schedule(1_000, tick) + } + clock.schedule(random() * 1_000, tick) + const ledger = (): void => { + flushLedger(director) + clock.schedule(250, ledger) + } + clock.schedule(250, ledger) + const reconcile = (): void => { + reconcileLedger(director) + clock.schedule(10 * 60_000 + random() * 60_000, reconcile) + } + // As on a real director: once at boot when the map is complete, then every 10 minutes. + director.bootReconcilePending = true + clock.schedule(10 * 60_000 + random() * 60_000, reconcile) + } + // A leased control's renewal fails once its row names another seat: the cell closes it. + for (const cell of cells) { + const renew = (): void => { + if (database.up) { + for (const [hostId, seat] of [...cell.seats]) { + const row = database.rows.get(hostId) + if (!seat.leased || !row || (row.cellId === cell.cellId && row.epoch === seat.epoch)) continue + cell.leave(hostId) + onSeatClosed(hostId, cell.cellId, seat.epoch) + } + } + clock.schedule(CONTROL_RENEWAL_MS, renew) + } + clock.schedule(random() * CONTROL_RENEWAL_MS, renew) + } + for (const cell of cells) { + const sweep = (): void => { + cell.book.sweep() + clock.schedule(1_000, sweep) + } + clock.schedule(1_000, sweep) + } + const fresh = hosts.slice(nextHost) + fresh.forEach((host, index) => { + clock.schedule((index / config.arrivalPerSec) * 1_000, () => connect(host, false)) + }) + for (const restart of config.cellRestarts ?? []) { + clock.schedule(restart.at, () => { + const cell = cellById.get(restart.cellId)! + for (const hostId of cell.restart()) { + const host = hostById.get(hostId)! + if (host.current?.cellId !== cell.cellId) continue + host.current = null + clock.schedule(random() * 10_000, () => connect(host, true)) + } + }) + } + for (const restart of config.directorRestarts ?? []) { + clock.schedule(restart.at, () => { + const director = directors[restart.director]! + director.reset() + director.restarted = true + director.bootReconcilePending = !faults.noBootReconcile + report.directorRestarts += 1 + }) + } + for (const stall of config.databaseStalls ?? []) { + clock.schedule(stall.at, () => { + database.up = false + clock.schedule(stall.durationMs, () => { + database.up = true + }) + }) + } + for (const drain of config.drains ?? []) { + clock.schedule(drain.at, () => { + const cell = cellById.get(drain.cellId)! + cell.draining = true + const seated = [...cell.seats.keys()] + seated.forEach((hostId, index) => { + clock.schedule((index / Math.max(1, seated.length - 1)) * drain.paceMs, () => { + const host = hostById.get(hostId)! + const seat = cell.seats.get(hostId) + if (!seat) return + // Drain-only: the seat keeps its splices for the grace while the desktop moves on. + clock.schedule(DRAIN_GRACE_MS, () => { + if (cell.seats.get(hostId) === seat) cell.leave(hostId) + }) + if (host.current?.cellId === cell.cellId) { + host.current = null + clock.schedule(1_000 + random() * 5_000, () => connect(host, true)) + } + }) + }) + }) + } + + // --- invariants checked after every event --- + const afterEach = (): void => { + for (const cell of cells) { + if (cell.occupancy() > cell.hardCap) { + violate(1, `${cell.cellId} at ${cell.occupancy()} over hard cap ${cell.hardCap}`) + } + const share = cell.occupancy() / cell.hardCap + report.maxOccupancyShare[cell.cellId] = Math.max(report.maxOccupancyShare[cell.cellId] ?? 0, share) + } + } + // A booking supersedes the old seat within a round trip; a duplicate nobody books past (a + // half-open seat while today's path places the host) lasts until that seat's silence close. + const supersedeBound = + Math.max(...directors.map((director) => config.pollLagMs(director.index))) + + 2 * Math.max(...cells.map((cell) => config.rttMs(cell.region))) + + 3_000 + const duplicateBound = CONTROL_SILENCE_CLOSE_MS + 2 * Math.max(...cells.map((cell) => config.rttMs(cell.region))) + 1_000 + // Invariant 3: a seat outranked by a newer one (a higher epoch elsewhere) is gone within the + // bound. Only seats on reserve cells are step 5's: between database-mode cells today's lease + // renewal closes the stale control, as it does now. + const outrankedSince = new Map() + const checkDuplicates = (): void => { + const seatsByHost = new Map>() + for (const cell of cells) { + for (const [host, seat] of cell.seats) { + if (seat.demoted) continue + const seats = seatsByHost.get(host) ?? [] + seats.push({ cell, seat }) + seatsByHost.set(host, seats) + } + } + const live = new Set() + for (const [host, seats] of seatsByHost) { + if (seats.length < 2) continue + const newest = Math.max(...seats.map((entry) => entry.seat.epoch)) + // A seat outranked by a booking is superseded within the booking director's poll lag and + // a round trip; anything else (today's path placed the host) waits for the silence close. + const booked = seats.some((entry) => entry.seat.epoch === newest && entry.seat.reservedBy !== undefined) + for (const { cell, seat } of seats) { + if (cell.mode !== 'reserve' || seat.epoch === newest) continue + // A leased seat goes by its renewal, as today. + const bound = booked && !seat.leased ? supersedeBound : duplicateBound + const key = `${host}\u0000${cell.cellId}\u0000${seat.joinedAt}` + live.add(key) + const since = outrankedSince.get(key) ?? clock.now + outrankedSince.set(key, since) + if (clock.now - since > bound) { + violate(3, `${host}@${seat.epoch} on ${cell.cellId} outranked for ${clock.now - since} ms`) + } + } + } + for (const key of [...outrankedSince.keys()]) if (!live.has(key)) outrankedSince.delete(key) + clock.schedule(1_000, checkDuplicates) + } + clock.schedule(1_000, checkDuplicates) + + await clock.runUntil(config.durationMs, afterEach) + quiescing = true + await clock.runUntil(config.durationMs + QUIESCE_MS, afterEach) + + // Invariant 2: no window holds more ok bookings than burst + rate x window. + for (const cell of cells) { + for (const times of cell.okBookingTimes) { + let lowest = Infinity + times.forEach((at, index) => { + lowest = Math.min(lowest, index - (cell.intakePerSec * at) / 1_000) + const excess = index - (cell.intakePerSec * at) / 1_000 - lowest + 1 + if (excess > CELL_INTAKE_BURST + 1e-6) { + violate(2, `${cell.cellId} booked ${excess.toFixed(2)} over its bucket`) + } + }) + } + } + // Invariant 5: the ledger is a guarded mirror. After one reconcile with the database up, a + // host seated once, at its highest epoch, on a reserve cell has a row at that epoch or above. + // No final reconcile: what the queues, the boot reconcile and the 10-minute one wrote is + // what a reader would see. + database.up = true + for (const director of directors) flushLedger(director) + for (const cell of cells) { + if (cell.mode !== 'reserve') continue + for (const [host, seat] of cell.seats) { + if (seat.demoted) continue + const otherSeat = cells.some( + (other) => other !== cell && other.seats.get(host) && !other.seats.get(host)!.demoted + ) + if (otherSeat) continue + const row = database.rows.get(host) + if (!row || row.epoch < seat.epoch) { + violate(5, `${host} row ${JSON.stringify(row)} is below its seat ${cell.cellId}@${seat.epoch}`) + } + } + } + report.seatedAtEnd = cells.reduce((sum, cell) => sum + cell.seats.size, 0) + const perHost = [...rowAheadDemotionsByHost.values()].sort((left, right) => left - right) + report.rowAheadDemotionsPerHost = { + max: perHost.at(-1) ?? 0, + p99: perHost[Math.min(perHost.length - 1, Math.floor(perHost.length * 0.99))] ?? 0 + } + return report +} From e9bf6d1c3f3eb7a957b2d99e26b8fe60523fc636 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:41:47 -0400 Subject: [PATCH 038/110] fix(relay): the cell switch tool changes only the named switches (#27056) * fix(relay): the cell switch tool changes only the named switches and checks every key on read-back A cell resets a key missing from its switch file to its default, so the tool now reads the current object and changes only what --set names. The read-back compares every known key and fails when the cell reports ignoredKeys. * fix(relay): the cell switch tool refuses a switch the cell's image does not support It reads supportedFlags from runtime-status (#27057). An image from before that field takes only readinessLocal and ticketCheck off/shadow, so ticketCheck=enforce cannot void an old cell's object. * fix(relay): the switch tool validates the whole object, refuses voided files, and records admitMode in Postgres Every key and value of the object to be written is checked against what the image accepts (an image without the list: only the keys it applies, ticketCheck off/shadow). A file the cell did not apply is refused. admitMode=reserve needs a typed RESERVE ; Postgres records reserve before the file is written, and db only after the cell reports every control leased again. * feat(relay): the switch tool knows reregisterInFlight (1-16), the flip-back pace * fix(relay): the switch tool closes the cache gap before reserve, undoes a refused write, and has a break-glass db flip After recording reserve in Postgres it waits past every director's 5 s cache and records it again (re-running the open-flow check) before writing the file. A file write refused outright (412 or 4xx) puts Postgres back to db; after a 5xx it stays reserve. An object the cell did not apply is repaired when the new object passes the image check. Break glass (typed BREAK GLASS ) records db for a dead or unreachable reserve cell without its read-back. * fix(relay): a director deploy refuses a traffic move across reserve-mode support while any cell is reserve * fix(relay): the flip-back wait scales with the cell's controls and pace, and says how to finish * fix(relay): the switch tool refuses to re-arm a tripped cell, breaks glass only on a silent one, and undoes reserve if the second post is refused * feat(relay): director deploys set reserve placement, and refuse turning it off while any cell is reserve * fix(relay): Terraform keeps the deploy-owned placement and shadow cells, and the deploy guard reads placement as built and refuses dropping a reserve cell from the shadow list * fix(relay): director deploys take --admin-audience alone, name a drifted deploy identity, and carry the shadow seat-feed cells Staging passes --admin-audience for the reserve guard without durable rehome verification, which the parser refused. Only the generation and control origin now travel together, and they still need the audience. The guard decodes the caller from its token and refuses, by name, a revision whose ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT is another account, instead of reading into a 401. --shadow-seat-feed-cells (preserve, all, or a cell list) is a workflow input on both director deploys; preserve keeps the serving value. A contract test parses every workflow's real argv for both deploy scripts. * fix(relay): the flag tool paces a flip back from the pool size the cell reports An image from before the field falls back to the deployed sizes. --- ...cloud-deploy-relay-production-director.yml | 17 +- .../workflows/cloud-deploy-relay-staging.yml | 19 + ...ud-operate-relay-production-cell-flags.yml | 42 +- cloud/dev/scripts/deploy-relay-blue-green.mjs | 224 +++++++++- .../scripts/deploy-relay-blue-green.test.mjs | 230 ++++++++++- .../dev/scripts/operate-relay-cell-flags.mjs | 381 ++++++++++++++++-- .../scripts/operate-relay-cell-flags.test.mjs | 359 ++++++++++++++++- ...lay-serving-regional-placement-version.mjs | 30 +- ...erving-regional-placement-version.test.mjs | 53 ++- .../relay-deploy-workflow-argv.test.mjs | 86 ++++ .../scripts/relay-director-deploy-plan.mjs | 2 + cloud/infra/terraform/relay.tf | 16 + 12 files changed, 1389 insertions(+), 70 deletions(-) create mode 100644 cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs diff --git a/.github/workflows/cloud-deploy-relay-production-director.yml b/.github/workflows/cloud-deploy-relay-production-director.yml index 07e1e74ec3c2..6094f25ccafc 100644 --- a/.github/workflows/cloud-deploy-relay-production-director.yml +++ b/.github/workflows/cloud-deploy-relay-production-director.yml @@ -18,6 +18,17 @@ on: required: true default: preserve type: string + reserve-placement: + description: Preserve step-5 reserve placement, or set it; turning it off is refused while any cell is reserve + required: true + default: preserve + type: choice + options: [preserve, 'off', dry-run, 'on'] + shadow-seat-feed-cells: + description: Preserve the step-3 shadow seat-feed cells, or set all or a comma list of cell ids; dropping a reserve cell is refused + required: true + default: preserve + type: string prune-incompatible-revisions: description: Retain only the newly verified serving and rollback revisions required: true @@ -68,6 +79,8 @@ jobs: IMAGE_DIGEST: ${{ inputs.image-digest }} REGIONAL_PLACEMENT_MODE: ${{ inputs.regional-placement-mode }} REGION_CORRECTION_COHORT_PERCENT: ${{ inputs.region-correction-cohort-percent }} + RESERVE_PLACEMENT: ${{ inputs.reserve-placement }} + SHADOW_SEAT_FEED_CELLS: ${{ inputs.shadow-seat-feed-cells }} PRUNE_INCOMPATIBLE_REVISIONS: ${{ inputs.prune-incompatible-revisions }} # Floor the served revision must keep, matching relay_min_instances in # environments/production.tfvars. This gate only fails a bad deploy; Terraform @@ -228,7 +241,9 @@ jobs: --prune-revisions "${PRUNE_INCOMPATIBLE_REVISIONS}" \ --release-id "${RELEASE_ID}" \ --regional-placement-secret-version "${target_version}" \ - --region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}" + --region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}" \ + --reserve-placement "${RESERVE_PLACEMENT}" \ + --shadow-seat-feed-cells "${SHADOW_SEAT_FEED_CELLS}" echo "REGIONAL_PLACEMENT_ENABLED=${desired}" >> "${GITHUB_ENV}" echo "REGIONAL_PLACEMENT_VERSION=${target_version}" >> "${GITHUB_ENV}" diff --git a/.github/workflows/cloud-deploy-relay-staging.yml b/.github/workflows/cloud-deploy-relay-staging.yml index 7ddfb9d7c733..bd82fbfb851d 100644 --- a/.github/workflows/cloud-deploy-relay-staging.yml +++ b/.github/workflows/cloud-deploy-relay-staging.yml @@ -7,6 +7,17 @@ on: description: Exact checked-in production Relay sha256 digest to deploy required: true type: string + reserve-placement: + description: Preserve step-5 reserve placement, or set it; turning it off is refused while any cell is reserve + required: true + default: preserve + type: choice + options: [preserve, 'off', dry-run, 'on'] + shadow-seat-feed-cells: + description: Preserve the step-3 shadow seat-feed cells, or set all or a comma list of cell ids; dropping a reserve cell is refused + required: true + default: preserve + type: string permissions: contents: read @@ -87,6 +98,11 @@ jobs: test "${DIGEST}" = "${EXPECTED_IMAGE_DIGEST}" - name: Deploy director blue/green + env: + # An image change reads every cell's admit mode before the traffic move. + ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.google-auth.outputs.id_token }} + RESERVE_PLACEMENT: ${{ inputs.reserve-placement }} + SHADOW_SEAT_FEED_CELLS: ${{ inputs.shadow-seat-feed-cells }} run: | regional_version="$(gcloud secrets versions describe latest \ --project "${GCP_PROJECT_ID}" --secret "${REGIONAL_PLACEMENT_SECRET}" \ @@ -104,6 +120,9 @@ jobs: --asia-proof-service-account "${ASIA_PROOF_SERVICE_ACCOUNT}" \ --regional-placement-secret-version "${regional_version}" \ --min-instances 0 \ + --admin-audience https://relay-staging.onorca.dev/v1/admin/drain \ + --reserve-placement "${RESERVE_PLACEMENT}" \ + --shadow-seat-feed-cells "${SHADOW_SEAT_FEED_CELLS}" \ --release-id "${RELEASE_ID}" - name: Smoke director health diff --git a/.github/workflows/cloud-operate-relay-production-cell-flags.yml b/.github/workflows/cloud-operate-relay-production-cell-flags.yml index 74576e0f6c70..a38b65978dcb 100644 --- a/.github/workflows/cloud-operate-relay-production-cell-flags.yml +++ b/.github/workflows/cloud-operate-relay-production-cell-flags.yml @@ -15,18 +15,26 @@ on: description: Exact cell id, e.g. production-gce-c26 required: true type: string - readiness-local: - description: /ready answers from the cell alone (local readiness) - required: true - default: 'false' - type: choice - options: ['false', 'true'] - ticket-check: - description: Shadow-check the director's assignment lease on host hellos + break-glass: + description: >- + Only for a dead or unreachable reserve cell, with set admitMode=default. Typed as + BREAK GLASS ; records db without the cell's read-back + required: false + default: '' + type: string + confirm-reserve: + description: Required for admitMode=reserve, typed as RESERVE + required: false + default: '' + type: string + set: + description: >- + Switches to change, e.g. readinessLocal=true,admitMode=reserve (key=default removes a + key). Every other switch keeps its current value. Known: readinessLocal, ticketCheck, + admitMode, intakePerSec, reserveDryRun, rejectionFence, readTimeoutMarginMs, + reregisterInFlight required: true - default: 'off' - type: choice - options: ['off', 'shadow'] + type: string expected-generation: description: Exact current object generation from a dry run, or 0 if none exists required: true @@ -55,15 +63,17 @@ jobs: operate: if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 10 + # A flip back waits for the cell to lease every control: 8-50 minutes, scaled to its controls. + timeout-minutes: 60 environment: production env: MODE: ${{ inputs.mode }} CELL_ID: ${{ inputs.cell-id }} - READINESS_LOCAL: ${{ inputs.readiness-local }} - TICKET_CHECK: ${{ inputs.ticket-check }} + FLAG_CHANGES: ${{ inputs.set }} EXPECTED_GENERATION: ${{ inputs.expected-generation }} CONFIRMATION: ${{ inputs.confirmation }} + CONFIRM_RESERVE: ${{ inputs.confirm-reserve }} + BREAK_GLASS: ${{ inputs.break-glass }} GCP_PROJECT_ID: onorca-cloud steps: - uses: actions/checkout@v4 @@ -95,7 +105,9 @@ jobs: --mode "${MODE}" --cell-id "${CELL_ID}" \ --cell-origin "https://${CELL_ID#production-gce-}.relay.onorca.dev" \ --project-id "${GCP_PROJECT_ID}" \ - --readiness-local "${READINESS_LOCAL}" --ticket-check "${TICKET_CHECK}" \ + --set "${FLAG_CHANGES}" \ + --director-origin "https://relay.onorca.dev" --confirm-reserve "${CONFIRM_RESERVE}" \ + --break-glass "${BREAK_GLASS}" \ --expected-generation "${EXPECTED_GENERATION}" --confirmation "${CONFIRMATION}" \ | tee "${RUNNER_TEMP}/relay-cell-flags.jsonl" diff --git a/cloud/dev/scripts/deploy-relay-blue-green.mjs b/cloud/dev/scripts/deploy-relay-blue-green.mjs index ddfe2bce8fc9..72bb63559867 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.mjs @@ -11,6 +11,11 @@ export const DIRECTOR_REGIONAL_PLACEMENT_SECRET = export const DIRECTOR_REGIONAL_PLACEMENT_ENV = 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' export const DIRECTOR_CORRECTION_COHORT_ENV = 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT' +// Step 5 placement on the directors; only this deploy changes it, so the reserve guard sees it. +export const DIRECTOR_RESERVE_PLACEMENT_ENV = 'ORCA_RELAY_RESERVE_PLACEMENT' +const RESERVE_PLACEMENT_MODES = ['off', 'dry-run', 'on'] +// The cells whose feeds a director polls; a reserve cell outside it has no placing director. +export const DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV = 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS' export const DIRECTOR_REHOME_IDENTITY_ENV = 'ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT' export const DIRECTOR_REHOME_AUDIENCE_ENV = 'ORCA_RELAY_REHOME_AUDIENCE' @@ -250,6 +255,12 @@ export function directorDeploymentEnvironment(config) { config['region-correction-cohort-percent'] !== 'preserve') { environment[DIRECTOR_CORRECTION_COHORT_ENV] = correctionCohortPercent(config['region-correction-cohort-percent']) } + if (config['reserve-placement'] !== undefined && config['reserve-placement'] !== 'preserve') { + environment[DIRECTOR_RESERVE_PLACEMENT_ENV] = config['reserve-placement'] + } + if (config['shadow-seat-feed-cells'] !== undefined && config['shadow-seat-feed-cells'] !== 'preserve') { + environment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV] = shadowSeatFeedCellsSetting(config['shadow-seat-feed-cells']) + } const serviceAccount = projectServiceAccount(config, 'capacity-service-account') const asiaProofServiceAccount = projectServiceAccount(config, 'asia-proof-service-account') const rehomeDirectorServiceAccount = projectServiceAccount( @@ -271,6 +282,16 @@ export function directorDeploymentEnvironment(config) { return environment } +// `all` or a comma list of cell ids, as the director's config reads it. +function shadowSeatFeedCellsSetting(value) { + if (value === 'all') return value + const cells = value.split(',').map((cell) => cell.trim()) + if (cells.length > 100 || cells.some((cell) => !/^[a-z][a-z0-9-]{0,39}$/.test(cell))) { + throw new Error('--shadow-seat-feed-cells must be preserve, all, or a comma list of cell ids') + } + return cells.join(',') +} + export function environmentUpdateValue(environment) { const entries = Object.entries(environment) if (entries.every(([key, value]) => !key.includes(',') && !value.includes(','))) { @@ -315,11 +336,19 @@ export function parseArguments(argv) { values['rehome-audience'] !== undefined || values['expected-rehome-generation'] !== undefined || values['rehome-control-origin'] !== undefined || - values['region-correction-cohort-percent'] !== undefined + values['region-correction-cohort-percent'] !== undefined || + values['reserve-placement'] !== undefined || + values['shadow-seat-feed-cells'] !== undefined ) { throw new Error('director configuration arguments require --role director') } } + if ( + values['reserve-placement'] !== undefined && + !['preserve', ...RESERVE_PLACEMENT_MODES].includes(values['reserve-placement']) + ) { + throw new Error('--reserve-placement must be preserve, off, dry-run or on') + } if ( values.role === 'director' && values['capacity-cell-id'] !== undefined && @@ -378,12 +407,14 @@ export function parseArguments(argv) { throw new Error('--rehome-audience must be an exact host-drain HTTPS URL') } } - const controlArguments = [ - 'expected-rehome-generation', - 'rehome-control-origin', - 'admin-audience' - ].map((key) => values[key] !== undefined) - if (controlArguments.some(Boolean) && !controlArguments.every(Boolean)) { + // --admin-audience alone serves the reserve guard's reads; durable verification needs all three. + const controlArguments = ['expected-rehome-generation', 'rehome-control-origin'].map( + (key) => values[key] !== undefined + ) + if ( + controlArguments.some(Boolean) && + (!controlArguments.every(Boolean) || values['admin-audience'] === undefined) + ) { throw new Error('durable rehome verification arguments must be configured together') } if ( @@ -429,9 +460,35 @@ export function suppliedAdminIdentityToken(environment = process.env) { return token } +// The unverified `email` claim, only to name the caller in an error; the director verifies it. +export function adminTokenEmail(token) { + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')) + return typeof payload?.email === 'string' ? payload.email : undefined + } catch { + return undefined + } +} + +// cell-admit-mode answers only the revision's deploy identity, so a drifted one (a revision that +// trusts another account than the workflow's) fails here by name instead of as a bare 401. +export function assertDirectorTrustsAdminCaller(token, environment, label) { + const trusted = environment.ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT + const caller = adminTokenEmail(token) + if (trusted === undefined || caller === undefined || caller === trusted) return + throw new Error( + `the ${label} director trusts ${trusted} for deploy admin reads, but this deploy authenticates as ${caller}; ` + + 'run as that identity or correct the revision\'s ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT' + ) +} + function adminIdentityToken(config) { + const supplied = suppliedAdminIdentityToken() + if (supplied === null && !config['admin-audience']) { + throw new Error('admin reads need --admin-audience or a supplied ORCA_RELAY_ADMIN_ID_TOKEN') + } return ( - suppliedAdminIdentityToken() ?? + supplied ?? commandText(['auth', 'print-identity-token', `--audiences=${config['admin-audience']}`], { sensitive: true }) @@ -721,6 +778,108 @@ export async function assertRegionalRehomeDisabled( return result.control } +// `undefined` when the director predates the reserve census (its router 404s the route). +export async function readReserveCells(config, origin, cellIds, fetchImpl = fetch) { + const reserve = [] + for (const cellId of cellIds) { + const response = await fetchImpl(`${origin}/v1/admin/cell-admit-mode`, { + method: 'POST', + headers: { + authorization: `Bearer ${adminIdentityToken(config)}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ v: 1, cellId }), + signal: AbortSignal.timeout(30_000) + }) + const result = await response.json().catch(() => null) + if (response.status === 404 && result === null) return undefined + if (!response.ok || result?.v !== 1 || !['db', 'reserve'].includes(result.admitMode)) { + throw new Error(`cell admit mode read failed for ${cellId}: ${result?.error ?? response.status}`) + } + if (result.admitMode === 'reserve') reserve.push(cellId) + } + return reserve +} + +// A director without the census places hosts on reserve cells from stale rows, so traffic may +// move between revisions that differ in it only while every cell is db. +export function assertReserveCompatibleTrafficMove(servingReserve, candidateReserve) { + if ((servingReserve === undefined) === (candidateReserve === undefined)) return + const reserve = servingReserve ?? candidateReserve + if (reserve.length > 0) { + throw new Error( + `director revisions differ in reserve-mode support while cells are reserve (${reserve.join(', ')}); flip them to db first` + ) + } +} + +// With placement off no director books a reserve cell, so each one's dead-man trips (60-120 s) +// and every host re-registers at once: the cells go to db first, on purpose. +export function assertReservePlacementKept(servingPlacement, candidatePlacement, reserve) { + if (servingPlacement !== 'on' || candidatePlacement === 'on') return + if (reserve !== undefined && reserve.length > 0) { + throw new Error( + `reserve placement would turn ${candidatePlacement} while cells are reserve (${reserve.join(', ')}); flip them to db first` + ) + } +} + +// Placement as the director built it (runtime-status), not as its env asks; an image from before +// the field reports nothing, and only its env can say. +export async function readDirectorPlacement(config, origin, fetchImpl = fetch) { + const response = await fetchImpl(`${origin}/v1/admin/runtime-status`, { + method: 'POST', + headers: { + authorization: `Bearer ${adminIdentityToken(config)}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ v: 1 }), + signal: AbortSignal.timeout(30_000) + }) + const result = await response.json().catch(() => null) + if (!response.ok || result?.v !== 1 || result.role !== 'director') { + throw new Error(`director runtime status failed at ${origin}: ${result?.error ?? response.status}`) + } + if (result.reservePlacement === undefined) return 'unreported' + if (!RESERVE_PLACEMENT_MODES.includes(result.reservePlacement)) { + throw new Error(`director at ${origin} reports reserve placement ${result.reservePlacement}`) + } + return result.reservePlacement +} + +const shadowCellSet = (value) => + (value ?? '').trim() === 'all' + ? 'all' + : new Set( + (value ?? '') + .split(',') + .map((cell) => cell.trim()) + .filter(Boolean) + ) + +// A reserve cell the directors stop polling gets no booking and no reserver poll: its dead-man +// trips. Shrinking the shadow-cell list past one is refused until that cell is db. +export function assertShadowCellsKeepReserveCells(servingCells, candidateCells, reserve) { + if (reserve === undefined || reserve.length === 0) return + const before = shadowCellSet(servingCells) + const after = shadowCellSet(candidateCells) + if (after === 'all') return + const dropped = reserve.filter((cell) => (before === 'all' || before.has(cell)) && !after.has(cell)) + if (dropped.length > 0) { + throw new Error( + `the shadow seat-feed cells would drop reserve cells (${dropped.join(', ')}); flip them to db first` + ) + } +} + +function directorCellIds(...environments) { + const ids = new Set() + for (const environment of environments) { + for (const cell of JSON.parse(environment.ORCA_RELAY_CELLS_JSON ?? '[]')) ids.add(cell.id) + } + return [...ids] +} + function assertDirectorRevisionIdentity(revision, config) { const expectedRuntimeServiceAccount = config['runtime-service-account'] if ( @@ -741,6 +900,9 @@ export async function deployDirector(config, tag, overrides = {}) { updateTraffic, waitForHealth, assertRegionalRehomeDisabled, + readReserveCells, + readDirectorPlacement, + adminIdentityToken, ...overrides } // Why: gcloud does not carry minScale onto a new revision, and the candidate below takes @@ -801,6 +963,12 @@ export async function deployDirector(config, tag, overrides = {}) { deploymentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ??= correctionCohortPercent( currentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ?? '0' ) + if (currentEnvironment[DIRECTOR_RESERVE_PLACEMENT_ENV] !== undefined) { + deploymentEnvironment[DIRECTOR_RESERVE_PLACEMENT_ENV] ??= currentEnvironment[DIRECTOR_RESERVE_PLACEMENT_ENV] + } + if (currentEnvironment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV] !== undefined) { + deploymentEnvironment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV] ??= currentEnvironment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV] + } if (config['region-correction-cohort-percent'] !== undefined && config['region-correction-cohort-percent'] !== 'preserve' && config['expected-rehome-generation'] === undefined) { @@ -911,6 +1079,46 @@ export async function deployDirector(config, tag, overrides = {}) { } await operations.waitForHealth(candidate.origin, requiredCapacityProtocol) await verifyRehomeDisabled(candidate.origin) + // The same image and the same placement and shadow-cell settings support the same things; + // anything else needs every cell's admit mode (and the admin token that reads it). + const sameImage = + servingRevision.spec?.containers?.[0]?.image?.split('@').at(-1) === config.image?.split('@').at(-1) + const sameSetting = (name) => (currentEnvironment[name] ?? '') === (environment[name] ?? '') + if (sameImage && sameSetting(DIRECTOR_RESERVE_PLACEMENT_ENV) && sameSetting(DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV)) { + console.warn(JSON.stringify({ event: 'director_reserve_support_checked', skipped: 'same image and settings' })) + } else { + const servingOrigin = initialService.status?.url + if (!servingOrigin) throw new Error('relay service reports no URL for the serving revision') + const token = operations.adminIdentityToken(config) + assertDirectorTrustsAdminCaller(token, currentEnvironment, 'serving') + assertDirectorTrustsAdminCaller(token, environment, 'candidate') + const cellIds = directorCellIds(currentEnvironment, environment) + const servingReserve = await operations.readReserveCells(config, servingOrigin, cellIds) + const candidateReserve = await operations.readReserveCells(config, candidate.origin, cellIds) + const reserve = candidateReserve ?? servingReserve + // Placement as each director built it, which an env string alone does not say. + const placementAt = async (origin, env) => { + const placement = await operations.readDirectorPlacement(config, origin) + return placement === 'unreported' ? (env[DIRECTOR_RESERVE_PLACEMENT_ENV] ?? 'off') : placement + } + const servingPlacement = await placementAt(servingOrigin, currentEnvironment) + const candidatePlacement = await placementAt(candidate.origin, environment) + assertReserveCompatibleTrafficMove(servingReserve, candidateReserve) + assertReservePlacementKept(servingPlacement, candidatePlacement, reserve) + assertShadowCellsKeepReserveCells( + currentEnvironment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], + environment[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], + reserve + ) + console.warn( + JSON.stringify({ + event: 'director_reserve_support_checked', + reservePlacement: { serving: servingPlacement, candidate: candidatePlacement }, + serving: servingReserve === undefined ? 'unsupported' : servingReserve, + candidate: candidateReserve === undefined ? 'unsupported' : candidateReserve + }) + ) + } operations.updateTraffic(config, [`--to-tags=${tag}=100`]) promoted = true removeDirectorTrafficTags(config, operations, new Set([SELECTOR_ROLLBACK_TAG])) diff --git a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs index a68ce50e9120..782ace852c73 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs @@ -13,13 +13,19 @@ import { DIRECTOR_REHOME_AUDIENCE_ENV, DIRECTOR_REHOME_IDENTITY_ENV, assertRegionalRehomeDisabled, + assertReserveCompatibleTrafficMove, + assertShadowCellsKeepReserveCells, deployDirector, + DIRECTOR_RESERVE_PLACEMENT_ENV, directorDeploymentEnvironment, + DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV, directorCellSetAddition, directorStartupProbeArguments, directorTopologyChange, environmentUpdateValue, parseArguments, + readDirectorPlacement, + readReserveCells, revisionEnvironment, revisionSecretEnvironment, suppliedAdminIdentityToken, @@ -418,6 +424,9 @@ test('requires exactly one active revision and reads queried tag metadata', () = assert.deepEqual(trafficTags(service), ['candidate-123']) }) +const adminToken = (email) => + `e30.${Buffer.from(JSON.stringify(email ? { email } : {})).toString('base64url')}.sig` + function directorHarness({ deployFailure, deleteFailure, @@ -428,7 +437,11 @@ function directorHarness({ // Reproduces gcloud dropping minScale from a newly created revision. dropRequestedMinimum = false, servingServiceAccount, - servingImageDigest = `sha256:${'f'.repeat(64)}` + servingImageDigest = `sha256:${'f'.repeat(64)}`, + reserveCells = {}, + servingEnvironment = {}, + placements = {}, + callerEmail } = {}) { const state = { activeRevision: 'relay-00001-old', @@ -439,7 +452,8 @@ function directorHarness({ 'relay-00001-old', { env: { - ORCA_RELAY_ROLE: 'director' + ORCA_RELAY_ROLE: 'director', + ...servingEnvironment }, secrets: { [DIRECTOR_REGIONAL_PLACEMENT_ENV]: { @@ -462,6 +476,7 @@ function directorHarness({ const operations = { describeService: () => ({ status: { + url: 'https://relay-hash-uc.a.run.app', traffic: [ { percent: 100, revisionName: state.activeRevision }, ...[...state.tags].map(([tag, revisionName]) => ({ @@ -501,7 +516,8 @@ function directorHarness({ deployCandidate: (config, tag, env, image, minimum, maximum, regionalVersion) => { const revision = `relay-${String(state.nextRevision++).padStart(5, '0')}-new` state.revisions.set(revision, { - env: { ORCA_RELAY_ROLE: 'director', ...env }, + // gcloud --update-env-vars keeps every variable the deploy does not name. + env: { ORCA_RELAY_ROLE: 'director', ...servingEnvironment, ...env }, secrets: { [DIRECTOR_REGIONAL_PLACEMENT_ENV]: { secret: DIRECTOR_REGIONAL_PLACEMENT_SECRET, @@ -544,7 +560,10 @@ function directorHarness({ }, waitForHealth: async (_origin, connectionCapacityProtocol) => { healthProtocols.push(connectionCapacityProtocol) - } + }, + readReserveCells: async (_config, origin) => reserveCells[origin], + readDirectorPlacement: async (_config, origin) => placements[origin] ?? 'unreported', + adminIdentityToken: () => adminToken(callerEmail) } return { state, removed, healthProtocols, operations } } @@ -854,3 +873,206 @@ test('sets a reviewed cohort only behind repeated disabled-control verification' assert.ok(verified >= 2) assert.equal(harness.state.revisions.get('relay-00003-new').env[DIRECTOR_CORRECTION_COHORT_ENV], '1') }) + +test('director deploy refuses a traffic move between reserve-support revisions while a cell is reserve', async () => { + const candidateOrigin = 'https://candidate-new---relay-hash-uc.a.run.app' + const oldServing = directorHarness({ + reserveCells: { [candidateOrigin]: ['production-gce-c3'] } + }) + await assert.rejects( + deployDirector({}, 'candidate-new', oldServing.operations), + /differ in reserve-mode support while cells are reserve \(production-gce-c3\)/ + ) + assert.equal(oldServing.state.activeRevision, 'relay-00001-old') + const rollbackToOld = directorHarness({ + reserveCells: { 'https://relay-hash-uc.a.run.app': ['production-gce-c3'] } + }) + await assert.rejects(deployDirector({}, 'candidate-new', rollbackToOld.operations), /flip them to db first/) + assert.equal(rollbackToOld.state.activeRevision, 'relay-00001-old') + const allDb = directorHarness({ reserveCells: { [candidateOrigin]: [] } }) + await deployDirector({}, 'candidate-new', allDb.operations) + assert.equal(allDb.state.activeRevision, 'relay-00003-new') +}) + +test('two revisions that both read the census may move traffic with reserve cells', () => { + assert.doesNotThrow(() => assertReserveCompatibleTrafficMove(['c3'], ['c3'])) + assert.doesNotThrow(() => assertReserveCompatibleTrafficMove(undefined, undefined)) + assert.throws(() => assertReserveCompatibleTrafficMove(undefined, ['c3']), /flip them to db first/) +}) + +test('reads a census-less director as unsupported and fails closed on any other refusal', async () => { + const environment = process.env.ORCA_RELAY_ADMIN_ID_TOKEN + process.env.ORCA_RELAY_ADMIN_ID_TOKEN = 'aaa.bbb.ccc' + const config = { 'admin-audience': 'https://relay.onorca.dev/v1/admin/drain' } + try { + assert.equal( + await readReserveCells(config, 'https://old.example.test', ['c1'], async () => + new Response('404 Not Found', { status: 404 }) + ), + undefined + ) + const modes = { c1: 'db', c2: 'reserve' } + assert.deepEqual( + await readReserveCells(config, 'https://new.example.test', ['c1', 'c2'], async (url, init) => { + assert.equal(url, 'https://new.example.test/v1/admin/cell-admit-mode') + const { cellId } = JSON.parse(init.body) + return new Response(JSON.stringify({ v: 1, cellId, admitMode: modes[cellId], updatedAt: 1 })) + }), + ['c2'] + ) + await assert.rejects( + readReserveCells(config, 'https://new.example.test', ['c1'], async () => + new Response(JSON.stringify({ error: 'invalid_token' }), { status: 401 }) + ), + /cell admit mode read failed for c1: invalid_token/ + ) + } finally { + if (environment === undefined) delete process.env.ORCA_RELAY_ADMIN_ID_TOKEN + else process.env.ORCA_RELAY_ADMIN_ID_TOKEN = environment + } +}) + +test('a same-image deploy reads no admit modes, and keeps the serving reserve placement', async () => { + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const harness = directorHarness({ servingEnvironment: { [DIRECTOR_RESERVE_PLACEMENT_ENV]: 'on' } }) + let reads = 0 + harness.operations.readReserveCells = async () => { + reads += 1 + return [] + } + await deployDirector({ image: `relay@${servingImageDigest}` }, 'candidate-new', harness.operations) + assert.equal(reads, 0) + assert.equal(harness.state.revisions.get(harness.state.activeRevision).env[DIRECTOR_RESERVE_PLACEMENT_ENV], 'on') +}) + +test('turning reserve placement off is refused while any cell is reserve, even on the same image', async () => { + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const candidateOrigin = 'https://candidate-new---relay-hash-uc.a.run.app' + const reserved = directorHarness({ + servingEnvironment: { [DIRECTOR_RESERVE_PLACEMENT_ENV]: 'on' }, + reserveCells: { 'https://relay-hash-uc.a.run.app': ['production-gce-c3'], [candidateOrigin]: ['production-gce-c3'] }, + placements: { 'https://relay-hash-uc.a.run.app': 'on', [candidateOrigin]: 'off' } + }) + await assert.rejects( + deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', reserved.operations), + /reserve placement would turn off while cells are reserve \(production-gce-c3\); flip them to db first/ + ) + assert.equal(reserved.state.activeRevision, 'relay-00001-old') + const allDb = directorHarness({ + servingEnvironment: { [DIRECTOR_RESERVE_PLACEMENT_ENV]: 'on' }, + reserveCells: { 'https://relay-hash-uc.a.run.app': [], [candidateOrigin]: [] } + }) + await deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', allDb.operations) + assert.equal(allDb.state.revisions.get(allDb.state.activeRevision).env[DIRECTOR_RESERVE_PLACEMENT_ENV], 'off') +}) + +test('--reserve-placement takes only a known mode, and only for a director', () => { + const base = ['--project', 'p', '--region', 'r', '--service', 's', '--image', `relay@sha256:${'a'.repeat(64)}`, '--release-id', '1'] + assert.equal(parseArguments([...base, '--role', 'director', '--reserve-placement', 'on'])['reserve-placement'], 'on') + assert.throws(() => parseArguments([...base, '--role', 'director', '--reserve-placement', 'yes']), /must be preserve, off, dry-run or on/) + assert.throws( + () => parseArguments([...base, '--role', 'cell', '--director-origin', 'https://d', '--admin-audience', 'a', '--reserve-placement', 'on']), + /require --role director/ + ) + assert.equal(directorDeploymentEnvironment({ 'reserve-placement': 'preserve' })[DIRECTOR_RESERVE_PLACEMENT_ENV], undefined) + assert.equal(directorDeploymentEnvironment({ 'reserve-placement': 'dry-run' })[DIRECTOR_RESERVE_PLACEMENT_ENV], 'dry-run') +}) + +test('the guard reads placement as built, so a director that never placed does not block turning it off', async () => { + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const candidateOrigin = 'https://candidate-new---relay-hash-uc.a.run.app' + // Configured on, but the serving director reports off: it was never feeding the dead-man. + const harness = directorHarness({ + servingEnvironment: { [DIRECTOR_RESERVE_PLACEMENT_ENV]: 'on' }, + reserveCells: { 'https://relay-hash-uc.a.run.app': ['production-gce-c3'], [candidateOrigin]: ['production-gce-c3'] }, + placements: { 'https://relay-hash-uc.a.run.app': 'off', [candidateOrigin]: 'off' } + }) + await deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', harness.operations) + assert.equal(harness.state.revisions.get(harness.state.activeRevision).env[DIRECTOR_RESERVE_PLACEMENT_ENV], 'off') +}) + +test('a shadow-cell change on the same image is checked, and a shrink past a reserve cell is refused', () => { + assert.throws( + () => assertShadowCellsKeepReserveCells('production-gce-c1,production-gce-c3', 'production-gce-c1', ['production-gce-c3']), + /would drop reserve cells \(production-gce-c3\); flip them to db first/ + ) + assert.throws(() => assertShadowCellsKeepReserveCells('all', 'production-gce-c1', ['production-gce-c3']), /production-gce-c3/) + assert.throws(() => assertShadowCellsKeepReserveCells('all', undefined, ['production-gce-c3']), /production-gce-c3/) + assert.doesNotThrow(() => assertShadowCellsKeepReserveCells('production-gce-c1', 'all', ['production-gce-c3'])) + assert.doesNotThrow(() => assertShadowCellsKeepReserveCells('production-gce-c1,production-gce-c3', 'production-gce-c1', [])) + // A reserve cell the serving list never polled is not being dropped by this deploy. + assert.doesNotThrow(() => assertShadowCellsKeepReserveCells('production-gce-c1', 'production-gce-c1', ['production-gce-c3'])) +}) + +test('reads a director placement from runtime-status, and reports an image without the field', async () => { + const environment = process.env.ORCA_RELAY_ADMIN_ID_TOKEN + process.env.ORCA_RELAY_ADMIN_ID_TOKEN = 'aaa.bbb.ccc' + const config = { 'admin-audience': 'https://relay.onorca.dev/v1/admin/drain' } + try { + const answer = (body) => async () => new Response(JSON.stringify({ v: 1, role: 'director', ...body })) + assert.equal(await readDirectorPlacement(config, 'https://d.example.test', answer({ reservePlacement: 'on' })), 'on') + assert.equal(await readDirectorPlacement(config, 'https://d.example.test', answer({})), 'unreported') + await assert.rejects(readDirectorPlacement(config, 'https://d.example.test', answer({ reservePlacement: 'maybe' })), /reports reserve placement maybe/) + await assert.rejects( + readDirectorPlacement(config, 'https://d.example.test', async () => new Response('{}', { status: 401 })), + /runtime status failed/ + ) + } finally { + if (environment === undefined) delete process.env.ORCA_RELAY_ADMIN_ID_TOKEN + else process.env.ORCA_RELAY_ADMIN_ID_TOKEN = environment + } +}) + +// Staging's director trusted gha-deploy while the workflow authenticated as gha-relay: a bare 401. +test('the guard names the deploy identity a drifted director trusts instead of reading into a 401', async () => { + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const harness = directorHarness({ + servingEnvironment: { ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'gha-deploy@p.iam.gserviceaccount.com' }, + callerEmail: 'gha-relay@p.iam.gserviceaccount.com' + }) + await assert.rejects( + deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', harness.operations), + /serving director trusts gha-deploy@p\.iam\.gserviceaccount\.com for deploy admin reads, but this deploy authenticates as gha-relay@p\.iam\.gserviceaccount\.com/ + ) + const trusted = directorHarness({ + servingEnvironment: { ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'gha-relay@p.iam.gserviceaccount.com' }, + callerEmail: 'gha-relay@p.iam.gserviceaccount.com' + }) + await deployDirector({ image: `relay@${servingImageDigest}`, 'reserve-placement': 'off' }, 'candidate-new', trusted.operations) +}) + +test('a director deploy takes --admin-audience alone for the reserve guard', () => { + const base = ['--project', 'p', '--region', 'r', '--service', 's', '--image', `relay@sha256:${'e'.repeat(64)}`, '--role', 'director', '--release-id', 'x'] + const audience = ['--admin-audience', 'https://relay.example.test'] + assert.equal(parseArguments([...base, ...audience])['admin-audience'], 'https://relay.example.test') + assert.throws( + () => parseArguments([...base, '--expected-rehome-generation', '1', '--rehome-control-origin', 'https://relay.example.test']), + /durable rehome verification arguments must be configured together/ + ) + assert.throws( + () => parseArguments([...base, ...audience, '--expected-rehome-generation', '1']), + /durable rehome verification arguments must be configured together/ + ) +}) + +test('--shadow-seat-feed-cells sets the director setting, and preserve keeps the serving one', async () => { + assert.equal( + directorDeploymentEnvironment({ 'shadow-seat-feed-cells': 'c1, c2' })[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], + 'c1,c2' + ) + assert.equal(directorDeploymentEnvironment({ 'shadow-seat-feed-cells': 'all' })[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], 'all') + assert.equal(directorDeploymentEnvironment({ 'shadow-seat-feed-cells': 'preserve' })[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], undefined) + assert.throws(() => directorDeploymentEnvironment({ 'shadow-seat-feed-cells': 'C1;x' }), /must be preserve, all, or a comma list/) + const servingImageDigest = `sha256:${'f'.repeat(64)}` + const harness = directorHarness({ servingEnvironment: { [DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV]: 'c1,c3' } }) + await deployDirector({ image: `relay@${servingImageDigest}`, 'shadow-seat-feed-cells': 'preserve' }, 'candidate-new', harness.operations) + assert.equal(harness.state.revisions.get(harness.state.activeRevision).env[DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV], 'c1,c3') + const shrink = directorHarness({ + servingEnvironment: { [DIRECTOR_SHADOW_SEAT_FEED_CELLS_ENV]: 'c1,c3' }, + reserveCells: { 'https://relay-hash-uc.a.run.app': ['c3'], 'https://candidate-new---relay-hash-uc.a.run.app': ['c3'] } + }) + await assert.rejects( + deployDirector({ image: `relay@${servingImageDigest}`, 'shadow-seat-feed-cells': 'c1' }, 'candidate-new', shrink.operations), + /would drop reserve cells \(c3\)/ + ) +}) diff --git a/cloud/dev/scripts/operate-relay-cell-flags.mjs b/cloud/dev/scripts/operate-relay-cell-flags.mjs index 01f1ae56a10b..1d962b4d2bde 100644 --- a/cloud/dev/scripts/operate-relay-cell-flags.mjs +++ b/cloud/dev/scripts/operate-relay-cell-flags.mjs @@ -16,18 +16,144 @@ export const READ_BACK_TIMEOUT_MS = 15_000 const READ_BACK_INTERVAL_MS = 1_000 const STORAGE_ROOT = 'https://storage.googleapis.com' +// Every switch a cell image may know, with what an absent key means on the cell. A cell +// resets a key missing from its object to this default, so a write must carry every key it +// does not mean to change: the tool reads the current object and changes only what is named. +const enumOf = (...options) => (value) => (options.includes(value) ? value : undefined) +const booleanValue = (value) => (value === 'true' ? true : value === 'false' ? false : undefined) +const numberIn = (min, max, integer) => (value) => { + const number = Number(value) + if (value === '' || !Number.isFinite(number) || number < min || number > max) return undefined + if (integer && !Number.isInteger(number)) return undefined + return number +} +export const CELL_FLAG_SPECS = { + readinessLocal: { parse: booleanValue, default: false }, + ticketCheck: { parse: enumOf('off', 'shadow', 'enforce'), default: 'off' }, + admitMode: { parse: enumOf('db', 'reserve'), default: 'db' }, + // Absent means the cell's region default. + intakePerSec: { + parse: (value) => { + const rate = numberIn(0, 1_000, false)(value) + return rate === 0 ? undefined : rate + } + }, + reserveDryRun: { parse: booleanValue, default: false }, + rejectionFence: { parse: booleanValue, default: true }, + // Absent keeps the cell's boot value. + readTimeoutMarginMs: { parse: numberIn(1_000, 60_000, true) }, + // Flip-back leases in flight; absent is a third of the cell's pool. + reregisterInFlight: { parse: numberIn(1, 16, true) } +} + +export const RESERVE_CONFIRMATION = 'RESERVE' +export const BREAK_GLASS_CONFIRMATION = 'BREAK GLASS' +// Longer than a director's cached read of the admit-mode table (5 s). +export const RESERVE_MODE_CACHE_WAIT_MS = 10_000 +// How long a flip back may take to lease every control the cell admitted from memory: its +// control count at a per-lane pace below the measured 0.38/s (170 ms trips), within the job. +export const REREGISTER_WAIT_MIN_MS = 8 * 60_000 +export const REREGISTER_WAIT_MAX_MS = 50 * 60_000 +const REREGISTER_CONTROLS_PER_LANE_PER_SEC = 0.3 +// The cell takes a third of its database pool, and caps the switch at the pool less two +// (host-session-registry.ts reregisterInFlightLimit). An image from before runtime-status +// reported the pool falls back to the deployed sizes (US 10, Asia 16). +const CELL_DATABASE_POOL_MAX = { 'asia-east2': 16 } +const CELL_DATABASE_POOL_MAX_DEFAULT = 10 + +export function reregisterLanes(runtime) { + const reported = runtime?.databasePoolMax + const pool = + Number.isInteger(reported) && reported > 0 + ? reported + : (CELL_DATABASE_POOL_MAX[runtime?.region] ?? CELL_DATABASE_POOL_MAX_DEFAULT) + const flag = runtime?.flagsApplied?.flags?.reregisterInFlight + if (flag === undefined) return Math.max(1, Math.floor(pool / 3)) + return Math.max(1, Math.min(Number(flag), pool - 2)) +} + +export function reregisterWaitMs(runtime) { + const controls = Number(runtime?.runtime?.controls ?? 0) + const lanes = reregisterLanes(runtime) + const estimate = (controls / (lanes * REREGISTER_CONTROLS_PER_LANE_PER_SEC)) * 1_000 + return Math.min(REREGISTER_WAIT_MAX_MS, Math.max(REREGISTER_WAIT_MIN_MS, Math.ceil(estimate))) +} + +// An image from before `supportedFlags` accepts the keys its applied flags list, and only +// ticketCheck off/shadow: anything else is dropped there, or voids the whole object. +function legacySupportedFlags(appliedFlags) { + return Object.fromEntries( + Object.entries(appliedFlags ?? {}).map(([key, value]) => [ + key, + key === 'ticketCheck' + ? { type: 'enum', values: ['off', 'shadow'] } + : { type: typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'enum', values: [value] } + ]) + ) +} + +function accepts(spec, value) { + if (spec?.type === 'boolean') return typeof value === 'boolean' + if (spec?.type === 'enum') return spec.values.includes(value) + if (spec?.type === 'number') { + return ( + typeof value === 'number' && + (spec.min === undefined || value >= spec.min) && + (spec.max === undefined || value <= spec.max) && + (!spec.integer || Number.isInteger(value)) + ) + } + return false +} + +// The whole object as it will be written, not only the change: a bad key or value already in +// the file would void or be dropped on every later write too. +export function assertSupportedObject(flags, runtime) { + const supported = runtime.supportedFlags ?? legacySupportedFlags(runtime.flagsApplied?.flags) + for (const [key, value] of Object.entries(flags)) { + if (!accepts(supported[key], value)) { + throw new Error( + `the cell's image does not support ${key}=${JSON.stringify(value)}; roll the image first` + ) + } + } +} + +// `key=value,key=value`; `key=default` removes the key so the cell uses its default. +export function parseFlagChanges(text) { + const changes = {} + for (const part of (text ?? '').split(',').map((entry) => entry.trim()).filter(Boolean)) { + const separator = part.indexOf('=') + const key = separator < 0 ? part : part.slice(0, separator) + const raw = separator < 0 ? '' : part.slice(separator + 1).trim() + const spec = CELL_FLAG_SPECS[key] + if (!spec) throw new Error(`unknown switch ${key}`) + if (key in changes) throw new Error(`switch ${key} named twice`) + if (raw === 'default') { + changes[key] = null + continue + } + const value = spec.parse(raw) + if (value === undefined) throw new Error(`switch ${key} cannot be ${JSON.stringify(raw)}`) + changes[key] = value + } + if (Object.keys(changes).length === 0) throw new Error('set names no switch to change') + return changes +} + +// The switches as the cell will apply them: every known key, absent ones at their default. +function effectiveFlags(flags) { + return Object.fromEntries( + Object.entries(CELL_FLAG_SPECS).map(([key, spec]) => [key, flags?.[key] ?? spec.default]) + ) +} + export function parseCellFlagsRequest(values) { const mode = values.mode if (mode !== 'dry-run' && mode !== 'write') throw new Error('mode must be dry-run or write') const cellId = values['cell-id'] ?? '' if (!CELL_ID_PATTERN.test(cellId)) throw new Error('cell id is not a relay GCE cell id') - if (values['readiness-local'] !== 'true' && values['readiness-local'] !== 'false') { - throw new Error('readiness-local must be true or false') - } - const ticketCheck = values['ticket-check'] - if (ticketCheck !== 'off' && ticketCheck !== 'shadow') { - throw new Error('ticket-check must be off or shadow') - } + const changes = parseFlagChanges(values.set) // 0 means "no object yet": the write then creates it and refuses if one appeared. const expectedGeneration = values['expected-generation'] ?? '' if (!/^(0|[1-9][0-9]{0,18})$/.test(expectedGeneration)) { @@ -36,6 +162,29 @@ export function parseCellFlagsRequest(values) { if (mode === 'write' && values.confirmation !== `${WRITE_CONFIRMATION} ${cellId}`) { throw new Error(`write requires the confirmation "${WRITE_CONFIRMATION} ${cellId}"`) } + // Reserve moves this cell's hosts off the database: it is typed for this cell, every time. + if ( + mode === 'write' && + changes.admitMode === 'reserve' && + values['confirm-reserve'] !== `${RESERVE_CONFIRMATION} ${cellId}` + ) { + throw new Error(`admitMode=reserve requires --confirm-reserve "${RESERVE_CONFIRMATION} ${cellId}"`) + } + // Break glass: a dead or unreachable reserve cell cannot report db, so nothing would ever + // let the sweeps re-place its hosts. Only admitMode=db, typed for this cell. + const breakGlass = values['break-glass'] ?? '' + if (breakGlass !== '') { + if (breakGlass !== `${BREAK_GLASS_CONFIRMATION} ${cellId}`) { + throw new Error(`break glass requires --break-glass "${BREAK_GLASS_CONFIRMATION} ${cellId}"`) + } + if (mode !== 'write' || changes.admitMode !== null || Object.keys(changes).length !== 1) { + throw new Error('break glass only writes admitMode=default (db), on its own') + } + } + const directorOrigin = values['director-origin'] ?? '' + if (changes.admitMode !== undefined && !/^https:\/\/relay(-staging)?\.onorca\.dev$/.test(directorOrigin)) { + throw new Error('an admitMode change needs --director-origin, where Postgres records it') + } const projectId = values['project-id'] ?? '' if (!/^[a-z][a-z0-9-]{4,62}$/.test(projectId)) throw new Error('project id is invalid') const cellOrigin = values['cell-origin'] ?? '' @@ -49,14 +198,25 @@ export function parseCellFlagsRequest(values) { mode, cellId, cellOrigin, + directorOrigin, + breakGlass: breakGlass !== '', projectId, expectedGeneration, - object: { - v: 1, - cellId, - flags: { readinessLocal: values['readiness-local'] === 'true', ticketCheck } - } + changes + } +} + +// Read-modify-write: the current object's switches with only the named ones changed. +export function desiredObject(request, current) { + if (current !== null && (current?.v !== 1 || current?.cellId !== request.cellId)) { + throw new Error('the current object is not this cell\'s v1 switch object; fix it by hand') + } + const flags = { ...(current?.flags ?? {}) } + for (const [key, value] of Object.entries(request.changes)) { + if (value === null) delete flags[key] + else flags[key] = value } + return { v: 1, cellId: request.cellId, flags } } function objectUrl(request, suffix = '') { @@ -101,7 +261,7 @@ async function readCurrentObject(fetchImpl, request, accessToken) { return { generation: String(generation), object: await media.json().catch(() => null) } } -async function writeObject(fetchImpl, request, accessToken, audit) { +async function writeObject(fetchImpl, request, object, accessToken, audit) { const boundary = `relay-cell-flags-${Date.now()}` const metadata = { name: `cells/${request.cellId}.json`, @@ -118,7 +278,7 @@ async function writeObject(fetchImpl, request, accessToken, audit) { `--${boundary}`, 'Content-Type: application/json', '', - JSON.stringify(request.object), + JSON.stringify(object), `--${boundary}--`, '' ].join('\r\n') @@ -134,21 +294,119 @@ async function writeObject(fetchImpl, request, accessToken, audit) { body } ) + // A 4xx means nothing was written; a 5xx or a lost reply may have been. if (response.status === 412) { await response.body?.cancel().catch(() => undefined) - throw new Error('the object changed since expected-generation; re-read and retry') + throw Object.assign(new Error('the object changed since expected-generation; re-read and retry'), { + notWritten: true + }) + } + if (!response.ok) { + throw Object.assign(new Error(`object write returned ${response.status}`), { + notWritten: response.status < 500 + }) } - if (!response.ok) throw new Error(`object write returned ${response.status}`) return String((await response.json()).generation) } -const sameFlags = (left, right) => - left?.readinessLocal === right.readinessLocal && left?.ticketCheck === right.ticketCheck +// The census's record (relay_cell_admit_modes, through a director). Without `admitMode` it only +// reads. Read back on every write: sweeps act on this, not on the cell's switch file. +async function directorAdmitMode(fetchImpl, request, idToken, wait, admitMode) { + const response = await fetchAdminOnceMore( + fetchImpl, + `${request.directorOrigin}/v1/admin/cell-admit-mode`, + { + method: 'POST', + headers: { authorization: `Bearer ${idToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ v: 1, cellId: request.cellId, ...(admitMode ? { admitMode } : {}) }) + }, + { wait, timeoutMs: 10_000, retryDelayMs: 1_000 } + ) + const body = await response.json().catch(() => null) + if (!response.ok) throw new Error(`director refused admitMode: ${response.status} ${body?.error ?? ''}`) + if (admitMode && body?.admitMode !== admitMode) { + throw new Error(`director recorded admitMode ${body?.admitMode}, not ${admitMode}`) + } + return body +} + +// Every known key, not just the ones this run changed: a cell that kept an old value of any +// switch has not applied this object. A key with no fixed default (the cell reports its own +// value) is compared only when the object names it. +export function sameFlags(applied, desired) { + const expected = effectiveFlags(desired) + const actual = effectiveFlags(applied) + return Object.entries(CELL_FLAG_SPECS).every( + ([key, spec]) => + (!('default' in spec) && desired?.[key] === undefined) || actual[key] === expected[key] + ) +} + +// Sweeps resume only once every control the cell admitted from memory holds a lease again. +async function waitForLeasedControls(fetchImpl, request, idToken, { now, sleep, log }) { + let waitMs + let deadline = now() + REREGISTER_WAIT_MIN_MS + while (now() < deadline) { + const runtime = await readRuntime(fetchImpl, request, idToken, sleep).catch(() => null) + // An image without the field never admitted from memory. + if (runtime && (runtime.admitModeEffective ?? 'db') === 'db') return + if (runtime && waitMs === undefined) { + waitMs = reregisterWaitMs(runtime) + deadline = now() + waitMs + log(JSON.stringify({ event: 'orca_relay_cell_reregistration_wait', cellId: request.cellId, waitMs })) + } + await sleep(5_000) + } + log(JSON.stringify({ event: 'orca_relay_cell_reregistration_still_running', cellId: request.cellId })) + throw new Error( + `cell still re-registering after ${waitMs ?? REREGISTER_WAIT_MIN_MS} ms. Its connections are safe and Postgres ` + + 'keeps reserve, so sweeps stay off the cell. Run again with --set admitMode=db, which records db once ' + + 'runtime-status reports admitModeEffective=db; to speed it up, name both in one write: ' + + '--set admitMode=db,reregisterInFlight=N' + ) +} + +// No cell read-back and no wait for its controls: the cell is not answering. The file says db +// so a cell that comes back boots in db, and Postgres says db so the sweeps re-place its hosts. +async function breakGlassToDatabase(request, dependencies, { fetchImpl, log, sleep }) { + const { accessToken, idToken, audit } = dependencies + // Only for a dead cell: one that answers, or still heartbeats, takes the ordinary flip back. + const answered = await fetchImpl(`${request.cellOrigin}/v1/admin/runtime-status`, { + method: 'POST', + headers: { authorization: `Bearer ${idToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ v: 1 }), + signal: AbortSignal.timeout(10_000) + }) + .then(async (response) => { + await response.body?.cancel().catch(() => undefined) + return response.ok + }) + .catch(() => false) + if (answered) throw new Error('the cell answers runtime-status: flip it back with --set admitMode=db, not break glass') + const recorded = await directorAdmitMode(fetchImpl, request, idToken, sleep) + if (recorded?.heartbeatFresh !== false) { + throw new Error( + `the director ${recorded?.heartbeatFresh ? 'still sees heartbeats from' : 'cannot say whether it hears'} ` + + 'the cell: break glass is only for a cell that has stopped' + ) + } + const current = await readCurrentObject(fetchImpl, request, accessToken) + if (current.generation !== request.expectedGeneration) { + throw new Error(`current generation is ${current.generation}, not expected ${request.expectedGeneration}`) + } + const object = desiredObject(request, current.object) + const generation = await writeObject(fetchImpl, request, object, accessToken, audit) + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') + const result = { event: 'orca_relay_cell_admit_mode_break_glass', cellId: request.cellId, generation, audit } + log(JSON.stringify(result)) + return { ...result, written: true } +} export async function operateCellFlags(request, dependencies) { const { fetchImpl = fetch, accessToken, idToken, audit, log = console.log } = dependencies const now = dependencies.now ?? Date.now const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) + if (request.breakGlass) return await breakGlassToDatabase(request, dependencies, { fetchImpl, log, sleep }) const runtime = await readRuntime(fetchImpl, request, idToken, sleep) // Proves the origin is this cell, and that its image reads switch files at all. if (runtime.role !== 'cell' || runtime.cellId !== request.cellId) { @@ -156,6 +414,20 @@ export async function operateCellFlags(request, dependencies) { } if (!runtime.flagsApplied) throw new Error('cell image has no flag channel (no flagsApplied)') const current = await readCurrentObject(fetchImpl, request, accessToken) + const object = desiredObject(request, current.object) + assertSupportedObject(object.flags, runtime) + // A tripped dead-man: any new generation that keeps reserve re-arms it, so the cell trips + // again and its re-registration restarts. Only a write that also says db is safe. + if ( + runtime.flagsApplied.flags?.admitMode === 'reserve' && + runtime.admitModeEffective === 'db' && + (object.flags.admitMode ?? 'db') === 'reserve' + ) { + throw new Error( + 'the cell tripped its dead-man (reserve configured, db effective); a write that keeps reserve re-arms it. ' + + 'Name admitMode=db in the same write, e.g. --set admitMode=db,reregisterInFlight=N' + ) + } const plan = { event: 'orca_relay_cell_flags_plan', mode: request.mode, @@ -163,7 +435,8 @@ export async function operateCellFlags(request, dependencies) { currentGeneration: current.generation, currentObject: current.object, appliedBefore: runtime.flagsApplied, - desired: request.object + changes: request.changes, + desired: object } // Before the generation check, so a dry run always shows the generation to pass next. log(JSON.stringify(plan)) @@ -172,8 +445,53 @@ export async function operateCellFlags(request, dependencies) { `current generation is ${current.generation}, not expected ${request.expectedGeneration}` ) } + // The cell applies every readable write; a generation it did not apply was voided there. A + // write of an object this image accepts whole (checked above) repairs it. + if (current.generation !== '0' && String(runtime.flagsApplied.generation) !== current.generation) { + log( + JSON.stringify({ + event: 'orca_relay_cell_flags_repairing_unapplied_object', + cellId: request.cellId, + appliedGeneration: runtime.flagsApplied.generation, + currentGeneration: current.generation + }) + ) + } + const admitModeChange = + request.changes.admitMode === undefined ? undefined : (request.changes.admitMode ?? 'db') + if (admitModeChange !== undefined) { + const recorded = await directorAdmitMode(fetchImpl, request, idToken, sleep) + log(JSON.stringify({ event: 'orca_relay_cell_admit_mode_recorded_before', ...recorded })) + } if (request.mode === 'dry-run') return { ...plan, written: false } - const generation = await writeObject(fetchImpl, request, accessToken, audit) + // Sweeps skip the cell before it starts admitting from memory, never after. + let generation + if (admitModeChange === 'reserve') { + const before = await directorAdmitMode(fetchImpl, request, idToken, sleep) + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'reserve') + // Every director's cached set expires (5 s) before the cell starts admitting from memory; + // the second write re-runs the open-flow check over anything started meanwhile. + await sleep(RESERVE_MODE_CACHE_WAIT_MS) + try { + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'reserve') + } catch (error) { + // A drain, migration or rehome opened during the wait: Postgres goes back as it was. + if (before?.admitMode === 'db') await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') + throw error + } + try { + generation = await writeObject(fetchImpl, request, object, accessToken, audit) + } catch (error) { + // The file was refused outright (412, 4xx): put Postgres back as it was. After a 5xx the + // object may be written, so Postgres keeps reserve (sweeps stay off: the safe side). + if (error?.notWritten && before?.admitMode === 'db') { + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') + } + throw error + } + } else { + generation = await writeObject(fetchImpl, request, object, accessToken, audit) + } log(JSON.stringify({ event: 'orca_relay_cell_flags_written', cellId: request.cellId, generation })) const deadline = now() + READ_BACK_TIMEOUT_MS let last = runtime.flagsApplied @@ -185,9 +503,22 @@ export async function operateCellFlags(request, dependencies) { // The object is written; a failed poll only delays the read-back until the deadline. continue } - if (String(last?.generation) === generation && sameFlags(last.flags, request.object.flags)) { + if (String(last?.generation) !== generation) continue + // The image predates a switch this object names: it applied the rest and dropped that one. + if (Array.isArray(last.ignoredKeys) && last.ignoredKeys.length > 0) { + throw new Error( + `cell applied generation ${generation} but ignores ${last.ignoredKeys.join(', ')}; ` + + 'its image predates them, so flip the object back' + ) + } + if (sameFlags(last.flags, object.flags)) { const result = { ...plan, written: true, generation, applied: last } log(JSON.stringify({ ...result, event: 'orca_relay_cell_flags_applied_read_back' })) + if (admitModeChange === 'db') { + await waitForLeasedControls(fetchImpl, request, idToken, { now, sleep, log }) + await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') + log(JSON.stringify({ event: 'orca_relay_cell_admit_mode_recorded', cellId: request.cellId, admitMode: 'db' })) + } return result } } @@ -205,10 +536,12 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { 'cell-id', 'cell-origin', 'project-id', - 'readiness-local', - 'ticket-check', + 'set', 'expected-generation', - 'confirmation' + 'confirmation', + 'confirm-reserve', + 'break-glass', + 'director-origin' ].map((name) => [name, { type: 'string' }]) ) }) diff --git a/cloud/dev/scripts/operate-relay-cell-flags.test.mjs b/cloud/dev/scripts/operate-relay-cell-flags.test.mjs index 23834755e336..3a5b565feff9 100644 --- a/cloud/dev/scripts/operate-relay-cell-flags.test.mjs +++ b/cloud/dev/scripts/operate-relay-cell-flags.test.mjs @@ -1,36 +1,104 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { + assertSupportedObject, + desiredObject, operateCellFlags, parseCellFlagsRequest, + parseFlagChanges, READ_BACK_TIMEOUT_MS, + REREGISTER_WAIT_MAX_MS, + REREGISTER_WAIT_MIN_MS, + reregisterLanes, + reregisterWaitMs, WRITE_CONFIRMATION } from './operate-relay-cell-flags.mjs' const CELL = 'production-gce-c26' +const RESERVE = { 'director-origin': 'https://relay.onorca.dev', 'confirm-reserve': `RESERVE ${CELL}` } const values = (overrides = {}) => ({ mode: 'write', 'cell-id': CELL, 'cell-origin': 'https://c26.relay.onorca.dev', 'project-id': 'onorca-cloud', - 'readiness-local': 'true', - 'ticket-check': 'off', + set: 'readinessLocal=true', 'expected-generation': '0', confirmation: `${WRITE_CONFIRMATION} ${CELL}`, ...overrides }) // A cell whose runtime status applies whatever was written, after `applyAfterPolls` polls. -function fakeGoogleAndCell({ stored = null, applyAfterPolls = 2, role = 'cell', cellId = CELL } = {}) { - const state = { stored, writes: [], polls: 0, applied: { generation: 0, flags: { readinessLocal: false, ticketCheck: 'off' } } } +const SUPPORTED = { + readinessLocal: { type: 'boolean' }, + ticketCheck: { type: 'enum', values: ['off', 'shadow', 'enforce'] }, + admitMode: { type: 'enum', values: ['db', 'reserve'] }, + intakePerSec: { type: 'number', min: 0, max: 1_000 }, + reserveDryRun: { type: 'boolean' }, + rejectionFence: { type: 'boolean' }, + readTimeoutMarginMs: { type: 'number', min: 1_000, max: 60_000, integer: true }, + reregisterInFlight: { type: 'number', min: 1, max: 16, integer: true } +} + +function fakeGoogleAndCell({ + stored = null, + applyAfterPolls = 2, + role = 'cell', + cellId = CELL, + supportedFlags = SUPPORTED, + // Polls that still report admitModeEffective=reserve after a flip back. + reregisteringPolls = 0, + controls, + // Forces admitModeEffective, as a tripped dead-man reports it. + effectiveOverride, + heartbeatFresh = false, + // The director refuses this numbered reserve post (1-based) with this error. + refuseReservePost +} = {}) { + const state = { + stored, + writes: [], + polls: 0, + // A cell that has applied whatever object is already there. + applied: stored + ? { generation: Number(stored.generation), flags: stored.object.flags } + : { generation: 0, flags: { readinessLocal: false, ticketCheck: 'off' } }, + directorAdmitMode: 'db', + reservePosts: 0, + order: [], + reregisteringPolls + } const fetchImpl = async (url, init = {}) => { const target = new URL(url) + if (target.pathname === '/v1/admin/cell-admit-mode') { + const body = JSON.parse(String(init.body)) + if (body.admitMode === 'reserve') { + state.reservePosts += 1 + if (refuseReservePost?.post === state.reservePosts) { + return Response.json({ error: refuseReservePost.error }, { status: 409 }) + } + } + if (body.admitMode) { + state.directorAdmitMode = body.admitMode + state.order.push(`director:${body.admitMode}`) + } + return Response.json({ v: 1, cellId: body.cellId, admitMode: state.directorAdmitMode, updatedAt: 1, heartbeatFresh }) + } if (target.pathname === '/v1/admin/runtime-status') { state.polls += 1 if (state.stored && state.polls > applyAfterPolls) { state.applied = { generation: Number(state.stored.generation), flags: state.stored.object.flags } } - return Response.json({ v: 1, role, cellId, flagsApplied: state.applied }) + const reserveApplied = state.applied.flags.admitMode === 'reserve' + const effective = reserveApplied || (state.writes.length > 0 && state.reregisteringPolls-- > 0) ? 'reserve' : 'db' + return Response.json({ + v: 1, + role, + cellId, + flagsApplied: state.applied, + admitModeEffective: effectiveOverride ?? effective, + ...(controls === undefined ? {} : { runtime: { controls } }), + ...(supportedFlags ? { supportedFlags } : {}) + }) } if (target.pathname.startsWith('/upload/')) { const expected = target.searchParams.get('ifGenerationMatch') @@ -41,6 +109,7 @@ function fakeGoogleAndCell({ stored = null, applyAfterPolls = 2, role = 'cell', const generation = String(Number(state.stored?.generation ?? 1000) + 1) state.stored = { generation, object } state.writes.push({ generation, object, metadata }) + state.order.push(`file:${object.flags.admitMode ?? 'db'}`) return Response.json({ generation }) } if (!state.stored) return new Response(null, { status: 404 }) @@ -73,7 +142,11 @@ const run = (request, fake, extra = {}) => { test('refuses malformed requests before touching anything', () => { assert.throws(() => parseCellFlagsRequest(values({ mode: 'apply' })), /mode/) assert.throws(() => parseCellFlagsRequest(values({ 'cell-id': 'all' })), /cell id/) - assert.throws(() => parseCellFlagsRequest(values({ 'ticket-check': 'enforce' })), /ticket-check/) + assert.throws(() => parseCellFlagsRequest(values({ set: 'ticketCheck=always' })), /ticketCheck/) + assert.throws(() => parseCellFlagsRequest(values({ set: 'placerMode=on' })), /unknown switch/) + assert.throws(() => parseCellFlagsRequest(values({ set: '' })), /names no switch/) + assert.throws(() => parseCellFlagsRequest(values({ set: 'admitMode=db,admitMode=reserve' })), /twice/) + assert.throws(() => parseCellFlagsRequest(values({ set: 'readTimeoutMarginMs=500' })), /cannot be/) assert.throws(() => parseCellFlagsRequest(values({ 'expected-generation': '' })), /expected/) assert.throws(() => parseCellFlagsRequest(values({ confirmation: WRITE_CONFIRMATION })), /confirmation/) assert.throws( @@ -92,7 +165,7 @@ test('a dry run reports the change and writes nothing', async () => { assert.equal(outcome.written, false) assert.equal(fake.state.writes.length, 0) assert.equal(lines[0].event, 'orca_relay_cell_flags_plan') - assert.deepEqual(lines[0].desired.flags, { readinessLocal: true, ticketCheck: 'off' }) + assert.deepEqual(lines[0].desired.flags, { readinessLocal: true }) }) test('writes with compare-and-swap, records who wrote it, and waits for the cell to apply it', async () => { @@ -101,12 +174,12 @@ test('writes with compare-and-swap, records who wrote it, and waits for the cell const outcome = await result assert.equal(outcome.written, true) assert.equal(outcome.generation, '1001') - assert.deepEqual(outcome.applied, { generation: 1001, flags: { readinessLocal: true, ticketCheck: 'off' } }) + assert.deepEqual(outcome.applied, { generation: 1001, flags: { readinessLocal: true } }) assert.deepEqual(fake.state.writes[0].metadata.metadata, { writtenBy: 'octocat', runId: '42', runAttempt: '1' }) assert.deepEqual(fake.state.writes[0].object, { v: 1, cellId: CELL, - flags: { readinessLocal: true, ticketCheck: 'off' } + flags: { readinessLocal: true } }) assert.deepEqual(lines.map((line) => line.event), [ 'orca_relay_cell_flags_plan', @@ -115,7 +188,7 @@ test('writes with compare-and-swap, records who wrote it, and waits for the cell ]) // Flip back: the next write must name the generation just written. const back = run( - parseCellFlagsRequest(values({ 'readiness-local': 'false', 'expected-generation': '1001' })), + parseCellFlagsRequest(values({ set: 'readinessLocal=false', 'expected-generation': '1001' })), fake ) assert.equal((await back.result).generation, '1002') @@ -182,3 +255,269 @@ test('fails loudly when the cell does not apply the write within the read-back w await assert.rejects(run(parseCellFlagsRequest(values()), fake).result, /did not apply generation 1001/) assert.equal(fake.state.writes.length, 1) }) + +test('changes only the named switches and keeps every other key the object already has', () => { + const current = { + v: 1, + cellId: CELL, + flags: { readinessLocal: true, ticketCheck: 'shadow', readTimeoutMarginMs: 5_000 } + } + const request = parseCellFlagsRequest( + values({ set: 'admitMode=reserve, intakePerSec=40, readTimeoutMarginMs=default', ...RESERVE }) + ) + assert.deepEqual(desiredObject(request, current), { + v: 1, + cellId: CELL, + flags: { readinessLocal: true, ticketCheck: 'shadow', admitMode: 'reserve', intakePerSec: 40 } + }) + assert.deepEqual(desiredObject(request, null).flags, { admitMode: 'reserve', intakePerSec: 40 }) + assert.throws(() => desiredObject(request, { v: 1, cellId: 'production-gce-c27', flags: {} }), /by hand/) + assert.deepEqual(parseFlagChanges('rejectionFence=false'), { rejectionFence: false }) + assert.deepEqual(parseFlagChanges('reregisterInFlight=8'), { reregisterInFlight: 8 }) + assert.throws(() => parseFlagChanges('reregisterInFlight=32'), /cannot be/) +}) + +test('a write keeps the switches it did not name, end to end', async () => { + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { ticketCheck: 'shadow' } } } + }) + const outcome = await run(parseCellFlagsRequest(values({ 'expected-generation': '7' })), fake).result + assert.equal(outcome.written, true) + assert.deepEqual(fake.state.writes[0].object.flags, { ticketCheck: 'shadow', readinessLocal: true }) +}) + +test('the read-back compares every switch, not only the one this run changed', async () => { + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { ticketCheck: 'shadow' } } } + }) + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + const response = await inner(url, init) + if (new URL(url).pathname !== '/v1/admin/runtime-status') return response + const body = await response.json() + // The cell reports the new generation but still runs the old ticketCheck. + if (body.flagsApplied.generation === 8) body.flagsApplied.flags = { readinessLocal: true } + return Response.json(body) + } + await assert.rejects( + run(parseCellFlagsRequest(values({ 'expected-generation': '7' })), fake).result, + /did not apply generation 8/ + ) +}) + +test('a switch the cell reports on its own is compared only when the object names it', async () => { + const fake = fakeGoogleAndCell() + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + const response = await inner(url, init) + if (new URL(url).pathname !== '/v1/admin/runtime-status') return response + const body = await response.json() + body.flagsApplied.flags = { ...body.flagsApplied.flags, intakePerSec: 50, readTimeoutMarginMs: 10_000 } + return Response.json(body) + } + assert.equal((await run(parseCellFlagsRequest(values()), fake).result).written, true) +}) + +test('fails the read-back when the cell ignores a switch the object names', async () => { + const fake = fakeGoogleAndCell() + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + const response = await inner(url, init) + if (new URL(url).pathname !== '/v1/admin/runtime-status') return response + const body = await response.json() + if (body.flagsApplied.generation === 1001) body.flagsApplied.ignoredKeys = ['admitMode'] + return Response.json(body) + } + await assert.rejects( + run(parseCellFlagsRequest(values({ set: 'admitMode=reserve', ...RESERVE })), fake).result, + /ignores admitMode/ + ) +}) + +test('refuses a switch the cell image does not support, before writing anything', async () => { + const old = fakeGoogleAndCell({ supportedFlags: null }) + await assert.rejects( + run(parseCellFlagsRequest(values({ set: 'ticketCheck=enforce' })), old).result, + /does not support ticketCheck="enforce"/ + ) + await assert.rejects( + run(parseCellFlagsRequest(values({ set: 'admitMode=reserve', ...RESERVE })), old).result, + /does not support admitMode/ + ) + assert.equal(old.state.writes.length, 0) + // An image from before supportedFlags still takes the two switches it always had. + assert.equal((await run(parseCellFlagsRequest(values()), old).result).written, true) + assert.throws( + () => assertSupportedObject({ readTimeoutMarginMs: 1_500.5 }, { supportedFlags: SUPPORTED }), + /readTimeoutMarginMs/ + ) + assertSupportedObject({ ticketCheck: 'enforce' }, { supportedFlags: SUPPORTED }) +}) + +test('refuses to carry a bad value already in the file', async () => { + const bad = fakeGoogleAndCell({ + supportedFlags: null, + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { ticketCheck: 'enforce' } } } + }) + // The old image voided generation 7 and still runs generation 0. + bad.state.applied = { generation: 0, flags: { readinessLocal: false, ticketCheck: 'off' } } + await assert.rejects( + run(parseCellFlagsRequest(values({ 'expected-generation': '7' })), bad).result, + /does not support ticketCheck="enforce"/ + ) + assert.equal(bad.state.writes.length, 0) +}) + +const DIRECTOR = { 'director-origin': RESERVE['director-origin'] } + +test('admitMode=reserve needs a typed confirmation, and Postgres records reserve before the file', async () => { + assert.throws( + () => parseCellFlagsRequest(values({ ...DIRECTOR, set: 'admitMode=reserve' })), + /--confirm-reserve "RESERVE production-gce-c26"/ + ) + assert.throws(() => parseCellFlagsRequest(values({ set: 'admitMode=db' })), /--director-origin/) + const fake = fakeGoogleAndCell() + const request = parseCellFlagsRequest( + values({ ...DIRECTOR, set: 'admitMode=reserve', 'confirm-reserve': `RESERVE ${CELL}` }) + ) + assert.equal((await run(request, fake).result).written, true) + // Recorded, re-checked after every director's cache has expired, then the file. + assert.deepEqual(fake.state.order, ['director:reserve', 'director:reserve', 'file:reserve']) +}) + +test('a refused file write after Postgres says reserve puts Postgres back to db', async () => { + const fake = fakeGoogleAndCell() + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + if (new URL(url).pathname.startsWith('/upload/')) return new Response(null, { status: 403 }) + return await inner(url, init) + } + const request = parseCellFlagsRequest(values({ set: 'admitMode=reserve', ...RESERVE })) + await assert.rejects(run(request, fake).result, /returned 403/) + assert.deepEqual(fake.state.order, ['director:reserve', 'director:reserve', 'director:db']) +}) + +test('repairs an object the cell did not apply when the whole new object is valid', async () => { + const voided = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { readinessLocal: true } } } + }) + voided.state.applied = { generation: 3, flags: { readinessLocal: false } } + const { result, lines } = run(parseCellFlagsRequest(values({ 'expected-generation': '7' })), voided) + assert.equal((await result).written, true) + assert.ok(lines.some((line) => line.event === 'orca_relay_cell_flags_repairing_unapplied_object')) +}) + +test('a flip back records db in Postgres only after the cell has leased every control', async () => { + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve' } } }, + reregisteringPolls: 3 + }) + fake.state.directorAdmitMode = 'reserve' + const request = parseCellFlagsRequest(values({ ...DIRECTOR, set: 'admitMode=default', 'expected-generation': '7' })) + assert.equal((await run(request, fake).result).written, true) + assert.deepEqual(fake.state.order, ['file:db', 'director:db']) + assert.equal(fake.state.reregisteringPolls, -1) +}) + +test('the flip-back wait scales with the control count and the switch-file pace', () => { + assert.equal(reregisterWaitMs({ runtime: { controls: 100 } }), REREGISTER_WAIT_MIN_MS) + // 1,500 controls at the default 3 lanes: 1,667 s. + assert.equal(reregisterWaitMs({ runtime: { controls: 1_500 } }), 1_666_667) + assert.equal( + reregisterWaitMs({ runtime: { controls: 1_500 }, flagsApplied: { flags: { reregisterInFlight: 10 } } }), + // The cell caps 10 at its US pool of 10 less two. + 625_000 + ) + assert.equal(reregisterWaitMs({ runtime: { controls: 3_000 } }), REREGISTER_WAIT_MAX_MS) +}) + +test('the wait counts the lanes the cell actually uses', () => { + assert.equal(reregisterLanes({ region: 'us-central1' }), 3) + assert.equal(reregisterLanes({ region: 'asia-east2' }), 5) + assert.equal(reregisterLanes({ region: 'us-central1', flagsApplied: { flags: { reregisterInFlight: 16 } } }), 8) + assert.equal(reregisterLanes({ region: 'asia-east2', flagsApplied: { flags: { reregisterInFlight: 16 } } }), 14) + // The pool the cell reports wins over the deployed-size table. + assert.equal(reregisterLanes({ region: 'us-central1', databasePoolMax: 22 }), 7) + assert.equal(reregisterLanes({ region: 'asia-east2', databasePoolMax: 12, flagsApplied: { flags: { reregisterInFlight: 16 } } }), 10) + assert.equal(reregisterLanes({ region: 'asia-east2', databasePoolMax: 'x' }), 5) +}) + +test('a tripped cell takes no write that keeps reserve, only one that also says db', async () => { + const stored = { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve' } } } + const tripped = () => fakeGoogleAndCell({ stored, effectiveOverride: 'db' }) + for (const set of ['readinessLocal=true', 'reregisterInFlight=4']) { + const fake = tripped() + const request = parseCellFlagsRequest(values({ set, 'expected-generation': '7' })) + await assert.rejects(run(request, fake).result, /tripped its dead-man[\s\S]*admitMode=db,reregisterInFlight=N/) + assert.deepEqual(fake.state.writes, []) + } + const fake = tripped() + fake.state.directorAdmitMode = 'reserve' + const request = parseCellFlagsRequest( + values({ ...DIRECTOR, set: 'admitMode=db,reregisterInFlight=4', 'expected-generation': '7' }) + ) + assert.equal((await run(request, fake).result).written, true) + assert.deepEqual(fake.state.writes[0].object.flags, { admitMode: 'db', reregisterInFlight: 4 }) +}) + +test('a migration opened during the cache wait puts Postgres back to db', async () => { + const fake = fakeGoogleAndCell({ refuseReservePost: { post: 2, error: 'cell_has_open_migration' } }) + const request = parseCellFlagsRequest(values({ ...RESERVE, set: 'admitMode=reserve' })) + await assert.rejects(run(request, fake).result, /cell_has_open_migration/) + assert.deepEqual(fake.state.order, ['director:reserve', 'director:db']) + assert.deepEqual(fake.state.writes, []) +}) + +test('break glass refuses a cell that answers or that the director still hears', async () => { + const glass = { ...RESERVE, set: 'admitMode=default', 'expected-generation': '7', 'break-glass': `BREAK GLASS ${CELL}` } + const stored = { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve' } } } + const answering = fakeGoogleAndCell({ stored }) + await assert.rejects(run(parseCellFlagsRequest(values(glass)), answering).result, /answers runtime-status/) + const heard = fakeGoogleAndCell({ stored, heartbeatFresh: true }) + const inner = heard.fetchImpl + heard.fetchImpl = async (url, init) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') throw new Error('connect refused') + return await inner(url, init) + } + await assert.rejects(run(parseCellFlagsRequest(values(glass)), heard).result, /still sees heartbeats/) + assert.deepEqual(heard.state.writes, []) + assert.deepEqual(answering.state.writes, []) +}) + +test('a flip back that outlasts its wait keeps Postgres on reserve and says how to finish', async () => { + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve' } } }, + reregisteringPolls: Number.POSITIVE_INFINITY, + controls: 1_500 + }) + fake.state.directorAdmitMode = 'reserve' + const request = parseCellFlagsRequest(values({ ...DIRECTOR, set: 'admitMode=default', 'expected-generation': '7' })) + const { lines, result } = run(request, fake) + await assert.rejects(result, /after 1666667 ms[\s\S]*Run again with --set admitMode=db[\s\S]*--set admitMode=db,reregisterInFlight=N/) + assert.deepEqual(fake.state.order, ['file:db']) + assert.equal(lines.find((line) => line.event === 'orca_relay_cell_reregistration_wait')?.waitMs, 1_666_667) +}) + +test('break glass records db for a cell that cannot answer, typed for that cell only', async () => { + const glass = { ...RESERVE, set: 'admitMode=default', 'expected-generation': '7' } + assert.throws(() => parseCellFlagsRequest(values({ ...glass, 'break-glass': 'BREAK GLASS production-gce-c27' })), /break glass requires/) + assert.throws( + () => parseCellFlagsRequest(values({ ...glass, set: 'admitMode=default,readinessLocal=true', 'break-glass': `BREAK GLASS ${CELL}` })), + /on its own/ + ) + const fake = fakeGoogleAndCell({ + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve', readinessLocal: true } } } + }) + fake.state.directorAdmitMode = 'reserve' + const inner = fake.fetchImpl + fake.fetchImpl = async (url, init) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') throw new Error('connect refused') + return await inner(url, init) + } + const { result, lines } = run(parseCellFlagsRequest(values({ ...glass, 'break-glass': `BREAK GLASS ${CELL}` })), fake) + assert.equal((await result).written, true) + assert.deepEqual(fake.state.writes[0].object.flags, { readinessLocal: true }) + assert.deepEqual(fake.state.order, ['file:db', 'director:db']) + assert.equal(lines.at(-1).event, 'orca_relay_cell_admit_mode_break_glass') +}) + diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs index 796d0e9d91ad..b8e368f6e1b2 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs @@ -53,7 +53,9 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { try { service = run(gcloudArguments('services', input)) } catch (error) { - if (error?.code === 'NOT_FOUND') return { version: input.bootstrap_version, cohort_percent: '0' } + if (error?.code === 'NOT_FOUND') { + return { version: input.bootstrap_version, cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' } + } throw error } const serving = (service.status?.traffic ?? []).filter( @@ -77,12 +79,21 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { throw new Error('serving region correction cohort is invalid') } const cohort_percent = cohortSettings[0]?.value ?? '0' + // Set by the director deploy (placement) or by hand (shadow cells), never by Terraform: carried + // so an apply keeps them, and empty when the serving revision has none. + const reserve_placement = servingPlainSetting(revision, 'ORCA_RELAY_RESERVE_PLACEMENT', /^(?:off|dry-run|on)$/) + const shadow_seat_feed_cells = servingPlainSetting( + revision, + 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS', + /^(?:all|[A-Za-z0-9_-]+(?:, ?[A-Za-z0-9_-]+)*)$/ + ) + const carried = { cohort_percent, reserve_placement, shadow_seat_feed_cells } const references = (revision.spec?.containers ?? []).flatMap((container) => (container.env ?? []).filter( (environment) => environment.name === 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' ) ) - if (references.length === 0) return { version: input.bootstrap_version, cohort_percent } + if (references.length === 0) return { version: input.bootstrap_version, ...carried } const reference = normalizeSecretReference(references[0]) if ( references.length !== 1 || @@ -91,7 +102,20 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { ) { throw new Error('serving regional placement secret reference is invalid') } - return { version: reference.version, cohort_percent } + return { version: reference.version, ...carried } +} + +// One plain value, or none; anything else (a secret, a duplicate, a malformed value) fails the +// plan rather than letting an apply drop or reset the setting. +function servingPlainSetting(revision, name, valid) { + const settings = (revision.spec?.containers ?? []).flatMap((container) => + (container.env ?? []).filter((environment) => environment.name === name) + ) + if (settings.length === 0) return '' + if (settings.length > 1 || typeof settings[0].value !== 'string' || !valid.test(settings[0].value)) { + throw new Error(`serving ${name} is invalid`) + } + return settings[0].value } // Why: the v2 API reports `valueSource.secretKeyRef.{secret,version}`, but diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs index 9c49d4127a5c..8fd0c3a551e2 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import test from 'node:test' +import { fileURLToPath } from 'node:url' import { classifyRelayServiceDescribeFailure, readRelayServingRegionalPlacementVersion @@ -67,7 +69,7 @@ test('reads the exact version from the sole traffic-serving revision', () => { } }) - assert.deepEqual(result, { version: '11', cohort_percent: '0' }) + assert.deepEqual(result, { version: '11', cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' }) assert.equal(calls[1][3], 'relay-serving') }) @@ -78,7 +80,7 @@ test('reads the gcloud v1 secret reference shape by bare id and by full resource ]) { assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? serving() : v1Revision(name, '1') - }), { version: '1', cohort_percent: '0' }) + }), { version: '1', cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' }) } }) @@ -100,12 +102,12 @@ test('falls back only when the service or setting is absent', () => { notFound.code = 'NOT_FOUND' assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: () => { throw notFound } - }), { version: '7', cohort_percent: '0' }) + }), { version: '7', cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' }) assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? { status: { traffic: [{ revisionName: 'relay-serving', percent: 100 }] } } : { spec: { containers: [{ env: [] }] } } - }), { version: '7', cohort_percent: '0' }) + }), { version: '7', cohort_percent: '0', reserve_placement: '', shadow_seat_feed_cells: '' }) }) test('classifies real absent-service stderr without weakening revision failures', () => { @@ -144,7 +146,7 @@ test('preserves the serving cohort including explicit disable across later Terra servingRevision.spec.containers[0].env.push({ name: 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT', value }) assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? serving() : servingRevision - }), { version: '11', cohort_percent: value }) + }), { version: '11', cohort_percent: value, reserve_placement: '', shadow_seat_feed_cells: '' }) } }) @@ -164,3 +166,44 @@ test('fails closed on malformed, secret-backed or duplicate cohorts rather than }), /cohort is invalid/) } }) + +test('carries the deploy-owned placement and shadow cells, so an apply keeps them', () => { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push( + { name: 'ORCA_RELAY_RESERVE_PLACEMENT', value: 'on' }, + { name: 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS', value: 'production-gce-c1,production-gce-c2' } + ) + assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), { + version: '11', + cohort_percent: '0', + reserve_placement: 'on', + shadow_seat_feed_cells: 'production-gce-c1,production-gce-c2' + }) +}) + +test('fails closed on a malformed, secret-backed or duplicate placement or shadow-cell setting', () => { + const cases = [ + [{ name: 'ORCA_RELAY_RESERVE_PLACEMENT', value: 'yes' }], + [{ name: 'ORCA_RELAY_RESERVE_PLACEMENT', value: 'on' }, { name: 'ORCA_RELAY_RESERVE_PLACEMENT', value: 'off' }], + [{ name: 'ORCA_RELAY_RESERVE_PLACEMENT', valueFrom: { secretKeyRef: { name: 'x', key: '1' } } }], + [{ name: 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS', value: 'c1;c2' }], + [{ name: 'ORCA_RELAY_SHADOW_SEAT_FEED_CELLS', value: '' }] + ] + for (const settings of cases) { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push(...settings) + assert.throws(() => readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), /is invalid/) + } +}) + +test('Terraform sets the carried settings only when the serving revision has them', () => { + const terraform = readFileSync(fileURLToPath(new URL('../../infra/terraform/relay.tf', import.meta.url)), 'utf8') + const block = /dynamic "env" \{([\s\S]*?)\n {6}\}/.exec(terraform)?.[1] ?? '' + assert.match(block, /ORCA_RELAY_RESERVE_PLACEMENT *= data\.external\.relay_serving_regional_placement_version\.result\.reserve_placement/) + assert.match(block, /ORCA_RELAY_SHADOW_SEAT_FEED_CELLS *= data\.external\.relay_serving_regional_placement_version\.result\.shadow_seat_feed_cells/) + assert.match(block, /if value != ""/) +}) diff --git a/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs b/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs new file mode 100644 index 000000000000..a4861625084d --- /dev/null +++ b/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments as parseBlueGreen } from './deploy-relay-blue-green.mjs' +import { parseArguments as parseGceCandidate } from './deploy-relay-gce-candidate.mjs' +import { RELAY_WORKFLOW_DIRECTORY, readRelayWorkflow } from './relay-repository.mjs' + +// Every workflow's real argv, literals as written: a flag combination the parser refuses +// (staging's lone --admin-audience once was one) fails here instead of on a release. + +const PROJECT = 'orca-test-project' +const DIGEST = `sha256:${'a'.repeat(64)}` + +// Shell-expanded values only; anything a workflow writes literally is parsed as written. +const SAMPLES = { + project: PROJECT, + image: `relay@${DIGEST}`, + 'predecessor-image-digest': DIGEST, + 'director-cells-json': JSON.stringify([ + { id: 'cell-a', url: 'https://cell-a.example.test', region: 'us-central1', capacityRequests: 10, initiallyEnabled: true } + ]), + 'regional-placement-secret-version': '3', + 'min-instances': '1', + 'max-instances': '5', + 'prune-revisions': 'false', + 'bootstrap-runtime-identity': 'false', + 'expected-rehome-generation': '4', + 'region-correction-cohort-percent': '10', + 'rehome-audience': 'https://relay.example.test/v1/admin/host-drain', + 'director-origin': 'https://relay.example.test', + 'admin-audience': 'https://relay.example.test/v1/admin/drain', + 'reserve-placement': 'preserve', + 'shadow-seat-feed-cells': 'preserve', + mode: 'audit' +} + +function sample(flag) { + if (flag in SAMPLES) return SAMPLES[flag] + if (flag.endsWith('service-account')) return `${flag.slice(0, 20).replace(/-$/, '')}@${PROJECT}.iam.gserviceaccount.com` + return `sample-${flag}` +} + +function invocations(source, script) { + const lines = source.split('\n') + const found = [] + for (let index = 0; index < lines.length; index++) { + if (!lines[index].includes(`dev/scripts/${script}`)) continue + const argv = [] + let line = lines[index] + while (line.trimEnd().endsWith('\\')) { + line = lines[++index] + const match = /^\s*(--[a-z0-9-]+)\s+(.+?)\s*\\?\s*$/.exec(line) + if (!match) throw new Error(`unparsed ${script} argument line: ${line.trim()}`) + const [, flag, raw] = match + // A trailing `)"` closes the command substitution the invocation sits in. + const value = raw.replace(/\)"$/, '').replace(/^"(.*)"$/, '$1') + argv.push(flag, value.includes('$') ? sample(flag.slice(2)) : value) + } + found.push(argv) + } + return found +} + +const SCRIPTS = [ + ['deploy-relay-blue-green.mjs', parseBlueGreen], + ['deploy-relay-gce-candidate.mjs', parseGceCandidate] +] + +for (const [script, parse] of SCRIPTS) { + test(`every workflow's ${script} argv parses`, () => { + let count = 0 + for (const file of readdirSync(RELAY_WORKFLOW_DIRECTORY).filter((name) => name.endsWith('.yml'))) { + for (const argv of invocations(readFileSync(new URL(file, RELAY_WORKFLOW_DIRECTORY), 'utf8'), script)) { + count++ + assert.doesNotThrow(() => parse(argv), `${file}: ${argv.join(' ')}`) + } + } + assert.ok(count > 0, `no workflow runs ${script}`) + }) +} + +test('the production and staging director workflows are among the parsed', () => { + for (const name of ['deploy-relay-production-director.yml', 'deploy-relay-staging.yml']) { + assert.equal(invocations(readRelayWorkflow(name), 'deploy-relay-blue-green.mjs').length, 1, name) + } +}) diff --git a/cloud/dev/scripts/relay-director-deploy-plan.mjs b/cloud/dev/scripts/relay-director-deploy-plan.mjs index aaeb82bddc97..5f45de5afb56 100644 --- a/cloud/dev/scripts/relay-director-deploy-plan.mjs +++ b/cloud/dev/scripts/relay-director-deploy-plan.mjs @@ -211,6 +211,8 @@ export function directorDeployInputs({ imageDigest, predecessorDigest, rehomeGen 'image-digest': imageDigest, 'regional-placement-mode': 'preserve', 'region-correction-cohort-percent': 'preserve', + 'reserve-placement': 'preserve', + 'shadow-seat-feed-cells': 'preserve', 'prune-incompatible-revisions': 'false', 'expected-rehome-generation': String(rehomeGeneration), 'bootstrap-runtime-identity': 'false', diff --git a/cloud/infra/terraform/relay.tf b/cloud/infra/terraform/relay.tf index 5df7372ff074..217f92cfd470 100644 --- a/cloud/infra/terraform/relay.tf +++ b/cloud/infra/terraform/relay.tf @@ -174,6 +174,22 @@ resource "google_cloud_run_v2_service" "relay" { value = data.external.relay_serving_regional_placement_version.result.cohort_percent } + # The director deploy owns these (shadow cells are set by hand): an apply keeps whatever the + # serving revision has, and adds nothing when it has none. + dynamic "env" { + for_each = { + for name, value in { + ORCA_RELAY_RESERVE_PLACEMENT = data.external.relay_serving_regional_placement_version.result.reserve_placement + ORCA_RELAY_SHADOW_SEAT_FEED_CELLS = data.external.relay_serving_regional_placement_version.result.shadow_seat_feed_cells + } : name => value if value != "" + } + + content { + name = env.key + value = env.value + } + } + ports { container_port = 8080 } From db2ed7ca3690cae4dd62bc6e16c581acca6d5559 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:45:23 -0700 Subject: [PATCH 039/110] perf(mac): name terminals in Orca instead of ps tty= (#27146) Ask ps for raw terminal device numbers and name them in Orca: a verified direct lookup of /dev/ttys per distinct terminal, with an exact first-alias /dev scan fallback. Removes Apple ps's per-row /dev scan (~1 s CPU per capture on a busy Mac). Fixes #24889 Co-authored-by: Giorgio Zamparelli Co-authored-by: out-of-place --- ...nal-host-cheap-tier-ps-scan-volume.test.ts | 167 ++++-- ...host-process-inspection-cheap-tier.test.ts | 188 +++++-- ...-foreground-process-ps-scan-volume.test.ts | 103 +++- .../darwin-process-table-capture.test.ts | 153 ++++++ src/shared/darwin-process-table-capture.ts | 94 ++++ ...rwin-process-table-inventory-drain.test.ts | 178 +++++++ .../darwin-terminal-device-stats.test.ts | 48 ++ .../darwin-terminal-names-real-ps.test.ts | 71 +++ src/shared/darwin-terminal-names.test.ts | 500 ++++++++++++++++++ src/shared/darwin-terminal-names.ts | 127 +++++ .../process-table-capture-parsing.test.ts | 41 ++ src/shared/process-table-ps-test-fixture.ts | 93 ++++ src/shared/process-table-snapshot-reader.ts | 30 +- src/shared/process-table-snapshot.test.ts | 23 +- src/shared/shell-foreground-snapshot.test.ts | 115 +++- 15 files changed, 1798 insertions(+), 133 deletions(-) create mode 100644 src/shared/darwin-process-table-capture.test.ts create mode 100644 src/shared/darwin-process-table-capture.ts create mode 100644 src/shared/darwin-process-table-inventory-drain.test.ts create mode 100644 src/shared/darwin-terminal-device-stats.test.ts create mode 100644 src/shared/darwin-terminal-names-real-ps.test.ts create mode 100644 src/shared/darwin-terminal-names.test.ts create mode 100644 src/shared/darwin-terminal-names.ts create mode 100644 src/shared/process-table-capture-parsing.test.ts create mode 100644 src/shared/process-table-ps-test-fixture.ts diff --git a/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts b/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts index 0633c811ba74..aadd4eb10400 100644 --- a/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts +++ b/src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts @@ -1,3 +1,15 @@ +import type * as FsPromises from 'node:fs/promises' +import type { Session } from './session' +import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names' +import type * as FullReader from '../../shared/process-table-snapshot-reader' +import { + createProcessTablePsFixture, + type PsFixtureRow +} from '../../shared/process-table-ps-test-fixture' +import type * as CheapReader from '../../shared/cheap-process-table-snapshot-reader' +import type * as TrackerModule from './pty-subprocess/foreground-process-tracker' +import type * as Inspection from './terminal-host-process-inspection' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Measurement for the cheap-tier process inspection. Drives the REAL daemon inspection // entrypoint (`inspectTerminalHostProcess`) for 8 idle agent panes over a simulated 60s idle // cadence (POLL_TIER_INTERVAL_MS.idle = 2,000ms) and counts `ps` forks BY COLUMN SET: a fork @@ -8,7 +20,6 @@ // // The second test is the zero-trade-off proof: the same tick sequence, including an agent exit // and a restart, produces the identical foregroundProcess series with the cheap tier on and off. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Two seams because the two tiers spawn differently: the full evidence reader still forks // through node:child_process, the cheap reader through Orca's runProcess entry point. @@ -19,11 +30,24 @@ const { execFileMock, runProcessMock } = vi.hoisted(() => ({ vi.mock('node:child_process', () => ({ execFile: execFileMock })) vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) -import { resetCheapProcessTableSnapshotForTests } from '../../shared/cheap-process-table-snapshot-reader' -import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' -import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker' -import { inspectTerminalHostProcess } from './terminal-host-process-inspection' -import type { Session } from './session' +// Inject /dev data without bypassing the production translator. +vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => { + const original = await importOriginal() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: async (path: string) => readProcStatMock(path) +})) + +let cheapReader: typeof CheapReader +let fullReader: typeof FullReader +let trackerModule: typeof TrackerModule +let inspection: typeof Inspection const PANE_COUNT = 8 const IDLE_POLL_INTERVAL_MS = 2_000 // POLL_TIER_INTERVAL_MS.idle @@ -33,50 +57,92 @@ const TICKS = Math.floor((WINDOW_SECONDS * 1000) / IDLE_POLL_INTERVAL_MS) const shellPid = (pane: number): number => 1000 + pane * 100 const agentPid = (pane: number): number => shellPid(pane) + 1 -type PaneState = { agent: boolean; agentStart: string } +type PaneState = { agent: boolean; agentStart: string; agentStartTicks: number } const panes: PaneState[] = Array.from({ length: PANE_COUNT }, () => ({ agent: true, - agentStart: 'Thu Sep 3 16:02:05 2026' + agentStart: 'Thu Sep 3 16:02:05 2026', + agentStartTicks: 1_400 })) const forks = { full: 0, cheap: 0 } -function renderRows(): { full: string; cheap: string } { - const full: string[] = [] - const cheap: string[] = [] - panes.forEach((pane, i) => { - const s = shellPid(i) - const a = agentPid(i) - const tpgid = pane.agent ? a : s - const shellStat = pane.agent ? 'Ss' : 'Ss+' - cheap.push(`${s} 1 ${s} ${tpgid} ${shellStat} Thu Sep 3 16:02:01 2026`) - full.push(`${s} 1 ${s} ${tpgid} ${shellStat} ttys00${i} Thu Sep 3 16:02:01 2026 -zsh`) +function processRows(): PsFixtureRow[] { + return panes.flatMap((pane, i) => { + const shell = shellPid(i) + const agent = agentPid(i) + const rows: PsFixtureRow[] = [ + { + pid: shell, + ppid: 1, + pgid: shell, + tpgid: pane.agent ? agent : shell, + stat: pane.agent ? 'Ss' : 'Ss+', + terminalMinor: i, + startTime: 'Thu Sep 3 16:02:01 2026', + startTicks: 1_000, + command: '-zsh' + } + ] if (pane.agent) { - cheap.push(`${a} ${s} ${a} ${a} S+ ${pane.agentStart}`) - full.push(`${a} ${s} ${a} ${a} S+ ttys00${i} ${pane.agentStart} node /usr/local/bin/claude`) + rows.push({ + pid: agent, + ppid: shell, + pgid: agent, + tpgid: agent, + stat: 'S+', + terminalMinor: i, + startTime: pane.agentStart, + startTicks: pane.agentStartTicks, + command: 'node /usr/local/bin/claude' + }) } + return rows }) - return { full: `${full.join('\n')}\n`, cheap: `${cheap.join('\n')}\n` } } +const psFixture = createProcessTablePsFixture(processRows) +const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path)) function installCountingPs(): void { - execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { - expect(args[1]).toContain('command=') - forks.full += 1 - ;(cb as (err: unknown, r: { stdout: string; stderr: string }) => void)(null, { - stdout: renderRows().full, - stderr: '' - }) - }) - runProcessMock.mockImplementation(async (spec: { args: readonly string[] }) => { - expect(spec.args[1]).not.toContain('command=') + execFileMock.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + callback: (err: unknown, r: { stdout: string; stderr: string }) => void + ) => { + expect(cmd).toBe('ps') + expect(args).toEqual( + process.platform === 'darwin' + ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command='] + : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command='] + ) + forks.full += 1 + callback(null, { + stdout: psFixture.render(args[1]), + stderr: '' + }) + } + ) + runProcessMock.mockImplementation(async (spec: { program: string; args: readonly string[] }) => { + expect(spec.program).toBe('ps') + expect(spec.args).toEqual( + process.platform === 'darwin' + ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,lstart='] + : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat='] + ) forks.cheap += 1 - return { code: 0, signal: null, stdout: renderRows().cheap, stderr: '', timedOut: false } + return { + code: 0, + signal: null, + stdout: psFixture.render(spec.args[1]), + stderr: '', + timedOut: false + } }) } function createSession(pane: number): Session { - const tracker = createPtyForegroundProcessTracker({ + const tracker = trackerModule.createPtyForegroundProcessTracker({ process: { pid: shellPid(pane), get process() { @@ -106,7 +172,7 @@ async function settle(): Promise { async function runTick(sessions: Session[], steadyState: boolean): Promise<(string | null)[]> { const results = await Promise.all( sessions.map((session, pane) => - inspectTerminalHostProcess({ + inspection.inspectTerminalHostProcess({ sessionId: `wt:pane-${pane}`, session, ...(steadyState ? { steadyState: true } : {}), @@ -119,22 +185,30 @@ async function runTick(sessions: Session[], steadyState: boolean): Promise<(stri return results.map((r) => r.foregroundProcess) } -describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => { +describe.each(['darwin', 'linux'])('cheap-tier ps scan volume (%s)', (hostPlatform) => { let platform: PropertyDescriptor | undefined - beforeEach(() => { + beforeEach(async () => { execFileMock.mockReset() + readProcStatMock.mockClear() runProcessMock.mockReset() - resetProcessTableSnapshotForTests() - resetCheapProcessTableSnapshotForTests() forks.full = 0 forks.cheap = 0 panes.forEach((pane) => { pane.agent = true pane.agentStart = 'Thu Sep 3 16:02:05 2026' + pane.agentStartTicks = 1_400 }) platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + // Column constants are chosen at import time, so each host needs a fresh module graph. + vi.resetModules() + fullReader = await import('../../shared/process-table-snapshot-reader') + fullReader.resetProcessTableSnapshotForTests() + cheapReader = await import('../../shared/cheap-process-table-snapshot-reader') + cheapReader.resetCheapProcessTableSnapshotForTests() + trackerModule = await import('./pty-subprocess/foreground-process-tracker') + inspection = await import('./terminal-host-process-inspection') vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(1_000_000) installCountingPs() @@ -145,6 +219,15 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => { if (platform) { Object.defineProperty(process, 'platform', platform) } + if (hostPlatform === 'darwin') { + expect(readProcStatMock).not.toHaveBeenCalled() + } else { + expect(readProcStatMock).toHaveBeenCalled() + } + // Production catches read errors; assert each path resolved against the fixture at read time. + for (const [index, result] of readProcStatMock.mock.results.entries()) { + expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return') + } }) it('replaces ~all full captures with cheap ones once every pane holds an anchor', async () => { @@ -176,11 +259,12 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => { it('completion detection is byte-for-byte unchanged: exit, idle, and restart resolve identically with and without the cheap tier', async () => { const script = async (steadyState: boolean): Promise<(string | null)[][]> => { - resetProcessTableSnapshotForTests() - resetCheapProcessTableSnapshotForTests() + fullReader.resetProcessTableSnapshotForTests() + cheapReader.resetCheapProcessTableSnapshotForTests() panes.forEach((pane) => { pane.agent = true pane.agentStart = 'Thu Sep 3 16:02:05 2026' + pane.agentStartTicks = 1_400 }) const sessions = Array.from({ length: PANE_COUNT }, (_, pane) => createSession(pane)) const series: (string | null)[][] = [] @@ -192,6 +276,7 @@ describe('cheap-tier ps scan volume at 8 idle agent panes over 60s', () => { if (tick === 12) { panes[2].agent = true // ...and is restarted with a new start time panes[2].agentStart = 'Thu Sep 3 16:30:00 2026' + panes[2].agentStartTicks = 2_000 } if (tick === 20) { panes[6].agent = false diff --git a/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts b/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts index dc91c8918a22..d624be84eab7 100644 --- a/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts +++ b/src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts @@ -1,3 +1,16 @@ +import type * as FsPromises from 'node:fs/promises' +import type { Session } from './session' +import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names' +import type * as FullReader from '../../shared/process-table-snapshot-reader' +import { + createProcessTablePsFixture, + type PsFixtureRow +} from '../../shared/process-table-ps-test-fixture' +import type * as CheapReader from '../../shared/cheap-process-table-snapshot-reader' +import type * as TrackerModule from './pty-subprocess/foreground-process-tracker' +import type * as Inspection from './terminal-host-process-inspection' +import type { TerminalHostInspectionTier } from './terminal-host-process-inspection' +import type * as AnchorModule from './terminal-host-steady-state-anchor' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Two seams because the two tiers spawn differently: the full evidence reader still forks @@ -9,15 +22,25 @@ const { execFileMock, runProcessMock } = vi.hoisted(() => ({ vi.mock('node:child_process', () => ({ execFile: execFileMock })) vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) -import { resetCheapProcessTableSnapshotForTests } from '../../shared/cheap-process-table-snapshot-reader' -import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' -import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker' -import { - inspectTerminalHostProcess, - type TerminalHostInspectionTier -} from './terminal-host-process-inspection' -import { getSteadyStateAnchor } from './terminal-host-steady-state-anchor' -import type { Session } from './session' +// Inject /dev data without bypassing the production translator. +vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => { + const original = await importOriginal() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: async (path: string) => readProcStatMock(path) +})) + +let cheapReader: typeof CheapReader +let fullReader: typeof FullReader +let trackerModule: typeof TrackerModule +let inspection: typeof Inspection +let anchorModule: typeof AnchorModule const SHELL_PID = 4242 const AGENT_PID = 4300 @@ -26,60 +49,99 @@ const START_AGENT = 'Thu Sep 3 16:02:05 2026' type Table = { agent: 'claude' | 'stopped' | 'gone' | 'replaced'; children?: number } -/** One host table rendered in both column sets, so each fork answers by the args it asked for. */ -function renderTable(table: Table): { full: string; cheap: string } { +const forks = { full: 0, cheap: 0 } +let table: Table = { agent: 'claude' } + +function processRows(): PsFixtureRow[] { const shellTpgid = table.agent === 'claude' || table.agent === 'replaced' ? AGENT_PID : SHELL_PID - const shellStat = shellTpgid === SHELL_PID ? 'Ss+' : 'Ss' - const rows: { cheap: string; full: string }[] = [ + const rows: PsFixtureRow[] = [ { - cheap: `${SHELL_PID} 1 ${SHELL_PID} ${shellTpgid} ${shellStat} ${START_SHELL}`, - full: `${SHELL_PID} 1 ${SHELL_PID} ${shellTpgid} ${shellStat} ttys004 ${START_SHELL} -zsh` + pid: SHELL_PID, + ppid: 1, + pgid: SHELL_PID, + tpgid: shellTpgid, + stat: shellTpgid === SHELL_PID ? 'Ss+' : 'Ss', + terminalMinor: 4, + startTime: START_SHELL, + startTicks: 1_000, + command: '-zsh' }, { - cheap: `9000 1 9000 9000 Ss+ Thu Sep 3 12:00:00 2026`, - full: `9000 1 9000 9000 Ss+ ttys009 Thu Sep 3 12:00:00 2026 -zsh` + pid: 9000, + ppid: 1, + pgid: 9000, + tpgid: 9000, + stat: 'Ss+', + terminalMinor: 9, + startTime: 'Thu Sep 3 12:00:00 2026', + startTicks: 500, + command: '-zsh' } ] if (table.agent !== 'gone') { - const stat = table.agent === 'stopped' ? 'T' : 'S+' - const start = table.agent === 'replaced' ? 'Thu Sep 3 16:30:00 2026' : START_AGENT rows.push({ - cheap: `${AGENT_PID} ${SHELL_PID} ${AGENT_PID} ${shellTpgid} ${stat} ${start}`, - full: `${AGENT_PID} ${SHELL_PID} ${AGENT_PID} ${shellTpgid} ${stat} ttys004 ${start} node /usr/local/bin/claude` + pid: AGENT_PID, + ppid: SHELL_PID, + pgid: AGENT_PID, + tpgid: shellTpgid, + stat: table.agent === 'stopped' ? 'T' : 'S+', + terminalMinor: 4, + startTime: table.agent === 'replaced' ? 'Thu Sep 3 16:30:00 2026' : START_AGENT, + startTicks: table.agent === 'replaced' ? 2_000 : 1_400, + command: 'node /usr/local/bin/claude' }) for (let i = 0; i < (table.children ?? 0); i += 1) { - const pid = AGENT_PID + 10 + i rows.push({ - cheap: `${pid} ${AGENT_PID} ${AGENT_PID} ${shellTpgid} S+ Thu Sep 3 16:05:0${i} 2026`, - full: `${pid} ${AGENT_PID} ${AGENT_PID} ${shellTpgid} S+ ttys004 Thu Sep 3 16:05:0${i} 2026 rg --files` + pid: AGENT_PID + 10 + i, + ppid: AGENT_PID, + pgid: AGENT_PID, + tpgid: shellTpgid, + stat: 'S+', + terminalMinor: 4, + startTime: `Thu Sep 3 16:05:0${i} 2026`, + startTicks: 1_500 + i, + command: 'rg --files' }) } } - return { - full: `${rows.map((r) => r.full).join('\n')}\n`, - cheap: `${rows.map((r) => r.cheap).join('\n')}\n` - } + return rows } - -const forks = { full: 0, cheap: 0 } -let table: Table = { agent: 'claude' } +const psFixture = createProcessTablePsFixture(processRows) +const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path)) function installPs(): void { - execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { - expect(args[1]).toContain('command=') - forks.full += 1 - ;(cb as (err: unknown, r: { stdout: string; stderr: string }) => void)(null, { - stdout: renderTable(table).full, - stderr: '' - }) - }) - runProcessMock.mockImplementation(async (spec: { args: readonly string[] }) => { - expect(spec.args[1]).not.toContain('command=') + execFileMock.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + callback: (err: unknown, r: { stdout: string; stderr: string }) => void + ) => { + expect(cmd).toBe('ps') + expect(args).toEqual( + process.platform === 'darwin' + ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command='] + : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command='] + ) + forks.full += 1 + callback(null, { + stdout: psFixture.render(args[1]), + stderr: '' + }) + } + ) + runProcessMock.mockImplementation(async (spec: { program: string; args: readonly string[] }) => { + expect(spec.program).toBe('ps') + expect(spec.args).toEqual( + process.platform === 'darwin' + ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,lstart='] + : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat='] + ) forks.cheap += 1 return { code: 0, signal: null, - stdout: renderTable(table).cheap, + stdout: psFixture.render(spec.args[1]), stderr: '', timedOut: false } @@ -88,7 +150,7 @@ function installPs(): void { function createSession(processName: () => string): Session { let dead = false - const tracker = createPtyForegroundProcessTracker({ + const tracker = trackerModule.createPtyForegroundProcessTracker({ process: { pid: SHELL_PID, get process() { @@ -120,10 +182,10 @@ async function inspect( options: { steadyState?: boolean; expectedIncarnationId?: string } = {} ): Promise<{ tier: TerminalHostInspectionTier - result: Awaited> + result: Awaited> }> { let tier: TerminalHostInspectionTier = 'full' - const result = await inspectTerminalHostProcess({ + const result = await inspection.inspectTerminalHostProcess({ sessionId: 'wt-1:pane-1', session, ...options, @@ -147,19 +209,27 @@ async function advance(ms: number): Promise { vi.setSystemTime(Date.now() + ms) } -describe('daemon cheap-tier process inspection', () => { +describe.each(['darwin', 'linux'])('daemon cheap-tier process inspection (%s)', (hostPlatform) => { let platform: PropertyDescriptor | undefined - beforeEach(() => { + beforeEach(async () => { execFileMock.mockReset() + readProcStatMock.mockClear() runProcessMock.mockReset() - resetProcessTableSnapshotForTests() - resetCheapProcessTableSnapshotForTests() forks.full = 0 forks.cheap = 0 table = { agent: 'claude' } platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + // Column constants are chosen at import time, so each host needs a fresh module graph. + vi.resetModules() + fullReader = await import('../../shared/process-table-snapshot-reader') + fullReader.resetProcessTableSnapshotForTests() + cheapReader = await import('../../shared/cheap-process-table-snapshot-reader') + cheapReader.resetCheapProcessTableSnapshotForTests() + trackerModule = await import('./pty-subprocess/foreground-process-tracker') + inspection = await import('./terminal-host-process-inspection') + anchorModule = await import('./terminal-host-steady-state-anchor') vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(1_000_000) installPs() @@ -170,6 +240,13 @@ describe('daemon cheap-tier process inspection', () => { if (platform) { Object.defineProperty(process, 'platform', platform) } + if (hostPlatform === 'darwin') { + expect(readProcStatMock).not.toHaveBeenCalled() + } + // Production catches read errors; assert each path resolved against the fixture at read time. + for (const [index, result] of readProcStatMock.mock.results.entries()) { + expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return') + } }) /** Bring a session to a recognized anchor the way production does: one full cadence tick. */ @@ -179,7 +256,11 @@ describe('daemon cheap-tier process inspection', () => { await settle() expect(first.tier).toBe('full') expect(first.result.foregroundProcess).toBe('claude') - expect(getSteadyStateAnchor(session)?.agentName).toBe('claude') + expect(first.result.foregroundProcessEvidence).toMatchObject({ + verdict: 'live', + processName: 'claude' + }) + expect(anchorModule.getSteadyStateAnchor(session)?.agentName).toBe('claude') return session } @@ -209,6 +290,9 @@ describe('daemon cheap-tier process inspection', () => { } expect(forks.cheap).toBe(4) expect(forks.full).toBe(fullBefore) + if (hostPlatform === 'linux') { + expect(readProcStatMock).toHaveBeenCalled() + } }) it('a request without steadyState (old client, remote client, restore path) always gets the full capture with evidence', async () => { @@ -286,7 +370,7 @@ describe('daemon cheap-tier process inspection', () => { table = { agent: 'gone' } await advance(2_000) await inspect(session, { steadyState: true }) - expect(getSteadyStateAnchor(session)).toBeNull() + expect(anchorModule.getSteadyStateAnchor(session)).toBeNull() // Back with a new agent, but the pane must re-anchor via a FULL capture first. table = { agent: 'claude' } await advance(2_000) diff --git a/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts b/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts index 178ff34660d9..6c0269d65d42 100644 --- a/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts +++ b/src/main/providers/agent-foreground-process-ps-scan-volume.test.ts @@ -4,10 +4,18 @@ // Drives the REAL local call site (`resolveAgentForegroundProcess`) under the // documented agent-completion cadence (ACTIVE_POLL_INTERVAL_MS = 750ms in // agent-completion-coordinator.ts) across several concurrently-inspecting agent -// panes, and counts how many `ps -axo pid=,ppid=,stat=,command=` scans actually +// panes, and counts how many full process-table scans actually // spawn. Pre-fix the call site forked one `ps` per pane per tick; with the // shared snapshot cache the scans collapse to ~one per tick regardless of pane // count, while each pane still resolves the same foreground identity. +import type * as FsPromises from 'node:fs/promises' +import type * as DarwinTerminalNames from '../../shared/darwin-terminal-names' +import type * as FullReader from '../../shared/process-table-snapshot-reader' +import { + createProcessTablePsFixture, + type PsFixtureRow +} from '../../shared/process-table-ps-test-fixture' +import type * as Foreground from './agent-foreground-process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { execFileMock, psScanCount } = vi.hoisted(() => ({ @@ -17,8 +25,22 @@ const { execFileMock, psScanCount } = vi.hoisted(() => ({ vi.mock('child_process', () => ({ execFile: execFileMock })) -import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' -import { resolveAgentForegroundProcess } from './agent-foreground-process' +// Inject /dev data without bypassing the production translator. +vi.mock('../../shared/darwin-terminal-names', async (importOriginal) => { + const original = await importOriginal() + return { + nameDarwinTerminals: (stdout: string, _deps: unknown, signal?: AbortSignal) => + psFixture.nameTerminals(original.nameDarwinTerminals, stdout, signal) + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: async (path: string) => readProcStatMock(path) +})) + +let fullReader: typeof FullReader +let foreground: typeof Foreground const ACTIVE_POLL_INTERVAL_MS = 750 // mirrors agent-completion-coordinator.ts const PANE_COUNT = 6 // reporter saw it with "only three projects" -> several agent panes @@ -30,33 +52,63 @@ const shellPid = (pane: number): number => 100 + pane * 1000 // A real `ps` returns the whole system, so one shared snapshot must contain // every pane's shell + foreground codex child. Each pane resolves its own // agent from the single scan. -const PS_OUTPUT = Array.from({ length: PANE_COUNT }, (_, pane) => { - const shell = shellPid(pane) - return [ - `${shell} 99 Ss bash -i`, - `${shell + 1} ${shell} S+ node /Users/dev/.nvm/versions/node/bin/codex` - ].join('\n') -}).join('\n') +function processRows(): PsFixtureRow[] { + return Array.from({ length: PANE_COUNT }, (_, pane) => { + const shell = shellPid(pane) + const agent = shell + 1 + const base = { terminalMinor: pane, startTime: 'Thu Sep 3 16:02:01 2026', startTicks: 1_000 } + return [ + { ...base, pid: shell, ppid: 99, pgid: shell, tpgid: agent, stat: 'Ss', command: 'bash -i' }, + { + ...base, + pid: agent, + ppid: shell, + pgid: agent, + tpgid: agent, + stat: 'S+', + command: 'node /Users/dev/.nvm/versions/node/bin/codex' + } + ] + }).flat() +} +const psFixture = createProcessTablePsFixture(processRows) +const readProcStatMock = vi.fn((path: string) => psFixture.readProcStat(path)) function installCountingPsMock(): void { - execFileMock.mockImplementation((cmd: string, args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - if (cmd === 'ps' && Array.isArray(args) && args.includes('-axo')) { + execFileMock.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + callback: (err: unknown, result: { stdout: string; stderr: string }) => void + ) => { + expect(cmd).toBe('ps') + expect(args[0]).toBe('-axo') + expect(args[1]).toBe( + process.platform === 'darwin' + ? 'pid=,ppid=,pgid=,tpgid=,stat=,tdev=,lstart=,command=' + : 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command=' + ) psScanCount.value += 1 + callback(null, { stdout: psFixture.render(args[1]), stderr: '' }) } - callback(null, { stdout: PS_OUTPUT, stderr: '' }) - }) + ) } -describe('#6288 agent foreground inspection ps-scan volume', () => { +describe.each(['darwin', 'linux'])('#6288 foreground ps scan volume (%s)', (hostPlatform) => { let platform: PropertyDescriptor | undefined - beforeEach(() => { + beforeEach(async () => { execFileMock.mockReset() - resetProcessTableSnapshotForTests() + readProcStatMock.mockClear() psScanCount.value = 0 platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + Object.defineProperty(process, 'platform', { configurable: true, value: hostPlatform }) + // Column constants are chosen at import time, so each host needs a fresh module graph. + vi.resetModules() + fullReader = await import('../../shared/process-table-snapshot-reader') + fullReader.resetProcessTableSnapshotForTests() + foreground = await import('./agent-foreground-process') vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(0) }) @@ -66,6 +118,15 @@ describe('#6288 agent foreground inspection ps-scan volume', () => { if (platform) { Object.defineProperty(process, 'platform', platform) } + if (hostPlatform === 'darwin') { + expect(readProcStatMock).not.toHaveBeenCalled() + } else { + expect(readProcStatMock).toHaveBeenCalled() + } + // Production catches read errors; assert each path resolved against the fixture at read time. + for (const [index, result] of readProcStatMock.mock.results.entries()) { + expect(result.type, readProcStatMock.mock.calls[index][0]).toBe('return') + } }) it('bounds ps scans by poll ticks, not by pane count, while resolving every pane', async () => { @@ -76,7 +137,7 @@ describe('#6288 agent foreground inspection ps-scan volume', () => { // All panes inspect concurrently within the tick (worst case for a busy relay). const resolved = await Promise.all( Array.from({ length: PANE_COUNT }, (_, pane) => - resolveAgentForegroundProcess(shellPid(pane), 'node') + foreground.resolveAgentForegroundProcess(shellPid(pane), 'node') ) ) // Caching must not change the answer: every pane still resolves the agent. @@ -87,7 +148,7 @@ describe('#6288 agent foreground inspection ps-scan volume', () => { // Pre-fix this equals totalInspections (one scan per inspection). With the // shared cache, concurrent panes within a tick share one scan and the 500ms // TTL forces a fresh scan each new 750ms tick -> ~one scan per tick. - expect(psScanCount.value).toBeLessThanOrEqual(TICKS + 1) + expect(psScanCount.value).toBe(TICKS) expect(psScanCount.value).toBeLessThan(totalInspections / 2) }) }) diff --git a/src/shared/darwin-process-table-capture.test.ts b/src/shared/darwin-process-table-capture.test.ts new file mode 100644 index 000000000000..76d5433cfafd --- /dev/null +++ b/src/shared/darwin-process-table-capture.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { naming } = vi.hoisted(() => ({ naming: vi.fn() })) +vi.mock('./darwin-terminal-names', () => ({ nameDarwinTerminals: naming })) + +import { + captureDarwinProcessTable, + resetDarwinProcessTableCaptureForTests +} from './darwin-process-table-capture' +import { PS_ARGS } from './process-table-snapshot' + +const raw = '123 1 123 123 S+ 16/9 Fri Oct 9 12:34:56 2026 /bin/zsh\n' +const canonical = raw.replace('16/9', 'ttys009') +const validate = (stdout: string): string => stdout +const capture = vi.fn(async (args: readonly string[]) => + args[1].includes('tdev=') ? raw : canonical +) + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resetDarwinProcessTableCaptureForTests() +}) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('Darwin translation deadline', () => { + it('rejects on deadline, refuses new inventory jobs until drain, and discards late names', async () => { + let finish!: (stdout: string) => void + const pending = new Promise((resolve) => { + finish = resolve + }) + let signal: AbortSignal | undefined + naming.mockImplementation((_stdout: string, _deps: undefined, current: AbortSignal) => { + signal = current + return pending + }) + + const first = captureDarwinProcessTable(capture, validate, 100) + const rejected = expect(first).rejects.toThrow('capture_over_budget') + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(signal?.aborted).toBe(true) + expect(capture.mock.calls.map(([args]) => args)).toEqual([ + ['-axo', PS_ARGS[1].replace('tty=', 'tdev=')] + ]) + for (let index = 0; index < 5; index++) { + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) + } + expect(naming).toHaveBeenCalledTimes(1) + + finish(raw.replace('16/9', 'stale-name')) + await pending + await expect(first).rejects.toThrow('capture_over_budget') + naming.mockResolvedValue(raw.replace('16/9', 'fresh-name')) + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe( + raw.replace('16/9', 'fresh-name') + ) + expect(naming).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it('only gives naming the budget left after ps completes', async () => { + let finish!: (stdout: string) => void + const pending = new Promise((resolve) => { + finish = resolve + }) + naming.mockReturnValue(pending) + const slowCapture = vi.fn(async (args: readonly string[]) => { + if (args[1].includes('tdev=')) { + await new Promise((resolve) => setTimeout(resolve, 80)) + return raw + } + return canonical + }) + const first = captureDarwinProcessTable(slowCapture, validate, 100) + const rejected = expect(first).rejects.toThrow('capture_over_budget') + await vi.advanceTimersByTimeAsync(99) + expect(slowCapture).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await rejected + expect(slowCapture).toHaveBeenCalledTimes(1) + finish(raw) + await pending + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not start naming after the capture has consumed its budget', async () => { + const lateCapture = vi.fn(async (args: readonly string[]) => { + if (args[1].includes('tdev=')) { + await new Promise((resolve) => setTimeout(resolve, 100)) + return raw + } + return canonical + }) + + const first = captureDarwinProcessTable(lateCapture, validate, 100) + const rejected = expect(first).rejects.toThrow('capture_over_budget') + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(naming).not.toHaveBeenCalled() + }) + + it('only gives the canonical fallback the time left after an inventory error', async () => { + naming.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 80)) + throw new Error('directory failed') + }) + const boundedCapture = vi.fn(async (args: readonly string[], timeoutMs?: number) => { + expect(timeoutMs).toBe(args[1].includes('tdev=') ? 100 : 20) + return args[1].includes('tdev=') ? raw : canonical + }) + const result = captureDarwinProcessTable(boundedCapture, validate, 100) + await vi.advanceTimersByTimeAsync(80) + + expect(await result).toBe(canonical) + expect(boundedCapture).toHaveBeenCalledTimes(2) + }) + + it('rejects late successful names even when Promise completion beats the overdue timer', async () => { + let clock = 0 + vi.spyOn(performance, 'now').mockImplementation(() => clock) + naming.mockImplementation(async () => { + await Promise.resolve() + clock = 101 + return canonical + }) + + await expect(captureDarwinProcessTable(capture, validate, 100)).rejects.toThrow( + 'capture_over_budget' + ) + expect(capture).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('rejects expiry during output validation without another capture', async () => { + let clock = 0 + vi.spyOn(performance, 'now').mockImplementation(() => clock) + naming.mockResolvedValue(canonical) + const slowValidation = (stdout: string): string => { + clock = 101 + return stdout + } + + await expect(captureDarwinProcessTable(capture, slowValidation, 100)).rejects.toThrow( + 'capture_over_budget' + ) + expect(capture).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/shared/darwin-process-table-capture.ts b/src/shared/darwin-process-table-capture.ts new file mode 100644 index 000000000000..6d8c36404dd9 --- /dev/null +++ b/src/shared/darwin-process-table-capture.ts @@ -0,0 +1,94 @@ +import { nameDarwinTerminals } from './darwin-terminal-names' +import { errorMessage } from './error-message' +import { ProcessTableCaptureError, PS_ARGS } from './process-table-snapshot' +import { withTimeout } from './promise-timeout-fallback' + +let deviceColumnUnsupported = false +let terminalNamingInFlight: Promise | null = null + +export async function captureDarwinProcessTable( + capture: (args: readonly string[], timeoutMs?: number) => Promise, + validate: (stdout: string) => string, + budgetMs: number +): Promise { + const startedAt = performance.now() + const remainingBudget = (): number => { + const remaining = Math.floor(budgetMs - (performance.now() - startedAt)) + if (remaining <= 0) { + throw new ProcessTableCaptureError('capture_over_budget') + } + return remaining + } + const canonicalCapture = (): Promise => capture(PS_ARGS, remainingBudget()) + if (deviceColumnUnsupported || terminalNamingInFlight) { + return canonicalCapture() + } + let captured: string + try { + captured = await capture(['-axo', PS_ARGS[1].replace('tty=', 'tdev=')], remainingBudget()) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + (error.code !== 1 && error.code !== 2) || + ('killed' in error && error.killed === true) || + !/^(?:ps: tdev: keyword not found|error: unknown user-defined format specifier "tdev")$/m.test( + errorMessage(error) + ) + ) { + throw error + } + deviceColumnUnsupported = true + return canonicalCapture() + } + remainingBudget() + if (terminalNamingInFlight) { + return canonicalCapture() + } + const controller = new AbortController() + const pending = nameDarwinTerminals(captured, undefined, controller.signal) + terminalNamingInFlight = pending + // Uncancelable filesystem calls keep the slot until they drain. + const clearNaming = (): void => { + if (terminalNamingInFlight === pending) { + terminalNamingInFlight = null + } + } + void pending.then(clearNaming, clearNaming) + let remainingMs: number + try { + remainingMs = remainingBudget() + } catch (error) { + controller.abort() + throw error + } + const named = await withTimeout<{ value: string } | { error: unknown } | null>( + pending.then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ), + remainingMs, + null + ) + if (named === null) { + controller.abort() + throw new ProcessTableCaptureError('capture_over_budget') + } + remainingBudget() + if ('error' in named) { + return canonicalCapture() + } + let validated: string + try { + validated = validate(named.value) + } catch { + return canonicalCapture() + } + remainingBudget() + return validated +} + +export function resetDarwinProcessTableCaptureForTests(): void { + deviceColumnUnsupported = false + terminalNamingInFlight = null +} diff --git a/src/shared/darwin-process-table-inventory-drain.test.ts b/src/shared/darwin-process-table-inventory-drain.test.ts new file mode 100644 index 000000000000..19dc21817e05 --- /dev/null +++ b/src/shared/darwin-process-table-inventory-drain.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { lstatMock, opendirMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + opendirMock: vi.fn() +})) +vi.mock('node:fs/promises', () => ({ lstat: lstatMock, opendir: opendirMock })) + +import { + captureDarwinProcessTable, + resetDarwinProcessTableCaptureForTests +} from './darwin-process-table-capture' + +const raw = '123 1 123 123 S+ 16/9 Fri Oct 9 12:34:56 2026 /bin/zsh\n' +const canonical = raw.replace('16/9', 'ttys009') +const capture = vi.fn(async (args: readonly string[]) => + args[1].includes('tdev=') ? raw : canonical +) +const validate = (stdout: string): string => stdout +const candidatePath = '/dev/ttys009' +const missingCandidate = (): Promise => Promise.reject(new Error('ENOENT')) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((finish) => { + resolve = finish + }) + return { promise, resolve } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.resetAllMocks() + resetDarwinProcessTableCaptureForTests() +}) +afterEach(() => vi.useRealTimers()) + +describe('Darwin inventory drain admission', () => { + it('keeps a held stat tracked after an earlier invocation throws synchronously', async () => { + opendirMock.mockResolvedValue( + Array.from({ length: 4 }, (_, index) => ({ name: `entry-${index}` })) + ) + const pending = deferred<{ + rdev: bigint + isCharacterDevice: () => boolean + }>() + lstatMock.mockImplementation((path: string) => { + if (path === candidatePath) { + return missingCandidate() + } + if (lstatMock.mock.calls.length === 2) { + throw new Error('stat invocation failed') + } + return pending.promise + }) + const first = captureDarwinProcessTable(capture, validate, 100) + let settled = false + void first.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(false) + expect(lstatMock).toHaveBeenCalledTimes(3) + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) + expect(opendirMock).toHaveBeenCalledTimes(1) + + const stat = { rdev: 0x10000009n, isCharacterDevice: () => true } + lstatMock.mockResolvedValue(stat) + pending.resolve(stat) + expect(await first).toBe(raw.replace('16/9', 'entry-1')) + expect(lstatMock).toHaveBeenCalledTimes(3) + expect(vi.getTimerCount()).toBe(0) + }) + + it('holds admission until its one uncancelable stat drains and admits no later reads', async () => { + opendirMock.mockResolvedValue( + Array.from({ length: 12 }, (_, index) => ({ name: `entry-${index}` })) + ) + const pending = deferred<{ + rdev: bigint + isCharacterDevice: () => boolean + }>() + lstatMock.mockImplementation((path: string) => + path === candidatePath ? missingCandidate() : pending.promise + ) + + const first = captureDarwinProcessTable(capture, validate, 100) + const rejected = expect(first).rejects.toThrow('capture_over_budget') + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(lstatMock).toHaveBeenCalledTimes(2) + const stat = { rdev: 0x10000009n, isCharacterDevice: () => true } + + for (let index = 0; index < 5; index++) { + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) + } + expect(opendirMock).toHaveBeenCalledTimes(1) + expect(lstatMock).toHaveBeenCalledTimes(2) + + pending.resolve(stat) + await vi.advanceTimersByTimeAsync(0) + expect(lstatMock).toHaveBeenCalledTimes(2) + lstatMock.mockImplementation(async (path: string) => + path === candidatePath ? missingCandidate() : stat + ) + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe( + raw.replace('16/9', 'entry-0') + ) + expect(opendirMock).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it('holds admission while an expired pty candidate stat drains', async () => { + const pending = deferred<{ + rdev: bigint + isCharacterDevice: () => boolean + }>() + lstatMock.mockReturnValue(pending.promise) + + const first = captureDarwinProcessTable(capture, validate, 100) + const rejected = expect(first).rejects.toThrow('capture_over_budget') + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) + expect(lstatMock).toHaveBeenCalledTimes(1) + expect(opendirMock).not.toHaveBeenCalled() + + const stat = { rdev: 0x10000009n, isCharacterDevice: () => true } + pending.resolve(stat) + await vi.advanceTimersByTimeAsync(0) + lstatMock.mockResolvedValue(stat) + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(opendirMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('closes a directory that opens after expiry and holds admission until its close drains', async () => { + const close = deferred() + let closeStarted = false + async function* entries() { + try { + yield { name: 'ttys009' } + } finally { + closeStarted = true + await close.promise + } + } + const opened = deferred>() + opendirMock.mockReturnValue(opened.promise) + lstatMock.mockImplementation(missingCandidate) + + const first = captureDarwinProcessTable(capture, validate, 100) + const rejected = expect(first).rejects.toThrow('capture_over_budget') + await vi.advanceTimersByTimeAsync(100) + await rejected + opened.resolve(entries()) + await vi.advanceTimersByTimeAsync(0) + expect(closeStarted).toBe(true) + expect(lstatMock.mock.calls).toEqual([[candidatePath, { bigint: true }]]) + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) + expect(opendirMock).toHaveBeenCalledTimes(1) + + close.resolve() + await vi.advanceTimersByTimeAsync(0) + opendirMock.mockResolvedValue([{ name: 'fresh-name' }]) + lstatMock.mockImplementation(async (path: string) => + path === candidatePath + ? missingCandidate() + : { rdev: 0x10000009n, isCharacterDevice: () => true } + ) + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe( + raw.replace('16/9', 'fresh-name') + ) + expect(opendirMock).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/shared/darwin-terminal-device-stats.test.ts b/src/shared/darwin-terminal-device-stats.test.ts new file mode 100644 index 000000000000..528d72a19260 --- /dev/null +++ b/src/shared/darwin-terminal-device-stats.test.ts @@ -0,0 +1,48 @@ +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { lstatMock, opendirMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + opendirMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + get opendir() { + return opendirMock() + } +})) + +import { nameDarwinTerminals } from './darwin-terminal-names' + +const row = '123 1 123 123 S+ 128/124 Fri Oct 9 12:34:56 2026 /bin/zsh\n' + +afterEach(() => { + vi.resetAllMocks() +}) + +describe('Darwin device filesystem access', () => { + it('does not access the Darwin directory API during import or no-terminal captures', async () => { + expect(opendirMock).not.toHaveBeenCalled() + expect(await nameDarwinTerminals(row.replace('128/124', '??'))).toBe( + row.replace('128/124', '??') + ) + expect(opendirMock).not.toHaveBeenCalled() + }) + + it('reads exact bigint device metadata without following links', async () => { + opendirMock.mockReturnValue(async () => [{ name: 'terminal' }]) + lstatMock.mockImplementation(async (path: string) => { + if (path === join('/dev', 'ttys124')) { + throw new Error('ENOENT') + } + return { rdev: 0xffffffff8000007cn, isCharacterDevice: () => true } + }) + + expect(await nameDarwinTerminals(row)).toBe(row.replace('128/124', 'terminal')) + expect(lstatMock.mock.calls).toEqual([ + [join('/dev', 'ttys124'), { bigint: true }], + [join('/dev', 'terminal'), { bigint: true }] + ]) + }) +}) diff --git a/src/shared/darwin-terminal-names-real-ps.test.ts b/src/shared/darwin-terminal-names-real-ps.test.ts new file mode 100644 index 000000000000..36368ef8af99 --- /dev/null +++ b/src/shared/darwin-terminal-names-real-ps.test.ts @@ -0,0 +1,71 @@ +import { type ChildProcess, execFileSync, spawn } from 'node:child_process' +import { lstat } from 'node:fs/promises' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { nameDarwinTerminals } from './darwin-terminal-names' +import { PS_ARGS } from './process-table-snapshot' + +const RAW_ARGS = ['-axo', PS_ARGS[1].replace('tty=', 'tdev=')] + +function rowsByPid(stdout: string): Map { + return new Map( + stdout + .split('\n') + .filter((line) => line.trim()) + .map((line) => [line.trim().split(/\s+/)[0], line]) + ) +} + +function ttyByPid(stdout: string): Map { + return new Map([...rowsByPid(stdout)].map(([pid, row]) => [pid, row.trim().split(/\s+/)[5]])) +} + +describe.runIf(process.platform === 'darwin')('nameDarwinTerminals against real ps', () => { + let holder: ChildProcess | undefined + let terminalPid = '' + + beforeAll(async () => { + // `script` gives its child a real pty, so a headless host still has one named terminal. + holder = spawn('script', ['-q', '/dev/null', 'sleep', '30'], { stdio: 'ignore' }) + for (let attempt = 0; attempt < 100 && !terminalPid; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)) + try { + terminalPid = execFileSync('pgrep', ['-P', String(holder.pid)], { encoding: 'utf8' }).trim() + } catch { + // No child yet. + } + } + }) + afterAll(() => { + holder?.kill() + }) + + it('names a real pty through the direct lookup without scanning /dev', async () => { + const row = rowsByPid(execFileSync('ps', RAW_ARGS, { encoding: 'utf8' })).get(terminalPid) + expect(row).toBeDefined() + const named = await nameDarwinTerminals(`${row}\n`, { + openDirectory: () => Promise.reject(new Error('direct lookup must not scan /dev')), + readDevice: (path) => lstat(path, { bigint: true }) + }) + expect(ttyByPid(named).get(terminalPid)).toBe( + ttyByPid(execFileSync('ps', [...PS_ARGS], { encoding: 'utf8' })).get(terminalPid) + ) + expect(ttyByPid(named).get(terminalPid)).toMatch(/^ttys\d{3,}$/) + }) + + it('matches ps terminal names for every process present in both captures', async () => { + // Retry once: a terminal can close between the two ps runs. + let mismatches: string[] = [] + for (let attempt = 0; attempt < 2; attempt += 1) { + const raw = execFileSync('ps', RAW_ARGS, { encoding: 'utf8' }) + const canonical = ttyByPid(execFileSync('ps', [...PS_ARGS], { encoding: 'utf8' })) + const named = ttyByPid(await nameDarwinTerminals(raw)) + mismatches = [...named] + .filter(([pid, tty]) => canonical.has(pid) && canonical.get(pid) !== tty) + .map(([pid, tty]) => `${pid}: ${tty} != ${canonical.get(pid)}`) + if (mismatches.length === 0) { + break + } + } + expect(mismatches).toEqual([]) + }) +}) diff --git a/src/shared/darwin-terminal-names.test.ts b/src/shared/darwin-terminal-names.test.ts new file mode 100644 index 000000000000..313e6f809779 --- /dev/null +++ b/src/shared/darwin-terminal-names.test.ts @@ -0,0 +1,500 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { nameDarwinTerminals } from './darwin-terminal-names' +import { PS_ARGS } from './process-table-snapshot' + +type DeviceEntry = { + name: string + rdev: bigint + kind?: 'character' | 'symlink' | 'file' + delayMs?: number + error?: Error +} + +class TestDeviceDirectory { + entries: DeviceEntry[] = [] + openedPaths: string[] = [] + readPaths: string[] = [] + closedDirectories = 0 + peakReads = 0 + openError?: Error + enumerationError?: Error + private activeReads = 0 + + openDirectory = async (path: string): Promise> => { + this.openedPaths.push(path) + if (this.openError) { + throw this.openError + } + return this.enumerate() + } + + private async *enumerate(): AsyncGenerator<{ name: string }> { + try { + for (const entry of this.entries) { + yield { name: entry.name } + if (this.enumerationError) { + throw this.enumerationError + } + } + } finally { + this.closedDirectories += 1 + } + } + + readDevice = async ( + path: string + ): Promise<{ rdev: bigint; isCharacterDevice: () => boolean }> => { + this.readPaths.push(path) + const entry = this.entries.find((candidate) => join('/dev', candidate.name) === path) + if (!entry) { + throw new Error('device disappeared') + } + this.activeReads += 1 + this.peakReads = Math.max(this.peakReads, this.activeReads) + try { + await new Promise((resolve) => setTimeout(resolve, entry.delayMs ?? 1)) + if (entry.error) { + throw entry.error + } + return { + rdev: entry.rdev, + isCharacterDevice: () => (entry.kind ?? 'character') === 'character' + } + } finally { + this.activeReads -= 1 + } + } +} + +function darwinRow( + device: string, + command = '/bin/zsh', + start = 'Fri Oct 9 12:34:56 2026' +): string { + return ` 123 1 123 123 S+ ${device} ${start} ${command}\r\n` +} + +describe('nameDarwinTerminals', () => { + it.each([ + 'Fri Oct 9 12:34:56 2026', + '金 10月 9 12:34:56 2026', + 'Fr 9. Okt 12:34:56 2026', + 'Пт окт 9 12:34:56 2026' + ])('preserves localized start markers and complete command whitespace (%s)', async (start) => { + const directory = new TestDeviceDirectory() + directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }] + const command = 'node\t--date="2026 10 09"\t\t--device=16/9 --flag' + + expect(await nameDarwinTerminals(darwinRow('16/9', command, start), directory)).toBe( + darwinRow('ttys009', command, start) + ) + expect(directory.openedPaths).toEqual([]) + expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) + }) + + it('preserves short and nonstandard opaque tails including date-only rows', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }] + const tails = ['truncated', '2026年10月9日 12:34:56 node', 'Fri Oct 9 12:34:56 2026'] + const capture = tails.map((tail) => darwinRow('16/9', '', tail)).join('') + expect(await nameDarwinTerminals(capture, directory)).toBe( + tails.map((tail) => darwinRow('ttys009', '', tail)).join('') + ) + }) + + it('discovers a previously unknown device on the next capture', async () => { + const directory = new TestDeviceDirectory() + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??')) + + directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }] + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009')) + directory.entries = [] + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??')) + directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }] + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009')) + expect(directory.openedPaths).toEqual(['/dev', '/dev']) + }) + + it('updates a known device name after a rename between captures', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [{ name: 'cu.before', rdev: 0x1600002an }] + expect(await nameDarwinTerminals(darwinRow('22/42'), directory)).toBe(darwinRow('cu.before')) + + directory.entries = [{ name: 'cu.after', rdev: 0x1600002an }] + expect(await nameDarwinTerminals(darwinRow('22/42'), directory)).toBe(darwinRow('cu.after')) + expect(directory.readPaths).toEqual( + ['ttys042', 'cu.before', 'ttys042', 'cu.after'].map((name) => join('/dev', name)) + ) + }) + + it('chooses the first directory alias despite lexical and completion order', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'z-first', rdev: 0x10000009n, delayMs: 20 }, + { name: 'a-later', rdev: 0x10000009n, delayMs: 0 } + ] + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('z-first')) + }) + + it('scans until a serial name resolves while excluding links and files', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'tty-link', rdev: 0x1600002an, kind: 'symlink' }, + { name: 'tty-file', rdev: 0x1600002an, kind: 'file' }, + { name: 'null', rdev: 0x03000002n }, + { name: 'cu.serial-adapter', rdev: 0x1600002an }, + { name: 'unrelated-entry', rdev: 0n, kind: 'file' } + ] + + expect(await nameDarwinTerminals(darwinRow('22/42'), directory)).toBe( + darwinRow('cu.serial-adapter') + ) + expect(directory.readPaths).toEqual( + ['ttys042', 'tty-link', 'tty-file', 'null', 'cu.serial-adapter'].map((name) => + join('/dev', name) + ) + ) + }) + + it('stops scheduling stats after cancellation while draining all active reads', async () => { + const directory = new TestDeviceDirectory() + directory.entries = Array.from({ length: 20 }, (_, index) => ({ + name: `entry-${index}`, + rdev: BigInt(index), + delayMs: 10 + })) + const controller = new AbortController() + const named = nameDarwinTerminals(darwinRow('16/9'), directory, controller.signal) + const rejection = expect(named).rejects.toThrow() + await new Promise((resolve) => setTimeout(resolve, 2)) + expect(directory.readPaths).toHaveLength(2) + controller.abort() + await rejection + + expect(directory.readPaths).toHaveLength(2) + expect(directory.closedDirectories).toBe(1) + expect(directory.peakReads).toBe(1) + }) + + it('bounds large inventories and closes the directory without admitting device reads', async () => { + const directory = new TestDeviceDirectory() + directory.entries = Array.from({ length: 20_000 }, (_, index) => ({ + name: `entry-${index}-${'x'.repeat(240)}`, + rdev: 0n + })) + + await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow( + 'Directory listing is too large' + ) + expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) + expect(directory.closedDirectories).toBe(1) + }) + + it('skips individual stat failures and uses the next direct character alias', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'gone', rdev: 0x10000009n, error: new Error('ENOENT') }, + { name: 'denied', rdev: 0x10000009n, error: new Error('EACCES') }, + { name: 'ttys009', rdev: 0x10000009n } + ] + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009')) + }) + + it('rejects directory open failures so the reader can fall back to ordinary tty ps', async () => { + const directory = new TestDeviceDirectory() + directory.openError = new Error('cannot open /dev') + + await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow( + 'cannot open /dev' + ) + expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) + }) + + it('closes an interrupted directory iterator and rejects partial inventories', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [{ name: 'cu.device', rdev: 0x10000009n }] + directory.enumerationError = new Error('directory enumeration failed') + + await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow( + 'directory enumeration failed' + ) + expect(directory.closedDirectories).toBe(1) + expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) + }) + + it('leaves valid no-terminal rows unchanged without a scan', async () => { + const directory = new TestDeviceDirectory() + directory.openError = new Error('must not open') + const capture = `\r\n${darwinRow('??', 'node --device=16/9')}\n` + + expect(await nameDarwinTerminals(capture, directory)).toBe(capture) + expect(directory.openedPaths).toEqual([]) + }) + + it.each([ + '123 1 S+ node 16/9\n', + 'not-pid 1 123 123 S+ 16/9 tail\n', + '123 nope 123 123 S+ 16/9 tail\n', + '123 1 nope 123 S+ 16/9 tail\n', + '123 1 123 nope S+ 16/9 tail\n', + 'PID PPID PGID TPGID STAT TDEV STARTED COMMAND\n', + '123 1 123 123 S+ 16/9 \t\r\n' + ])('rejects unsupported row framing without a device scan (%s)', async (row) => { + const directory = new TestDeviceDirectory() + await expect(nameDarwinTerminals(darwinRow('16/9') + row, directory)).rejects.toThrow( + 'malformed_darwin_device_row' + ) + expect(directory.openedPaths).toEqual([]) + }) + + it('maps malformed or out-of-range device tokens to the ordinary unknown marker', async () => { + const directory = new TestDeviceDirectory() + const capture = ['not-a-device', '-1/0', '1/-1', '256/0', '1/16777216', '1.0/1', '16,9'] + .map((device) => darwinRow(device)) + .join('') + expect(await nameDarwinTerminals(capture, directory)).toBe(darwinRow('??').repeat(7)) + expect(directory.openedPaths).toEqual([]) + }) + + it('names zero-padded device tokens by their numeric device', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }] + + expect(await nameDarwinTerminals(darwinRow('016/009'), directory)).toBe(darwinRow('ttys009')) + expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) + }) + + it('rejects undecodable filenames and closes the directory before any stat reads', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'cu.device', rdev: 0x10000009n }, + { name: 'serial-\uFFFD', rdev: 0x1600002an } + ] + await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow( + 'undecodable_device_name' + ) + expect(directory.closedDirectories).toBe(1) + expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) + }) + + it('accepts signed and sign-extended device numbers while ignoring arbitrary wide rdev', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'signed-device', rdev: -2147483525n }, + { name: 'sign-extended-device', rdev: 0xffffffff8000007cn }, + { name: 'high-device', rdev: 0xfffffffen }, + { name: 'wide-device', rdev: 0x1001600002an } + ] + const capture = ['128/123', '128/124', '255/16777214', '22/42'] + .map((device) => darwinRow(device)) + .join('') + + expect(await nameDarwinTerminals(capture, directory)).toBe( + ['signed-device', 'sign-extended-device', 'high-device', '??'] + .map((device) => darwinRow(device)) + .join('') + ) + }) + + it.each(['x'.repeat(255), `${'é'.repeat(127)}x`])( + 'uses the Apple buffer limit for the first alias (%s)', + async (oversizedName) => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: oversizedName, rdev: 0x10000009n }, + { name: 'later-short-alias', rdev: 0x10000009n } + ] + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??')) + } + ) + + it('accepts a 254-byte multibyte alias without stripping its basename', async () => { + const directory = new TestDeviceDirectory() + const name = 'é'.repeat(127) + directory.entries = [{ name, rdev: 0x10000009n }] + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow(name)) + }) + + it('admits one device read at a time while inspecting the full directory', async () => { + const directory = new TestDeviceDirectory() + directory.entries = Array.from({ length: 21 }, (_, index) => ({ + name: `entry-${index}`, + rdev: BigInt(index), + delayMs: 1 + })) + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??')) + expect(directory.peakReads).toBe(1) + expect(directory.readPaths).toHaveLength(directory.entries.length + 1) + }) + + it('resolves a pty with one verified candidate stat and no directory scan', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }] + directory.openError = new Error('must not open') + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009')) + expect(directory.openedPaths).toEqual([]) + expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) + }) + + it('falls back to the first scanned alias when the pty candidate has another device', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'cu.alias', rdev: 0x10000009n }, + { name: 'ttys009', rdev: 0x0f000009n } + ] + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.alias')) + expect(directory.openedPaths).toEqual(['/dev']) + }) + + it.each(['symlink', 'file'] as const)( + 'rejects a %s pty candidate and falls back to a character device', + async (kind) => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'ttys009', rdev: 0x10000009n, kind }, + { name: 'cu.real', rdev: 0x10000009n } + ] + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.real')) + expect(directory.openedPaths).toEqual(['/dev']) + } + ) + + it('falls back when the pty candidate stat rejects', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'ttys009', rdev: 0x10000009n, error: new Error('EIO') }, + { name: 'cu.real', rdev: 0x10000009n } + ] + + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.real')) + }) + + it('falls back when the pty candidate stat throws synchronously', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'ttys009', rdev: 0x10000009n }, + { name: 'cu.real', rdev: 0x10000009n } + ] + const deps = { + openDirectory: directory.openDirectory, + readDevice: (path: string) => { + if (path === join('/dev', 'ttys009')) { + throw new Error('stat invocation failed') + } + return directory.readDevice(path) + } + } + + expect(await nameDarwinTerminals(darwinRow('16/9'), deps)).toBe(darwinRow('cu.real')) + }) + + it('scans only for unresolved devices and stops once they resolve', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'console', rdev: 0n }, + { name: 'ttys001', rdev: 0x10000001n }, + { name: 'ttys002', rdev: 0x10000002n }, + { name: 'later', rdev: 0x1600002an } + ] + const capture = ['16/1', '16/2', '0/0'].map((device) => darwinRow(device)).join('') + + expect(await nameDarwinTerminals(capture, directory)).toBe( + ['ttys001', 'ttys002', 'console'].map((device) => darwinRow(device)).join('') + ) + expect(directory.openedPaths).toEqual(['/dev']) + expect(directory.readPaths).toEqual( + ['ttys001', 'ttys002', 'ttys000', 'console'].map((name) => join('/dev', name)) + ) + }) + + it('stops candidate stats after cancellation and rejects once the active read drains', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [1, 2, 3].map((minor) => ({ + name: `ttys00${minor}`, + rdev: 0x10000000n + BigInt(minor), + delayMs: 10 + })) + const capture = ['16/1', '16/2', '16/3'].map((device) => darwinRow(device)).join('') + const controller = new AbortController() + const named = nameDarwinTerminals(capture, directory, controller.signal) + let settled = false + void named.then( + () => (settled = true), + () => (settled = true) + ) + const rejection = expect(named).rejects.toThrow() + await new Promise((resolve) => setTimeout(resolve, 2)) + controller.abort() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(settled).toBe(false) + await rejection + + expect(directory.readPaths).toEqual([join('/dev', 'ttys001')]) + expect(directory.openedPaths).toEqual([]) + expect(directory.peakReads).toBe(1) + }) + + it('admits one device read at a time across candidate and scan phases', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + ...Array.from({ length: 10 }, (_, minor) => ({ + name: `ttys00${minor}`, + rdev: 0x10000000n + BigInt(minor), + delayMs: 1 + })), + { name: 'cu.serial', rdev: 0x1600002an, delayMs: 1 } + ] + const devices = [...Array.from({ length: 10 }, (_, minor) => `16/${minor}`), '22/42'] + + expect( + await nameDarwinTerminals(devices.map((device) => darwinRow(device)).join(''), directory) + ).toBe(directory.entries.map((entry) => darwinRow(entry.name)).join('')) + expect(directory.peakReads).toBe(1) + expect(directory.openedPaths).toEqual(['/dev']) + }) + + it('accepts a four-digit pty candidate only when its device matches', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [{ name: 'ttys1000', rdev: 0x100003e8n }] + expect(await nameDarwinTerminals(darwinRow('16/1000'), directory)).toBe(darwinRow('ttys1000')) + expect(directory.openedPaths).toEqual([]) + + directory.entries = [{ name: 'ttys1000', rdev: 0x0f0003e8n }] + expect(await nameDarwinTerminals(darwinRow('16/1000'), directory)).toBe(darwinRow('??')) + expect(directory.openedPaths).toEqual(['/dev']) + }) + + it('looks up each distinct device once across repeated rows', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'ttys009', rdev: 0x10000009n }, + { name: 'ttys010', rdev: 0x1000000an } + ] + const capture = Array.from({ length: 50 }, () => darwinRow('16/9') + darwinRow('16/10')).join( + '' + ) + + expect(await nameDarwinTerminals(capture, directory)).toBe( + Array.from({ length: 50 }, () => darwinRow('ttys009') + darwinRow('ttys010')).join('') + ) + expect(directory.readPaths).toEqual([join('/dev', 'ttys009'), join('/dev', 'ttys010')]) + }) + + it('keeps the canonical public ps arguments on both Darwin and Linux', () => { + expect(PS_ARGS).toEqual([ + '-axo', + process.platform === 'darwin' + ? 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,lstart=,command=' + : 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command=' + ]) + }) +}) diff --git a/src/shared/darwin-terminal-names.ts b/src/shared/darwin-terminal-names.ts new file mode 100644 index 000000000000..f0a5db9257b7 --- /dev/null +++ b/src/shared/darwin-terminal-names.ts @@ -0,0 +1,127 @@ +import { lstat, opendir } from 'node:fs/promises' +import { join } from 'node:path' +import { DirectoryListingBudget } from './directory-listing-budget' +import { ProcessTableCaptureError } from './process-table-snapshot' + +type DarwinTerminalNameDeps = { + openDirectory: (path: string) => Promise> + readDevice: (path: string) => Promise<{ rdev: bigint; isCharacterDevice: () => boolean }> +} + +const deviceFilesystem: DarwinTerminalNameDeps = { + openDirectory: (path) => opendir(path), + readDevice: (path) => lstat(path, { bigint: true }) +} + +// The full capture's first six columns are fixed; localized dates and argv stay opaque. +const TDEV_COLUMN = + /^([^\S\r\n]*\d+[^\S\r\n]+\d+[^\S\r\n]+-?\d+[^\S\r\n]+-?\d+[^\S\r\n]+\S+[^\S\r\n]+)(\S+)(?=[^\S\r\n]+\S)/ +const TDEV_COLUMNS = new RegExp(TDEV_COLUMN.source, 'gm') + +function deviceToken(value: string): string | null { + const match = value.match(/^(\d+)\/(\d+)$/) + if (!match || Number(match[1]) > 255 || Number(match[2]) > 0xffffff) { + return null + } + return `${Number(match[1])}/${Number(match[2])}` +} + +function deviceKey(rdev: bigint): string | null { + const device = BigInt.asUintN(32, rdev) + const signedDevice = BigInt.asIntN(32, rdev) + if (rdev !== device && rdev !== signedDevice && rdev !== BigInt.asUintN(64, signedDevice)) { + return null + } + return `${device >> 24n}/${device & 0xffffffn}` +} + +async function readDeviceKey(deps: DarwinTerminalNameDeps, name: string): Promise { + try { + const stat = await deps.readDevice(join('/dev', name)).catch(() => null) + return stat?.isCharacterDevice() ? deviceKey(stat.rdev) : null + } catch { + return null + } +} + +// XNU names every pty slave ttys%03d by minor with one /dev entry; verify rdev before trusting it. +// Unlike devname, a root-made earlier alias of a pty cannot win here. +async function readPtyNames( + devices: ReadonlySet, + deps: DarwinTerminalNameDeps, + signal?: AbortSignal +): Promise> { + const names = new Map() + for (const device of devices) { + signal?.throwIfAborted() + const name = `ttys${device.slice(device.indexOf('/') + 1).padStart(3, '0')}` + if ((await readDeviceKey(deps, name)) === device) { + names.set(device, name) + } + } + signal?.throwIfAborted() + return names +} + +async function readTerminalNames( + devices: ReadonlySet, + names: Map, + deps: DarwinTerminalNameDeps, + signal?: AbortSignal +): Promise { + const entries: string[] = [] + const budget = new DirectoryListingBudget() + signal?.throwIfAborted() + for await (const entry of await deps.openDirectory('/dev')) { + signal?.throwIfAborted() + if (entry.name.includes('\uFFFD')) { + throw new ProcessTableCaptureError('undecodable_device_name') + } + budget.record(entry.name) + entries.push(entry.name) + } + let unresolved = [...devices].filter((device) => !names.has(device)).length + for (const name of entries) { + if (unresolved === 0) { + break + } + signal?.throwIfAborted() + const device = await readDeviceKey(deps, name) + // Apple devname chooses the first direct character entry in native directory order. + if (device !== null && devices.has(device) && !names.has(device)) { + names.set(device, Buffer.byteLength(name, 'utf8') < 255 ? name : '??') + unresolved -= 1 + } + } + signal?.throwIfAborted() +} + +/** Name terminals using this capture's /dev inventory, without retaining positive or misses. */ +export async function nameDarwinTerminals( + stdout: string, + deps: DarwinTerminalNameDeps = deviceFilesystem, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const devices = new Set() + for (const line of stdout.split(/\r?\n/)) { + if (!line.trim()) { + continue + } + const match = line.match(TDEV_COLUMN) + if (!match) { + throw new ProcessTableCaptureError('malformed_darwin_device_row') + } + const device = deviceToken(match[2]) + if (device !== null) { + devices.add(device) + } + } + const names = await readPtyNames(devices, deps, signal) + if (names.size < devices.size) { + await readTerminalNames(devices, names, deps, signal) + } + return stdout.replace(TDEV_COLUMNS, (_row, head: string, device: string) => { + return head + (names.get(deviceToken(device) ?? '') ?? '??') + }) +} diff --git a/src/shared/process-table-capture-parsing.test.ts b/src/shared/process-table-capture-parsing.test.ts new file mode 100644 index 000000000000..1c55cb4fdcb6 --- /dev/null +++ b/src/shared/process-table-capture-parsing.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import * as processTableFormat from './process-table-snapshot' +import { + getProcessTableSnapshot, + getStrictProcessTableSnapshot, + resetProcessTableSnapshotForTests +} from './process-table-snapshot-reader' + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +afterEach(() => { + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() + resetProcessTableSnapshotForTests() +}) + +describe('process table parsing on non-Linux hosts', () => { + it.each(['darwin', 'win32'])('only parses the requested view on %s', async (hostPlatform) => { + Object.defineProperty(process, 'platform', { value: hostPlatform }) + resetProcessTableSnapshotForTests() + const lenient = vi.spyOn(processTableFormat, 'parseProcessTableRows') + const strict = vi.spyOn(processTableFormat, 'parseStrictProcessTableRows') + execFileMock.mockImplementation((_program, _args, _options, callback) => { + callback(null, { + stdout: '100 1 100 100 Ss+ ?? Fri Oct 9 12:34:56 2026 /bin/zsh\n', + stderr: '' + }) + }) + + expect((await getStrictProcessTableSnapshot())[0]?.pid).toBe(100) + expect(lenient).not.toHaveBeenCalled() + expect(strict).toHaveBeenCalledTimes(1) + await getStrictProcessTableSnapshot() + expect(strict).toHaveBeenCalledTimes(1) + expect((await getProcessTableSnapshot())[0]?.pid).toBe(100) + expect(lenient).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/shared/process-table-ps-test-fixture.ts b/src/shared/process-table-ps-test-fixture.ts new file mode 100644 index 000000000000..b4274744b7f5 --- /dev/null +++ b/src/shared/process-table-ps-test-fixture.ts @@ -0,0 +1,93 @@ +import { basename } from 'node:path' +import type { nameDarwinTerminals } from './darwin-terminal-names' + +export type PsFixtureRow = { + pid: number + ppid: number + pgid: number + tpgid: number + stat: string + terminalMinor: number + startTime: string + startTicks: number + command: string +} + +/** One synthetic host supplies requested ps columns, /dev devices, and stable /proc starts. */ +export function createProcessTablePsFixture(readRows: () => readonly PsFixtureRow[]) { + function render(columns: string): string { + const output = readRows() + .map((row) => + columns + .split(',') + .map((column) => { + switch (column) { + case 'pid=': + return row.pid + case 'ppid=': + return row.ppid + case 'pgid=': + return row.pgid + case 'tpgid=': + return row.tpgid + case 'stat=': + return row.stat + case 'tdev=': + return `16/${row.terminalMinor}` + case 'tty=': + return process.platform === 'darwin' + ? `ttys${String(row.terminalMinor).padStart(3, '0')}` + : `pts/${row.terminalMinor}` + case 'lstart=': + return row.startTime + case 'etimes=': + // Deliberate drift makes /proc the only stable Linux start marker. + return String(100 + Math.floor(Date.now() / 1000)) + case 'command=': + return row.command + default: + throw new Error(`unsupported fixture ps column: ${column}`) + } + }) + .join(' ') + ) + .join('\n') + return `${output}\n` + } + + function readProcStat(path: string): string { + const match = path.match(/^\/proc\/(\d+)\/stat$/) + const row = match ? readRows().find((candidate) => candidate.pid === Number(match[1])) : null + if (!row) { + throw new Error(`no fixture process at ${path}`) + } + const start = String(row.startTicks) + return `${row.pid} (fixture) ${['S', ...Array.from({ length: 18 }, () => '0'), start, '0'].join(' ')}` + } + + function nameTerminals( + translate: typeof nameDarwinTerminals, + stdout: string, + signal?: AbortSignal + ) { + const minors = new Set(readRows().map((row) => row.terminalMinor)) + return translate( + stdout, + { + openDirectory: async () => + (async function* () { + for (const minor of minors) { + yield { name: `ttys${String(minor).padStart(3, '0')}` } + } + })(), + readDevice: async (path: string) => ({ + rdev: (16n << 24n) | BigInt(basename(path).slice(4)), + isCharacterDevice: () => true + }) + }, + signal + ) + } + + return { render, readProcStat, nameTerminals } +} diff --git a/src/shared/process-table-snapshot-reader.ts b/src/shared/process-table-snapshot-reader.ts index 06d3407f3b98..a5f34aff034d 100644 --- a/src/shared/process-table-snapshot-reader.ts +++ b/src/shared/process-table-snapshot-reader.ts @@ -1,6 +1,10 @@ import { execFile as execFileCb } from 'node:child_process' import { readFile } from 'node:fs/promises' import { promisify } from 'node:util' +import { + captureDarwinProcessTable, + resetDarwinProcessTableCaptureForTests +} from './darwin-process-table-capture' import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS, PS_ARGS, @@ -23,7 +27,6 @@ const execFile = promisify(execFileCb) // whole subsystem answered "unverifiable" about a table it could read. This keeps a wedged // `ps` bounded while staying out of reach of a host that is merely busy. export const PS_TIMEOUT_MS = 15_000 -const DEFAULT_SNAPSHOT_TTL_MS = PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS type Snapshot = { value: T; capturedAtMs: number; completedAtMs: number } @@ -42,7 +45,7 @@ export function createProcessTableSnapshotReader( getFreshSnapshot: () => Promise reset: () => void } { - const ttlMs = deps.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS + const ttlMs = deps.ttlMs ?? PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS let cached: Snapshot | null = null let inFlight: Promise | null = null let sequence = 0 @@ -226,10 +229,7 @@ export function parseLinuxProcStatStartTime(stat: string): string | null { /** Read Linux's stable PID start-time ticks without spawning another process. */ async function readLinuxProcessStartTimes( rows: readonly ProcessTableRow[] -): Promise | undefined> { - if (process.platform !== 'linux') { - return undefined - } +): Promise> { const candidates = rows.filter((row) => row.tty !== undefined && row.tty !== '?') const starts = await Promise.all( candidates.map(async (row) => { @@ -252,12 +252,12 @@ async function readLinuxProcessStartTimes( return result } -async function captureProcessTable(args: readonly string[]): Promise { +async function captureProcessTable(args: readonly string[], timeout = PS_TIMEOUT_MS) { let stdout: string try { ;({ stdout } = await execFile('ps', [...args], { encoding: 'utf-8', - timeout: PS_TIMEOUT_MS, + timeout, maxBuffer: PS_MAX_BUFFER_BYTES })) } catch (error) { @@ -272,10 +272,15 @@ async function captureProcessTable(args: readonly string[]): Promise { const processTableReader = createProcessTableSnapshotReader({ runPs: async () => { - const stdout = await captureProcessTable(PS_ARGS) - const baseCapture = createProcessTableCapture(stdout) - const startTimesByPid = await readLinuxProcessStartTimes(baseCapture.lenient()) - return createProcessTableCapture(stdout, startTimesByPid, process.platform === 'linux') + const stdout = + process.platform === 'darwin' + ? await captureDarwinProcessTable(captureProcessTable, assertWholeCapture, PS_TIMEOUT_MS) + : await captureProcessTable(PS_ARGS) + if (process.platform !== 'linux') { + return createProcessTableCapture(stdout) + } + const startTimesByPid = await readLinuxProcessStartTimes(parseProcessTableRows(stdout)) + return createProcessTableCapture(stdout, startTimesByPid, true) }, now: () => Date.now() }) @@ -355,4 +360,5 @@ export async function getStrictProcessTableSnapshotWithAge(): Promise<{ export function resetProcessTableSnapshotForTests(): void { processTableReader.reset() shellForegroundReader.reset() + resetDarwinProcessTableCaptureForTests() } diff --git a/src/shared/process-table-snapshot.test.ts b/src/shared/process-table-snapshot.test.ts index 19aa43127649..f7e126f5566f 100644 --- a/src/shared/process-table-snapshot.test.ts +++ b/src/shared/process-table-snapshot.test.ts @@ -269,7 +269,11 @@ describe('shared process-table capture', () => { it('serves the strict and lenient views from ONE ps fork per TTL window', async () => { // Why: both views run byte-identical argv, so separate memoizers would double // the relay's idle fork rate — the regression issue #6288 removed. - const forks = mockPsCaptures('100 1 100 100 Ss+ /bin/zsh\n', '200 1 200 200 Ss+ /bin/bash\n') + const tty = process.platform === 'darwin' ? '??' : '?' + const forks = mockPsCaptures( + `100 1 100 100 Ss+ ${tty} Fri Oct 9 12:34:56 2026 /bin/zsh\n`, + `200 1 200 200 Ss+ ${tty} Fri Oct 9 12:34:56 2026 /bin/bash\n` + ) const [lenient, strict] = await Promise.all([ getProcessTableSnapshot(), @@ -282,7 +286,11 @@ describe('shared process-table capture', () => { }) it('reuses the cached capture for a later strict read inside the TTL', async () => { - const forks = mockPsCaptures('100 1 100 100 Ss+ /bin/zsh\n', '200 1 200 200 Ss+ /bin/bash\n') + const tty = process.platform === 'darwin' ? '??' : '?' + const forks = mockPsCaptures( + `100 1 100 100 Ss+ ${tty} Fri Oct 9 12:34:56 2026 /bin/zsh\n`, + `200 1 200 200 Ss+ ${tty} Fri Oct 9 12:34:56 2026 /bin/bash\n` + ) await getProcessTableSnapshot() const strict = await getStrictProcessTableSnapshot() @@ -295,6 +303,8 @@ describe('shared process-table capture', () => { pgid: 100, tpgid: 100, stat: 'Ss+', + tty, + ...(process.platform === 'linux' ? {} : { startTime: 'Fri Oct 9 12:34:56 2026' }), command: '/bin/zsh' } ]) @@ -306,7 +316,7 @@ describe('shared process-table capture', () => { const lenient = await getProcessTableSnapshot() await expect(getStrictProcessTableSnapshot()).rejects.toBeInstanceOf(ProcessTableCaptureError) - expect(forks()).toBe(1) + expect(forks()).toBe(process.platform === 'darwin' ? 2 : 1) expect(lenient).toEqual([{ pid: 100, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }]) }) }) @@ -664,7 +674,10 @@ describe('evidence-publishing capture budget', () => { }) /** A `ps` that answers after `durationMs` of wall clock, the way a loaded host does. */ - function mockPsTaking(durationMs: number, stdout = '1 0 1 1 S+ ?? Jan 1 00:00:00 2026 bash\n') { + function mockPsTaking( + durationMs: number, + stdout = `1 0 1 1 S+ ${process.platform === 'darwin' ? '??' : '?'} Thu Jan 1 00:00:00 2026 bash\n` + ) { execFileMock.mockImplementation( (_command: string, _args: string[], _options: unknown, callback: unknown) => { const done = callback as (err: unknown, result: { stdout: string; stderr: string }) => void @@ -676,8 +689,10 @@ describe('evidence-publishing capture budget', () => { it('answers from a capture that lands one tick inside the budget', async () => { mockPsTaking(PROCESS_TABLE_EVIDENCE_BUDGET_MS - 1) const pending = getStrictProcessTableSnapshotWithAge() + const settled = pending.catch(() => undefined) await vi.advanceTimersByTimeAsync(PROCESS_TABLE_EVIDENCE_BUDGET_MS) + await settled // The age carries the capture's own duration, which is the whole reason the budget is this // far under the 2,000ms admission ceiling rather than under PS_TIMEOUT_MS. diff --git a/src/shared/shell-foreground-snapshot.test.ts b/src/shared/shell-foreground-snapshot.test.ts index e4cfa5f53a5c..ab4227d05a1a 100644 --- a/src/shared/shell-foreground-snapshot.test.ts +++ b/src/shared/shell-foreground-snapshot.test.ts @@ -5,10 +5,13 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { getFreshShellForegroundSnapshot, + getFreshProcessTableSnapshot, getProcessTableSnapshot, + getStrictProcessTableSnapshotWithAge, resetProcessTableSnapshotForTests } from './process-table-snapshot-reader' -import { parseShellForegroundRows } from './process-table-snapshot' +import { parseShellForegroundRows, PS_ARGS } from './process-table-snapshot' +import * as darwinTerminalNames from './darwin-terminal-names' type Callback = (error: Error | null, result: { stdout: string; stderr: string }) => void const platform = Object.getOwnPropertyDescriptor(process, 'platform')! @@ -24,7 +27,7 @@ afterEach(() => Object.defineProperty(process, 'platform', platform)) it('answers concurrent shell proofs without waiting for a pending full capture', async () => { let finishFull!: Callback execFileMock.mockImplementation((_program, args: string[], _options, callback: Callback) => { - if (args[1]?.includes('tty=')) { + if (args[1]?.includes('tdev=')) { finishFull = callback } else { expect(args).toEqual(['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,command=']) @@ -41,7 +44,10 @@ it('answers concurrent shell proofs without waiting for a pending full capture', ]) expect(second).toBe(first) expect(execFileMock).toHaveBeenCalledTimes(2) - finishFull(null, { stdout: shell, stderr: '' }) + finishFull(null, { + stdout: '100 99 100 100 Ss+ ?? Fri Oct 9 12:34:56 2026 /bin/zsh -l', + stderr: '' + }) await full await getFreshShellForegroundSnapshot() expect(execFileMock).toHaveBeenCalledTimes(3) @@ -75,3 +81,106 @@ it('rejects an unreadable shell capture', async () => { }) await expect(getFreshShellForegroundSnapshot()).rejects.toThrow('empty_capture') }) + +it('recaptures ordinary tty columns when the optimized full row has incomplete framing', async () => { + execFileMock.mockImplementation((_program, args: string[], _options, callback: Callback) => { + callback(null, { + stdout: args[1]?.includes('tdev=') + ? '100 99 100 100 Ss+ 16/9' + : '100 99 100 100 Ss+ ttys009 Fri Oct 9 12:34:56 2026 /bin/zsh -l', + stderr: '' + }) + }) + + expect((await getProcessTableSnapshot())[0]?.tty).toBe('ttys009') + expect(execFileMock.mock.calls.map((call) => call[1][1])).toEqual([ + PS_ARGS[1].replace('tty=', 'tdev='), + PS_ARGS[1] + ]) +}) + +it('shares one translated capture and includes naming time in its age and completion TTL', async () => { + const raw = '100 99 100 100 Ss+ 16/9 Fri Oct 9 12:34:56 2026 /bin/zsh -l' + const named = raw.replace('16/9', 'ttys009') + let finishNaming: (stdout: string) => void = () => { + throw new Error('naming did not start') + } + const naming = vi.spyOn(darwinTerminalNames, 'nameDarwinTerminals').mockImplementation( + () => + new Promise((resolve) => { + finishNaming = resolve + }) + ) + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + try { + execFileMock.mockImplementation((_program, _args, _options, callback: Callback) => { + callback(null, { stdout: raw, stderr: '' }) + }) + const lenient = getProcessTableSnapshot() + const strict = getStrictProcessTableSnapshotWithAge() + await vi.waitFor(() => expect(naming).toHaveBeenCalledTimes(1)) + now.mockReturnValue(2_000) + finishNaming(named) + + expect((await lenient)[0]?.tty).toBe('ttys009') + expect(await strict).toMatchObject({ capturedAgeMs: 1_000, rows: [{ tty: 'ttys009' }] }) + now.mockReturnValue(2_300) + expect((await getProcessTableSnapshot())[0]?.tty).toBe('ttys009') + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(naming).toHaveBeenCalledTimes(1) + } finally { + naming.mockRestore() + now.mockRestore() + } +}) + +it.each([ + { message: 'ps: tdev: keyword not found', code: 1 }, + { message: 'error: unknown user-defined format specifier "tdev"', code: 2 } +])( + 'remembers an unsupported device column without caching terminal names ($message)', + async ({ message, code }) => { + execFileMock.mockImplementation((_program, args: string[], _options, callback: Callback) => { + if (args[1]?.includes('tdev=')) { + callback(Object.assign(new Error(message), { code }), { stdout: '', stderr: message }) + } else { + callback(null, { + stdout: '100 99 100 100 Ss+ ttys009 Fri Oct 9 12:34:56 2026 /bin/zsh -l', + stderr: '' + }) + } + }) + + expect((await getProcessTableSnapshot())[0]?.tty).toBe('ttys009') + expect((await getFreshProcessTableSnapshot())[0]?.tty).toBe('ttys009') + expect(execFileMock.mock.calls.map((call) => call[1][1])).toEqual([ + PS_ARGS[1].replace('tty=', 'tdev='), + PS_ARGS[1], + PS_ARGS[1] + ]) + resetProcessTableSnapshotForTests() + await getProcessTableSnapshot() + expect(execFileMock).toHaveBeenCalledTimes(5) + } +) + +it.each([ + Object.assign(new Error('ps: another: keyword not found'), { code: 1 }), + Object.assign(new Error('ps: Operation not permitted'), { code: 1 }), + Object.assign(new Error('ps: tdev: keyword not found'), { code: 'EACCES' }), + Object.assign(new Error('ps: tdev: keyword not found'), { code: 1, killed: true }), + Object.assign(new Error('stdout maxBuffer length exceeded'), { + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + }) +])('does not retry or remember an unrelated failed capture (%s)', async (error) => { + execFileMock.mockImplementation((_program, _args, _options, callback: Callback) => { + callback(error, { stdout: '', stderr: '' }) + }) + + await expect(getProcessTableSnapshot()).rejects.toThrow() + await expect(getProcessTableSnapshot()).rejects.toThrow() + expect(execFileMock.mock.calls.map((call) => call[1][1])).toEqual([ + PS_ARGS[1].replace('tty=', 'tdev='), + PS_ARGS[1].replace('tty=', 'tdev=') + ]) +}) From 19f20c8b1634ccd65c7ee707657389b12ec371b4 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 16:49:47 -0700 Subject: [PATCH 040/110] test(e2e): pass the catalog owner to the remaining fetchRepos callers (#27325) Co-authored-by: m4air --- tests/e2e/ephemeral-vm-provisioned-root.spec.ts | 3 ++- tests/e2e/helpers/golden-source-control.ts | 3 ++- tests/e2e/helpers/nested-runtime-same-id-pairing.ts | 3 ++- tests/e2e/helpers/orca-restart.ts | 3 ++- tests/e2e/helpers/setup-split-and-sleep-journeys.ts | 3 ++- tests/e2e/helpers/ssh-test-target-connection.ts | 3 ++- tests/e2e/helpers/terminal-layout-journeys.ts | 3 ++- tests/e2e/helpers/worktree-registration.ts | 3 ++- tests/e2e/repo-registration-async.spec.ts | 3 ++- tests/e2e/repo-registration-concurrency.spec.ts | 3 ++- tests/e2e/runtime-host-status-recovery.spec.ts | 3 ++- tests/e2e/ssh-codex-reconnect-replay-driver.ts | 3 ++- tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts | 3 ++- tests/e2e/terminal-pane-divider-capture-loss.spec.ts | 3 ++- tests/e2e/windows-project-runtime-smoke.spec.ts | 3 ++- tests/e2e/worktree-scroll-to-current.spec.ts | 3 ++- tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs | 3 ++- tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs | 3 ++- tests/tools/benchmarks/terminal-perf-bench.mjs | 3 ++- tests/tools/benchmarks/worktree-deletion-dev-bench.mjs | 3 ++- 20 files changed, 40 insertions(+), 20 deletions(-) diff --git a/tests/e2e/ephemeral-vm-provisioned-root.spec.ts b/tests/e2e/ephemeral-vm-provisioned-root.spec.ts index bb2e4d4a7fd5..3082fea02abe 100644 --- a/tests/e2e/ephemeral-vm-provisioned-root.spec.ts +++ b/tests/e2e/ephemeral-vm-provisioned-root.spec.ts @@ -127,7 +127,8 @@ async function addRecipeRepo(page: Parameters[0], re throw new Error(result.error) } const store = window.__store! - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) await store.getState().updateSettings({ experimentalEphemeralVms: true }) store.getState().setActiveRepo(result.repo.id) return result.repo.id diff --git a/tests/e2e/helpers/golden-source-control.ts b/tests/e2e/helpers/golden-source-control.ts index a076721704cf..29428ee12451 100644 --- a/tests/e2e/helpers/golden-source-control.ts +++ b/tests/e2e/helpers/golden-source-control.ts @@ -162,7 +162,8 @@ export async function activateGoldenWorktree( if (!store) { throw new Error('window.__store is not available') } - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) const repos: { id: string path: string diff --git a/tests/e2e/helpers/nested-runtime-same-id-pairing.ts b/tests/e2e/helpers/nested-runtime-same-id-pairing.ts index 2d8e7d99d6d4..3d344a819429 100644 --- a/tests/e2e/helpers/nested-runtime-same-id-pairing.ts +++ b/tests/e2e/helpers/nested-runtime-same-id-pairing.ts @@ -41,7 +41,8 @@ export async function replaceRuntimePairingInPlace(args: { throw new Error('Same-ID re-paired desktop could not select the HUB runtime') } // Why: same-ID selection is a no-op, so explicitly rehydrate the graph from the replacement transport. - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) await store.getState().fetchAllWorktrees() await store.getState().fetchWorktreeLineage() return window.api.runtimeEnvironments.resolve({ selector }) diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index 380b403abf13..e04c2fe80141 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -265,7 +265,8 @@ export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): P } // Why: repos.add emits a concurrent refresh whose generation can // supersede this fetch; poll until either refresh publishes the repo. - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) const repo = store.getState().repos.find((candidate) => candidate.id === repoId) if (!repo) { return false diff --git a/tests/e2e/helpers/setup-split-and-sleep-journeys.ts b/tests/e2e/helpers/setup-split-and-sleep-journeys.ts index af3df53f9654..6104501e0060 100644 --- a/tests/e2e/helpers/setup-split-and-sleep-journeys.ts +++ b/tests/e2e/helpers/setup-split-and-sleep-journeys.ts @@ -98,7 +98,8 @@ export async function openSetupSplitWorktree(page: Page, userDataDir: string): P .poll(async () => { setupWorktreeId = await page.evaluate(async () => { const store = window.__store! - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) for (const repo of store.getState().repos) { await store.getState().fetchWorktrees(repo.id) } diff --git a/tests/e2e/helpers/ssh-test-target-connection.ts b/tests/e2e/helpers/ssh-test-target-connection.ts index 94bf2168e590..d6fbb74551ac 100644 --- a/tests/e2e/helpers/ssh-test-target-connection.ts +++ b/tests/e2e/helpers/ssh-test-target-connection.ts @@ -110,7 +110,8 @@ export async function connectSshTestTarget( }) }) } - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) await waitForRepoOwner() const currentState = store.getState().sshConnectionStates.get(createdTarget.id) if ( diff --git a/tests/e2e/helpers/terminal-layout-journeys.ts b/tests/e2e/helpers/terminal-layout-journeys.ts index 76754e278099..f3d5a330eb38 100644 --- a/tests/e2e/helpers/terminal-layout-journeys.ts +++ b/tests/e2e/helpers/terminal-layout-journeys.ts @@ -58,7 +58,8 @@ export async function addFolderWorkspace(page: Page): Promise { worktreeId = await page.evaluate(async (id) => { const store = window.__store! - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) await store.getState().fetchWorktrees(id) const worktree = store.getState().worktreesByRepo[id]?.[0] if (worktree) { diff --git a/tests/e2e/helpers/worktree-registration.ts b/tests/e2e/helpers/worktree-registration.ts index 1fd6a7f9bb9a..2e823f739f1a 100644 --- a/tests/e2e/helpers/worktree-registration.ts +++ b/tests/e2e/helpers/worktree-registration.ts @@ -32,7 +32,8 @@ async function resolveE2eWorktreeId( if (!store) { throw new Error('window.__store is not available') } - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) const repo = store.getState().repos.find((entry) => entry.path === repoPath) if (!repo) { throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) diff --git a/tests/e2e/repo-registration-async.spec.ts b/tests/e2e/repo-registration-async.spec.ts index 01fa0bf276bf..cb1ad1a61d36 100644 --- a/tests/e2e/repo-registration-async.spec.ts +++ b/tests/e2e/repo-registration-async.spec.ts @@ -55,7 +55,8 @@ test('registration resolves nested and linked paths to one existing project', as if (results.some((result) => 'error' in result)) { throw new Error(JSON.stringify(results)) } - await window.__store!.getState().fetchRepos() + const owner = window.__store!.getState().settings?.activeRuntimeEnvironmentId ?? null + await window.__store!.getState().fetchRepos({ runtimeEnvironmentId: owner }) await window.__store!.getState().awaitLocalRepoCatalogSettlement() return { ids: results.map((result) => ('repo' in result ? result.repo.id : null)), diff --git a/tests/e2e/repo-registration-concurrency.spec.ts b/tests/e2e/repo-registration-concurrency.spec.ts index 70e9577c87c1..c1e6375441cb 100644 --- a/tests/e2e/repo-registration-concurrency.spec.ts +++ b/tests/e2e/repo-registration-concurrency.spec.ts @@ -56,7 +56,8 @@ test('concurrent registration keeps one identity, including nested and linked pa if (results.some((result) => 'error' in result)) { throw new Error(JSON.stringify(results)) } - await window.__store!.getState().fetchRepos() + const owner = window.__store!.getState().settings?.activeRuntimeEnvironmentId ?? null + await window.__store!.getState().fetchRepos({ runtimeEnvironmentId: owner }) await window.__store!.getState().awaitLocalRepoCatalogSettlement() return { ids: results.map((result) => ('repo' in result ? result.repo.id : null)), diff --git a/tests/e2e/runtime-host-status-recovery.spec.ts b/tests/e2e/runtime-host-status-recovery.spec.ts index 09707606da17..d24e5fd84495 100644 --- a/tests/e2e/runtime-host-status-recovery.spec.ts +++ b/tests/e2e/runtime-host-status-recovery.spec.ts @@ -120,7 +120,8 @@ for (const topology of ['desktop', 'headless'] as const) { ? headless.client.call('repo.add', { path: testRepoPath }) : page.evaluate(async (path) => { await window.api.repos.add({ path }) - await window.__store?.getState().fetchRepos() + const owner = window.__store?.getState().settings?.activeRuntimeEnvironmentId ?? null + await window.__store?.getState().fetchRepos({ runtimeEnvironmentId: owner }) }, testRepoPath)) proxy = await interruptibleHost(offer) client = await launchPairedElectronClient(offer, testInfo, 'Direct host') diff --git a/tests/e2e/ssh-codex-reconnect-replay-driver.ts b/tests/e2e/ssh-codex-reconnect-replay-driver.ts index 4770297d965e..9eb58daed6e9 100644 --- a/tests/e2e/ssh-codex-reconnect-replay-driver.ts +++ b/tests/e2e/ssh-codex-reconnect-replay-driver.ts @@ -69,7 +69,8 @@ export async function connectDockerRemote( if ('error' in result) { throw new Error(result.error) } - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) await store.getState().fetchWorktrees(result.repo.id) return { targetId: createdTarget.id, repoId: result.repo.id, repoPath: result.repo.path } } finally { diff --git a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts index fe48f36973f5..2645fdded01e 100644 --- a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts +++ b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts @@ -119,7 +119,8 @@ async function addRealOrcaRepo(page: Page, repoPath: string): Promise { throw new Error('window.__store unavailable') } const state = store.getState() - await state.fetchRepos() + const owner = state.settings?.activeRuntimeEnvironmentId ?? null + await state.fetchRepos({ runtimeEnvironmentId: owner }) const repo = store.getState().repos.find((candidate) => candidate.path === repoPath) if (!repo) { throw new Error(`Real Orca repo did not load: ${repoPath}`) diff --git a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts index 8120c2a60e79..827f7593397e 100644 --- a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts +++ b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts @@ -37,7 +37,8 @@ async function addTestRepo(page: Page, repoPath: string): Promise { if (!store) { return false } - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) await store.getState().fetchWorktrees(id) const worktree = store .getState() diff --git a/tests/e2e/windows-project-runtime-smoke.spec.ts b/tests/e2e/windows-project-runtime-smoke.spec.ts index 087038752582..3d46cf0eef34 100644 --- a/tests/e2e/windows-project-runtime-smoke.spec.ts +++ b/tests/e2e/windows-project-runtime-smoke.spec.ts @@ -101,7 +101,8 @@ test.describe('Windows project runtime smoke', () => { } await window.api.repos.add({ path: wslRepoPath }) - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) const state = store.getState() const hostRepo = state.repos.find((repo) => repo.path === hostRepoPath) const wslRepo = state.repos.find((repo) => repo.path === wslRepoPath) diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index b09995aafdb1..a65dab40a815 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -74,7 +74,8 @@ test.describe('Reveal active workspace button', () => { await expect .poll(() => orcaPage.evaluate(async (id) => { - await window.__store!.getState().fetchRepos() + const owner = window.__store!.getState().settings?.activeRuntimeEnvironmentId ?? null + await window.__store!.getState().fetchRepos({ runtimeEnvironmentId: owner }) return window.__store!.getState().repos.some((repo) => repo.id === id) }, filterRepoId) ) diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index ffac29bddea3..95c8f1dc228c 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -126,7 +126,8 @@ async function setupWorkspaces(page, fixture) { if ('error' in addResult) { throw new Error(addResult.error) } - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) const state = store.getState() const repo = state.repos.find((c) => c.path === repoPath) ?? addResult.repo await state.updateRepo(repo.id, { diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index 70bf1f66944b..1f12cc8a02c2 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -134,7 +134,8 @@ async function setupWorkspaces(page, fixture) { if ('error' in addResult) { throw new Error(addResult.error) } - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) const state = store.getState() const repo = state.repos.find((c) => c.path === repoPath) ?? addResult.repo await state.updateRepo(repo.id, { diff --git a/tests/tools/benchmarks/terminal-perf-bench.mjs b/tests/tools/benchmarks/terminal-perf-bench.mjs index 551f33d0433e..19c44521572d 100644 --- a/tests/tools/benchmarks/terminal-perf-bench.mjs +++ b/tests/tools/benchmarks/terminal-perf-bench.mjs @@ -162,7 +162,8 @@ async function setupWorkspaces(page, fixture) { if ('error' in addResult) { throw new Error(addResult.error) } - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) const state = store.getState() const repo = state.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo diff --git a/tests/tools/benchmarks/worktree-deletion-dev-bench.mjs b/tests/tools/benchmarks/worktree-deletion-dev-bench.mjs index 325f0209565b..aa2c5f00e7ad 100644 --- a/tests/tools/benchmarks/worktree-deletion-dev-bench.mjs +++ b/tests/tools/benchmarks/worktree-deletion-dev-bench.mjs @@ -441,7 +441,8 @@ async function verifyRestart(instanceConfig, fixture, port, repoId) { await window.api.repos.list() let recovery = { repoPresent: false, worktreeCount: 0 } for (let attempt = 0; attempt < 60; attempt += 1) { - await store.getState().fetchRepos() + const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null + await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) if (store.getState().repos.some((repo) => repo.id === fixtureRepoId)) { await store.getState().fetchWorktrees(fixtureRepoId) } From b154baf6d72979e8bf4fbea42fb3333fd61b08fc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:53:44 -0400 Subject: [PATCH 041/110] fix(relay): staging director deploys run as relay-dir with rehome identity, and can register one cell's cap (#27118) Rebased onto main as one commit after its parent squash-merged. --- ...cloud-bootstrap-relay-staging-capacity.yml | 1 - .../workflows/cloud-deploy-relay-staging.yml | 95 ++++++++++++++++++- .../cloud-prove-relay-staging-capacity.yml | 1 - .../relay-deploy-workflow-argv.test.mjs | 20 ++++ .../relay-staging-deploy-identity.test.mjs | 10 ++ 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml b/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml index 29e68510b214..4ca3e3567926 100644 --- a/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml +++ b/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml @@ -544,7 +544,6 @@ jobs: --capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \ --capacity-cell-id "${cell_id}" \ --director-cells-json "${desired_cells_json}" \ - --min-instances 0 \ --prune-revisions true \ --release-id "bootstrap-${cell_id}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}")" echo "${director_result}" diff --git a/.github/workflows/cloud-deploy-relay-staging.yml b/.github/workflows/cloud-deploy-relay-staging.yml index bd82fbfb851d..4e74c8351006 100644 --- a/.github/workflows/cloud-deploy-relay-staging.yml +++ b/.github/workflows/cloud-deploy-relay-staging.yml @@ -13,6 +13,35 @@ on: default: preserve type: choice options: [preserve, 'off', dry-run, 'on'] + expected-rehome-generation: + description: Exact durable regional-rehome generation; it must remain disabled + required: true + type: string + bootstrap-runtime-identity: + description: One-time move from the stamped-cell identity to the director identity + required: true + default: false + type: boolean + predecessor-image-digest: + description: Bootstrap only. Exact immutable serving predecessor digest + required: false + type: string + capacity-cell-id: + description: Register this cell's reviewed staging.tfvars cap and bound with the director + required: true + default: none + type: choice + options: [none, staging-gce-c1, staging-gce-c2, staging-gce-c3, staging-gce-c4] + min-instances: + description: Director floor; preserve keeps the serving revision's, so a reserve run's 2 survives + required: true + default: preserve + type: choice + options: [preserve, '0', '2'] + confirmation: + description: BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY for a bootstrap + required: false + type: string shadow-seat-feed-cells: description: Preserve the step-3 shadow seat-feed cells, or set all or a comma list of cell ids; dropping a reserve cell is refused required: true @@ -47,11 +76,34 @@ jobs: CAPACITY_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }} ASIA_PROOF_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT }} REGIONAL_PLACEMENT_SECRET: orca-cloud-relay-regional-placement-enabled + DIRECTOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay-dir@onorca-cloud-staging.iam.gserviceaccount.com + PREDECESSOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay@onorca-cloud-staging.iam.gserviceaccount.com + REHOME_AUDIENCE: https://relay-staging.onorca.dev/v1/admin/host-drain + EXPECTED_REHOME_GENERATION: ${{ inputs.expected-rehome-generation }} + BOOTSTRAP_RUNTIME_IDENTITY: ${{ inputs.bootstrap-runtime-identity }} + PREDECESSOR_IMAGE_DIGEST: ${{ inputs.predecessor-image-digest }} + CAPACITY_CELL_ID: ${{ inputs.capacity-cell-id }} + MIN_INSTANCES: ${{ inputs.min-instances }} steps: - uses: actions/checkout@v4 - - name: Require the expected immutable image - run: '[[ "${EXPECTED_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]]' + - name: Require the expected immutable image and identity request + shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: | + set -euo pipefail + [[ "${EXPECTED_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] + [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] + [[ "${BOOTSTRAP_RUNTIME_IDENTITY}" =~ ^(true|false)$ ]] + [[ "${MIN_INSTANCES}" =~ ^(preserve|0|2)$ ]] + if test "${BOOTSTRAP_RUNTIME_IDENTITY}" = true; then + [[ "${PREDECESSOR_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] + test "${CAPACITY_CELL_ID}" = none + test "${CONFIRMATION}" = BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY + else + test -z "${PREDECESSOR_IMAGE_DIGEST}" + fi - uses: hashicorp/setup-terraform@v3 with: @@ -79,6 +131,16 @@ jobs: test "${IMAGE}" = \ "${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}@${EXPECTED_IMAGE_DIGEST}" echo "IMAGE=${IMAGE}" >> "${GITHUB_ENV}" + if test "${CAPACITY_CELL_ID}" != none; then + # The deploy script refuses any topology change beyond this one cell's cap and bound. + DESIRED_CELLS_JSON="$(terraform -chdir=infra/terraform console \ + -var-file=environments/staging.tfvars \ + <<< 'local.relay_director_cells_json' | jq -er '.')" + jq -e --arg cell "${CAPACITY_CELL_ID}" \ + 'any(.[]; .id == $cell and .connectionHardCap != null)' \ + <<< "${DESIRED_CELLS_JSON}" >/dev/null + echo "DESIRED_CELLS_JSON=${DESIRED_CELLS_JSON}" >> "${GITHUB_ENV}" + fi - uses: google-github-actions/setup-gcloud@v2 @@ -109,6 +171,26 @@ jobs: --format='value(name)' | awk -F/ '{print $NF}')" [[ "${regional_version}" =~ ^[1-9][0-9]*$ ]] RELEASE_ID="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_SHA:0:8}" + identity_args=() + if test "${BOOTSTRAP_RUNTIME_IDENTITY}" = true; then + identity_args=( + --bootstrap-runtime-identity true + --predecessor-runtime-service-account "${PREDECESSOR_RUNTIME_SERVICE_ACCOUNT}" + --predecessor-image-digest "${PREDECESSOR_IMAGE_DIGEST}" + ) + fi + # Omitted, the deploy script inherits the serving revision's floor. + floor_args=() + if test "${MIN_INSTANCES}" != preserve; then + floor_args=(--min-instances "${MIN_INSTANCES}") + fi + topology_args=() + if test "${CAPACITY_CELL_ID}" != none; then + topology_args=( + --capacity-cell-id "${CAPACITY_CELL_ID}" + --director-cells-json "${DESIRED_CELLS_JSON}" + ) + fi node dev/scripts/deploy-relay-blue-green.mjs \ --project "${GCP_PROJECT_ID}" \ --region "${GCP_REGION}" \ @@ -119,7 +201,14 @@ jobs: --capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \ --asia-proof-service-account "${ASIA_PROOF_SERVICE_ACCOUNT}" \ --regional-placement-secret-version "${regional_version}" \ - --min-instances 0 \ + "${floor_args[@]}" \ + --runtime-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ + "${identity_args[@]}" \ + --rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ + --rehome-audience "${REHOME_AUDIENCE}" \ + --rehome-control-origin https://relay-staging.onorca.dev \ + --expected-rehome-generation "${EXPECTED_REHOME_GENERATION}" \ + "${topology_args[@]}" \ --admin-audience https://relay-staging.onorca.dev/v1/admin/drain \ --reserve-placement "${RESERVE_PLACEMENT}" \ --shadow-seat-feed-cells "${SHADOW_SEAT_FEED_CELLS}" \ diff --git a/.github/workflows/cloud-prove-relay-staging-capacity.yml b/.github/workflows/cloud-prove-relay-staging-capacity.yml index 279007cbc146..15d61e91dc69 100644 --- a/.github/workflows/cloud-prove-relay-staging-capacity.yml +++ b/.github/workflows/cloud-prove-relay-staging-capacity.yml @@ -417,7 +417,6 @@ jobs: --capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \ --capacity-cell-id "${TARGET_CELL_ID}" \ --director-cells-json "${DESIRED_CELLS_JSON}" \ - --min-instances 0 \ --prune-revisions true \ --release-id "${RELEASE_ID}")" echo "${DEPLOY_RESULT}" diff --git a/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs b/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs index a4861625084d..8dea82fe6e9e 100644 --- a/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs +++ b/cloud/dev/scripts/relay-deploy-workflow-argv.test.mjs @@ -40,6 +40,19 @@ function sample(flag) { return `sample-${flag}` } +// Flags a workflow passes through a bash array (`"${name[@]}"`), from every `name=(...)` it +// assigns; parsing them all at once is the widest set that workflow can send. +function arrayArguments(source, name) { + const flags = [] + for (const match of source.matchAll(new RegExp(`\\b${name}=\\(([^)]*)\\)`, 'g'))) { + for (const entry of match[1].matchAll(/(--[a-z0-9-]+)\s+("[^"]*"|\S+)/g)) { + flags.push([entry[1], entry[2].replace(/^"(.*)"$/, '$1')]) + } + } + if (flags.length === 0) throw new Error(`no flags assigned to ${name}`) + return flags +} + function invocations(source, script) { const lines = source.split('\n') const found = [] @@ -49,6 +62,13 @@ function invocations(source, script) { let line = lines[index] while (line.trimEnd().endsWith('\\')) { line = lines[++index] + const array = /^\s*"\$\{([a-z_]+)\[@\]\}"\s*\\?\s*$/.exec(line) + if (array) { + for (const [flag, value] of arrayArguments(source, array[1])) { + argv.push(flag, value.includes('$') ? sample(flag.slice(2)) : value) + } + continue + } const match = /^\s*(--[a-z0-9-]+)\s+(.+?)\s*\\?\s*$/.exec(line) if (!match) throw new Error(`unparsed ${script} argument line: ${line.trim()}`) const [, flag, raw] = match diff --git a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs index 8afbfb5ca949..8b7fb069915f 100644 --- a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs +++ b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs @@ -223,3 +223,13 @@ test('the identity is exposed through its own outputs', () => { assert.match(outputs, /output "github_staging_relay_deploy_workload_identity_provider"/) assert.match(outputs, /output "github_staging_relay_deploy_service_account"/) }) + +// Why: relay-deploy-workflow-argv.test.mjs proves the argv parses; this pins the identity it names. +test('the staging director deploy runs as relay-dir with its rehome identity', () => { + const source = workflow('deploy-relay-staging.yml') + const start = source.indexOf('node dev/scripts/deploy-relay-blue-green.mjs') + const block = source.slice(start, source.indexOf('\n\n', start)) + assert.match(block, /--runtime-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/) + assert.match(block, /--rehome-director-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/) + assert.match(source, /DIRECTOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay-dir@/) +}) From 2110887080ce8fe9d86183661123882b81d64473 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:03:43 -0400 Subject: [PATCH 042/110] fix(relay): staging director deploys set the admin identity the director trusts to gha-relay (#27121) Rebased onto main as one commit after its parent squash-merged. --- .../workflows/cloud-deploy-relay-staging.yml | 3 +++ cloud/dev/scripts/deploy-relay-blue-green.mjs | 6 +++++ .../scripts/deploy-relay-blue-green.test.mjs | 24 +++++++++++++++++++ .../relay-staging-deploy-identity.test.mjs | 1 + 4 files changed, 34 insertions(+) diff --git a/.github/workflows/cloud-deploy-relay-staging.yml b/.github/workflows/cloud-deploy-relay-staging.yml index 4e74c8351006..93ef7acd2640 100644 --- a/.github/workflows/cloud-deploy-relay-staging.yml +++ b/.github/workflows/cloud-deploy-relay-staging.yml @@ -74,6 +74,8 @@ jobs: IMAGE_NAME: relay EXPECTED_IMAGE_DIGEST: ${{ inputs.expected-image-digest }} CAPACITY_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }} + # The director must trust the identity every staging Relay workflow authenticates as. + DEPLOY_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }} ASIA_PROOF_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT }} REGIONAL_PLACEMENT_SECRET: orca-cloud-relay-regional-placement-enabled DIRECTOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay-dir@onorca-cloud-staging.iam.gserviceaccount.com @@ -199,6 +201,7 @@ jobs: --role director \ --max-instances 2 \ --capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \ + --deploy-service-account "${DEPLOY_SERVICE_ACCOUNT}" \ --asia-proof-service-account "${ASIA_PROOF_SERVICE_ACCOUNT}" \ --regional-placement-secret-version "${regional_version}" \ "${floor_args[@]}" \ diff --git a/cloud/dev/scripts/deploy-relay-blue-green.mjs b/cloud/dev/scripts/deploy-relay-blue-green.mjs index 72bb63559867..d93b2617e86f 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.mjs @@ -263,6 +263,7 @@ export function directorDeploymentEnvironment(config) { } const serviceAccount = projectServiceAccount(config, 'capacity-service-account') const asiaProofServiceAccount = projectServiceAccount(config, 'asia-proof-service-account') + const deployServiceAccount = projectServiceAccount(config, 'deploy-service-account') const rehomeDirectorServiceAccount = projectServiceAccount( config, 'rehome-director-service-account' @@ -274,6 +275,10 @@ export function directorDeploymentEnvironment(config) { if (asiaProofServiceAccount !== undefined) { environment.ORCA_RELAY_ASIA_PROOF_SERVICE_ACCOUNT = asiaProofServiceAccount } + // The admin identity the director trusts; omitted, the serving revision's value carries over. + if (deployServiceAccount !== undefined) { + environment.ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT = deployServiceAccount + } if (rehomeDirectorServiceAccount !== undefined) { environment[DIRECTOR_REHOME_IDENTITY_ENV] = rehomeDirectorServiceAccount environment[DIRECTOR_REHOME_AUDIENCE_ENV] = config['rehome-audience'] @@ -330,6 +335,7 @@ export function parseArguments(argv) { } if ( values['capacity-service-account'] !== undefined || + values['deploy-service-account'] !== undefined || values['director-cells-json'] !== undefined || values['runtime-service-account'] !== undefined || values['rehome-director-service-account'] !== undefined || diff --git a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs index 782ace852c73..4ab531b58a7d 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs @@ -1023,6 +1023,30 @@ test('reads a director placement from runtime-status, and reports an image witho } }) +// Why: staging drifted to a director that trusts gha-deploy while every staging workflow +// authenticates as gha-relay; the deploy, not Terraform, owns the live value. +test('a director deploy sets the trusted deploy identity only when asked', () => { + const config = { project: 'onorca-cloud-staging' } + const relay = 'orca-cloud-staging-gha-relay@onorca-cloud-staging.iam.gserviceaccount.com' + assert.equal( + directorDeploymentEnvironment({ ...config, 'deploy-service-account': relay }) + .ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT, + relay + ) + assert.equal( + 'ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT' in directorDeploymentEnvironment(config), + false + ) + assert.throws( + () => + directorDeploymentEnvironment({ + ...config, + 'deploy-service-account': 'foreign@other-project.iam.gserviceaccount.com' + }), + /selected project/ + ) +}) + // Staging's director trusted gha-deploy while the workflow authenticated as gha-relay: a bare 401. test('the guard names the deploy identity a drifted director trusts instead of reading into a 401', async () => { const servingImageDigest = `sha256:${'f'.repeat(64)}` diff --git a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs index 8b7fb069915f..dbb3f1d7a14d 100644 --- a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs +++ b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs @@ -232,4 +232,5 @@ test('the staging director deploy runs as relay-dir with its rehome identity', ( assert.match(block, /--runtime-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/) assert.match(block, /--rehome-director-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/) assert.match(source, /DIRECTOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay-dir@/) + assert.match(block, /--deploy-service-account "\$\{DEPLOY_SERVICE_ACCOUNT\}"/) }) From 7d81ce26c4ef8c5211e2db58b402f6b7d0a0239f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:03:55 -0400 Subject: [PATCH 043/110] infra(relay): staging c1 gets a 600/60 cap, and c1/c4 get rehome trust, for the step-5 e2e (#27117) * infra(relay): staging c1 gets a 600/60 cap and c1/c4 get rehome trust for the step-5 e2e The reserve and demote routes answer only on cells with rehome trust, and Terraform requires a rehome source to carry explicit connection limits. Staging only. * infra(relay): pin staging c1 to c2's cap-capable image with its new cap The legacy 2d0f6e6 image cannot report capped connection telemetry, so main holding it with a 600/60 cap would leave any c1 apply failing its heartbeats for good. --- cloud/infra/terraform/environments/staging.tfvars | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index 1cb260e11ea6..8c095bcb9cec 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -39,7 +39,11 @@ relay_gce_cells = { boot_disk_gb = 30 boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-7" capacity_requests = 4000 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:2d0f6e6db2b0eb9d6aba188698de8330f8c30b4e76badfcf0fac3f3eb9508a87" + # c2's image: the legacy 2d0f6e6 cannot report capped telemetry, so a cap needs this one. + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:1239830d0946dc92ded3c9edde1c0b827f584a7a2be5c177beed900056d76f69" + # Step-5 staging e2e: c1 is the second US reserve cell, so it needs a cap and rehome trust. + connection_hard_cap = 600 + connection_unobserved_bound = 60 } "staging-gce-c2" = { hostname = "c2" @@ -80,7 +84,8 @@ relay_gce_cells = { } } -relay_region_rehome_source_cell_ids = ["staging-gce-c2", "staging-gce-c3"] +# c1 and c4 hold rehome trust for the step-5 staging e2e: the reserve and demote routes need it. +relay_region_rehome_source_cell_ids = ["staging-gce-c1", "staging-gce-c2", "staging-gce-c3", "staging-gce-c4"] # Push is currently provisioned only in production. push_gateway_enabled = false From ecc3433bdf1735194202b83a45c81efa6514a233 Mon Sep 17 00:00:00 2001 From: star-nebula <105629895+star-nebula@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:02:29 +0800 Subject: [PATCH 044/110] feat(native-chat): read ZCode session transcripts through the OpenCode SQLite reader ZCode's CLI persists an OpenCode-v1-shaped history in its own SQLite store (~/.zcode/cli/db/db.sqlite). The Agent Session History scan already lists those sessions, but opening one in the native chat found nothing: the chat transcript path had no ZCode awareness. Resolve a zcode transcript against the ZCode database (native home, then WSL homes) before any OpenCode install, tag every read (signal, page, subscribe) with the agent, and apply the same hidden-transcript filter the scan uses so ZCode's hidden bookkeeping rows stay out of chat renders. --- ...ession-scanner-opencode-sqlite-dispatch.ts | 2 +- ...scanner-opencode-sqlite-worker-protocol.ts | 2 + ...on-scanner-opencode-sqlite-worker-spawn.ts | 2 + .../transcript-opencode-database.ts | 42 ++++++++++++- .../transcript-opencode-sqlite-query.ts | 25 ++++++-- .../transcript-opencode-subscribe.ts | 32 ++++------ src/main/native-chat/transcript-opencode.ts | 62 ++++++++++++++++--- src/main/native-chat/transcript-read-cache.ts | 7 ++- src/main/native-chat/transcript-reader.ts | 7 ++- .../native-chat/transcript-tail-reader.ts | 6 +- src/main/native-chat/transcript-watch.ts | 7 ++- src/shared/native-chat-agent-support.test.ts | 5 ++ src/shared/native-chat-agent-support.ts | 9 ++- 13 files changed, 166 insertions(+), 42 deletions(-) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts index d9bc0b3a27c6..d4e66dd48b02 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts @@ -25,7 +25,7 @@ export async function handleOpenCodeSqliteRequest( if (request.kind === 'native-page' || request.kind === 'native-signal') { const value = request.kind === 'native-signal' - ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId) + ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId, request.agent) : readOpenCodeTranscriptPage({ ...request, limit: request.limit ?? 50 }) return { id: request.id, ok: true, value } } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index afee7778e3db..aea241b250f6 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -50,6 +50,8 @@ export type OpenCodeNativeChatReadRequest = { sessionId: string limit?: number beforeMessageRowId?: number + /** When 'zcode', reads apply ZCode's hidden-transcript filter. */ + agent?: 'zcode' } export type OpenCodeNativeChatReadValue = OpenCodeTranscriptPage | OpenCodeTranscriptSignal | null diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index e89a6361b1e2..7e6f5eb31e0d 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -265,6 +265,7 @@ export async function readOpenCodeTranscriptPageViaWorker( sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }, signal?: AbortSignal ) { @@ -289,6 +290,7 @@ export async function readOpenCodeTranscriptSignalViaWorker( args: { dbPath: string sessionId: string + agent?: 'zcode' }, signal?: AbortSignal ) { diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts index 92b97e7c9b09..a99de75a0816 100644 --- a/src/main/native-chat/transcript-opencode-database.ts +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -1,3 +1,4 @@ +import { homedir } from 'node:os' import { join } from 'node:path' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' @@ -13,9 +14,17 @@ import { listOpenCodeDatabasesInDirectory } from '../opencode-usage/opencode-database-discovery' +// ZCode's CLI keeps its OpenCode-v1-shaped history in its own database, so a +// zcode transcript must never be claimed by an unrelated OpenCode install (the +// same separation the AI Vault scanner draws in session-scanner-zcode-sources). +function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] { + return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite')) +} + export async function discoverOpenCodeTranscriptDatabase( sessionId?: string, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ): Promise { signal?.throwIfAborted() const deadline = new AbortController() @@ -47,7 +56,10 @@ export async function discoverOpenCodeTranscriptDatabase( try { if ( await waitForPromiseWithSignal( - readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, boundedSignal), + readOpenCodeTranscriptSignalViaWorker( + { dbPath, sessionId, ...(agent ? { agent } : {}) }, + boundedSignal + ), boundedSignal ) ) { @@ -61,6 +73,32 @@ export async function discoverOpenCodeTranscriptDatabase( return null } try { + // A zcode transcript only ever lives in a ZCode database: probe those first + // and never fall through to OpenCode installs that cannot hold the session. + if (agent === 'zcode') { + const homes = await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal) + const native = await findSession(zcodeTranscriptDatabasePaths([])) + if (native) { + return native + } + const readerRoots = homes + if (readerRoots.length > 0) { + const readers = await waitForPromiseWithSignal( + prepareOpenCodeWslReaders(readerRoots), + boundedSignal + ) + boundedSignal.throwIfAborted() + configureOpenCodeWslReaders(readers) + } + const wsl = await findSession(zcodeTranscriptDatabasePaths(homes)) + if (wsl) { + return wsl + } + if (refusals[0]) { + throw refusals[0] + } + return null + } const primary = await waitForPromiseWithSignal( listOpenCodeDatabases(onRefusal, undefined, boundedSignal), boundedSignal diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index d245020b619a..9583c9cc2100 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -16,6 +16,11 @@ import { } from './transcript-opencode-part-blocks' // Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2. +// ZCode stores hidden bookkeeping transcripts in the same message table; the +// same predicate the AI Vault scan applies keeps them out of chat renders. +const ZCODE_HIDDEN_MESSAGE_FILTER = + "AND COALESCE(json_extract(data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" + export type OpenCodeTranscriptItem = { rowid: number fingerprint: string @@ -70,7 +75,8 @@ function rowsWithinBudget( export function readOpenCodeTranscriptSignal( dbPath: string, - sessionId: string + sessionId: string, + agent?: 'zcode' ): OpenCodeTranscriptSignal | null { const db = openOpenCodeDatabaseReadonly(dbPath) try { @@ -84,14 +90,22 @@ export function readOpenCodeTranscriptSignal( // Aggregates without GROUP BY always yield exactly one row, so [0] is it. const [messageRow] = rowsOf<{ message_count: number; max_message_rowid: number }>( db.prepare( - 'SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ?' + `SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ? ${ + agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' + }` ), sessionId ) const [partRow] = rowsOf<{ part_count: number; max_part_time_updated: number }>( db.prepare( - 'SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated FROM part WHERE session_id = ?' + `SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated + FROM part WHERE session_id = ? AND message_id IN ( + SELECT id FROM message WHERE session_id = ? ${ + agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' + } + )` ), + sessionId, sessionId ) return { @@ -110,6 +124,7 @@ export function readOpenCodeTranscriptPage(args: { sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }): OpenCodeTranscriptPage | null { const db = openOpenCodeDatabaseReadonly(args.dbPath) try { @@ -127,7 +142,9 @@ export function readOpenCodeTranscriptPage(args: { const select = db.prepare( `SELECT rowid AS message_rowid, id, time_created, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM message - WHERE session_id = ? AND rowid < ? + WHERE session_id = ? AND rowid < ? ${ + args.agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' + } ORDER BY rowid DESC LIMIT ?` ) diff --git a/src/main/native-chat/transcript-opencode-subscribe.ts b/src/main/native-chat/transcript-opencode-subscribe.ts index b5faaf884ca3..c1997f3ed550 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.ts @@ -6,7 +6,7 @@ import { type SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import type { OpenCodeTranscriptItem } from './transcript-opencode-sqlite-query' -import { openCodeTranscriptDefaultDeps, type OpenCodeTranscriptDeps } from './transcript-opencode' +import { bindOpenCodeTranscriptDeps, type OpenCodeTranscriptDeps } from './transcript-opencode' import { openCodeTranscriptPageLimit, OPENCODE_TRANSCRIPT_MAX_WINDOW @@ -31,23 +31,13 @@ export const pruneOpenCodeFingerprintCacheForTest = pruneOpenCodeFingerprintCach export function subscribeOpenCodeNativeChatTranscript( args: SubscribeNativeChatTranscriptArgs, setupSignal?: AbortSignal, - deps: OpenCodeTranscriptDeps = {} + deps: OpenCodeTranscriptDeps = {}, + // When 'zcode': discovery probes ZCode's database, reads drop hidden rows. + agent?: 'zcode' ): NativeChatTranscriptSubscription { setupSignal?.throwIfAborted() const controller = new AbortController() - const resolveDbPath = () => - (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( - args.sessionId, - controller.signal - ) - const readSignal = (dbPath: string, sessionId: string) => - (deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)( - dbPath, - sessionId, - controller.signal - ) - const readPage = (page: Parameters>[0]) => - (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(page, controller.signal) + const bound = bindOpenCodeTranscriptDeps(deps, controller.signal, agent) const pollMs = args.resolvePollIntervalMs ?? OPENCODE_POLL_MS const initialLimit = openCodeTranscriptPageLimit( args.initialLimit && args.initialLimit > 0 ? args.initialLimit : DESKTOP_READ_WINDOW @@ -117,7 +107,7 @@ export function subscribeOpenCodeNativeChatTranscript( return } try { - dbPath ??= await resolveDbPath() + dbPath ??= await bound.resolveDbPath() if (closed) { return } @@ -126,7 +116,7 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } - const signal = await readSignal(dbPath, args.sessionId) + const signal = await bound.readSignal(dbPath, args.sessionId) if (closed) { return } @@ -141,7 +131,7 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } - const page = await readPage({ + const page = await bound.readPage({ dbPath, sessionId: args.sessionId, limit: firstSnapshot @@ -193,7 +183,7 @@ export function subscribeOpenCodeNativeChatTranscript( const displaced = [...fingerprints.keys()].filter((rowid) => rowid < oldest) if (displaced.length > 0) { // Verify the displaced fringe: cap eviction and a real deletion look identical in the tail. - const older = await readPage({ + const older = await bound.readPage({ dbPath, sessionId: args.sessionId, limit: displaced.length, @@ -267,14 +257,14 @@ export function subscribeOpenCodeNativeChatTranscript( // overlap, so the gap retries instead of dropping rows. async function replaceWithBridgedWindow( db: string, - page: Awaited>, + page: Awaited>, shrinking = false ): Promise { let limit = Math.max(windowLimit, page?.items.length ?? 0) for (;;) { const replacement = page ?? - (await readPage({ + (await bound.readPage({ dbPath: db, sessionId: args.sessionId, limit diff --git a/src/main/native-chat/transcript-opencode.ts b/src/main/native-chat/transcript-opencode.ts index 497635de87e1..eada2eca9854 100644 --- a/src/main/native-chat/transcript-opencode.ts +++ b/src/main/native-chat/transcript-opencode.ts @@ -20,11 +20,16 @@ import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-pa // OpenCode SQLite reads use the same bounded worker as AI Vault. export type OpenCodeTranscriptDeps = { - resolveDbPath?: (sessionId?: string, signal?: AbortSignal) => Promise + resolveDbPath?: ( + sessionId?: string, + signal?: AbortSignal, + agent?: 'zcode' + ) => Promise readSignal?: ( dbPath: string, sessionId: string, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ) => Promise readPage?: ( args: { @@ -32,6 +37,7 @@ export type OpenCodeTranscriptDeps = { sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }, signal?: AbortSignal ) => Promise @@ -43,17 +49,51 @@ export function resolveOpenCodeTranscriptDbPath(sessionId?: string): Promise = { resolveDbPath: discoverOpenCodeTranscriptDatabase, - readSignal: (dbPath, sessionId, signal) => - readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, signal), + readSignal: (dbPath, sessionId, signal, agent) => + readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId, agent }, signal), readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal) } +/** + * Resolve a subscribe's read legs against its own abort signal, with the agent + * tag (when any) already applied to every leg. Kept here so the poll loop stays + * free of default-vs-override plumbing. + */ +export function bindOpenCodeTranscriptDeps( + deps: OpenCodeTranscriptDeps, + signal: AbortSignal, + agent?: 'zcode' +): { + resolveDbPath: () => Promise + readSignal: (dbPath: string, sessionId: string) => Promise + readPage: ( + page: Parameters>[0] + ) => Promise +} { + return { + resolveDbPath: () => + (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(undefined, signal, agent), + readSignal: (dbPath, sessionId) => + (deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)( + dbPath, + sessionId, + signal, + agent + ), + readPage: (page) => + (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)( + { ...page, ...(agent ? { agent } : {}) }, + signal + ) + } +} + export type OpenCodeTailResult = | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } | { error: string; notFound?: true } export async function readOpenCodeNativeChatTranscriptTail( - args: { sessionId: string; limit: number; beforeOffset?: number }, + args: { sessionId: string; limit: number; beforeOffset?: number; agent?: 'zcode' }, deps: OpenCodeTranscriptDeps = {}, signal?: AbortSignal ): Promise { @@ -61,7 +101,8 @@ export async function readOpenCodeNativeChatTranscriptTail( try { const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( args.sessionId, - signal + signal, + args.agent ) if (!dbPath) { return { error: 'Transcript unavailable', notFound: true } @@ -71,7 +112,8 @@ export async function readOpenCodeNativeChatTranscriptTail( dbPath, sessionId: args.sessionId, limit, - ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}) + ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}), + ...(args.agent ? { agent: args.agent } : {}) }, signal ) @@ -91,7 +133,8 @@ export async function readOpenCodeNativeChatTranscriptTail( export async function readOpenCodeNativeChatTranscriptFull( sessionId: string, deps: OpenCodeTranscriptDeps = {}, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ): Promise { // Pages arrive newest-window first; reverse windows, preserving message order. const pages: NativeChatMessage[][] = [] @@ -113,7 +156,8 @@ export async function readOpenCodeNativeChatTranscriptFull( dbPath, sessionId, limit: 500, - ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}) + ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}), + ...(agent ? { agent } : {}) }, signal ) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 7233f001de48..25072906d17b 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -107,7 +107,12 @@ export async function readNativeChatTranscriptCached( transcriptPath?: string ): Promise { if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptFull(sessionId) + return readOpenCodeNativeChatTranscriptFull( + sessionId, + {}, + undefined, + agent === 'zcode' ? 'zcode' : undefined + ) } const epoch = cacheEpoch let filePath: string | null diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 6cad15a7757f..b90b8681b445 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -45,7 +45,12 @@ export async function readNativeChatTranscript( options: ReadTranscriptOptions = {} ): Promise { if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptFull(sessionId) + return readOpenCodeNativeChatTranscriptFull( + sessionId, + {}, + undefined, + agent === 'zcode' ? 'zcode' : undefined + ) } let filePath: string | null try { diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index fb44470047bf..fce4b630e4e8 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -246,7 +246,11 @@ export async function readNativeChatTranscriptTail( | { error: string; notFound?: true } > { if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptTail(args, {}, signal) + return readOpenCodeNativeChatTranscriptTail( + { ...args, agent: args.agent === 'zcode' ? 'zcode' : undefined }, + {}, + signal + ) } const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index a5090b4c8008..69bb21e8c714 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -267,7 +267,12 @@ export async function subscribeNativeChatTranscript( ): Promise { setupSignal?.throwIfAborted() if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { - return subscribeOpenCodeNativeChatTranscript(args, setupSignal) + return subscribeOpenCodeNativeChatTranscript( + args, + setupSignal, + {}, + args.agent === 'zcode' ? 'zcode' : undefined + ) } const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index 23749bb0dfbf..3da69dd80a0d 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -15,6 +15,9 @@ describe('resolveNativeChatTranscriptAgent', () => { it('passes codex, grok and omp through and rejects everything else', () => { expect(resolveNativeChatTranscriptAgent('opencode')).toBe('opencode') expect(resolveNativeChatTranscriptAgent('opencode2')).toBe('opencode') + // ZCode keeps an OpenCode-v1-shaped history in its own SQLite DB, so its + // transcripts read through the OpenCode SQLite reader. + expect(resolveNativeChatTranscriptAgent('zcode')).toBe('opencode') expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp') @@ -28,6 +31,7 @@ describe('isNativeChatSupportedAgent', () => { it('recognizes the parseable agents and rejects unknown / nullish input', () => { expect(isNativeChatSupportedAgent('opencode')).toBe(true) expect(isNativeChatSupportedAgent('opencode2')).toBe(true) + expect(isNativeChatSupportedAgent('zcode')).toBe(true) expect(isNativeChatSupportedAgent('claude')).toBe(true) expect(isNativeChatSupportedAgent('openclaude')).toBe(true) expect(isNativeChatSupportedAgent('omp')).toBe(true) @@ -43,6 +47,7 @@ describe('nativeChatRequiresLocalTranscript', () => { // native chat has to find their file on a disk this process can read. expect(nativeChatRequiresLocalTranscript('opencode')).toBe(true) expect(nativeChatRequiresLocalTranscript('opencode2')).toBe(true) + expect(nativeChatRequiresLocalTranscript('zcode')).toBe(true) expect(nativeChatRequiresLocalTranscript('grok')).toBe(true) expect(nativeChatRequiresLocalTranscript('omp')).toBe(true) expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index e2bb9c40cacc..081086bae923 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -11,7 +11,8 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [ 'grok', 'omp', 'opencode', - 'opencode2' + 'opencode2', + 'zcode' ] export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set( @@ -48,6 +49,12 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'opencode' || agent === 'opencode2') { return 'opencode' } + // Why: ZCode's CLI stores an OpenCode-v1-shaped history in its own SQLite DB + // (~/.zcode/cli/db/db.sqlite), so its transcripts read through the same + // OpenCode SQLite reader as OpenCode 1.x — the agent identity stays zcode. + if (agent === 'zcode') { + return 'opencode' + } if (agent === 'codex' || agent === 'grok' || agent === 'omp') { return agent } From 45c4d6ecbb21c071304ff94abfad9aab01a20630 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 21:44:16 -0700 Subject: [PATCH 045/110] fix(native-chat): preserve ZCode store selection and transcript order --- .../transcript-opencode-database.test.ts | 51 ++++ .../transcript-opencode-database.ts | 40 ++- .../transcript-opencode-fingerprint-window.ts | 17 ++ .../transcript-opencode-sqlite-query.ts | 83 ++++-- .../transcript-opencode-subscribe.test.ts | 18 ++ .../transcript-opencode-subscribe.ts | 58 ++-- .../native-chat/transcript-opencode.test.ts | 16 ++ src/main/native-chat/transcript-opencode.ts | 37 +-- src/main/native-chat/transcript-zcode.test.ts | 254 ++++++++++++++++++ .../methods/native-chat-rpc-page-bounds.ts | 1 + src/shared/native-chat-agent-support.ts | 8 +- .../native-chat-reasoning-order.test.ts | 15 ++ .../native-chat-transcript-projection.ts | 10 + src/shared/native-chat-types.ts | 2 + .../e2e/native-chat-zcode-transcript.spec.ts | 88 ++++++ 15 files changed, 593 insertions(+), 105 deletions(-) create mode 100644 src/main/native-chat/transcript-opencode-fingerprint-window.ts create mode 100644 src/main/native-chat/transcript-zcode.test.ts create mode 100644 tests/e2e/native-chat-zcode-transcript.spec.ts diff --git a/src/main/native-chat/transcript-opencode-database.test.ts b/src/main/native-chat/transcript-opencode-database.test.ts index daaeca3b6856..165d3ac20e46 100644 --- a/src/main/native-chat/transcript-opencode-database.test.ts +++ b/src/main/native-chat/transcript-opencode-database.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' +import { homedir } from 'node:os' import type { OpenCodeTranscriptSignal } from './transcript-opencode-sqlite-query' import { WslTranscriptFsError } from './wsl-transcript-fs-error' @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({ ), prepare: vi.fn(async () => []), configure: vi.fn(), + access: vi.fn(async () => true), wslPath: vi.fn((_path: string): { distro: string; linuxPath: string } | null => null), readSignal: vi.fn( async ( @@ -23,6 +25,7 @@ const mocks = vi.hoisted(() => ({ ) })) vi.mock('../ai-vault/cached-session-list', () => ({ getAiVaultWslHomeDirs: mocks.homes })) +vi.mock('./wsl-transcript-fs-access', () => ({ wslGatedAccess: mocks.access })) vi.mock('../opencode-usage/opencode-database-discovery', () => ({ listOpenCodeDatabases: mocks.native, listOpenCodeDatabasesInDirectory: mocks.directory, @@ -50,6 +53,54 @@ afterEach(() => { }) describe('OpenCode transcript owning-host database discovery', () => { + it('reports a missing ZCode install as unavailable without a SQLite refusal', async () => { + mocks.access.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + await expect( + openCodeTranscriptDefaultDeps.resolveDbPath('z-session', undefined, 'zcode') + ).resolves.toBeNull() + expect(mocks.readSignal).not.toHaveBeenCalled() + }) + it('opens a native ZCode session before an unresolved WSL lookup', async () => { + vi.useFakeTimers() + mocks.homes.mockImplementation(() => new Promise(() => {})) + mocks.readSignal.mockResolvedValue({ + messageCount: 1, + partCount: 1, + maxMessageRowId: 1, + maxPartTimeUpdated: 1 + }) + const settled = vi.fn() + void openCodeTranscriptDefaultDeps + .resolveDbPath('z-session', undefined, 'zcode') + .then(settled, settled) + await vi.advanceTimersByTimeAsync(50) + expect(settled).toHaveBeenCalledWith(join(homedir(), '.zcode', 'cli', 'db', 'db.sqlite')) + expect(mocks.homes).not.toHaveBeenCalled() + expect(mocks.native).not.toHaveBeenCalled() + }) + + it('finds a WSL ZCode session without probing OpenCode databases', async () => { + const home = 'wsl-home' + const dbPath = join(home, '.zcode', 'cli', 'db', 'db.sqlite') + mocks.homes.mockResolvedValue([home]) + mocks.readSignal.mockResolvedValueOnce(null).mockResolvedValueOnce({ + messageCount: 1, + partCount: 1, + maxMessageRowId: 1, + maxPartTimeUpdated: 1 + }) + await expect( + openCodeTranscriptDefaultDeps.resolveDbPath('z-session', undefined, 'zcode') + ).resolves.toBe(dbPath) + expect(mocks.prepare).toHaveBeenCalledWith([home]) + expect(mocks.readSignal).toHaveBeenLastCalledWith( + { dbPath, sessionId: 'z-session', agent: 'zcode' }, + expect.any(AbortSignal) + ) + expect(mocks.native).not.toHaveBeenCalled() + expect(mocks.directory).not.toHaveBeenCalled() + }) + it.each(['homes', 'directory', 'prepare'] as const)( 'opens a matching native database without waiting for WSL %s', async (stage) => { diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts index a99de75a0816..2a9ad947b141 100644 --- a/src/main/native-chat/transcript-opencode-database.ts +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -1,4 +1,5 @@ import { homedir } from 'node:os' +import { wslGatedAccess } from './wsl-transcript-fs-access' import { join } from 'node:path' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' @@ -14,9 +15,7 @@ import { listOpenCodeDatabasesInDirectory } from '../opencode-usage/opencode-database-discovery' -// ZCode's CLI keeps its OpenCode-v1-shaped history in its own database, so a -// zcode transcript must never be claimed by an unrelated OpenCode install (the -// same separation the AI Vault scanner draws in session-scanner-zcode-sources). +// ZCode sessions belong only to its own store. function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] { return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite')) } @@ -50,10 +49,19 @@ export async function discoverOpenCodeTranscriptDatabase( } boundedSignal.throwIfAborted() probed.add(dbPath) - if (!sessionId) { - return dbPath - } try { + if ( + agent === 'zcode' && + !(await waitForPromiseWithSignal( + wslGatedAccess(dbPath, 'exact', boundedSignal), + boundedSignal + )) + ) { + continue + } + if (!sessionId) { + return dbPath + } if ( await waitForPromiseWithSignal( readOpenCodeTranscriptSignalViaWorker( @@ -67,24 +75,32 @@ export async function discoverOpenCodeTranscriptDatabase( } } catch (error) { boundedSignal.throwIfAborted() + if ( + agent === 'zcode' && + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + continue + } refusals.push(error instanceof Error ? error : new Error(String(error))) } } return null } try { - // A zcode transcript only ever lives in a ZCode database: probe those first - // and never fall through to OpenCode installs that cannot hold the session. if (agent === 'zcode') { - const homes = await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal) const native = await findSession(zcodeTranscriptDatabasePaths([])) if (native) { return native } - const readerRoots = homes - if (readerRoots.length > 0) { + const homes = (await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal)).slice( + 0, + 32 + ) + if (homes.length > 0) { const readers = await waitForPromiseWithSignal( - prepareOpenCodeWslReaders(readerRoots), + prepareOpenCodeWslReaders(homes), boundedSignal ) boundedSignal.throwIfAborted() diff --git a/src/main/native-chat/transcript-opencode-fingerprint-window.ts b/src/main/native-chat/transcript-opencode-fingerprint-window.ts new file mode 100644 index 000000000000..baa60be503cf --- /dev/null +++ b/src/main/native-chat/transcript-opencode-fingerprint-window.ts @@ -0,0 +1,17 @@ +export function pruneOpenCodeFingerprintCache( + fingerprints: Map, + cap: number, + agent?: 'zcode' +): void { + if (fingerprints.size <= cap) { + return + } + const keys = [...fingerprints.keys()] + const newest = agent === 'zcode' ? keys.toReversed() : keys.sort((a, b) => b - a) + const keep = new Set(newest.slice(0, cap)) + for (const rowid of fingerprints.keys()) { + if (!keep.has(rowid)) { + fingerprints.delete(rowid) + } + } +} diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index 9583c9cc2100..99b1c0d1577b 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -1,4 +1,6 @@ import { openOpenCodeDatabaseReadonly } from '../ai-vault/session-scanner-opencode-sqlite-open' +import { zcodeVisibleMessageFilter } from '../ai-vault/session-scanner-zcode-visibility' +import { zcodeTranscriptOrder } from '../ai-vault/session-scanner-zcode-order' import { readOpenCode2TranscriptPage, readOpenCode2TranscriptSignal @@ -16,11 +18,6 @@ import { } from './transcript-opencode-part-blocks' // Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2. -// ZCode stores hidden bookkeeping transcripts in the same message table; the -// same predicate the AI Vault scan applies keeps them out of chat renders. -const ZCODE_HIDDEN_MESSAGE_FILTER = - "AND COALESCE(json_extract(data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'" - export type OpenCodeTranscriptItem = { rowid: number fingerprint: string @@ -88,31 +85,41 @@ export function readOpenCodeTranscriptSignal( return null } // Aggregates without GROUP BY always yield exactly one row, so [0] is it. - const [messageRow] = rowsOf<{ message_count: number; max_message_rowid: number }>( + const visibility = zcodeVisibleMessageFilter(agent ?? 'opencode', 'data') + const [messageRow] = rowsOf<{ + message_count: number + max_message_rowid: number + max_message_time_updated: number + }>( db.prepare( - `SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ? ${ - agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' - }` + `SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid, + COALESCE(MAX(time_updated), 0) AS max_message_time_updated + FROM message WHERE session_id = ? ${visibility}` ), sessionId ) const [partRow] = rowsOf<{ part_count: number; max_part_time_updated: number }>( db.prepare( `SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated - FROM part WHERE session_id = ? AND message_id IN ( - SELECT id FROM message WHERE session_id = ? ${ - agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' - } + FROM part WHERE session_id = ? ${ + agent === 'zcode' + ? `AND message_id IN ( + SELECT id FROM message WHERE session_id = ? ${visibility} )` + : '' + }` ), sessionId, - sessionId + ...(agent === 'zcode' ? [sessionId] : []) ) return { messageCount: messageRow?.message_count ?? 0, partCount: partRow?.part_count ?? 0, maxMessageRowId: messageRow?.max_message_rowid ?? 0, - maxPartTimeUpdated: partRow?.max_part_time_updated ?? 0 + maxPartTimeUpdated: Math.max( + partRow?.max_part_time_updated ?? 0, + agent === 'zcode' ? (messageRow?.max_message_time_updated ?? 0) : 0 + ) } } finally { db.close() @@ -136,16 +143,33 @@ export function readOpenCodeTranscriptPage(args: { return null } const limit = openCodeTranscriptPageLimit(args.limit) + // Rank only inside the query; wire cursors retain the stable storage row identity. + const ranked = args.agent === 'zcode' + const orderColumn = ranked ? 'ordered.transcript_order' : 'm.rowid' + if ( + ranked && + args.beforeMessageRowId !== undefined && + !db + .prepare('SELECT 1 FROM message WHERE session_id = ? AND rowid = ?') + .get(args.sessionId, args.beforeMessageRowId) + ) { + throw new Error('Transcript pagination cursor was removed; reload the conversation') + } + const ordered = ranked + ? `WITH ordered AS ( + SELECT m.rowid AS storage_rowid, ROW_NUMBER() OVER ( + ORDER BY ${zcodeTranscriptOrder(db, 'zcode', 'message', 'm', 'ASC')} + ) AS transcript_order FROM message m WHERE session_id = ? + )` + : '' // The upper bound is always bound: batching advances the cursor mid-page, // so the statement cannot vary with `beforeMessageRowId`'s presence. // MAX_SAFE_INTEGER is the "from the newest row" sentinel. const select = db.prepare( - `SELECT rowid AS message_rowid, id, time_created, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data - FROM message - WHERE session_id = ? AND rowid < ? ${ - args.agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : '' - } - ORDER BY rowid DESC + `${ordered} SELECT m.rowid AS message_rowid, ${orderColumn} AS transcript_order, m.id, m.time_created, m.time_updated, CASE WHEN length(CAST(m.data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN m.data ELSE NULL END AS data + FROM message m ${ranked ? 'JOIN ordered ON ordered.storage_rowid = m.rowid' : ''} + WHERE m.session_id = ? AND ${orderColumn} < ${ranked ? 'COALESCE((SELECT transcript_order FROM ordered WHERE storage_rowid = ?), 9007199254740991)' : '?'} ${zcodeVisibleMessageFilter(args.agent ?? 'opencode')} + ORDER BY ${orderColumn} DESC LIMIT ?` ) const collected: OpenCodeTranscriptItem[] = [] @@ -158,6 +182,7 @@ export function readOpenCodeTranscriptPage(args: { const rows = rowsWithinBudget( select, rawBudget, + ...(ranked ? [args.sessionId] : []), args.sessionId, cursor ?? Number.MAX_SAFE_INTEGER, limit + 1 @@ -173,7 +198,7 @@ export function readOpenCodeTranscriptPage(args: { hasMore = rows.length > limit const selected = hasMore ? rows.slice(0, limit) : rows cursor = selected.at(-1)!.message_rowid - const mapped = mapMessageRows(db, args.sessionId, selected, rawBudget) + const mapped = mapMessageRows(db, args.sessionId, selected, rawBudget, args.agent) pageBytes += Buffer.byteLength(JSON.stringify(mapped)) if (pageBytes > 16 * 1024 * 1024) { throw new Error('OpenCode transcript page exceeds its byte limit') @@ -206,6 +231,7 @@ export function readOpenCodeTranscriptPage(args: { type MessageRow = { message_rowid: number + transcript_order: number id: string time_created: number time_updated: number @@ -222,7 +248,8 @@ function mapMessageRows( db: SyncDatabase, sessionId: string, rows: MessageRow[], - rawBudget: { bytes: number } + rawBudget: { bytes: number }, + agent?: 'zcode' ): OpenCodeTranscriptItem[] { if (rows.length === 0) { return [] @@ -234,9 +261,9 @@ function mapMessageRows( const placeholders = batch.map(() => '?').join(', ') const partRows = rowsWithinBudget( db.prepare( - `SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part + `SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part p WHERE session_id = ? AND message_id IN (${placeholders}) - ORDER BY rowid LIMIT 10001` + ORDER BY ${agent === 'zcode' ? zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC') : 'rowid'} LIMIT 10001` ), rawBudget, sessionId, @@ -273,7 +300,11 @@ function mapMessageRows( items.push({ rowid: row.message_rowid, fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, - message: { ...message, transcriptOffset: row.message_rowid } + message: { + ...message, + transcriptOffset: row.message_rowid, + ...(agent === 'zcode' ? { transcriptOrder: row.transcript_order } : {}) + } }) } } diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts index cce4b243cf8a..caa7e574a088 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.test.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts @@ -11,6 +11,24 @@ import { import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' const fixtures: { db: Database.Database; root: string; stop: () => void }[] = [] +it.each([undefined, 'zcode'] as const)( + 'discovers the subscribed session in its own store (%s)', + async (agent) => { + const resolveDbPath = vi.fn(async () => null) + const subscription = subscribeOpenCodeNativeChatTranscript( + { agent: agent ?? 'opencode', sessionId: 'second-database-session', onAppend: vi.fn() }, + undefined, + { resolveDbPath }, + agent + ) + subscription.unsubscribe() + expect(resolveDbPath).toHaveBeenCalledWith( + 'second-database-session', + expect.any(AbortSignal), + agent + ) + } +) afterEach(() => { for (const { db, root, stop } of fixtures.splice(0)) { stop() diff --git a/src/main/native-chat/transcript-opencode-subscribe.ts b/src/main/native-chat/transcript-opencode-subscribe.ts index c1997f3ed550..637218d04ee4 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.ts @@ -1,3 +1,5 @@ +import { pruneOpenCodeFingerprintCache } from './transcript-opencode-fingerprint-window' +export { pruneOpenCodeFingerprintCache as pruneOpenCodeFingerprintCacheForTest } from './transcript-opencode-fingerprint-window' import { errorMessage } from '../ai-vault/session-scanner-values' import { DESKTOP_READ_WINDOW, @@ -6,7 +8,7 @@ import { type SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import type { OpenCodeTranscriptItem } from './transcript-opencode-sqlite-query' -import { bindOpenCodeTranscriptDeps, type OpenCodeTranscriptDeps } from './transcript-opencode' +import { openCodeTranscriptDefaultDeps, type OpenCodeTranscriptDeps } from './transcript-opencode' import { openCodeTranscriptPageLimit, OPENCODE_TRANSCRIPT_MAX_WINDOW @@ -14,30 +16,21 @@ import { const OPENCODE_POLL_MS = 1_000 -function pruneOpenCodeFingerprintCache(fingerprints: Map, cap: number): void { - if (fingerprints.size <= cap) { - return - } - const keep = new Set([...fingerprints.keys()].sort((a, b) => b - a).slice(0, cap)) - for (const rowid of fingerprints.keys()) { - if (!keep.has(rowid)) { - fingerprints.delete(rowid) - } - } -} - -export const pruneOpenCodeFingerprintCacheForTest = pruneOpenCodeFingerprintCache - export function subscribeOpenCodeNativeChatTranscript( args: SubscribeNativeChatTranscriptArgs, setupSignal?: AbortSignal, deps: OpenCodeTranscriptDeps = {}, - // When 'zcode': discovery probes ZCode's database, reads drop hidden rows. agent?: 'zcode' ): NativeChatTranscriptSubscription { setupSignal?.throwIfAborted() const controller = new AbortController() - const bound = bindOpenCodeTranscriptDeps(deps, controller.signal, agent) + const resolveDbPath = deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath + const readSignal = deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal + const readPage = (page: Parameters>[0]) => + (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)( + { ...page, ...(agent ? { agent } : {}) }, + controller.signal + ) const pollMs = args.resolvePollIntervalMs ?? OPENCODE_POLL_MS const initialLimit = openCodeTranscriptPageLimit( args.initialLimit && args.initialLimit > 0 ? args.initialLimit : DESKTOP_READ_WINDOW @@ -107,7 +100,7 @@ export function subscribeOpenCodeNativeChatTranscript( return } try { - dbPath ??= await bound.resolveDbPath() + dbPath ??= await resolveDbPath(args.sessionId, controller.signal, agent) if (closed) { return } @@ -116,7 +109,7 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } - const signal = await bound.readSignal(dbPath, args.sessionId) + const signal = await readSignal(dbPath, args.sessionId, controller.signal, agent) if (closed) { return } @@ -131,7 +124,7 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } - const page = await bound.readPage({ + const page = await readPage({ dbPath, sessionId: args.sessionId, limit: firstSnapshot @@ -165,6 +158,17 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } + // Imported rows can precede the frontier regardless of their storage rowid. + if (agent === 'zcode') { + if ( + await replaceWithBridgedWindow(dbPath, page, signal.messageCount < lastCounts.messages) + ) { + lastSignal = fingerprint + lastCounts = { messages: signal.messageCount, parts: signal.partCount } + } + scheduleTick() + return + } let changed = page.items.some( (item) => item.rowid <= lastEmittedRowId && fingerprints.get(item.rowid) !== item.fingerprint @@ -183,7 +187,7 @@ export function subscribeOpenCodeNativeChatTranscript( const displaced = [...fingerprints.keys()].filter((rowid) => rowid < oldest) if (displaced.length > 0) { // Verify the displaced fringe: cap eviction and a real deletion look identical in the tail. - const older = await bound.readPage({ + const older = await readPage({ dbPath, sessionId: args.sessionId, limit: displaced.length, @@ -246,7 +250,7 @@ export function subscribeOpenCodeNativeChatTranscript( lastEmittedRowId = item.rowid } } - pruneOpenCodeFingerprintCache(fingerprints, OPENCODE_TRANSCRIPT_MAX_WINDOW) + pruneOpenCodeFingerprintCache(fingerprints, OPENCODE_TRANSCRIPT_MAX_WINDOW, agent) windowLimit = Math.max(windowLimit, fingerprints.size) } @@ -257,14 +261,14 @@ export function subscribeOpenCodeNativeChatTranscript( // overlap, so the gap retries instead of dropping rows. async function replaceWithBridgedWindow( db: string, - page: Awaited>, + page: Awaited>, shrinking = false ): Promise { let limit = Math.max(windowLimit, page?.items.length ?? 0) for (;;) { const replacement = page ?? - (await bound.readPage({ + (await readPage({ dbPath: db, sessionId: args.sessionId, limit @@ -283,7 +287,11 @@ export function subscribeOpenCodeNativeChatTranscript( emitSafely(() => args.onReplace?.([], hasMore, before), 'replace') return true } - if (shrinking || oldest <= lastEmittedRowId || !replacement.hasMore) { + const overlaps = + agent === 'zcode' + ? replacement.items.some((item) => fingerprints.has(item.rowid)) + : oldest <= lastEmittedRowId + if (shrinking || overlaps || !replacement.hasMore) { fingerprints.clear() lastEmittedRowId = 0 rememberItems(replacement.items) diff --git a/src/main/native-chat/transcript-opencode.test.ts b/src/main/native-chat/transcript-opencode.test.ts index 6cf17a8cb75d..7b49dcac3f26 100644 --- a/src/main/native-chat/transcript-opencode.test.ts +++ b/src/main/native-chat/transcript-opencode.test.ts @@ -66,6 +66,22 @@ describe('readOpenCodeNativeChatTranscriptFull', () => { expect(result).toEqual({ error: 'Transcript unavailable', notFound: true }) }) + it('reads the selected ZCode store on every full-read page', async () => { + const resolveDbPath = vi.fn(async () => '/fake/zcode.db') + const readPage = vi.fn(async () => ({ items: [], hasMore: false, beforeMessageRowId: null })) + await readOpenCodeNativeChatTranscriptFull( + 'z-session', + { resolveDbPath, readPage }, + undefined, + 'zcode' + ) + expect(resolveDbPath).toHaveBeenCalledWith('z-session', undefined, 'zcode') + expect(readPage).toHaveBeenCalledWith( + { dbPath: '/fake/zcode.db', sessionId: 'z-session', limit: 500, agent: 'zcode' }, + undefined + ) + }) + it('returns a retryable error when DB discovery throws', async () => { const result = await readOpenCodeNativeChatTranscriptFull('ses-1', { resolveDbPath: async () => { diff --git a/src/main/native-chat/transcript-opencode.ts b/src/main/native-chat/transcript-opencode.ts index eada2eca9854..93f14b3bbc63 100644 --- a/src/main/native-chat/transcript-opencode.ts +++ b/src/main/native-chat/transcript-opencode.ts @@ -54,40 +54,6 @@ export const openCodeTranscriptDefaultDeps: Required = { readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal) } -/** - * Resolve a subscribe's read legs against its own abort signal, with the agent - * tag (when any) already applied to every leg. Kept here so the poll loop stays - * free of default-vs-override plumbing. - */ -export function bindOpenCodeTranscriptDeps( - deps: OpenCodeTranscriptDeps, - signal: AbortSignal, - agent?: 'zcode' -): { - resolveDbPath: () => Promise - readSignal: (dbPath: string, sessionId: string) => Promise - readPage: ( - page: Parameters>[0] - ) => Promise -} { - return { - resolveDbPath: () => - (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(undefined, signal, agent), - readSignal: (dbPath, sessionId) => - (deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)( - dbPath, - sessionId, - signal, - agent - ), - readPage: (page) => - (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)( - { ...page, ...(agent ? { agent } : {}) }, - signal - ) - } -} - export type OpenCodeTailResult = | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } | { error: string; notFound?: true } @@ -144,7 +110,8 @@ export async function readOpenCodeNativeChatTranscriptFull( try { const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( sessionId, - signal + signal, + agent ) if (!dbPath) { return { error: 'Transcript unavailable', notFound: true } diff --git a/src/main/native-chat/transcript-zcode.test.ts b/src/main/native-chat/transcript-zcode.test.ts new file mode 100644 index 000000000000..31fcc45b5b75 --- /dev/null +++ b/src/main/native-chat/transcript-zcode.test.ts @@ -0,0 +1,254 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import Database from '../sqlite/sync-database' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' +import { + readOpenCodeNativeChatTranscriptFull, + readOpenCodeNativeChatTranscriptTail +} from './transcript-opencode' +import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' +import { handleOpenCodeSqliteRequest } from '../ai-vault/session-scanner-opencode-sqlite-dispatch' + +const cleanups: (() => void)[] = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0).toReversed()) { + cleanup() + } + vi.useRealTimers() +}) + +function fixture(sequenced = true) { + const root = mkdtempSync(join(tmpdir(), 'orca-zcode-transcript-')) + const dbPath = join(root, 'db.sqlite') + const db = new Database(dbPath) + cleanups.push(() => { + db.close() + rmSync(root, { recursive: true, force: true }) + }) + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER${sequenced ? ', sequence INTEGER' : ''}); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER${sequenced ? ', sequence INTEGER' : ''}); + INSERT INTO session VALUES ('session'); + `) + const insert = (id: string, sequence: number | null, visibility?: string) => { + const values = [ + id, + 'session', + JSON.stringify({ role: 'user', semantics: { transcriptVisibility: visibility } }), + 1, + 1 + ] + db.prepare(`INSERT INTO message VALUES (?, ?, ?, ?, ?${sequenced ? ', ?' : ''})`).run( + ...values, + ...(sequenced ? [sequence] : []) + ) + db.prepare(`INSERT INTO part VALUES (?, ?, ?, ?, 1, 1${sequenced ? ', ?' : ''})`).run( + id, + id, + 'session', + JSON.stringify({ type: 'text', text: id }), + ...(sequenced ? [0] : []) + ) + } + const deps = { + resolveDbPath: vi.fn(async () => dbPath), + readSignal: async (path: string, sessionId: string, _signal?: AbortSignal, agent?: 'zcode') => + readOpenCodeTranscriptSignal(path, sessionId, agent), + readPage: async (args: Parameters[0]) => + readOpenCodeTranscriptPage(args) + } + return { db, dbPath, insert, deps } +} + +it.each([true, false])( + 'filters hidden messages through full, tail and worker reads (sequence=%s)', + async (sequenced) => { + const f = fixture(sequenced) + f.insert('legacy-visible', 0) + f.insert('hidden', 1, 'hidden') + f.insert('visible', 2, 'visible') + expect(readOpenCodeTranscriptSignal(f.dbPath, 'session', 'zcode')).toMatchObject({ + messageCount: 2, + partCount: 2 + }) + expect(readOpenCodeTranscriptSignal(f.dbPath, 'session')).toMatchObject({ + messageCount: 3, + partCount: 3 + }) + const full = await readOpenCodeNativeChatTranscriptFull('session', f.deps, undefined, 'zcode') + expect(full).toMatchObject({ messages: [{ id: 'legacy-visible' }, { id: 'visible' }] }) + const tail = await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'session', limit: 1, agent: 'zcode' }, + f.deps + ) + expect(tail).toMatchObject({ messages: [{ id: 'visible' }], hasMore: true }) + const response = await handleOpenCodeSqliteRequest({ + id: 1, + kind: 'native-page', + dbPath: f.dbPath, + sessionId: 'session', + limit: 10, + agent: 'zcode' + }) + expect(response).toMatchObject({ + ok: true, + value: { items: [{ message: { id: 'legacy-visible' } }, { message: { id: 'visible' } }] } + }) + } +) + +it('paginates imported messages and parts by their recorded sequence', async () => { + const f = fixture() + f.insert('last', 2) + f.insert('first', 0) + f.insert('hidden', 1, 'hidden') + f.insert('second', 1) + f.insert('legacy-null', null) + f.db.prepare('UPDATE part SET sequence = 1 WHERE id = ?').run('second') + f.db + .prepare('INSERT INTO part VALUES (?, ?, ?, ?, 1, 1, 0)') + .run( + 'earlier-part', + 'second', + 'session', + JSON.stringify({ type: 'text', text: 'earlier part' }) + ) + const full = await readOpenCodeNativeChatTranscriptFull('session', f.deps, undefined, 'zcode') + expect(full).toMatchObject({ + messages: [ + { id: 'first' }, + { id: 'second', blocks: [{ text: 'earlier part' }, { text: 'second' }] }, + { id: 'last' }, + { id: 'legacy-null' } + ] + }) + const collected: string[] = [] + let cursor: number | undefined + for (let index = 0; index < 5; index++) { + const page = readOpenCodeTranscriptPage({ + dbPath: f.dbPath, + sessionId: 'session', + limit: 1, + agent: 'zcode', + beforeMessageRowId: cursor + }) + collected.unshift(...(page?.items.map((item) => item.message.id) ?? [])) + if (!page?.hasMore) { + break + } + cursor = page.beforeMessageRowId ?? undefined + } + expect(collected).toEqual(['first', 'second', 'last', 'legacy-null']) +}) + +it('reconciles visibility changes, ordered imports and deletions in a live chat', async () => { + vi.useFakeTimers() + const f = fixture() + f.insert('last', 2) + f.insert('first', 0) + f.insert('hidden', 1, 'hidden') + let displayed: NativeChatMessage[] = [] + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'zcode', + sessionId: 'session', + resolvePollIntervalMs: 5, + onInitialSnapshot: (messages) => { + displayed = messages + }, + onReplace: (messages) => { + displayed = messages + }, + onAppend: (messages) => { + displayed.push(...messages) + } + }, + undefined, + f.deps, + 'zcode' + ) + cleanups.push(subscription.unsubscribe) + await vi.advanceTimersByTimeAsync(0) + expect(displayed.map((message) => message.id)).toEqual(['first', 'last']) + f.db + .prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?') + .run(JSON.stringify({ role: 'user' }), 'hidden') + await vi.advanceTimersByTimeAsync(5) + expect(displayed.map((message) => message.id)).toEqual(['first', 'hidden', 'last']) + f.insert('middle', 1) + await vi.advanceTimersByTimeAsync(5) + expect(displayed.map((message) => message.id)).toEqual(['first', 'hidden', 'middle', 'last']) + f.db.prepare('DELETE FROM message WHERE id = ?').run('first') + f.db.prepare('DELETE FROM part WHERE message_id = ?').run('first') + await vi.advanceTimersByTimeAsync(5) + expect(displayed.map((message) => message.id)).toEqual(['hidden', 'middle', 'last']) +}) + +it('keeps a paging cursor stable when earlier messages are removed or imported', () => { + const f = fixture() + f.insert('last', 3) + f.insert('first', 0) + f.insert('middle', 2) + const newest = readOpenCodeTranscriptPage({ + dbPath: f.dbPath, + sessionId: 'session', + limit: 1, + agent: 'zcode' + }) + f.db.prepare('DELETE FROM message WHERE id = ?').run('first') + f.db.prepare('DELETE FROM part WHERE message_id = ?').run('first') + f.insert('imported', 1) + const older = readOpenCodeTranscriptPage({ + dbPath: f.dbPath, + sessionId: 'session', + limit: 10, + agent: 'zcode', + beforeMessageRowId: newest?.beforeMessageRowId ?? undefined + }) + expect(older?.items.map((item) => item.message.id)).toEqual(['imported', 'middle']) +}) + +it('observes a balanced visibility swap even when part counts and times stay unchanged', async () => { + vi.useFakeTimers() + const f = fixture() + f.insert('first', 0) + f.insert('hidden', 1, 'hidden') + f.insert('last', 2) + let displayed: NativeChatMessage[] = [] + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'zcode', + sessionId: 'session', + resolvePollIntervalMs: 5, + onInitialSnapshot: (messages) => { + displayed = messages + }, + onReplace: (messages) => { + displayed = messages + }, + onAppend: (messages) => { + displayed.push(...messages) + } + }, + undefined, + f.deps, + 'zcode' + ) + cleanups.push(subscription.unsubscribe) + await vi.advanceTimersByTimeAsync(0) + f.db + .prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?') + .run(JSON.stringify({ role: 'user', semantics: { transcriptVisibility: 'hidden' } }), 'first') + f.db + .prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?') + .run(JSON.stringify({ role: 'user' }), 'hidden') + await vi.advanceTimersByTimeAsync(5) + expect(displayed.map((message) => message.id)).toEqual(['hidden', 'last']) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts index 1ebdf1056dc1..9aa659da6683 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts @@ -15,6 +15,7 @@ function oversizedRow(message: NativeChatMessage): NativeChatMessage { blocks: [{ type: 'text', text, presentation }], timestamp: message.timestamp, source: message.source, + ...(message.transcriptOrder !== undefined ? { transcriptOrder: message.transcriptOrder } : {}), ...(message.transcriptOffset !== undefined ? { transcriptOffset: message.transcriptOffset } : {}) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 081086bae923..26127d797e34 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -46,13 +46,7 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'claude' || agent === 'openclaude') { return 'claude' } - if (agent === 'opencode' || agent === 'opencode2') { - return 'opencode' - } - // Why: ZCode's CLI stores an OpenCode-v1-shaped history in its own SQLite DB - // (~/.zcode/cli/db/db.sqlite), so its transcripts read through the same - // OpenCode SQLite reader as OpenCode 1.x — the agent identity stays zcode. - if (agent === 'zcode') { + if (agent === 'opencode' || agent === 'opencode2' || agent === 'zcode') { return 'opencode' } if (agent === 'codex' || agent === 'grok' || agent === 'omp') { diff --git a/src/shared/native-chat-reasoning-order.test.ts b/src/shared/native-chat-reasoning-order.test.ts index ea3fd56305b4..d5b7ae84ea8c 100644 --- a/src/shared/native-chat-reasoning-order.test.ts +++ b/src/shared/native-chat-reasoning-order.test.ts @@ -44,3 +44,18 @@ it('keeps timestamp and journal authority ahead of a derived reasoning key', () laterReasoning.timestamp = 0 expect(compareNativeChatTranscriptMessages(answer, laterReasoning)).toBeLessThan(0) }) + +it('preserves a provider sequence ahead of timestamps and keeps split reasoning beside its answer', () => { + const messages = [ + row('pending-send', 'user', 50), + { ...row('a', 'assistant', 0), transcriptOrder: 2 }, + { ...row('z', 'assistant', 100), transcriptOrder: 1 }, + { ...row('z:reasoning', 'reasoning', 100), transcriptOrder: 1 } + ] + expect(projectNativeChatTranscriptMessages(messages).map((message) => message.id)).toEqual([ + 'z:reasoning', + 'z', + 'a', + 'pending-send' + ]) +}) diff --git a/src/shared/native-chat-transcript-projection.ts b/src/shared/native-chat-transcript-projection.ts index 8833d6c83040..70d6283341c4 100644 --- a/src/shared/native-chat-transcript-projection.ts +++ b/src/shared/native-chat-transcript-projection.ts @@ -54,6 +54,16 @@ export function compareNativeChatTranscriptMessages( if (a.journalPosition || b.journalPosition) { return a.journalPosition ? -1 : 1 } + if ( + a.transcriptOrder !== undefined && + b.transcriptOrder !== undefined && + a.transcriptOrder !== b.transcriptOrder + ) { + return a.transcriptOrder - b.transcriptOrder + } + if ((a.transcriptOrder === undefined) !== (b.transcriptOrder === undefined)) { + return a.transcriptOrder === undefined ? 1 : -1 + } return compareNativeChatMessagesByTime(a, b) } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 46955890ac55..28e9f86f9f3d 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -219,6 +219,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { source: NativeChatSource /** Optional provider row cursor; split projections share it for whole-row paging. */ transcriptOffset?: number + /** Display order when the provider's sequence differs from its stable paging cursor. */ + transcriptOrder?: number /** Model id that produced an assistant response, as the provider API names it. */ model?: string /** The agent's provider that served `model`, where the agent records one. */ diff --git a/tests/e2e/native-chat-zcode-transcript.spec.ts b/tests/e2e/native-chat-zcode-transcript.spec.ts new file mode 100644 index 000000000000..f9510d51446b --- /dev/null +++ b/tests/e2e/native-chat-zcode-transcript.spec.ts @@ -0,0 +1,88 @@ +import { DatabaseSync } from 'node:sqlite' +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import { + enableNativeChatSetting, + toggleTerminalTabToChatView +} from './helpers/native-chat-transcript-fixture' + +test('ZCode native chat reads ordered visible history and follows new messages', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const home = await electronApp.evaluate(({ app }) => app.getPath('home')) + const dbPath = path.join(home, '.zcode', 'cli', 'db', 'db.sqlite') + mkdirSync(path.dirname(dbPath), { recursive: true }) + const db = new DatabaseSync(dbPath) + try { + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT, sequence INTEGER); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT, sequence INTEGER); + INSERT INTO session VALUES ('zcode-proof'); + `) + const insert = (id: string, role: string, text: string, sequence: number, hidden = false) => { + db.prepare('INSERT INTO message VALUES (?, ?, 1, 1, ?, ?)').run( + id, + 'zcode-proof', + JSON.stringify({ + role, + semantics: { transcriptVisibility: hidden ? 'hidden' : 'visible' } + }), + sequence + ) + db.prepare('INSERT INTO part VALUES (?, ?, ?, 1, 1, ?, 0)').run( + id, + id, + 'zcode-proof', + JSON.stringify({ type: 'text', text }) + ) + } + insert('answer', 'assistant', 'The stored conversation now opens in native chat.', 2) + insert('question', 'user', 'Show my saved ZCode conversation.', 0) + insert('hidden', 'user', 'HIDDEN BOOKKEEPING MUST NOT RENDER', 1, true) + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId] = descriptor.paneKey.split(':') + await enableNativeChatSetting(orcaPage) + await orcaPage.evaluate(({ paneKey, worktreeId }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'waiting', prompt: '', agentType: 'zcode' }, + 'ZCode', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: 'zcode-proof' } } + ) + }, descriptor) + await orcaPage.screenshot({ path: testInfo.outputPath('01-terminal-before-chat.png') }) + await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId }) + const chat = orcaPage.locator('[data-native-chat-root="true"]') + await expect(chat).toBeVisible({ timeout: 15_000 }) + await expect(chat.getByText('Show my saved ZCode conversation.')).toBeVisible({ + timeout: 30_000 + }) + await expect(chat.getByText('The stored conversation now opens in native chat.')).toBeVisible() + await expect(chat.getByText('HIDDEN BOOKKEEPING MUST NOT RENDER')).toHaveCount(0) + const transcriptText = await chat.innerText() + expect(transcriptText.indexOf('Show my saved ZCode conversation.')).toBeLessThan( + transcriptText.indexOf('The stored conversation now opens in native chat.') + ) + await orcaPage.screenshot({ path: testInfo.outputPath('02-ordered-history.png') }) + insert('followup', 'assistant', 'Live updates also reach this conversation.', 3) + await expect(chat.getByText('Live updates also reach this conversation.')).toBeVisible({ + timeout: 15_000 + }) + await orcaPage.screenshot({ path: testInfo.outputPath('03-live-update.png') }) + } finally { + db.close() + } +}) From 4fd739801352318d2470b2afeaaba50edcfb97f6 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 21:44:54 -0700 Subject: [PATCH 046/110] test(native-chat): run ZCode SQLite fixtures on the production runtime --- config/scripts/vitest-sqlite-database-runtime-files.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 0d2c20aeb30c..2777ad707ef5 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -18,6 +18,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts', 'src/main/native-chat/transcript-opencode-sqlite-query.test.ts', 'src/main/native-chat/transcript-opencode-subscribe.test.ts', + 'src/main/native-chat/transcript-zcode.test.ts', 'src/main/native-chat/transcript-opencode2-sqlite.test.ts', 'src/main/opencode-usage/cache-read-reproduction.test.ts', 'src/main/opencode-usage/opencode2-session-usage.test.ts', From 3d51f67f2f4cf2ae5b73eb573cd228deb0706a7f Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 22:03:18 -0700 Subject: [PATCH 047/110] test(native-chat): seed ZCode through the execution host --- .../NativeChatSupportedAgents.test.tsx | 3 +- .../e2e/native-chat-zcode-transcript.spec.ts | 39 +++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx index e087d0be7a37..860ce62e7aa5 100644 --- a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx +++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx @@ -19,7 +19,8 @@ const EXPECTED_SUPPORTED_AGENTS = [ 'grok', 'omp', 'opencode', - 'opencode2' + 'opencode2', + 'zcode' ] as const satisfies readonly TuiAgent[] const SUPPORTED_AGENTS_LABEL_KEY = 'auto.components.settings.NativeChatSupportedAgents.label' diff --git a/tests/e2e/native-chat-zcode-transcript.spec.ts b/tests/e2e/native-chat-zcode-transcript.spec.ts index f9510d51446b..f96e18066542 100644 --- a/tests/e2e/native-chat-zcode-transcript.spec.ts +++ b/tests/e2e/native-chat-zcode-transcript.spec.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import { readHookEndpoint } from './helpers/agent-hook-endpoint' import { enableNativeChatSetting, toggleTerminalTabToChatView @@ -51,18 +52,34 @@ test('ZCode native chat reads ordered visible history and follows new messages', const descriptor = await waitForActivePaneHookDescriptor(orcaPage) const [tabId] = descriptor.paneKey.split(':') await enableNativeChatSetting(orcaPage) - await orcaPage.evaluate(({ paneKey, worktreeId }) => { - window.__store - ?.getState() - .setAgentStatus( - paneKey, - { state: 'waiting', prompt: '', agentType: 'zcode' }, - 'ZCode', - undefined, - { worktreeId }, - { providerSession: { key: 'session_id', id: 'zcode-proof' } } + const endpoint = await readHookEndpoint(electronApp) + const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/zcode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify({ + ...descriptor, + tabId, + env: endpoint.env, + version: endpoint.version, + payload: { + hook_event_name: 'SessionStart', + source: 'resume', + session_id: 'zcode-proof' + } + }) + }) + expect(response.status).toBe(204) + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store?.getState().agentStatusByPaneKey[paneKey]?.agentType, + descriptor.paneKey ) - }, descriptor) + ) + .toBe('zcode') await orcaPage.screenshot({ path: testInfo.outputPath('01-terminal-before-chat.png') }) await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId }) const chat = orcaPage.locator('[data-native-chat-root="true"]') From b74e9464eb520993edda349c54c79c351bbc5abd Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 22:12:19 -0700 Subject: [PATCH 048/110] fix(native-chat): prepare SQLite readers for ZCode-only WSL homes --- .../opencode-wsl-runtime-preparation.test.ts | 15 +++++++++------ .../ai-vault/opencode-wsl-runtime-preparation.ts | 3 ++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index 8ba01bf46176..923baf824d41 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -263,7 +263,7 @@ describe('WSL SQLite runtime preparation', () => { mocks.run.mockResolvedValueOnce(success('')) await prepare([home]) await vi.waitFor(async () => - expect((await prepare([home]))[0]?.error).toContain('No OpenCode database') + expect((await prepare([home]))[0]?.error).toContain('No OpenCode or ZCode database') ) expect(mocks.run).toHaveBeenCalledOnce() expect(mocks.download).not.toHaveBeenCalled() @@ -274,16 +274,19 @@ describe('WSL SQLite runtime preparation', () => { expect(mocks.running).toHaveBeenCalledWith(expect.any(Array), { requireConfirmed: true }) }) - it.skipIf(process.platform === 'win32')( - 'keeps the discovery default visible when guest overrides point elsewhere', - async () => { + it.skipIf(process.platform === 'win32').each(['opencode', 'zcode'] as const)( + 'prepares a %s-only distro despite unrelated OpenCode overrides', + async (agent) => { await prepared() const script = mocks.run.mock.calls[0]?.[0].script const directory = await mkdtemp(join(tmpdir(), 'orca-wsl-presence-')) try { - const data = join(directory, '.local', 'share', 'opencode') + const data = + agent === 'zcode' + ? join(directory, '.zcode', 'cli', 'db') + : join(directory, '.local', 'share', 'opencode') await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') + await writeFile(join(data, agent === 'zcode' ? 'db.sqlite' : 'opencode.db'), '') const result = await runProcess({ program: '/bin/sh', args: ['-c', script], diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index 1f242772acd3..c107a3082483 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -113,6 +113,7 @@ async function prepare(distro: string): Promise { const hasDatabase = await run({ script: [ 'data="${XDG_DATA_HOME:-$HOME/.local/share}/opencode"', + 'if [ -f "$HOME/.zcode/cli/db/db.sqlite" ]; then printf present; exit 0; fi', // WSL discovery still enumerates the default data root independently of guest overrides. 'for db in "$HOME/.local/share/opencode"/opencode.db "$HOME/.local/share/opencode"/opencode-*.db; do if [ -f "$db" ]; then printf present; exit 0; fi; done', 'case "${OPENCODE_DB-}" in', @@ -125,7 +126,7 @@ async function prepare(distro: string): Promise { loginPath: 'none' }) if (hasDatabase !== 'present') { - return { distro, error: 'No OpenCode database is present in this WSL distro.' } + return { distro, error: 'No OpenCode or ZCode database is present in this WSL distro.' } } const readerPath = await run({ program: 'wslpath', From dac3ae167c11607afa8f27e6ad6d767be7146668 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:29:15 -0700 Subject: [PATCH 049/110] test(native-chat): verify ZCode readers on every desktop platform --- .../workflows/zcode-transcript-contract.yml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/zcode-transcript-contract.yml diff --git a/.github/workflows/zcode-transcript-contract.yml b/.github/workflows/zcode-transcript-contract.yml new file mode 100644 index 000000000000..bb2b86cec484 --- /dev/null +++ b/.github/workflows/zcode-transcript-contract.yml @@ -0,0 +1,40 @@ +name: ZCode transcript contract +on: + pull_request: + paths: + - 'src/main/ai-vault/session-scanner-zcode*' + - 'src/main/ai-vault/session-scanner-opencode*' + - 'src/main/ai-vault/opencode-wsl-runtime*' + - 'src/main/native-chat/transcript-opencode*' + - 'src/main/native-chat/transcript-zcode*' + - 'src/shared/native-chat-types.ts' + - 'src/shared/native-chat-transcript-projection*' + - '.github/workflows/zcode-transcript-contract.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: zcode-transcript-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + transcript-readers: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: config/.bun-version + - name: Verify SQLite ordering, legacy readers, and host routing + run: pnpm test src/main/native-chat/transcript-zcode.test.ts src/main/native-chat/transcript-opencode.test.ts src/main/native-chat/transcript-opencode-sqlite-query.test.ts src/main/native-chat/transcript-opencode2-sqlite.test.ts src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts src/main/native-chat/transcript-opencode-read-bounds.test.ts src/main/native-chat/transcript-opencode-subscribe.test.ts src/main/ai-vault/session-scanner-zcode.test.ts src/main/ai-vault/session-scanner-zcode-sources.test.ts src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts src/shared/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts From 8b2c795d8a8c4dbf5f8afa618512caa9ce1b2a27 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:32:13 -0700 Subject: [PATCH 050/110] test(native-chat): seed large transcript fixtures in one transaction --- .../native-chat/transcript-opencode-semantic-fidelity.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts index aa30a4b743a5..94d21df4b450 100644 --- a/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts +++ b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts @@ -177,12 +177,14 @@ describe.each(['v1', 'v2'] as const)('%s semantic pagination', (version) => { }) it('reconstructs a full read beyond its 500-message window without losing a split row', async () => { - const { path, insert, prefix } = fixture(version) + const { db, path, insert, prefix } = fixture(version) const expected: string[] = [] + db.exec('BEGIN') for (let index = 0; index < 260; index++) { insert(String(index), mixed()) expected.push(`${prefix}${index}:reasoning`, `${prefix}${index}`) } + db.exec('COMMIT') const result = await readOpenCodeNativeChatTranscriptFull('session', { resolveDbPath: async () => path, readPage: async (args) => readOpenCodeTranscriptPage(args) From c445584584ae373647884950dad42b605b24f8bb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:05:13 -0400 Subject: [PATCH 051/110] feat(relay): a staging cell-flags workflow, trusted by the staging relay deploy identity (#27113) Rebased onto main as one commit after its parent squash-merged. --- ...cloud-operate-relay-staging-cell-flags.yml | 125 ++++++++++++++++++ .../relay-public-workflow-contract.test.mjs | 2 +- .../relay-staging-deploy-identity.test.mjs | 9 +- ...oad-identity-attribute-conditions.test.mjs | 2 +- .../terraform/relay-staging-deploy-iam.tf | 12 +- 5 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/cloud-operate-relay-staging-cell-flags.yml diff --git a/.github/workflows/cloud-operate-relay-staging-cell-flags.yml b/.github/workflows/cloud-operate-relay-staging-cell-flags.yml new file mode 100644 index 000000000000..8a239c325dc4 --- /dev/null +++ b/.github/workflows/cloud-operate-relay-staging-cell-flags.yml @@ -0,0 +1,125 @@ +name: Operate Relay Staging Cell Flags + +# The staging twin of Operate Relay Production Cell Flags: writes one staging cell's runtime +# switch object and waits for the cell to report it applied. One cell per run. dry-run writes +# nothing. +on: + workflow_dispatch: + inputs: + mode: + description: dry-run prints the change; write applies it + required: true + default: dry-run + type: choice + options: [dry-run, write] + cell-id: + description: Exact cell id, e.g. staging-gce-c2 + required: true + type: string + break-glass: + description: >- + Only for a dead or unreachable reserve cell, with set admitMode=default. Typed as + BREAK GLASS ; records db without the cell's read-back + required: false + default: '' + type: string + confirm-reserve: + description: Required for admitMode=reserve, typed as RESERVE + required: false + default: '' + type: string + set: + description: >- + Switches to change, e.g. readinessLocal=true,admitMode=reserve (key=default removes a + key). Every other switch keeps its current value. Known: readinessLocal, ticketCheck, + admitMode, intakePerSec, reserveDryRun, rejectionFence, readTimeoutMarginMs, + reregisterInFlight + required: true + type: string + expected-generation: + description: Exact current object generation from a dry run, or 0 if none exists + required: true + type: string + confirmation: + description: Required for write, exactly "WRITE_RELAY_CELL_FLAGS " + required: false + type: string + +permissions: + contents: read + id-token: write + +# Not relay-staging-mutation: a switch write must run while a staging deploy or load run is +# in flight, and it touches neither Cloud SQL nor a revision. +concurrency: + group: relay-staging-cell-flags + cancel-in-progress: false + +defaults: + run: + # Explicit bash runs with pipefail, so the script's exit code survives the tee. + shell: bash + working-directory: cloud + +jobs: + operate: + if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' }} + runs-on: ubuntu-latest + # A flip back waits for the cell to lease every control: 8-50 minutes, scaled to its controls. + timeout-minutes: 60 + environment: staging + env: + MODE: ${{ inputs.mode }} + CELL_ID: ${{ inputs.cell-id }} + FLAG_CHANGES: ${{ inputs.set }} + EXPECTED_GENERATION: ${{ inputs.expected-generation }} + CONFIRMATION: ${{ inputs.confirmation }} + CONFIRM_RESERVE: ${{ inputs.confirm-reserve }} + BREAK_GLASS: ${{ inputs.break-glass }} + GCP_PROJECT_ID: onorca-cloud-staging + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - id: google-auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.STAGING_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.STAGING_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }} + token_format: id_token + id_token_audience: https://relay-staging.onorca.dev/v1/admin/drain + id_token_include_email: true + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Write or preview the cell's switch object + env: + ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.google-auth.outputs.id_token }} + run: | + [[ "${CELL_ID}" =~ ^staging-gce-c[0-9]{1,3}$ ]] + ORCA_RELAY_CONTROL_ACCESS_TOKEN="$(gcloud auth print-access-token)" + echo "::add-mask::${ORCA_RELAY_CONTROL_ACCESS_TOKEN}" + export ORCA_RELAY_CONTROL_ACCESS_TOKEN + node dev/scripts/operate-relay-cell-flags.mjs \ + --mode "${MODE}" --cell-id "${CELL_ID}" \ + --cell-origin "https://${CELL_ID#staging-gce-}.relay-staging.onorca.dev" \ + --project-id "${GCP_PROJECT_ID}" \ + --set "${FLAG_CHANGES}" \ + --director-origin "https://relay-staging.onorca.dev" --confirm-reserve "${CONFIRM_RESERVE}" \ + --break-glass "${BREAK_GLASS}" \ + --expected-generation "${EXPECTED_GENERATION}" --confirmation "${CONFIRMATION}" \ + | tee "${RUNNER_TEMP}/relay-cell-flags.jsonl" + + - name: Audit summary + if: ${{ always() }} + run: | + { + echo "### Relay staging cell flags: ${MODE} ${CELL_ID}" + echo "Triggered by: ${GITHUB_TRIGGERING_ACTOR}, run ${GITHUB_RUN_ID} attempt ${GITHUB_RUN_ATTEMPT}" + echo '```json' + cat "${RUNNER_TEMP}/relay-cell-flags.jsonl" 2>/dev/null || echo '(no output)' + echo '```' + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/cloud/dev/scripts/relay-public-workflow-contract.test.mjs b/cloud/dev/scripts/relay-public-workflow-contract.test.mjs index 486914aaf3ef..3f34e96a1078 100644 --- a/cloud/dev/scripts/relay-public-workflow-contract.test.mjs +++ b/cloud/dev/scripts/relay-public-workflow-contract.test.mjs @@ -20,7 +20,7 @@ const UNGATED = relayWorkflowFile('verify.yml') const relayWorkflows = () => workflowFiles().filter((file) => file !== UNGATED) test('the copy carries every relay workflow', () => { - assert.equal(relayWorkflows().length, 26) + assert.equal(relayWorkflows().length, 27) }) // Why: workflow_run chains match by display name, not filename. Renaming a file is safe; renaming diff --git a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs index dbb3f1d7a14d..1f4d946557c0 100644 --- a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs +++ b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs @@ -22,12 +22,13 @@ const stagingTfvars = readFileSync( 'utf8' ) -// The exact five staging Relay workflows the relay-owned deploy identity serves. +// The exact six staging Relay workflows the relay-owned deploy identity serves. const DEPLOY_WORKFLOWS = [ 'bootstrap-relay-staging-capacity.yml', 'deploy-relay-staging-gce-candidate.yml', 'deploy-relay-staging.yml', 'operate-relay-asia-admission.yml', + 'operate-relay-staging-cell-flags.yml', 'power-relay-staging.yml' ] @@ -74,7 +75,7 @@ test('no Relay workflow authenticates as the shared staging deploy identity', () } }) -test('the five staging Relay workflows name the relay deploy pair', () => { +test('the six staging Relay workflows name the relay deploy pair', () => { for (const name of DEPLOY_WORKFLOWS) { const source = workflow(name) assert.match(source, /vars\.STAGING_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER\b/, name) @@ -90,7 +91,7 @@ test('the Asia admission production arm keeps the production deploy pair', () => assert.match(source, /vars\.PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT\b/) }) -test('the provider allowlists exactly those five workflow refs', () => { +test('the provider allowlists exactly those six workflow refs', () => { const files = providerWorkflowFiles() assert.deepEqual([...files].sort(), [...DEPLOY_WORKFLOWS].sort()) for (const file of files) { @@ -137,7 +138,7 @@ test('the rendered attribute condition stays inside the provider limit', () => { `(${workflowRefs.map((ref) => `assertion.workflow_ref == '${ref}'`).join(' || ')})` ].join(' && ') assert.ok(rendered.length < 4096, `rendered condition is ${rendered.length} characters`) - assert.equal(rendered.length, 791) + assert.equal(rendered.length, 913) }) // Why: the census is the point. A binding added here without a workflow step behind it, or one diff --git a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs index 5bdc40fcd780..490bfde35206 100644 --- a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs +++ b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs @@ -15,7 +15,7 @@ const EXPECTED_CONDITIONS = { github_staging_relay_capacity: "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-recover-relay-staging-c4-image.yml@refs/heads/main')", github_staging_relay_deploy: - "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-power-relay-staging.yml@refs/heads/main')", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-staging-cell-flags.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-power-relay-staging.yml@refs/heads/main')", github_relay_asia_topology: "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'", github_relay_asia_proof: diff --git a/cloud/infra/terraform/relay-staging-deploy-iam.tf b/cloud/infra/terraform/relay-staging-deploy-iam.tf index ac5f5632d57d..a45652f2f682 100644 --- a/cloud/infra/terraform/relay-staging-deploy-iam.tf +++ b/cloud/infra/terraform/relay-staging-deploy-iam.tf @@ -1,13 +1,14 @@ # The staging Relay deploy identity. # # In production the shared `github_deploy` account is relay-owned; in staging it belongs to -# infra/terraform-apps and four app workflows can also mint it. These resources give the five +# infra/terraform-apps and four app workflows can also mint it. These resources give the six # staging Relay workflows their own account, provider, and bindings so the relay root owns every # credential its own workflows use. # # Bindings that already name local.relay_github_deploy_service_account_member follow that local # (relay-shared.tf) and are NOT repeated here: the staging power custom role, serviceAccountUser -# on relay_runtime and relay_director_runtime, and the three regional-placement secret bindings. +# on relay_runtime and relay_director_runtime, the three regional-placement secret bindings, and +# the cell-flags workflow's relay_control_workflow_writer. # Everything below replaces a grant the apps root still makes to `github_deploy`. locals { @@ -19,6 +20,7 @@ locals { "deploy-relay-staging-gce-candidate.yml", "deploy-relay-staging.yml", "operate-relay-asia-admission.yml", + "operate-relay-staging-cell-flags.yml", "power-relay-staging.yml" ] github_staging_relay_deploy_workflow_clauses = [ @@ -78,7 +80,7 @@ resource "google_service_account_iam_member" "github_staging_relay_deploy_worklo member = "principalSet://iam.googleapis.com/${local.relay_workload_identity_pool_name}/attribute.relay_ops_identity/staging-deploy" } -# Every one of the five runs `terraform init` (or `infra.mjs init --env staging`) first, and the +# Every one but the cell-flags workflow runs `terraform init` (or `infra.mjs init --env staging`) first, and the # GCS backend lists the bucket before it can open the state. Bucket metadata only, no object # access; this mirrors relay_fence_broker_bucket_reader. resource "google_storage_bucket_iam_member" "github_staging_relay_deploy_state_list" { @@ -90,7 +92,7 @@ resource "google_storage_bucket_iam_member" "github_staging_relay_deploy_state_l } # Reads reviewed topology: `terraform output -json relay_gce_cell_deployments` and the -# `terraform console` binds in Deploy Relay Staging. No step in the five applies, so this is +# `terraform console` binds in Deploy Relay Staging. No step in the six applies, so this is # objectViewer, not the capacity identity's objectAdmin, over the same two objects. resource "google_storage_bucket_iam_member" "github_staging_relay_deploy_state" { count = local.create_staging_relay_deploy_identity ? 1 : 0 @@ -110,7 +112,7 @@ resource "google_storage_bucket_iam_member" "github_staging_relay_deploy_state" } # Deploy Relay Staging step "Require the mirrored immutable image" runs -# `gcloud artifacts docker images describe`; nothing in the five writes to the repository. +# `gcloud artifacts docker images describe`; nothing in the six writes to the repository. resource "google_artifact_registry_repository_iam_member" "github_staging_relay_deploy_artifact_reader" { count = local.create_staging_relay_deploy_identity ? 1 : 0 From 15ee25e3a691990e5ba1780aeae7a6d244ab11e2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:05:29 -0400 Subject: [PATCH 052/110] feat(relay): a free-form staging load mode on Prove Relay Asia Staging (#27115) Staging auth mints relay load principals for one workflow file only, so the step-5 staging run's load lives as a mode of that workflow. Load runs take their own concurrency group and no Cloud SQL lease, so deploys and switch writes can run under load. Origins, region, principals and sharding are pinned by the workflow. --- .../cloud-prove-relay-asia-staging.yml | 165 ++++++++++++++++-- .../scripts/cloud-sql-rollout-lock-census.mjs | 12 +- 2 files changed, 163 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cloud-prove-relay-asia-staging.yml b/.github/workflows/cloud-prove-relay-asia-staging.yml index 56677a98600d..4f521dd86b94 100644 --- a/.github/workflows/cloud-prove-relay-asia-staging.yml +++ b/.github/workflows/cloud-prove-relay-asia-staging.yml @@ -3,24 +3,50 @@ name: Prove Relay Asia Staging on: workflow_dispatch: inputs: - image-digest: - description: Exact immutable Relay digest deployed on staging C4 + mode: + description: prove runs the fixed C4 proof; load runs the generator with free-form arguments required: true + default: prove + type: choice + options: [prove, load] + image-digest: + description: Prove only. Exact immutable Relay digest deployed on staging C4 + required: false type: string selector-generation: - description: Exact selector generation with C4 migration-only - required: true + description: Prove only. Exact selector generation with C4 migration-only + required: false type: string promote-attempt-id: - description: Durable unique C4 promotion attempt ID - required: true + description: Prove only. Durable unique C4 promotion attempt ID + required: false type: string rollback-attempt-id: - description: Durable unique C4 rollback attempt ID - required: true + description: Prove only. Durable unique C4 rollback attempt ID + required: false + type: string + load-region: + description: Load only. asia-east2 runs on the Asia load runner; us-central1 on a hosted runner + required: false + default: asia-east2 + type: choice + options: [asia-east2, us-central1] + load-shards: + description: Load only. Generator processes, each with its own shard of the 32 load principals + required: false + default: '4' + type: choice + options: ['1', '2', '3', '4'] + load-args: + description: >- + Load only. Per-shard generator arguments, e.g. --controls 150 --splices 15 + --reconnect-max-seconds 30 --duration-seconds 900. Origins, region, principals and + sharding are set by the workflow + required: false + default: '' type: string confirmation: - description: Enter PROVE_ASIA_STAGING + description: Enter PROVE_ASIA_STAGING (prove) or LOAD_RELAY_STAGING (load) required: true type: string @@ -28,8 +54,10 @@ permissions: contents: read id-token: write +# A load run drives traffic only, so it must not queue behind (or hold up) the staging deploys +# and switch writes it is there to exercise. concurrency: - group: relay-staging-mutation + group: ${{ inputs.mode == 'load' && 'relay-staging-load' || 'relay-staging-mutation' }} cancel-in-progress: false defaults: @@ -38,7 +66,7 @@ defaults: jobs: prove: - if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') }} + if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') && inputs.mode == 'prove' }} runs-on: [self-hosted, linux, x64, relay-asia-east2-load] timeout-minutes: 75 environment: staging @@ -282,7 +310,7 @@ jobs: retention-days: 7 recover: - if: ${{ always() && github.ref == 'refs/heads/main' }} + if: ${{ always() && github.ref == 'refs/heads/main' && inputs.mode == 'prove' }} needs: prove runs-on: blacksmith-2vcpu-ubuntu-2204 timeout-minutes: 15 @@ -331,3 +359,116 @@ jobs: --attempt-id "${ROLLBACK_ATTEMPT_ID}" \ --image-digest "${IMAGE_DIGEST}")" test "$(jq -r '.states["staging-gce-c4"]' <<< "${result}")" = migration-only + + # The staging auth service mints relay load principals only for this workflow file, so the + # free-form staging load lives here rather than in a workflow of its own. + load: + if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') && inputs.mode == 'load' }} + runs-on: ${{ inputs.load-region == 'us-central1' && 'ubuntu-latest' || fromJSON('["self-hosted","linux","x64","relay-asia-east2-load"]') }} + timeout-minutes: 180 + environment: staging + env: + DIRECTOR_ORIGIN: https://relay-staging.onorca.dev + AUTH_ORIGIN: https://auth-staging.onorca.dev + LOAD_REGION: ${{ inputs.load-region }} + LOAD_SHARDS: ${{ inputs.load-shards }} + LOAD_ARGS: ${{ inputs.load-args }} + steps: + - uses: actions/checkout@v4 + + - name: Validate the staging load request + shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: | + set -euo pipefail + test "${CONFIRMATION}" = LOAD_RELAY_STAGING + [[ "${LOAD_REGION}" =~ ^(asia-east2|us-central1)$ ]] + [[ "${LOAD_SHARDS}" =~ ^[1-4]$ ]] + read -r -a args <<< "${LOAD_ARGS}" + for arg in "${args[@]}"; do + [[ "${arg}" =~ ^[A-Za-z0-9=:/._,-]+$ ]] || { echo "unsupported characters in load-args" >&2; exit 1; } + case "${arg}" in + --target-origin|--director-origin|--auth-origin|--signing-key-file|--preferred-region|\ + --relay-asia-load-principals|--shard-count|--shard-index|--phase-barrier-dir) + echo "${arg} is set by the workflow" >&2 + exit 1 + ;; + esac + done + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT }} + token_format: id_token + id_token_audience: https://relay-staging.onorca.dev/v1/admin/drain + id_token_include_email: true + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: cloud/package.json + + - name: Install exact load-harness dependencies + run: pnpm install --frozen-lockfile + + - name: Build the Relay load-harness contract + run: pnpm --filter @orca-cloud/relay-contract build + + - name: Run the sharded staging load + env: + # Probes that read director capacity need an admin token; it lasts about an hour. + ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }} + shell: bash + run: | + set -euo pipefail + load_dir="${RUNNER_TEMP}/relay-staging-load" + mkdir -p "${load_dir}" + fd_limit="$(ulimit -n)" + if test "${fd_limit}" != unlimited; then + [[ "${fd_limit}" =~ ^[0-9]+$ ]] + test "${fd_limit}" -ge 4096 + fi + read -r -a args <<< "${LOAD_ARGS}" + pids=() + stop_shards() { + for pid in "${pids[@]}"; do kill "${pid}" 2>/dev/null || true; done + for pid in "${pids[@]}"; do wait "${pid}" 2>/dev/null || true; done + } + trap stop_shards EXIT + for ((shard = 0; shard < LOAD_SHARDS; shard++)); do + node dev/scripts/load-relay-controls.mjs \ + --director-origin "${DIRECTOR_ORIGIN}" \ + --auth-origin "${AUTH_ORIGIN}" \ + --preferred-region "${LOAD_REGION}" \ + --relay-asia-load-principals 32 \ + --shard-count "${LOAD_SHARDS}" \ + --shard-index "${shard}" \ + "${args[@]}" \ + > "${load_dir}/shard-${shard}.jsonl" & + pids+=("$!") + done + failed=0 + for pid in "${pids[@]}"; do + if ! wait "${pid}"; then failed=1; fi + done + trap - EXIT + for ((shard = 0; shard < LOAD_SHARDS; shard++)); do + jq -c 'select(.event == "relay_load_complete" or .event == "relay_load_progress")' \ + "${load_dir}/shard-${shard}.jsonl" | tail -n 1 || true + done + exit "${failed}" + + - name: Upload the load reports + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: relay-staging-load-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/relay-staging-load/ + if-no-files-found: warn + retention-days: 7 diff --git a/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs b/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs index bbfe72ec2b4a..80b86aa4582b 100644 --- a/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs +++ b/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs @@ -24,6 +24,9 @@ export const PRODUCTION_GROUP = 'production-cloud-sql-rollout' export const STAGING_GROUP = 'relay-staging-mutation' export const SELECTABLE_GROUP = "${{ inputs.environment == 'production' && 'production-cloud-sql-rollout' || 'relay-staging-mutation' }}" +// Prove Relay Asia Staging's load mode drives traffic only and must not hold the staging lock. +export const STAGING_OR_LOAD_GROUP = + "${{ inputs.mode == 'load' && 'relay-staging-load' || 'relay-staging-mutation' }}" const selectable = (production, staging) => `\${{ inputs.environment == 'production' && '${production}' || '${staging}' }}` @@ -33,7 +36,12 @@ export const SELECTABLE_LEASE = { object: selectable(PRODUCTION_LEASE.object, STAGING_LEASE.object) } -export const LOCK_GROUPS = new Set([PRODUCTION_GROUP, STAGING_GROUP, SELECTABLE_GROUP]) +export const LOCK_GROUPS = new Set([ + PRODUCTION_GROUP, + STAGING_GROUP, + SELECTABLE_GROUP, + STAGING_OR_LOAD_GROUP +]) export function readWorkflow(file) { return readFileSync(new URL(file, WORKFLOW_ROOT), 'utf8') @@ -289,7 +297,7 @@ export const LEASED_WORKFLOWS = named([ ['deploy-relay-staging-gce-candidate.yml', staging()], ['bootstrap-relay-staging-capacity.yml', staging()], ['power-relay-staging.yml', staging()], - ['prove-relay-asia-staging.yml', staging()], + ['prove-relay-asia-staging.yml', staging({ group: STAGING_OR_LOAD_GROUP })], [ 'prove-relay-staging-capacity.yml', staging({ exclusiveBy: "inputs.mode == 'refresh-asia-c4-image'" }) From c2da6337b4349b6a092449895770428691d19ae7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:05:33 -0400 Subject: [PATCH 053/110] fix(relay): staging sleep runs under the admission selector instead of refusing (#27126) * fix(relay): staging sleep runs under the admission selector instead of refusing The selector generation is monotonic and staging is past 0, so the nightly sleep could never run again. Under a selector, admission belongs to the selector and the selector-era wake restores it, so the sleep proves every cell empty and not reserve, never writes a cell's admission, and refuses if the selector moved during the sleep. * fix(relay): staging sleep re-reads reserve cells just before stopping the MIGs The cell-flags workflow runs outside the staging lock, so a reserve flip can land while Cloud Run scales down. --- cloud/dev/scripts/power-staging-relay.mjs | 56 ++++++++++++- .../dev/scripts/power-staging-relay.test.mjs | 78 +++++++++++++++++-- 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/cloud/dev/scripts/power-staging-relay.mjs b/cloud/dev/scripts/power-staging-relay.mjs index 557042941926..e68c01eadb75 100644 --- a/cloud/dev/scripts/power-staging-relay.mjs +++ b/cloud/dev/scripts/power-staging-relay.mjs @@ -329,6 +329,57 @@ function assertQuiescent(status) { } } +// `undefined` when the director predates the step-5 reserve census (its router 404s the route). +async function reserveCells(deps, cells) { + const token = deps.adminToken() + const reserve = [] + for (const cell of cells) { + const response = await deps.fetch(`${DIRECTOR_ORIGIN}/v1/admin/cell-admit-mode`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ v: 1, cellId: cell.cellId }), + signal: AbortSignal.timeout(30_000) + }) + const result = await response.json().catch(() => null) + if (response.status === 404 && result === null) return undefined + if (!response.ok || result?.v !== 1 || !['db', 'reserve'].includes(result.admitMode)) { + throw new Error(`${cell.cellId} admit mode read failed: ${result?.error ?? response.status}`) + } + if (result.admitMode === 'reserve') reserve.push(cell.cellId) + } + return reserve +} + +// Under the monotonic selector, admission belongs to the selector, and the selector-era wake +// restores it; so this sleep proves the fleet empty and never writes a cell's admission. +async function sleepUnderSelector(deps, cells, adminPost, selector) { + const reserve = await reserveCells(deps, cells) + if (reserve?.length) { + throw new Error(`staging sleep refuses reserve-mode cells: ${reserve.join(', ')}`) + } + for (const service of CLOUD_RUN_SERVICES) await ensureCloudRunScaleToZero(deps, service) + const final = await Promise.all(cells.map((cell) => cellStatus(adminPost, cell))) + for (const status of final) assertQuiescent(status) + const after = await inspectAdmissionSelector( + async (path, body) => await adminPost(DIRECTOR_ORIGIN, path, body) + ) + if (after.selector.generation !== selector.generation) { + throw new Error('admission selector changed during staging sleep') + } + // The flag workflow runs outside the staging lock, so a reserve flip can land mid-sleep. + const lateReserve = await reserveCells(deps, cells) + if (lateReserve?.length) { + throw new Error(`staging sleep refuses reserve-mode cells: ${lateReserve.join(', ')}`) + } + await Promise.all(cells.map((cell) => setMigSize(deps, cell, 0))) + await ensureSqlPolicy(deps, 'NEVER') + deps.emit({ + event: 'staging_relay_slept', + stoppedCells: cells.map((cell) => cell.cellId), + selectorGeneration: selector.generation + }) +} + async function setCellState(adminPost, cell, enabled) { await adminPost(DIRECTOR_ORIGIN, '/v1/admin/cell-state', { v: 1, @@ -376,9 +427,8 @@ async function sleepStaging(deps, cells) { async (path, body) => await adminPost(DIRECTOR_ORIGIN, path, body) ) if (selector.selector.generation > 0) { - throw new Error( - 'staging sleep cannot reverse the monotonic admission selector; keep staging awake' - ) + await sleepUnderSelector(deps, cells, adminPost, selector.selector) + return } const previouslyEnabled = new Set(initial.filter((status) => status.enabled).map((status) => status.cellId)) diff --git a/cloud/dev/scripts/power-staging-relay.test.mjs b/cloud/dev/scripts/power-staging-relay.test.mjs index 21c7794d48e5..847e1de8744a 100644 --- a/cloud/dev/scripts/power-staging-relay.test.mjs +++ b/cloud/dev/scripts/power-staging-relay.test.mjs @@ -65,7 +65,8 @@ function harness({ sqlPolicy = 'ALWAYS', migSize = 1, observedRequests = 0, - selectorGeneration = 0 + selectorGeneration = 0, + admitModes } = {}) { const cells = new Map( ['staging-gce-c1', 'staging-gce-c2', 'staging-gce-c3', 'staging-gce-c4'].map((cellId, index) => [ @@ -88,6 +89,7 @@ function harness({ ]) const commands = [] const events = [] + const posts = [] let activationPolicy = sqlPolicy let clock = 0 @@ -154,6 +156,12 @@ function harness({ const parsed = new URL(url) if (!options.method) return response({ ok: true }) const body = JSON.parse(options.body) + posts.push(parsed.pathname) + if (parsed.pathname === '/v1/admin/cell-admit-mode') { + // A pre-census director's router answers an unknown route with a plain-text 404. + if (!admitModes) return new Response('404 Not Found', { status: 404 }) + return response({ v: 1, cellId: body.cellId, admitMode: admitModes[body.cellId] ?? 'db' }) + } if (parsed.pathname === '/v1/admin/cell-status') { const state = cells.get(body.cellId) const index = [...cells.keys()].indexOf(body.cellId) @@ -225,6 +233,8 @@ function harness({ cells, commands, events, + posts, + admitModes, deps: { command, commandJson, @@ -292,13 +302,71 @@ test('sleeps only after disabling admission and proving zero active work', async ) }) -test('refuses staging sleep after the monotonic selector boundary', async () => { - const testHarness = harness({ selectorGeneration: 1 }) +test('sleeps under the selector without writing any cell admission', async () => { + for (const admitModes of [undefined, {}]) { + const testHarness = harness({ selectorGeneration: 3, admitModes }) + const before = [...testHarness.cells.values()].map((cell) => cell.enabled) + await runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), testHarness.deps) + + assert.equal(testHarness.sqlPolicy(), 'NEVER') + assert.deepEqual([...testHarness.cells.values()].map((cell) => cell.targetSize), [0, 0, 0, 0]) + assert.deepEqual([...testHarness.cells.values()].map((cell) => cell.enabled), before) + assert.equal(testHarness.posts.includes('/v1/admin/cell-state'), false) + assert.equal(testHarness.posts.includes('/v1/admin/cell-admit-mode'), true) + assert.deepEqual(testHarness.events.at(-1), { + event: 'staging_relay_slept', + stoppedCells: ['staging-gce-c1', 'staging-gce-c2', 'staging-gce-c3', 'staging-gce-c4'], + selectorGeneration: 3 + }) + } +}) + +test('refuses a selector-era sleep while any cell is reserve or busy', async () => { + const reserve = harness({ selectorGeneration: 3, admitModes: { 'staging-gce-c2': 'reserve' } }) await assert.rejects( - runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), testHarness.deps), - /cannot reverse the monotonic admission selector/ + runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), reserve.deps), + /refuses reserve-mode cells: staging-gce-c2/ + ) + assert.equal(reserve.commands.length, 0) + + // A flip that lands after the first read, while Cloud Run scales down, still stops the resize. + const late = harness({ selectorGeneration: 3, admitModes: {} }) + const run = late.deps.command + late.deps.command = (args) => { + if (args[0] === 'run' && args[2] === 'update-traffic') late.admitModes['staging-gce-c4'] = 'reserve' + return run(args) + } + await assert.rejects( + runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), late.deps), + /refuses reserve-mode cells: staging-gce-c4/ + ) + assert.deepEqual([...late.cells.values()].map((cell) => cell.targetSize), [1, 1, 1, 1]) + assert.equal(late.sqlPolicy(), 'ALWAYS') + + const busy = harness({ selectorGeneration: 3, observedRequests: 1 }) + await assert.rejects( + runStagingRelayPower(argumentConfig(topologyFile(), 'sleep'), busy.deps), + /still has active Relay work/ ) + assert.equal(busy.commands.length, 0) + assert.deepEqual([...busy.cells.values()].map((cell) => cell.targetSize), [1, 1, 1, 1]) +}) + +test('a selector-era sleep then wake restores the selector membership', async () => { + const testHarness = harness({ selectorGeneration: 3 }) + const file = topologyFile() + await runStagingRelayPower(argumentConfig(file, 'sleep'), testHarness.deps) + for (const state of testHarness.cells.values()) state.observedRequests = 0 + await runStagingRelayPower(argumentConfig(file, 'wake'), testHarness.deps) + + assert.equal(testHarness.sqlPolicy(), 'ALWAYS') assert.deepEqual([...testHarness.cells.values()].map((cell) => cell.targetSize), [1, 1, 1, 1]) + assert.equal(testHarness.posts.includes('/v1/admin/cell-state'), false) + assert.deepEqual(testHarness.events.at(-1), { + event: 'staging_relay_woke', + runningCells: ['staging-gce-c1', 'staging-gce-c2', 'staging-gce-c3', 'staging-gce-c4'], + admissionCells: ['staging-gce-c3'] + }) }) test('refuses to terminate workers from an unknown partially asleep state', async () => { From 6404b7e83deec2108f2253b11e0664487134455b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:06:42 -0400 Subject: [PATCH 054/110] test(relay): load generator can send two concurrent assigns per host (--duplicate-assign-probes) (#27107) Rebased onto main as one commit; kept both sides of the package.json test-list conflict with #27096. --- cloud/dev/scripts/load-relay-controls.mjs | 48 ++++++- cloud/dev/scripts/relay-load-control-peer.mjs | 12 +- .../scripts/relay-load-control-peer.test.mjs | 31 +++++ .../scripts/relay-load-duplicate-assign.mjs | 66 +++++++++ .../relay-load-duplicate-assign.test.mjs | 127 ++++++++++++++++++ cloud/dev/scripts/relay-load-profile.mjs | 13 ++ cloud/dev/scripts/relay-load-profile.test.mjs | 17 +++ cloud/package.json | 2 +- 8 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 cloud/dev/scripts/relay-load-duplicate-assign.mjs create mode 100644 cloud/dev/scripts/relay-load-duplicate-assign.test.mjs diff --git a/cloud/dev/scripts/load-relay-controls.mjs b/cloud/dev/scripts/load-relay-controls.mjs index e78ebdb4b2b5..59a1714f3ed0 100644 --- a/cloud/dev/scripts/load-relay-controls.mjs +++ b/cloud/dev/scripts/load-relay-controls.mjs @@ -20,6 +20,7 @@ import { waitForRelayLoadRebindGate } from './relay-load-rebind-boundary.mjs' import { proveRelayLoadRegionBehavior } from './relay-load-region-behavior.mjs' +import { proveRelayLoadDuplicateAssign } from './relay-load-duplicate-assign.mjs' import { openRelayLoadInviteOffers, proveRelayLoadRequestUnitBoundary @@ -95,6 +96,7 @@ function report(state, final = false) { regionalFallbacksProved: state.regionalFallbacksProved, oldClientUsFirstProved: state.oldClientUsFirstProved, stickyAssignmentProved: state.stickyAssignmentProved, + duplicateAssign: state.duplicateAssign, requestUnitInvitesOpened: state.requestUnitInvitesOpened, requestUnitPrincipalCount: state.requestUnitPrincipalCount, relayAsiaLoadPrincipalCount: state.relayAsiaLoadPrincipalCount, @@ -231,6 +233,7 @@ const state = { regionalFallbacksProved: 0, oldClientUsFirstProved: 0, stickyAssignmentProved: 0, + duplicateAssign: null, requestUnitInvitesOpened: 0, requestUnitOverflowReason: null, requestUnitCleanupProved: 0, @@ -255,6 +258,8 @@ const state = { } const peers = new Map() const reconnectTimers = new Set() +// Duplicate-assign probe hosts: outside `peers`, so they never count toward the run's controls. +const probePeers = [] async function readRuntimeQueuedBytes(origin) { const response = await fetch(`${origin}/v1/admin/runtime-status`, { @@ -396,6 +401,47 @@ progressTimer.unref() await runRelayLoadWithShutdown(async () => { await Promise.all(initialConnections) assertRelayLoadRampAccepted(state.rampConnectionFailures, config.maxRampConnectionFailures) + if (config.duplicateAssignProbes > 0) { + const reconnects = { closes: 0, closesByCode: {}, reconnectFailures: 0 } + // A probe host closed by a demotion re-assigns through the director, as a desktop would. + const observeProbe = (peer) => (type, detail) => { + if (type !== 'closed' || detail.stopped) return + reconnects.closes++ + const code = String(detail.code) + reconnects.closesByCode[code] = (reconnects.closesByCode[code] ?? 0) + 1 + if (state.stopping) return + const timeout = setTimeout(() => { + reconnectTimers.delete(timeout) + void peer.connect().catch(() => { + reconnects.reconnectFailures++ + }) + }, 1_000 + Math.floor(Math.random() * 5_000)) + reconnectTimers.add(timeout) + } + const base = config.controls * config.shardCount + 20_000 + const pairs = Array.from({ length: config.duplicateAssignProbes }, (_, probe) => { + const index = base + probe * config.shardCount + config.shardIndex + const primary = new RelayLoadControlPeer(index, peerOptions(index), (type, detail) => + observeProbe(primary)(type, detail) + ) + const twin = new RelayLoadControlPeer( + index, + peerOptions(index, { keys: primary.keys }), + () => undefined + ) + probePeers.push(primary, twin) + return { primary, twin } + }) + state.duplicateAssign = { + ...(await proveRelayLoadDuplicateAssign({ + pairs, + holdMs: config.duplicateAssignHoldMs, + delay, + failureReason: relayLoadFailureReason + })), + probeReconnects: reconnects + } + } if ( config.rebindProbes > 0 || config.placementOverflowProbes > 0 || config.regionalFallbackProbes > 0 @@ -527,7 +573,7 @@ await runRelayLoadWithShutdown(async () => { clearInterval(progressTimer) for (const timeout of reconnectTimers) clearTimeout(timeout) reconnectTimers.clear() - await Promise.all([...peers.values()].map((peer) => peer.shutdown())) + await Promise.all([...peers.values(), ...probePeers].map((peer) => peer.shutdown())) eventLoopDelay.disable() state.shutdownEvidence = { peerShutdowns: state.peerShutdowns, diff --git a/cloud/dev/scripts/relay-load-control-peer.mjs b/cloud/dev/scripts/relay-load-control-peer.mjs index 26d0ca6a69b0..57b5da37506a 100644 --- a/cloud/dev/scripts/relay-load-control-peer.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.mjs @@ -188,7 +188,8 @@ export class RelayLoadControlPeer { this.index = index this.options = options this.observe = observe - this.keys = nacl.box.keyPair() + // A twin shares a host's keys, so it can seat the same relay host on a second cell. + this.keys = options.keys ?? nacl.box.keyPair() this.relayHostId = createHash('sha256') .update(this.keys.publicKey) .digest('base64url') @@ -210,7 +211,8 @@ export class RelayLoadControlPeer { this.spliceSequence = 0 } - connect() { + // `assignment`, when given, is used instead of asking the director. + connect(assignment) { if ( this.stopped || this.connecting || @@ -219,7 +221,7 @@ export class RelayLoadControlPeer { return Promise.resolve() } this.connecting = true - const operation = this.connectOnce() + const operation = this.connectOnce(assignment) this.inFlight.add(operation) const finish = () => { this.connecting = false @@ -233,12 +235,12 @@ export class RelayLoadControlPeer { return this.lastAssignment?.cellUrl } - async connectOnce() { + async connectOnce(givenAssignment) { let socket = null try { const relayToken = await this.relayToken() if (this.stopped) return - const assignment = await this.assignment(relayToken) + const assignment = givenAssignment ?? (await this.assignment(relayToken)) if (this.stopped) return this.lastAssignment = assignment socket = this.createSocket(assignment, relayToken) diff --git a/cloud/dev/scripts/relay-load-control-peer.test.mjs b/cloud/dev/scripts/relay-load-control-peer.test.mjs index 08bb3291a11f..2a41ae16d9cc 100644 --- a/cloud/dev/scripts/relay-load-control-peer.test.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.test.mjs @@ -1003,3 +1003,34 @@ for (const outcome of ['success', 'failure']) { }) } } + +test('a twin shares its host keys and connects with a given assignment, not the director', async (context) => { + const originalFetch = global.fetch + context.after(() => { + global.fetch = originalFetch + }) + const fetched = [] + global.fetch = async (url) => { + fetched.push(String(url)) + throw new Error('the director must not be asked') + } + const primary = new RelayLoadControlPeer(0, peerOptions(), () => undefined) + const twin = new RelayLoadControlPeer(0, peerOptions({ keys: primary.keys }), () => undefined) + assert.equal(twin.relayHostId, primary.relayHostId) + const socket = fakeHandshakeSocket() + let socketCell + twin.createSocket = (assignment) => { + socketCell = assignment.cellUrl + openOnNextTurn(socket) + return socket + } + + const connecting = twin.connect({ cellUrl: 'https://c3.relay.test', assignmentEpoch: 7 }) + await new Promise((resolve) => setTimeout(resolve, 10)) + await twin.shutdown() + await connecting.catch(() => undefined) + + assert.equal(socketCell, 'https://c3.relay.test') + assert.equal(JSON.parse(socket.sent[0]).assignmentEpoch, 7) + assert.equal(fetched.some((url) => url.endsWith('/v1/assign')), false) +}) diff --git a/cloud/dev/scripts/relay-load-duplicate-assign.mjs b/cloud/dev/scripts/relay-load-duplicate-assign.mjs new file mode 100644 index 000000000000..4aceec865bfc --- /dev/null +++ b/cloud/dev/scripts/relay-load-duplicate-assign.mjs @@ -0,0 +1,66 @@ +// Step 5's equal-epoch case: two fresh assigns for one host that reach two director instances +// can each book a cell at the same epoch. The host connects with the first answer, and a twin +// with the same keys holds the other seat half-open, as a desktop that joined both would. +export async function proveRelayLoadDuplicateAssign({ pairs, holdMs, delay, failureReason }) { + const result = { + probes: pairs.length, + bothAnswered: 0, + splitCells: 0, + sameEpochSplits: 0, + joinedBoth: 0, + refusalsByReason: {}, + connectFailuresByReason: {}, + // Not secrets: synthetic load hosts, listed so the ledger rows can be checked afterwards. + splits: [] + } + const count = (bucket, error) => { + const reason = failureReason(error) + bucket[reason] = (bucket[reason] ?? 0) + 1 + } + const twins = [] + await Promise.all( + pairs.map(async ({ primary, twin }) => { + const answers = await Promise.allSettled([ + primary.requestAssignment(), + primary.requestAssignment() + ]) + for (const answer of answers) { + if (answer.status === 'rejected') count(result.refusalsByReason, answer.reason) + } + const [first, second] = answers + .filter((answer) => answer.status === 'fulfilled') + .map((answer) => answer.value) + if (!first) return + if (second) result.bothAnswered++ + const split = second !== undefined && second.cellUrl !== first.cellUrl + if (split) { + result.splitCells++ + if (second.assignmentEpoch === first.assignmentEpoch) result.sameEpochSplits++ + result.splits.push({ + relayHostId: primary.relayHostId, + first: { cellUrl: first.cellUrl, assignmentEpoch: first.assignmentEpoch }, + second: { cellUrl: second.cellUrl, assignmentEpoch: second.assignmentEpoch } + }) + } + try { + await primary.connect(first) + } catch (error) { + count(result.connectFailuresByReason, error) + return + } + if (!split) return + try { + await twin.connect(second) + result.joinedBoth++ + twins.push(twin) + } catch (error) { + count(result.connectFailuresByReason, error) + } + }) + ) + if (twins.length > 0) { + await delay(holdMs) + await Promise.all(twins.map((twin) => twin.shutdown())) + } + return result +} diff --git a/cloud/dev/scripts/relay-load-duplicate-assign.test.mjs b/cloud/dev/scripts/relay-load-duplicate-assign.test.mjs new file mode 100644 index 000000000000..aaf15d5d4aa2 --- /dev/null +++ b/cloud/dev/scripts/relay-load-duplicate-assign.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { proveRelayLoadDuplicateAssign } from './relay-load-duplicate-assign.mjs' + +const c2 = 'https://c2.relay-staging.onorca.dev' +const c3 = 'https://c3.relay-staging.onorca.dev' + +function pair(relayHostId, answers, { twinFails = false } = {}) { + const queue = [...answers] + const log = { primary: [], twin: [], twinShutdowns: 0 } + const primary = { + relayHostId, + requestAssignment: async () => { + const answer = queue.shift() + if (answer instanceof Error) throw answer + return answer + }, + connect: async (assignment) => { + log.primary.push(assignment) + } + } + const twin = { + connect: async (assignment) => { + if (twinFails) throw new Error('control closed: 4409 wrong cell') + log.twin.push(assignment) + }, + shutdown: async () => { + log.twinShutdowns++ + } + } + return { pair: { primary, twin }, log } +} + +const failureReason = (error) => (error.message.startsWith('control closed: 4409') ? 'control_close_4409' : 'other') + +test('counts same-epoch splits, joins the second cell with a twin, then lets it go', async () => { + const split = pair('aaaaaaaaaaaaaaaa', [ + { cellUrl: c2, assignmentEpoch: 5 }, + { cellUrl: c3, assignmentEpoch: 5 } + ]) + const same = pair('bbbbbbbbbbbbbbbb', [ + { cellUrl: c2, assignmentEpoch: 3 }, + { cellUrl: c2, assignmentEpoch: 3 } + ]) + const delays = [] + const result = await proveRelayLoadDuplicateAssign({ + pairs: [split.pair, same.pair], + holdMs: 20_000, + delay: async (ms) => { + delays.push(ms) + }, + failureReason + }) + + assert.equal(result.probes, 2) + assert.equal(result.bothAnswered, 2) + assert.equal(result.splitCells, 1) + assert.equal(result.sameEpochSplits, 1) + assert.equal(result.joinedBoth, 1) + assert.deepEqual(result.splits, [ + { + relayHostId: 'aaaaaaaaaaaaaaaa', + first: { cellUrl: c2, assignmentEpoch: 5 }, + second: { cellUrl: c3, assignmentEpoch: 5 } + } + ]) + assert.deepEqual(split.log.primary, [{ cellUrl: c2, assignmentEpoch: 5 }]) + assert.deepEqual(split.log.twin, [{ cellUrl: c3, assignmentEpoch: 5 }]) + assert.equal(split.log.twinShutdowns, 1) + assert.deepEqual(same.log.twin, []) + assert.deepEqual(delays, [20_000]) +}) + +test('a split at different epochs is not a same-epoch split', async () => { + const split = pair('cccccccccccccccc', [ + { cellUrl: c2, assignmentEpoch: 5 }, + { cellUrl: c3, assignmentEpoch: 6 } + ]) + const result = await proveRelayLoadDuplicateAssign({ + pairs: [split.pair], + holdMs: 1_000, + delay: async () => undefined, + failureReason + }) + assert.equal(result.splitCells, 1) + assert.equal(result.sameEpochSplits, 0) +}) + +test('a refused second assign still connects the host once and holds nothing', async () => { + const refused = pair('dddddddddddddddd', [ + { cellUrl: c2, assignmentEpoch: 2 }, + new Error('relay assignment failed: 503') + ]) + const delays = [] + const result = await proveRelayLoadDuplicateAssign({ + pairs: [refused.pair], + holdMs: 20_000, + delay: async (ms) => { + delays.push(ms) + }, + failureReason + }) + assert.equal(result.bothAnswered, 0) + assert.deepEqual(result.refusalsByReason, { other: 1 }) + assert.deepEqual(refused.log.primary, [{ cellUrl: c2, assignmentEpoch: 2 }]) + assert.deepEqual(delays, []) +}) + +test('a twin the second cell refuses is counted, not joined', async () => { + const split = pair( + 'eeeeeeeeeeeeeeee', + [ + { cellUrl: c2, assignmentEpoch: 5 }, + { cellUrl: c3, assignmentEpoch: 5 } + ], + { twinFails: true } + ) + const result = await proveRelayLoadDuplicateAssign({ + pairs: [split.pair], + holdMs: 20_000, + delay: async () => undefined, + failureReason + }) + assert.equal(result.joinedBoth, 0) + assert.deepEqual(result.connectFailuresByReason, { control_close_4409: 1 }) + assert.equal(split.log.twinShutdowns, 0) +}) diff --git a/cloud/dev/scripts/relay-load-profile.mjs b/cloud/dev/scripts/relay-load-profile.mjs index b68b7567e5f1..6a78e9f15db8 100644 --- a/cloud/dev/scripts/relay-load-profile.mjs +++ b/cloud/dev/scripts/relay-load-profile.mjs @@ -49,6 +49,8 @@ export function parseRelayLoadArguments(argv) { const placementOverflowProbes = integer('--placement-overflow-probes', 0) const regionalFallbackProbes = integer('--regional-fallback-probes', 0) const regionBehaviorProbes = integer('--region-behavior-probes', 0) + const duplicateAssignProbes = integer('--duplicate-assign-probes', 0) + const duplicateAssignHoldMs = integer('--duplicate-assign-hold-ms', 20_000) const requestUnitInvites = integer('--request-unit-invites', 0) const requestUnitInvitesPerSecond = integer('--request-unit-invites-per-second', 0) const requestUnitPrincipalCount = integer('--request-unit-principals', 0) @@ -218,6 +220,15 @@ export function parseRelayLoadArguments(argv) { !directorOrigin || !capacityCellOrigin || preferredRegionValue(values) !== 'asia-east2' || (shardCount > 1 && shardIndex !== 0) )) throw new Error('regional behavior proof requires the coordinating Asia shard') + if (duplicateAssignProbes > 1_000) { + throw new Error('--duplicate-assign-probes cannot exceed 1000') + } + if (duplicateAssignProbes > 0 && !directorOrigin) { + throw new Error('duplicate assign probes require --director-origin') + } + if (duplicateAssignHoldMs < 1_000 || duplicateAssignHoldMs > 120_000) { + throw new Error('--duplicate-assign-hold-ms must be between 1000 and 120000') + } if (phaseBarrierDir && shardCount < 2) { throw new Error('phase barrier requires multiple shards') } @@ -276,6 +287,8 @@ export function parseRelayLoadArguments(argv) { placementOverflowProbes, regionalFallbackProbes, regionBehaviorProbes, + duplicateAssignProbes, + duplicateAssignHoldMs, requestUnitInvites, requestUnitInvitesPerSecond, requestUnitPrincipalCount, diff --git a/cloud/dev/scripts/relay-load-profile.test.mjs b/cloud/dev/scripts/relay-load-profile.test.mjs index 3609ba64f2e1..9763d6a699f3 100644 --- a/cloud/dev/scripts/relay-load-profile.test.mjs +++ b/cloud/dev/scripts/relay-load-profile.test.mjs @@ -393,3 +393,20 @@ test('requires queue, memory, and expected close evidence', () => { /did not close with 4429/ ) }) + +test('bounds duplicate-assign probes to a director run', () => { + const config = parseRelayLoadArguments([...required, '--duplicate-assign-probes', '100']) + assert.equal(config.duplicateAssignProbes, 100) + assert.equal(config.duplicateAssignHoldMs, 20_000) + assert.equal(parseRelayLoadArguments(required).duplicateAssignProbes, 0) + assert.throws(() => parseRelayLoadArguments([ + '--auth-origin', 'https://auth.test', '--target-origin', 'https://cell.test', + '--duplicate-assign-probes', '1' + ]), /require --director-origin/) + assert.throws(() => parseRelayLoadArguments([ + ...required, '--duplicate-assign-probes', '1001' + ]), /cannot exceed 1000/) + assert.throws(() => parseRelayLoadArguments([ + ...required, '--duplicate-assign-probes', '1', '--duplicate-assign-hold-ms', '500' + ]), /between 1000 and 120000/) +}) diff --git a/cloud/package.json b/cloud/package.json index 94bb37609500..88eed0a0549a 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -22,7 +22,7 @@ "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-control-flag-alerts.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-reserve-dead-man-alert.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-duplicate-assign.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { From ae861b76c1a5771a4a0989bb092ec38f0e53995a Mon Sep 17 00:00:00 2001 From: petersindex <287557875+petersindex@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:10:28 +1100 Subject: [PATCH 055/110] fix(github): don't link a PR headed by the default branch a branch tracks A branch made with `git switch -c feature/x origin/develop` tracks the default branch. When its own PR lookup missed, the tracked-upstream fallback attached the newest PR whose head is `develop` (an unrelated develop to release PR) and cached its number, so the wrong link survived restarts. Drop a match headed by the tracked upstream when that upstream is the default branch of a repo PRs target. The check runs only after such a match, so normal polling adds no git calls, and it also heals numbers cached before the fix. Fixes #26948 --- ...nt-tracked-upstream-default-branch.test.ts | 160 ++++++++++++++++++ ...client-tracked-upstream-fork-owner.test.ts | 6 +- .../client/lookup/branch-lookup-resolution.ts | 25 ++- .../lookup/tracked-upstream-default-branch.ts | 30 ++++ 4 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 src/main/github/client-tracked-upstream-default-branch.test.ts create mode 100644 src/main/github/client/lookup/tracked-upstream-default-branch.ts diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts new file mode 100644 index 000000000000..132aa7b6b8c9 --- /dev/null +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -0,0 +1,160 @@ +/* + * Issue #26948: a branch created with `git switch -c feature/x origin/develop` + * tracks the default branch. Its PR lookup misses, falls back to the tracked + * upstream, and attaches the newest PR whose head is `develop` (an unrelated + * develop -> release PR), then caches that number so the link survives restarts. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' + +const { clientMocks, moduleMocks } = await vi.hoisted(async () => { + const moduleMocks = await import('./client-test-mocks') + return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks } +}) + +vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) +vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) +vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) +vi.mock('./local-git-config-signature', () => + moduleMocks.localGitConfigSignatureModuleMock(clientMocks) +) +vi.mock('./github-enterprise-repository', async (importOriginal) => + moduleMocks.githubEnterpriseRepositoryModuleMock( + await importOriginal() + ) +) +vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks)) +vi.mock('./github-api-repository', async (importOriginal) => + moduleMocks.githubApiRepositoryModuleMock( + clientMocks, + await importOriginal() + ) +) + +import { getPRForBranch } from './client/lookup/get-pr-for-branch' +import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' +import { resetPRForBranchMocks } from './client-test-harness' + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidatesMock, + gitExecFileAsyncMock +} = clientMocks + +const ACME = { owner: 'acme', repo: 'widgets' } + +function primeGit(trackedUpstream: string): void { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(upstream)')) { + return { stdout: `refs/heads/feature/my-change\0${trackedUpstream}\n`, stderr: '' } + } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/develop\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === 'HEAD') { + return { stdout: 'feature-head-oid\n', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) +} + +function restPR(headOwnerRef: string): Record { + return { + number: 7, + title: 'Release develop', + state: 'closed', + merged_at: null, + html_url: 'https://github.com/acme/widgets/pull/7', + updated_at: '2025-01-01T00:00:00Z', + draft: false, + mergeable: null, + base: { ref: 'release', sha: 'release-oid' }, + head: { ref: headOwnerRef, sha: 'old-develop-oid' } + } +} + +/** Branch-list responses keyed by the `head=` filter; every other gh call fails. */ +function primeGh(listsByHead: Record[]>): void { + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const head = args[1]?.match(/pulls\?head=([^&]+)/)?.[1] + if (args[0] === 'api' && head) { + return { stdout: JSON.stringify(listsByHead[decodeURIComponent(head)] ?? []) } + } + if (args[0] === 'pr' && args[1] === 'view' && args[2] === '7') { + return { + stdout: JSON.stringify({ + number: 7, + title: 'Release develop', + state: 'CLOSED', + url: 'https://github.com/acme/widgets/pull/7', + statusCheckRollup: [], + updatedAt: '2025-01-01T00:00:00Z', + isDraft: false, + mergeable: 'UNKNOWN', + baseRefName: 'release', + headRefName: 'develop', + baseRefOid: 'release-oid', + headRefOid: 'old-develop-oid' + }) + } + } + throw new Error(`gh unavailable: ${args.join(' ')}`) + }) +} + +describe('issue #26948: a branch tracking the default branch', () => { + beforeEach(() => { + resetPRForBranchMocks(clientMocks) + getOwnerRepoMock.mockResolvedValue(ACME) + }) + + it('does not attach a PR whose head is the default branch', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({ 'acme:develop': [restPR('develop')] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toBeNull() + }) + + it('drops a cached PR number whose head is the tracked default branch', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({}) + + const outcome = await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7) + + expect(outcome.kind).toBe('no-pr') + }) + + it('skips the default branch on a second remote that PRs target (fork checkout off upstream)', async () => { + const UPSTREAM = { owner: 'stablyai', repo: 'widgets' } + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [UPSTREAM, ACME], + headRepo: ACME + }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'upstream' ? UPSTREAM : remoteName === 'origin' ? ACME : null + ) + primeGit('refs/remotes/upstream/develop') + primeGh({ 'stablyai:develop': [restPR('develop')] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toBeNull() + }) + + it("still follows a contributor fork's branch that shares the default branch's name", async () => { + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'contributor' ? { owner: 'contributor', repo: 'widgets' } : ACME + ) + primeGit('refs/remotes/contributor/develop') + primeGh({ 'contributor:develop': [{ ...restPR('develop'), state: 'open' }] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toMatchObject({ number: 7, headRepo: { owner: 'contributor', repo: 'widgets' } }) + }) +}) diff --git a/src/main/github/client-tracked-upstream-fork-owner.test.ts b/src/main/github/client-tracked-upstream-fork-owner.test.ts index ee967202c7da..bc38313c2e9d 100644 --- a/src/main/github/client-tracked-upstream-fork-owner.test.ts +++ b/src/main/github/client-tracked-upstream-fork-owner.test.ts @@ -361,7 +361,11 @@ describe('getPRForBranch', () => { await vi.advanceTimersByTimeAsync(30_001) const pr = await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') - expect(sshGitProvider.exec).toHaveBeenCalledTimes(2) + // Why: a found upstream PR also resolves the default branch (#26948); count snapshot probes only. + const snapshotProbes = sshGitProvider.exec.mock.calls.filter(([args]) => + args.includes('refs/heads') + ) + expect(snapshotProbes).toHaveLength(2) expect(pr).toMatchObject({ number: 81, title: 'Fresh SSH upstream PR' diff --git a/src/main/github/client/lookup/branch-lookup-resolution.ts b/src/main/github/client/lookup/branch-lookup-resolution.ts index 2efe7d9cd256..71751a273555 100644 --- a/src/main/github/client/lookup/branch-lookup-resolution.ts +++ b/src/main/github/client/lookup/branch-lookup-resolution.ts @@ -24,8 +24,12 @@ import { lookupPRByBranchName } from './pr-branch-lookup' import { lookupPRByNumber } from './pr-number-lookup' import { derivePRRefreshData } from './branch-lookup-derived-data' import { assemblePRRefreshFoundOutcome } from './pr-refresh-outcome-assembly' -import { shouldRetryTrackedUpstreamBranch } from './tracked-upstream-cache' +import { + shouldRetryTrackedUpstreamBranch, + type TrackedUpstreamBranch +} from './tracked-upstream-cache' import { getTrackedUpstreamBranch } from './tracked-upstream-branch' +import { isTrackedUpstreamDefaultBranch } from './tracked-upstream-default-branch' import { PR_BRANCH_LOOKUP_BUCKETS } from './pr-lookup-rate-limit' import type { HostedReviewLocalGitOptions } from './../github-exec-scope' export async function resolvePRForBranchOutcome(input: { @@ -74,6 +78,7 @@ export async function resolvePRForBranchOutcome(input: { let hasPendingBranchLookupError = false let currentHeadOidForMergedImplicit: string | null | undefined let usedExactNumberLookup = false + let retriedUpstream: { branch: TrackedUpstreamBranch; headRepo: OwnerRepo } | null = null const explicitCurrentHeadOid = typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 @@ -190,6 +195,7 @@ export async function resolvePRForBranchOutcome(input: { upstreamHeadRepo && shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo) ) { + retriedUpstream = { branch: upstreamBranch, headRepo: upstreamHeadRepo } const upstreamLookup = await lookupPRByBranchName({ candidates, headRepo: upstreamHeadRepo, @@ -228,6 +234,23 @@ export async function resolvePRForBranchOutcome(input: { data = fallbackLookup.data dataRepo = fallbackLookup.dataRepo } + // Why: covers the cached fallback number too, so a link stored before #26948 heals. + if ( + data && + retriedUpstream && + data.headRefName === retriedUpstream.branch.branchName && + (await isTrackedUpstreamDefaultBranch({ + upstreamBranch: retriedUpstream.branch, + upstreamHeadRepo: retriedUpstream.headRepo, + candidates, + repoPath, + connectionId, + localGitOptions + })) + ) { + data = null + dataRepo = null + } if (!data) { if (hasPendingBranchLookupError) { return prRefreshUpstreamError(pendingBranchLookupError) diff --git a/src/main/github/client/lookup/tracked-upstream-default-branch.ts b/src/main/github/client/lookup/tracked-upstream-default-branch.ts new file mode 100644 index 000000000000..41b89157150e --- /dev/null +++ b/src/main/github/client/lookup/tracked-upstream-default-branch.ts @@ -0,0 +1,30 @@ +import type { OwnerRepo } from '../../gh-utils' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { getRepoDefaultBranchName } from '../../../source-control/repo-default-branch' +import type { HostedReviewLocalGitOptions } from './../github-exec-scope' +import type { TrackedUpstreamBranch } from './tracked-upstream-cache' + +/** + * #26948: `git switch -c x origin/develop` makes a branch track the default + * branch, whose PRs (e.g. develop -> release) belong to that branch, not this one. + * A same-named branch on a fork outside the PR candidates is a real head, so it stays. + */ +export async function isTrackedUpstreamDefaultBranch(input: { + upstreamBranch: TrackedUpstreamBranch + upstreamHeadRepo: OwnerRepo + candidates: OwnerRepo[] + repoPath: string + connectionId?: string | null + localGitOptions: HostedReviewLocalGitOptions +}): Promise { + const upstreamRepoKey = githubRepoIdentityKey(input.upstreamHeadRepo) + if (!input.candidates.some((candidate) => githubRepoIdentityKey(candidate) === upstreamRepoKey)) { + return false + } + const defaultBranchName = await getRepoDefaultBranchName( + input.repoPath, + input.connectionId, + input.localGitOptions + ) + return defaultBranchName === input.upstreamBranch.branchName +} From 7ac9f4d2f1e9efbb480db24432b20bc71b539069 Mon Sep 17 00:00:00 2001 From: petersindex <287557875+petersindex@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:07:54 +1100 Subject: [PATCH 056/110] fix(github): compare a tracked upstream with its own remote's default branch A fork's upstream remote can default to a different branch than origin. Read refs/remotes//HEAD for the tracked remote and fall back to origin's default only when git never recorded it. Reuses the default-branch resolver's host routing and cache. --- ...nt-tracked-upstream-default-branch.test.ts | 55 +++++++++-- .../lookup/tracked-upstream-default-branch.ts | 24 +++-- .../source-control/repo-default-branch.ts | 96 +++++++++++++------ src/shared/git-default-base-ref.ts | 25 +++++ 4 files changed, 155 insertions(+), 45 deletions(-) diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts index 132aa7b6b8c9..89082ecf2e34 100644 --- a/src/main/github/client-tracked-upstream-default-branch.test.ts +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -46,13 +46,23 @@ const { const ACME = { owner: 'acme', repo: 'widgets' } -function primeGit(trackedUpstream: string): void { +/** remoteHeads maps a remote to the branch its `refs/remotes//HEAD` points at. */ +function primeGit( + trackedUpstream: string, + remoteHeads: Record = { origin: 'develop' } +): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(upstream)')) { return { stdout: `refs/heads/feature/my-change\0${trackedUpstream}\n`, stderr: '' } } if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { - return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/develop\n', stderr: '' } + const stdout = Object.entries(remoteHeads) + .filter(([remote]) => args.includes(`refs/remotes/${remote}/HEA[D]`)) + .map( + ([remote, branch]) => `refs/remotes/${remote}/HEAD\0refs/remotes/${remote}/${branch}\n` + ) + .join('') + return { stdout, stderr: '' } } if (args[0] === 'rev-parse' && args[1] === 'HEAD') { return { stdout: 'feature-head-oid\n', stderr: '' } @@ -105,6 +115,18 @@ function primeGh(listsByHead: Record[]>): void { }) } +/** A fork clone: `origin` is the fork, `upstream` is the repo PRs target. */ +function primeForkWithUpstream(): void { + const UPSTREAM = { owner: 'stablyai', repo: 'widgets' } + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [UPSTREAM, ACME], + headRepo: ACME + }) + getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => + remoteName === 'upstream' ? UPSTREAM : remoteName === 'origin' ? ACME : null + ) +} + describe('issue #26948: a branch tracking the default branch', () => { beforeEach(() => { resetPRForBranchMocks(clientMocks) @@ -130,14 +152,7 @@ describe('issue #26948: a branch tracking the default branch', () => { }) it('skips the default branch on a second remote that PRs target (fork checkout off upstream)', async () => { - const UPSTREAM = { owner: 'stablyai', repo: 'widgets' } - resolvePRRepositoryCandidatesMock.mockResolvedValue({ - candidates: [UPSTREAM, ACME], - headRepo: ACME - }) - getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => - remoteName === 'upstream' ? UPSTREAM : remoteName === 'origin' ? ACME : null - ) + primeForkWithUpstream() primeGit('refs/remotes/upstream/develop') primeGh({ 'stablyai:develop': [restPR('develop')] }) @@ -146,6 +161,26 @@ describe('issue #26948: a branch tracking the default branch', () => { expect(pr).toBeNull() }) + it("uses the tracked remote's own default branch when it differs from origin's", async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) + primeGh({ 'stablyai:develop': [restPR('develop')] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toBeNull() + }) + + it("keeps a PR headed by a tracked branch that is only origin's default", async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'develop', upstream: 'main' }) + primeGh({ 'stablyai:develop': [restPR('develop')] }) + + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + + expect(pr).toMatchObject({ number: 7 }) + }) + it("still follows a contributor fork's branch that shares the default branch's name", async () => { getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => remoteName === 'contributor' ? { owner: 'contributor', repo: 'widgets' } : ACME diff --git a/src/main/github/client/lookup/tracked-upstream-default-branch.ts b/src/main/github/client/lookup/tracked-upstream-default-branch.ts index 41b89157150e..baea2f0aaae4 100644 --- a/src/main/github/client/lookup/tracked-upstream-default-branch.ts +++ b/src/main/github/client/lookup/tracked-upstream-default-branch.ts @@ -1,6 +1,9 @@ import type { OwnerRepo } from '../../gh-utils' import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' -import { getRepoDefaultBranchName } from '../../../source-control/repo-default-branch' +import { + getRemoteHeadBranchName, + getRepoDefaultBranchName +} from '../../../source-control/repo-default-branch' import type { HostedReviewLocalGitOptions } from './../github-exec-scope' import type { TrackedUpstreamBranch } from './tracked-upstream-cache' @@ -21,10 +24,19 @@ export async function isTrackedUpstreamDefaultBranch(input: { if (!input.candidates.some((candidate) => githubRepoIdentityKey(candidate) === upstreamRepoKey)) { return false } - const defaultBranchName = await getRepoDefaultBranchName( - input.repoPath, - input.connectionId, - input.localGitOptions - ) + const { remoteName } = input.upstreamBranch + // Why: a fork's `upstream` can default to a different branch than `origin`; use origin's only when the remote never recorded its HEAD. + const remoteDefaultBranchName = + remoteName === 'origin' + ? null + : await getRemoteHeadBranchName( + input.repoPath, + remoteName, + input.connectionId, + input.localGitOptions + ) + const defaultBranchName = + remoteDefaultBranchName ?? + (await getRepoDefaultBranchName(input.repoPath, input.connectionId, input.localGitOptions)) return defaultBranchName === input.upstreamBranch.branchName } diff --git a/src/main/source-control/repo-default-branch.ts b/src/main/source-control/repo-default-branch.ts index d24cde4ea134..6baaf4ce7e31 100644 --- a/src/main/source-control/repo-default-branch.ts +++ b/src/main/source-control/repo-default-branch.ts @@ -1,4 +1,5 @@ import { resolveDefaultBaseRefViaExec } from '../git/repo' +import { resolveRemoteHeadBranchViaExec, type GitExec } from '../../shared/git-default-base-ref' import { gitExecFileAsync } from '../git/runner' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { HostedReviewLocalGitOptions } from './hosted-review-git-options' @@ -45,17 +46,80 @@ function pruneRepoDefaultBranchCache(now: number): void { } } +/** Git over the transport the hosted-review call uses; null when the SSH provider is gone. */ +function createRepoGitExec( + repoPath: string, + connectionId: string | null | undefined, + localGitOptions: HostedReviewLocalGitOptions +): GitExec | null { + const provider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !provider) { + // Why: a dropped SSH provider must not fall back to local git — the + // repoPath is remote, so a local run could answer for the wrong repo. + return null + } + const resolutionDeadline = Date.now() + REPO_DEFAULT_BRANCH_RESOLUTION_BUDGET_MS + // Why: the resolver can try five refs; share one deadline so an unhealthy + // local/WSL/SSH host cannot multiply the refresh delay per fallback probe. + return (argv) => { + const timeoutMs = resolutionDeadline - Date.now() + if (timeoutMs <= 0) { + return Promise.reject(new Error('Default branch resolution timed out.')) + } + return provider + ? provider.exec(argv, repoPath, { timeoutMs }) + : gitExecFileAsync(argv, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier + ? { admissionTier: localGitOptions.admissionTier } + : {}), + timeout: timeoutMs + }) + } +} + /** * Resolve the repository's default branch NAME (`main`, `master`, …) over the * transport the surrounding hosted-review call already uses. Returns null when * unresolvable so callers fail open (#9171 guard skipped, current behavior). */ -export async function getRepoDefaultBranchName( +export function getRepoDefaultBranchName( repoPath: string, connectionId?: string | null, localGitOptions: HostedReviewLocalGitOptions = {} ): Promise { - const cacheKey = getRepoDefaultBranchCacheKey(repoPath, connectionId, localGitOptions) + return resolveCachedBranchName( + getRepoDefaultBranchCacheKey(repoPath, connectionId, localGitOptions), + async () => { + const exec = createRepoGitExec(repoPath, connectionId, localGitOptions) + const baseRef = exec ? await resolveDefaultBaseRefViaExec(exec) : null + // Same base-ref → branch-name normalization as git/repo.ts getRemoteFileUrl. + return baseRef ? baseRef.replace(/^origin\//, '') : null + } + ) +} + +/** Branch the remote's own HEAD names; null on older Git or a remote added without it. */ +export function getRemoteHeadBranchName( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: HostedReviewLocalGitOptions = {} +): Promise { + return resolveCachedBranchName( + [getRepoDefaultBranchCacheKey(repoPath, connectionId, localGitOptions), remoteName].join('\0'), + async () => { + const exec = createRepoGitExec(repoPath, connectionId, localGitOptions) + return exec ? resolveRemoteHeadBranchViaExec(exec, remoteName) : null + } + ) +} + +async function resolveCachedBranchName( + cacheKey: string, + resolve: () => Promise +): Promise { const now = Date.now() const cached = repoDefaultBranchCache.get(cacheKey) if (cached && cached.expiresAt > now) { @@ -71,33 +135,7 @@ export async function getRepoDefaultBranchName( const resolution = (async (): Promise => { let branchName: string | null = null try { - const provider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !provider) { - // Why: a dropped SSH provider must not fall back to local git — the - // repoPath is remote, so a local run could answer for the wrong repo. - return null - } - const resolutionDeadline = Date.now() + REPO_DEFAULT_BRANCH_RESOLUTION_BUDGET_MS - // Why: the resolver can try five refs; share one deadline so an unhealthy - // local/WSL/SSH host cannot multiply the refresh delay per fallback probe. - const baseRef = await resolveDefaultBaseRefViaExec((argv) => { - const timeoutMs = resolutionDeadline - Date.now() - if (timeoutMs <= 0) { - return Promise.reject(new Error('Default branch resolution timed out.')) - } - return provider - ? provider.exec(argv, repoPath, { timeoutMs }) - : gitExecFileAsync(argv, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - ...(localGitOptions.admissionTier - ? { admissionTier: localGitOptions.admissionTier } - : {}), - timeout: timeoutMs - }) - }) - // Same base-ref → branch-name normalization as git/repo.ts getRemoteFileUrl. - branchName = baseRef ? baseRef.replace(/^origin\//, '') : null + branchName = await resolve() } catch { branchName = null } diff --git a/src/shared/git-default-base-ref.ts b/src/shared/git-default-base-ref.ts index 539b0f573006..e7bf9990e423 100644 --- a/src/shared/git-default-base-ref.ts +++ b/src/shared/git-default-base-ref.ts @@ -35,3 +35,28 @@ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise/HEAD` points at, or null when git never recorded it. */ +export async function resolveRemoteHeadBranchViaExec( + exec: GitExec, + remoteName: string +): Promise { + const headRef = `refs/remotes/${remoteName}/HEAD` + const branchPrefix = `refs/remotes/${remoteName}/` + try { + const { stdout } = await exec([ + 'for-each-ref', + '--format=%(refname)%00%(symref)', + `${headRef.slice(0, -1)}[${headRef.slice(-1)}]` + ]) + for (const line of iterateProcessOutputLines(stdout)) { + const [ref, target] = line.split('\0') + if (ref === headRef && target?.startsWith(branchPrefix)) { + return target.slice(branchPrefix.length) + } + } + return null + } catch { + return null + } +} From 0bc07c995453c308dc1fd695b869fc420a8dbaca Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 16:17:47 -0700 Subject: [PATCH 057/110] fix(github): validate implicit default-branch PR repository identity --- .../client-merge-queue-auto-merge.test.ts | 2 +- .../github/client-pr-branch-discovery.test.ts | 2 +- .../github/client-pr-fallback-number.test.ts | 10 +- .../github/client-pr-linked-lookup.test.ts | 18 +- ...nt-tracked-upstream-default-branch.test.ts | 160 +++++++++++++++--- .../client-tracked-upstream-snapshot.test.ts | 2 +- .../client/lookup/branch-lookup-resolution.ts | 89 +++++----- .../lookup/implicit-default-branch-pr.ts | 60 +++++++ .../client/lookup/pull-request-lookup-data.ts | 47 ++++- .../lookup/tracked-upstream-default-branch.ts | 42 ----- .../source-control/repo-default-branch.ts | 2 +- .../repo-remote-head-branch.test.ts | 123 ++++++++++++++ ...olution-binary-compatibility.test-cases.ts | 23 ++- 13 files changed, 444 insertions(+), 136 deletions(-) create mode 100644 src/main/github/client/lookup/implicit-default-branch-pr.ts delete mode 100644 src/main/github/client/lookup/tracked-upstream-default-branch.ts create mode 100644 src/main/source-control/repo-remote-head-branch.test.ts diff --git a/src/main/github/client-merge-queue-auto-merge.test.ts b/src/main/github/client-merge-queue-auto-merge.test.ts index b6fe0d535d50..f0d62ff75c6b 100644 --- a/src/main/github/client-merge-queue-auto-merge.test.ts +++ b/src/main/github/client-merge-queue-auto-merge.test.ts @@ -110,7 +110,7 @@ describe('GitHub GraphQL rate-limit guard', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root', host: 'github.com' } ) diff --git a/src/main/github/client-pr-branch-discovery.test.ts b/src/main/github/client-pr-branch-discovery.test.ts index 82167d8bdda0..3db1e981ed11 100644 --- a/src/main/github/client-pr-branch-discovery.test.ts +++ b/src/main/github/client-pr-branch-discovery.test.ts @@ -188,7 +188,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client-pr-fallback-number.test.ts b/src/main/github/client-pr-fallback-number.test.ts index 2c63f4ddc016..397a22fa1b5f 100644 --- a/src/main/github/client-pr-fallback-number.test.ts +++ b/src/main/github/client-pr-fallback-number.test.ts @@ -126,7 +126,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -162,7 +162,7 @@ describe('getPRForBranch', () => { '--limit', '1', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -308,7 +308,7 @@ describe('getPRForBranch', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -381,7 +381,7 @@ describe('getPRForBranch', () => { '--limit', '1', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -507,7 +507,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client-pr-linked-lookup.test.ts b/src/main/github/client-pr-linked-lookup.test.ts index 36b2d0df0ca1..a7ce6cce203e 100644 --- a/src/main/github/client-pr-linked-lookup.test.ts +++ b/src/main/github/client-pr-linked-lookup.test.ts @@ -80,7 +80,7 @@ describe('getPRForBranch', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -93,7 +93,7 @@ describe('getPRForBranch', () => { '--repo', 'fork/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -132,7 +132,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -411,7 +411,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -502,7 +502,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -543,7 +543,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) @@ -579,7 +579,7 @@ describe('getPRForBranch', () => { 'view', '77', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/non-github-repo' } ) @@ -618,7 +618,7 @@ describe('getPRForBranch', () => { 'view', 'feature/test', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/non-github-repo' } ) @@ -658,7 +658,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts index 89082ecf2e34..361a6edb0986 100644 --- a/src/main/github/client-tracked-upstream-default-branch.test.ts +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -1,9 +1,4 @@ -/* - * Issue #26948: a branch created with `git switch -c feature/x origin/develop` - * tracks the default branch. Its PR lookup misses, falls back to the tracked - * upstream, and attaches the newest PR whose head is `develop` (an unrelated - * develop -> release PR), then caches that number so the link survives restarts. - */ +// #26948: tracking a base branch must not attach its integration PR to a feature branch. import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' @@ -35,6 +30,8 @@ vi.mock('./github-api-repository', async (importOriginal) => import { getPRForBranch } from './client/lookup/get-pr-for-branch' import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' import { resetPRForBranchMocks } from './client-test-harness' +import type { RestPullRequest } from './client/lookup/pull-request-lookup-data' +import type { OwnerRepo } from './gh-utils' const { ghExecFileAsyncMock, @@ -45,6 +42,8 @@ const { } = clientMocks const ACME = { owner: 'acme', repo: 'widgets' } +const UPSTREAM = { owner: 'stablyai', repo: 'widgets' } +const CONTRIBUTOR = { owner: 'contributor', repo: 'widgets' } /** remoteHeads maps a remote to the branch its `refs/remotes//HEAD` points at. */ function primeGit( @@ -71,43 +70,59 @@ function primeGit( }) } -function restPR(headOwnerRef: string): Record { +function restPR( + headRefName = 'develop', + headRepo: OwnerRepo = ACME, + prRepo: OwnerRepo = ACME +): RestPullRequest { return { number: 7, title: 'Release develop', state: 'closed', merged_at: null, - html_url: 'https://github.com/acme/widgets/pull/7', + html_url: `https://github.com/${prRepo.owner}/${prRepo.repo}/pull/7`, updated_at: '2025-01-01T00:00:00Z', draft: false, mergeable: null, base: { ref: 'release', sha: 'release-oid' }, - head: { ref: headOwnerRef, sha: 'old-develop-oid' } + head: { + ref: headRefName, + sha: 'old-develop-oid', + repo: { name: headRepo.repo, owner: { login: headRepo.owner } } + } } } -/** Branch-list responses keyed by the `head=` filter; every other gh call fails. */ -function primeGh(listsByHead: Record[]>): void { +function primeGh( + listsByHead: Record, + exactPR: RestPullRequest = Object.values(listsByHead).flat()[0] ?? restPR() +): void { + const prsByNumber = new Map( + [...Object.values(listsByHead).flat(), exactPR].map((pr) => [pr.number, pr]) + ) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { const head = args[1]?.match(/pulls\?head=([^&]+)/)?.[1] if (args[0] === 'api' && head) { return { stdout: JSON.stringify(listsByHead[decodeURIComponent(head)] ?? []) } } - if (args[0] === 'pr' && args[1] === 'view' && args[2] === '7') { + const exact = prsByNumber.get(Number(args[2])) + if (args[0] === 'pr' && args[1] === 'view' && exact) { return { stdout: JSON.stringify({ - number: 7, - title: 'Release develop', - state: 'CLOSED', - url: 'https://github.com/acme/widgets/pull/7', + number: exact.number, + title: exact.title, + state: exact.merged_at ? 'MERGED' : exact.state.toUpperCase(), + url: exact.html_url, statusCheckRollup: [], - updatedAt: '2025-01-01T00:00:00Z', - isDraft: false, + updatedAt: exact.updated_at, + isDraft: exact.draft, mergeable: 'UNKNOWN', - baseRefName: 'release', - headRefName: 'develop', - baseRefOid: 'release-oid', - headRefOid: 'old-develop-oid' + baseRefName: exact.base?.ref, + headRefName: exact.head?.ref, + baseRefOid: exact.base?.sha, + headRefOid: exact.head?.sha, + headRepositoryOwner: exact.head?.repo?.owner, + headRepository: exact.head?.repo ? { name: exact.head.repo.name } : null }) } } @@ -117,7 +132,6 @@ function primeGh(listsByHead: Record[]>): void { /** A fork clone: `origin` is the fork, `upstream` is the repo PRs target. */ function primeForkWithUpstream(): void { - const UPSTREAM = { owner: 'stablyai', repo: 'widgets' } resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [UPSTREAM, ACME], headRepo: ACME @@ -153,8 +167,8 @@ describe('issue #26948: a branch tracking the default branch', () => { it('skips the default branch on a second remote that PRs target (fork checkout off upstream)', async () => { primeForkWithUpstream() - primeGit('refs/remotes/upstream/develop') - primeGh({ 'stablyai:develop': [restPR('develop')] }) + primeGit('refs/remotes/upstream/develop', { origin: 'develop', upstream: 'develop' }) + primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) const pr = await getPRForBranch('/repo-root', 'feature/my-change') @@ -164,7 +178,7 @@ describe('issue #26948: a branch tracking the default branch', () => { it("uses the tracked remote's own default branch when it differs from origin's", async () => { primeForkWithUpstream() primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) - primeGh({ 'stablyai:develop': [restPR('develop')] }) + primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) const pr = await getPRForBranch('/repo-root', 'feature/my-change') @@ -174,7 +188,7 @@ describe('issue #26948: a branch tracking the default branch', () => { it("keeps a PR headed by a tracked branch that is only origin's default", async () => { primeForkWithUpstream() primeGit('refs/remotes/upstream/develop', { origin: 'develop', upstream: 'main' }) - primeGh({ 'stablyai:develop': [restPR('develop')] }) + primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) const pr = await getPRForBranch('/repo-root', 'feature/my-change') @@ -186,10 +200,100 @@ describe('issue #26948: a branch tracking the default branch', () => { remoteName === 'contributor' ? { owner: 'contributor', repo: 'widgets' } : ACME ) primeGit('refs/remotes/contributor/develop') - primeGh({ 'contributor:develop': [{ ...restPR('develop'), state: 'open' }] }) + primeGh({ 'contributor:develop': [{ ...restPR('develop', CONTRIBUTOR), state: 'open' }] }) const pr = await getPRForBranch('/repo-root', 'feature/my-change') expect(pr).toMatchObject({ number: 7, headRepo: { owner: 'contributor', repo: 'widgets' } }) }) + + it('preserves a cached contributor PR sharing the tracked default branch name', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({}, { ...restPR('develop', CONTRIBUTOR), state: 'open' }) + const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) + expect(pr).toMatchObject({ number: 7, headRepo: CONTRIBUTOR }) + }) + + it('preserves a PR from the origin fork default branch into upstream', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/origin/develop') + primeGh({ 'acme:develop': [{ ...restPR('develop', ACME, UPSTREAM), state: 'open' }] }) + const pr = await getPRForBranch('/repo-root', 'feature/my-change') + expect(pr).toMatchObject({ number: 7, headRepo: ACME, prRepo: UPSTREAM }) + }) + + it('clears an old cached integration PR after upstream tracking is removed', async () => { + primeGit('') + primeGh({}) + expect( + (await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7)).kind + ).toBe('no-pr') + }) + + it('tries the cached feature PR after rejecting an unrelated upstream result', async () => { + primeGit('refs/remotes/origin/develop') + primeGh( + { 'acme:develop': [restPR()] }, + { ...restPR('feature/my-change'), number: 8, state: 'open' } + ) + const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 8) + expect(pr).toMatchObject({ number: 8, headRefName: 'feature/my-change' }) + }) + + it('does not borrow origin default when upstream HEAD is unknown', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'develop' }) + primeGh({ 'stablyai:develop': [restPR('develop', UPSTREAM, UPSTREAM)] }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ number: 7 }) + }) + + it('uses PR head default metadata when upstream has no recorded HEAD', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'main' }) + const pr = restPR('develop', UPSTREAM, UPSTREAM) + primeGh({ + 'stablyai:develop': [ + { + ...pr, + head: { + ...pr.head, + repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'develop' } + } + } + ] + }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull() + }) + + it('preserves explicit links to integration PRs', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({}) + expect(await getPRForBranch('/repo-root', 'feature/my-change', 7)).toMatchObject({ number: 7 }) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('preserves cached review context while detached', async () => { + primeGh({}) + expect(await getPRForBranch('/repo-root', '', null, null, 7)).toMatchObject({ number: 7 }) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not infer a head repository for a cached PR with unavailable fork metadata', async () => { + primeGit('refs/remotes/origin/develop') + const pr = restPR() + primeGh({}, { ...pr, head: { ref: 'develop', sha: 'deleted-fork-oid', repo: null } }) + expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ + number: 7 + }) + }) + + it('does not fetch the rejected integration PR twice when its number is cached', async () => { + primeGit('refs/remotes/origin/develop') + primeGh({ 'acme:develop': [restPR()] }) + expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toBeNull() + const exactCalls = ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'pr' && args[1] === 'view' + ) + expect(exactCalls).toHaveLength(1) + }) }) diff --git a/src/main/github/client-tracked-upstream-snapshot.test.ts b/src/main/github/client-tracked-upstream-snapshot.test.ts index 1eb2200415d2..894c4481a097 100644 --- a/src/main/github/client-tracked-upstream-snapshot.test.ts +++ b/src/main/github/client-tracked-upstream-snapshot.test.ts @@ -113,7 +113,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client/lookup/branch-lookup-resolution.ts b/src/main/github/client/lookup/branch-lookup-resolution.ts index 71751a273555..f5afb563b27b 100644 --- a/src/main/github/client/lookup/branch-lookup-resolution.ts +++ b/src/main/github/client/lookup/branch-lookup-resolution.ts @@ -17,6 +17,8 @@ import { isMergedImplicitPR, shouldHideMergedImplicitPR, getCurrentHeadOid, + pullRequestHeadRepository, + linkedMergedPRDivergedHeadOid, type PullRequestLookupData, type GitHubPRBranchLookupOptions } from './pull-request-lookup-data' @@ -29,7 +31,7 @@ import { type TrackedUpstreamBranch } from './tracked-upstream-cache' import { getTrackedUpstreamBranch } from './tracked-upstream-branch' -import { isTrackedUpstreamDefaultBranch } from './tracked-upstream-default-branch' +import { shouldHideImplicitDefaultBranchPR } from './implicit-default-branch-pr' import { PR_BRANCH_LOOKUP_BUCKETS } from './pr-lookup-rate-limit' import type { HostedReviewLocalGitOptions } from './../github-exec-scope' export async function resolvePRForBranchOutcome(input: { @@ -105,29 +107,6 @@ export async function resolvePRForBranchOutcome(input: { } return membership } - const recordLinkedMergedPRDivergence = async ( - candidate: PullRequestLookupData | null, - candidateRepo: OwnerRepo | null - ): Promise => { - if ( - typeof linkedPRNumber !== 'number' || - !candidate || - mapPRState(candidate.state, candidate.isDraft) !== 'merged' || - explicitCurrentHeadOid === null || - candidate.headRefOid === explicitCurrentHeadOid - ) { - return - } - const membership = await mergedPRContainsHead( - candidate, - candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url), - explicitCurrentHeadOid - ) - if (membership === 'not-contained') { - // explicitCurrentHeadOid is non-null here (guarded above); record the exact diverged head so consumers clear only that worktree. - headDivergedFromMergedPRAtOid = explicitCurrentHeadOid - } - } const hideMergedImplicitPR = async ( candidate: PullRequestLookupData | null, candidateRepo: OwnerRepo | null @@ -216,6 +195,29 @@ export async function resolvePRForBranchOutcome(input: { } } } + let rejectedDefaultPRNumber: number | null = null + const hideUnrelatedDefaultPR = async (): Promise => { + if ( + data && + (await shouldHideImplicitDefaultBranchPR({ + data, + prRepo: dataRepo, + originHeadRepo: headRepo, + trackedUpstream: retriedUpstream, + branchName, + linkedPRNumber, + repoPath, + connectionId, + localGitOptions + })) + ) { + rejectedDefaultPRNumber = data.number + data = null + dataRepo = null + dataHeadRepo = headRepo + } + } + await hideUnrelatedDefaultPR() let mergedBranchLookupNumber: number | null = null if (await hideMergedImplicitPR(data, dataRepo)) { mergedBranchLookupNumber = data?.number ?? null @@ -223,7 +225,12 @@ export async function resolvePRForBranchOutcome(input: { dataRepo = null dataHeadRepo = headRepo } - if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') { + if ( + !data && + typeof linkedPRNumber !== 'number' && + typeof fallbackPRNumber === 'number' && + fallbackPRNumber !== rejectedDefaultPRNumber + ) { usedExactNumberLookup = true const fallbackLookup = await lookupPRByNumber({ candidates, @@ -234,30 +241,26 @@ export async function resolvePRForBranchOutcome(input: { data = fallbackLookup.data dataRepo = fallbackLookup.dataRepo } - // Why: covers the cached fallback number too, so a link stored before #26948 heals. - if ( - data && - retriedUpstream && - data.headRefName === retriedUpstream.branch.branchName && - (await isTrackedUpstreamDefaultBranch({ - upstreamBranch: retriedUpstream.branch, - upstreamHeadRepo: retriedUpstream.headRepo, - candidates, - repoPath, - connectionId, - localGitOptions - })) - ) { - data = null - dataRepo = null - } + // Recheck exact fallbacks so links cached before #26948 heal without upstream tracking. + await hideUnrelatedDefaultPR() if (!data) { if (hasPendingBranchLookupError) { return prRefreshUpstreamError(pendingBranchLookupError) } return { kind: 'no-pr', fetchedAt: Date.now() } } - await recordLinkedMergedPRDivergence(data, dataRepo) + dataHeadRepo = pullRequestHeadRepository(data, dataRepo) ?? dataHeadRepo + headDivergedFromMergedPRAtOid = await linkedMergedPRDivergedHeadOid( + data, + linkedPRNumber, + explicitCurrentHeadOid, + (candidate, headOid) => + mergedPRContainsHead( + candidate, + dataRepo ?? ownerRepoFromPullRequestUrl(candidate.url), + headOid + ) + ) const fallbackConfirmedMergedBranch = typeof fallbackPRNumber === 'number' && mergedBranchLookupNumber === fallbackPRNumber && diff --git a/src/main/github/client/lookup/implicit-default-branch-pr.ts b/src/main/github/client/lookup/implicit-default-branch-pr.ts new file mode 100644 index 000000000000..58f882b34d35 --- /dev/null +++ b/src/main/github/client/lookup/implicit-default-branch-pr.ts @@ -0,0 +1,60 @@ +import type { OwnerRepo } from '../../gh-utils' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { + getRemoteHeadBranchName, + getRepoDefaultBranchName +} from '../../../source-control/repo-default-branch' +import type { HostedReviewLocalGitOptions } from './../github-exec-scope' +import type { TrackedUpstreamBranch } from './tracked-upstream-cache' +import { pullRequestHeadRepository, type PullRequestLookupData } from './pull-request-lookup-data' + +export async function shouldHideImplicitDefaultBranchPR(input: { + data: PullRequestLookupData + prRepo: OwnerRepo | null + originHeadRepo: OwnerRepo | null + trackedUpstream: { branch: TrackedUpstreamBranch; headRepo: OwnerRepo } | null + branchName: string + linkedPRNumber?: number | null + repoPath: string + connectionId?: string | null + localGitOptions: HostedReviewLocalGitOptions +}): Promise { + const { data, branchName, prRepo } = input + if ( + !branchName || + !data.headRefName || + data.headRefName === branchName || + typeof input.linkedPRNumber === 'number' + ) { + return false + } + const headRepo = pullRequestHeadRepository(data, prRepo) + // Fork heads are real review branches even when their name is a default branch elsewhere. + if (!headRepo || !prRepo || githubRepoIdentityKey(headRepo) !== githubRepoIdentityKey(prRepo)) { + return false + } + if (data.headDefaultBranchName) { + return data.headRefName === data.headDefaultBranchName + } + const headRepoKey = githubRepoIdentityKey(headRepo) + const remoteName = + input.trackedUpstream && githubRepoIdentityKey(input.trackedUpstream.headRepo) === headRepoKey + ? input.trackedUpstream.branch.remoteName + : input.originHeadRepo && githubRepoIdentityKey(input.originHeadRepo) === headRepoKey + ? 'origin' + : null + if (!remoteName) { + return false + } + // An unknown upstream HEAD cannot borrow a different repository's origin default. + const defaultBranchName = + remoteName === 'origin' + ? await getRepoDefaultBranchName(input.repoPath, input.connectionId, input.localGitOptions) + : await getRemoteHeadBranchName( + input.repoPath, + remoteName, + input.connectionId, + input.localGitOptions + ) + return defaultBranchName !== null && data.headRefName === defaultBranchName +} diff --git a/src/main/github/client/lookup/pull-request-lookup-data.ts b/src/main/github/client/lookup/pull-request-lookup-data.ts index 03c431b2e8db..60263b5915bc 100644 --- a/src/main/github/client/lookup/pull-request-lookup-data.ts +++ b/src/main/github/client/lookup/pull-request-lookup-data.ts @@ -4,10 +4,11 @@ import type { PRMergeableState, PRReviewDecision } from '../../../../shared/github/pull-request-types' -import { gitExecFileAsync } from '../../gh-utils' +import { gitExecFileAsync, type OwnerRepo } from '../../gh-utils' import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' import type { HostedReviewExecutionOptions } from '../../../source-control/hosted-review-git-options' import { mapPRState } from '../../mappers' +import type { MergedPRCommitMembership } from '../../merged-pr-commit-membership' import { normalizePRMergeable, normalizeReviewDecision, @@ -35,6 +36,9 @@ export type PullRequestLookupData = { headRefName?: string baseRefOid?: string headRefOid?: string + headRepositoryOwner?: { login?: string } | null + headRepository?: { name?: string } | null + headDefaultBranchName?: string stack?: GitHubPRStack stackMetadataChecked?: boolean } @@ -51,7 +55,12 @@ export type RestPullRequest = { mergeable?: boolean | null mergeable_state?: string | null base?: { ref?: string; sha?: string } - head?: { ref?: string; sha?: string } + head?: { + ref?: string + sha?: string + repo?: { name?: string; owner?: { login?: string }; default_branch?: string } | null + user?: { login?: string } + } stack?: { number?: number position?: number @@ -61,10 +70,10 @@ export type RestPullRequest = { } export const PR_LOOKUP_JSON_FIELDS = - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' export const PR_BRANCH_LIST_JSON_FIELDS = - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid,headRepositoryOwner,headRepository' export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & { acceptMergedFallbackPR?: boolean @@ -118,11 +127,23 @@ export function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { headRefName: pr.head?.ref, baseRefOid: pr.base?.sha, headRefOid: pr.head?.sha, + headRepositoryOwner: pr.head?.repo?.owner ?? pr.head?.user, + headRepository: pr.head?.repo, + headDefaultBranchName: pr.head?.repo?.default_branch, stackMetadataChecked: true, ...(stack ? { stack } : {}) } } +export function pullRequestHeadRepository( + data: PullRequestLookupData, + prRepo: OwnerRepo | null +): OwnerRepo | null { + const owner = data.headRepositoryOwner?.login?.trim() + const repo = data.headRepository?.name?.trim() + return owner && repo ? { owner, repo, ...(prRepo?.host ? { host: prRepo.host } : {}) } : null +} + export function isMergedImplicitPR( data: PullRequestLookupData, linkedPRNumber?: number | null @@ -143,6 +164,24 @@ export function shouldHideMergedImplicitPR( return !currentHeadOid || data.headRefOid !== currentHeadOid } +export async function linkedMergedPRDivergedHeadOid( + data: PullRequestLookupData | null, + linkedPRNumber: number | null | undefined, + currentHeadOid: string | null, + containsHead: (data: PullRequestLookupData, headOid: string) => Promise +): Promise { + if ( + typeof linkedPRNumber !== 'number' || + !data || + mapPRState(data.state, data.isDraft) !== 'merged' || + currentHeadOid === null || + data.headRefOid === currentHeadOid + ) { + return null + } + return (await containsHead(data, currentHeadOid)) === 'not-contained' ? currentHeadOid : null +} + export function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData { return { ...data, diff --git a/src/main/github/client/lookup/tracked-upstream-default-branch.ts b/src/main/github/client/lookup/tracked-upstream-default-branch.ts deleted file mode 100644 index baea2f0aaae4..000000000000 --- a/src/main/github/client/lookup/tracked-upstream-default-branch.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { OwnerRepo } from '../../gh-utils' -import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' -import { - getRemoteHeadBranchName, - getRepoDefaultBranchName -} from '../../../source-control/repo-default-branch' -import type { HostedReviewLocalGitOptions } from './../github-exec-scope' -import type { TrackedUpstreamBranch } from './tracked-upstream-cache' - -/** - * #26948: `git switch -c x origin/develop` makes a branch track the default - * branch, whose PRs (e.g. develop -> release) belong to that branch, not this one. - * A same-named branch on a fork outside the PR candidates is a real head, so it stays. - */ -export async function isTrackedUpstreamDefaultBranch(input: { - upstreamBranch: TrackedUpstreamBranch - upstreamHeadRepo: OwnerRepo - candidates: OwnerRepo[] - repoPath: string - connectionId?: string | null - localGitOptions: HostedReviewLocalGitOptions -}): Promise { - const upstreamRepoKey = githubRepoIdentityKey(input.upstreamHeadRepo) - if (!input.candidates.some((candidate) => githubRepoIdentityKey(candidate) === upstreamRepoKey)) { - return false - } - const { remoteName } = input.upstreamBranch - // Why: a fork's `upstream` can default to a different branch than `origin`; use origin's only when the remote never recorded its HEAD. - const remoteDefaultBranchName = - remoteName === 'origin' - ? null - : await getRemoteHeadBranchName( - input.repoPath, - remoteName, - input.connectionId, - input.localGitOptions - ) - const defaultBranchName = - remoteDefaultBranchName ?? - (await getRepoDefaultBranchName(input.repoPath, input.connectionId, input.localGitOptions)) - return defaultBranchName === input.upstreamBranch.branchName -} diff --git a/src/main/source-control/repo-default-branch.ts b/src/main/source-control/repo-default-branch.ts index 6baaf4ce7e31..61de5b0537de 100644 --- a/src/main/source-control/repo-default-branch.ts +++ b/src/main/source-control/repo-default-branch.ts @@ -100,7 +100,7 @@ export function getRepoDefaultBranchName( ) } -/** Branch the remote's own HEAD names; null on older Git or a remote added without it. */ +/** Branch the remote's own HEAD names; null when Git has not recorded it. */ export function getRemoteHeadBranchName( repoPath: string, remoteName: string, diff --git a/src/main/source-control/repo-remote-head-branch.test.ts b/src/main/source-control/repo-remote-head-branch.test.ts new file mode 100644 index 000000000000..b2dfccaad08f --- /dev/null +++ b/src/main/source-control/repo-remote-head-branch.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) + +import { + getRemoteHeadBranchName, + getRepoDefaultBranchName, + __resetRepoDefaultBranchCacheForTests +} from './repo-default-branch' + +function remoteHead(remote: string, branch: string): { stdout: string; stderr: string } { + return { stdout: `refs/remotes/${remote}/HEAD\0refs/remotes/${remote}/${branch}\n`, stderr: '' } +} + +describe('getRemoteHeadBranchName', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + getSshGitProviderMock.mockReset() + __resetRepoDefaultBranchCacheForTests() + }) + + it('keeps remote HEAD caches separate from each other and the repository default', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => + args.includes('refs/remotes/upstream/HEA[D]') + ? remoteHead('upstream', 'develop') + : remoteHead('origin', 'main') + ) + await expect(getRepoDefaultBranchName('/repo')).resolves.toBe('main') + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('develop') + await expect(getRemoteHeadBranchName('/repo', 'origin')).resolves.toBe('main') + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('develop') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/remotes/upstream/HEA[D]'], + { cwd: '/repo', timeout: expect.any(Number) } + ) + }) + + it('returns null for a missing remote HEAD without resolving origin', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('ignores a symbolic HEAD pointing into another remote', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'refs/remotes/upstream/HEAD\0refs/remotes/origin/main\n', + stderr: '' + }) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + }) + + it('shares concurrent probes only for the same remote', async () => { + let releaseProbe: (() => void) | undefined + const gate = new Promise((resolve) => { + releaseProbe = resolve + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + await gate + return args.includes('refs/remotes/upstream/HEA[D]') + ? remoteHead('upstream', 'develop') + : remoteHead('origin', 'main') + }) + const first = getRemoteHeadBranchName('/repo', 'upstream') + const second = getRemoteHeadBranchName('/repo', 'upstream') + const origin = getRemoteHeadBranchName('/repo', 'origin') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)) + releaseProbe?.() + await expect(Promise.all([first, second, origin])).resolves.toEqual([ + 'develop', + 'develop', + 'main' + ]) + }) + + it('isolates native, WSL and SSH results for the same path and remote', async () => { + gitExecFileAsyncMock.mockImplementation(async (_args, options: { wslDistro?: string }) => + remoteHead('upstream', options.wslDistro ? 'wsl-default' : 'native-default') + ) + const exec = vi.fn().mockResolvedValue(remoteHead('upstream', 'ssh-default')) + getSshGitProviderMock.mockReturnValue({ exec }) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('native-default') + await expect( + getRemoteHeadBranchName('/repo', 'upstream', null, { wslDistro: 'Ubuntu' }) + ).resolves.toBe('wsl-default') + await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('ssh-default') + expect(exec).toHaveBeenCalledWith( + ['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/remotes/upstream/HEA[D]'], + '/repo', + { timeoutMs: expect.any(Number) } + ) + }) + + it('does not run local Git for a disconnected SSH host', async () => { + getSshGitProviderMock.mockReturnValue(undefined) + await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-gone')).resolves.toBeNull() + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('caches failed probes briefly, then retries', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('unavailable')) + .mockResolvedValue(remoteHead('upstream', 'develop')) + try { + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + now.mockReturnValue(32_000) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('develop') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + } finally { + now.mockRestore() + } + }) +}) diff --git a/src/shared/git-resolution-binary-compatibility.test-cases.ts b/src/shared/git-resolution-binary-compatibility.test-cases.ts index dbfc14b285ed..b6c56e6b813f 100644 --- a/src/shared/git-resolution-binary-compatibility.test-cases.ts +++ b/src/shared/git-resolution-binary-compatibility.test-cases.ts @@ -1,6 +1,9 @@ import { expect, it } from 'vitest' import { resolveConfiguredGitPushTarget } from './git-push-target-resolution' -import { resolveDefaultBaseRefViaExec } from './git-default-base-ref' +import { + resolveDefaultBaseRefViaExec, + resolveRemoteHeadBranchViaExec +} from './git-default-base-ref' import { buildGitSshPolicyEnv, GIT_SSH_CONFIG_ARGS, parseGitSshConfig } from './git-ssh-policy-env' export function registerGitResolutionBinaryCompatibilityCases( @@ -86,4 +89,22 @@ export function registerGitResolutionBinaryCompatibilityCases( await runGit(['update-ref', '--no-deref', 'refs/remotes/origin/HEAD', head]) await expect(resolveDefaultBaseRefViaExec(runGit)).resolves.toBe('origin/master') }) + + it('resolves each remote HEAD independently with Git 2.25-compatible ref output', async () => { + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + await runGit(['update-ref', 'refs/remotes/origin/review-main', head]) + await runGit(['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/review-main']) + await runGit(['update-ref', 'refs/remotes/upstream/release/stable', head]) + await runGit([ + 'symbolic-ref', + 'refs/remotes/upstream/alias', + 'refs/remotes/upstream/release/stable' + ]) + await runGit(['symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/alias']) + await expect(resolveRemoteHeadBranchViaExec(runGit, 'origin')).resolves.toBe('review-main') + await expect(resolveRemoteHeadBranchViaExec(runGit, 'upstream')).resolves.toBe('release/stable') + await expect(resolveRemoteHeadBranchViaExec(runGit, 'missing')).resolves.toBeNull() + await runGit(['update-ref', '-d', 'refs/remotes/upstream/release/stable']) + await expect(resolveRemoteHeadBranchViaExec(runGit, 'upstream')).resolves.toBeNull() + }) } From 82c5484f66c83f56bb908f8da294d9a47cebf3e1 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 16:43:23 -0700 Subject: [PATCH 058/110] fix(github): scope PR selection and clear rejected cached links Apply the default-branch policy while probing each repository, so an integration PR cannot preempt a valid result or suppress the same number in another repository. Check branch heads by repository and server. Invalidate remote defaults when their review scope or SSH provider changes. Report rejected PR URLs through an optional refresh-result field, so merged integration PRs clear from both caches even at the current worktree head. Share the manual and coordinator cache update implementation. Co-authored-by: petersindex <287557875+petersindex@users.noreply.github.com> --- ...nt-tracked-upstream-default-branch.test.ts | 180 +++++++++++++++--- .../client/lookup/branch-lookup-resolution.ts | 78 ++++---- .../lookup/implicit-default-branch-pr.ts | 39 +++- .../github/client/lookup/pr-branch-lookup.ts | 33 +++- .../github/client/lookup/pr-number-lookup.ts | 12 +- .../client/lookup/pull-request-lookup-data.ts | 20 ++ .../repo-default-branch.test.ts | 3 +- .../source-control/repo-default-branch.ts | 12 +- .../repo-remote-head-branch.test.ts | 40 +++- .../src/store/github/pr-result-cache.ts | 136 ++++--------- .../store/github/pull-request-execution.ts | 4 +- .../src/store/github/refresh-event-actions.ts | 2 + .../slices/github-pr-rejected-cache.test.ts | 130 +++++++++++++ .../pull-request-for-branch-outcome.test.ts | 7 + .../github/pull-request-refresh-types.ts | 2 +- 15 files changed, 505 insertions(+), 193 deletions(-) create mode 100644 src/renderer/src/store/slices/github-pr-rejected-cache.test.ts diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts index 361a6edb0986..ca01f0a06f98 100644 --- a/src/main/github/client-tracked-upstream-default-branch.test.ts +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -80,7 +80,7 @@ function restPR( title: 'Release develop', state: 'closed', merged_at: null, - html_url: `https://github.com/${prRepo.owner}/${prRepo.repo}/pull/7`, + html_url: `https://${prRepo.host ?? 'github.com'}/${prRepo.owner}/${prRepo.repo}/pull/7`, updated_at: '2025-01-01T00:00:00Z', draft: false, mergeable: null, @@ -95,39 +95,53 @@ function restPR( function primeGh( listsByHead: Record, - exactPR: RestPullRequest = Object.values(listsByHead).flat()[0] ?? restPR() + exactPR: RestPullRequest | RestPullRequest[] = Object.values(listsByHead).flat()[0] ?? restPR() ): void { - const prsByNumber = new Map( - [...Object.values(listsByHead).flat(), exactPR].map((pr) => [pr.number, pr]) + const repositorySlug = (pr: RestPullRequest): string => + new URL(pr.html_url ?? '').pathname.split('/').slice(1, 3).join('/') + const repositoryHost = (pr: RestPullRequest): string => new URL(pr.html_url ?? '').host + const exactPRs = Array.isArray(exactPR) ? exactPR : [exactPR] + const prsByRepositoryAndNumber = new Map( + [...Object.values(listsByHead).flat(), ...exactPRs].map((pr) => [ + `${repositoryHost(pr)}/${repositorySlug(pr)}#${pr.number}`, + pr + ]) ) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const head = args[1]?.match(/pulls\?head=([^&]+)/)?.[1] - if (args[0] === 'api' && head) { - return { stdout: JSON.stringify(listsByHead[decodeURIComponent(head)] ?? []) } - } - const exact = prsByNumber.get(Number(args[2])) - if (args[0] === 'pr' && args[1] === 'view' && exact) { - return { - stdout: JSON.stringify({ - number: exact.number, - title: exact.title, - state: exact.merged_at ? 'MERGED' : exact.state.toUpperCase(), - url: exact.html_url, - statusCheckRollup: [], - updatedAt: exact.updated_at, - isDraft: exact.draft, - mergeable: 'UNKNOWN', - baseRefName: exact.base?.ref, - headRefName: exact.head?.ref, - baseRefOid: exact.base?.sha, - headRefOid: exact.head?.sha, - headRepositoryOwner: exact.head?.repo?.owner, - headRepository: exact.head?.repo ? { name: exact.head.repo.name } : null - }) + ghExecFileAsyncMock.mockImplementation( + async (args: string[], options: { host?: string } = {}) => { + const host = options.host ?? 'github.com' + const match = args[1]?.match(/^repos\/([^/]+\/[^/]+)\/pulls\?head=([^&]+)/) + if (args[0] === 'api' && match) { + const list = (listsByHead[decodeURIComponent(match[2])] ?? []).filter( + (pr) => repositoryHost(pr) === host && repositorySlug(pr) === match[1] + ) + return { stdout: JSON.stringify(list) } + } + const repoIndex = args.indexOf('--repo') + const exact = prsByRepositoryAndNumber.get(`${host}/${args[repoIndex + 1]}#${args[2]}`) + if (args[0] === 'pr' && args[1] === 'view' && exact) { + return { + stdout: JSON.stringify({ + number: exact.number, + title: exact.title, + state: exact.merged_at ? 'MERGED' : exact.state.toUpperCase(), + url: exact.html_url, + statusCheckRollup: [], + updatedAt: exact.updated_at, + isDraft: exact.draft, + mergeable: 'UNKNOWN', + baseRefName: exact.base?.ref, + headRefName: exact.head?.ref, + baseRefOid: exact.base?.sha, + headRefOid: exact.head?.sha, + headRepositoryOwner: exact.head?.repo?.owner, + headRepository: exact.head?.repo ? { name: exact.head.repo.name } : null + }) + } } + throw new Error(`gh unavailable: ${args.join(' ')}`) } - throw new Error(`gh unavailable: ${args.join(' ')}`) - }) + ) } /** A fork clone: `origin` is the fork, `upstream` is the repo PRs target. */ @@ -165,6 +179,26 @@ describe('issue #26948: a branch tracking the default branch', () => { expect(outcome.kind).toBe('no-pr') }) + it('identifies the rejected merged integration PR so head-current caches can clear it', async () => { + primeGit('refs/remotes/origin/develop') + const integrationPR = { + ...restPR(), + merged_at: '2025-01-02T00:00:00Z', + head: { ...restPR().head, sha: 'feature-head-oid' } + } + primeGh({ 'acme:develop': [integrationPR] }) + + expect( + await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7, { + currentHeadOid: 'feature-head-oid', + acceptMergedFallbackPR: true + }) + ).toMatchObject({ + kind: 'no-pr', + rejectedPRUrls: ['https://github.com/acme/widgets/pull/7'] + }) + }) + it('skips the default branch on a second remote that PRs target (fork checkout off upstream)', async () => { primeForkWithUpstream() primeGit('refs/remotes/upstream/develop', { origin: 'develop', upstream: 'develop' }) @@ -296,4 +330,90 @@ describe('issue #26948: a branch tracking the default branch', () => { ) expect(exactCalls).toHaveLength(1) }) + + it('does not reject a cached PR because another repository rejected the same number', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) + const unrelated = restPR('develop', UPSTREAM, UPSTREAM) + const cached = { ...restPR('feature/my-change'), state: 'open' } + primeGh({ 'stablyai:develop': [unrelated] }, [unrelated, cached]) + const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) + expect(pr).toMatchObject({ number: 7, prRepo: ACME, headRefName: 'feature/my-change' }) + }) + + it('continues cached number recovery after rejecting another repository default PR', async () => { + primeForkWithUpstream() + primeGit('refs/remotes/upstream/develop', { origin: 'main', upstream: 'develop' }) + primeGh({}, [ + restPR('develop', UPSTREAM, UPSTREAM), + { ...restPR('feature/my-change'), state: 'open' } + ]) + const pr = await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7) + expect(pr).toMatchObject({ number: 7, prRepo: ACME, headRefName: 'feature/my-change' }) + }) + + it('does not accept another repository head with the same owner and branch name', async () => { + const other = { owner: 'acme', repo: 'other-widgets' } + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [other, ACME], + headRepo: ACME + }) + primeGit('') + primeGh({ + 'acme:feature/my-change': [{ ...restPR('feature/my-change', other, other), state: 'open' }] + }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull() + }) + + it('does not query another GitHub server for a known public GitHub head', async () => { + const enterprise = { ...ACME, host: 'ghe.example' } + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [enterprise, ACME], + headRepo: ACME + }) + primeGit('') + primeGh({ + 'acme:feature/my-change': [ + { ...restPR('feature/my-change', enterprise, enterprise), state: 'open' } + ] + }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toBeNull() + expect( + ghExecFileAsyncMock.mock.calls.some(([, options]) => options?.host === 'ghe.example') + ).toBe(false) + }) + + it('preserves a tracked contributor fork with a different repository name', async () => { + const fork = { owner: 'contributor', repo: 'renamed-widgets' } + getOwnerRepoForRemoteMock.mockImplementation(async (_path: string, remote: string) => + remote === 'contributor' ? fork : ACME + ) + primeGit('refs/remotes/contributor/develop') + primeGh({ 'contributor:develop': [{ ...restPR('develop', fork), state: 'open' }] }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ + number: 7, + headRepo: fork, + prRepo: ACME + }) + }) + + it('continues to a valid branch result after another repository returns the wrong head', async () => { + const other = { owner: 'acme', repo: 'other-widgets' } + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [other, ACME], + headRepo: ACME + }) + primeGit('') + primeGh({ + 'acme:feature/my-change': [ + { ...restPR('feature/my-change', other, other), state: 'open' }, + { ...restPR('feature/my-change'), state: 'open' } + ] + }) + expect(await getPRForBranch('/repo-root', 'feature/my-change')).toMatchObject({ + number: 7, + headRepo: ACME, + prRepo: ACME + }) + }) }) diff --git a/src/main/github/client/lookup/branch-lookup-resolution.ts b/src/main/github/client/lookup/branch-lookup-resolution.ts index f5afb563b27b..242645abfcb4 100644 --- a/src/main/github/client/lookup/branch-lookup-resolution.ts +++ b/src/main/github/client/lookup/branch-lookup-resolution.ts @@ -26,12 +26,12 @@ import { lookupPRByBranchName } from './pr-branch-lookup' import { lookupPRByNumber } from './pr-number-lookup' import { derivePRRefreshData } from './branch-lookup-derived-data' import { assemblePRRefreshFoundOutcome } from './pr-refresh-outcome-assembly' -import { - shouldRetryTrackedUpstreamBranch, - type TrackedUpstreamBranch -} from './tracked-upstream-cache' +import { shouldRetryTrackedUpstreamBranch } from './tracked-upstream-cache' import { getTrackedUpstreamBranch } from './tracked-upstream-branch' -import { shouldHideImplicitDefaultBranchPR } from './implicit-default-branch-pr' +import { + createImplicitDefaultBranchPRPolicy, + type ImplicitDefaultBranchPRContext +} from './implicit-default-branch-pr' import { PR_BRANCH_LOOKUP_BUCKETS } from './pr-lookup-rate-limit' import type { HostedReviewLocalGitOptions } from './../github-exec-scope' export async function resolvePRForBranchOutcome(input: { @@ -80,14 +80,22 @@ export async function resolvePRForBranchOutcome(input: { let hasPendingBranchLookupError = false let currentHeadOidForMergedImplicit: string | null | undefined let usedExactNumberLookup = false - let retriedUpstream: { branch: TrackedUpstreamBranch; headRepo: OwnerRepo } | null = null + const defaultBranchPRContext: ImplicitDefaultBranchPRContext = { + originHeadRepo: headRepo, + trackedUpstream: null, + branchName, + linkedPRNumber, + repoPath, + connectionId, + localGitOptions + } + const implicitPRPolicy = createImplicitDefaultBranchPRPolicy(defaultBranchPRContext) const explicitCurrentHeadOid = typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 ? options.currentHeadOid.trim() : null let confirmedContainedHeadOid: string | null = null - let headDivergedFromMergedPRAtOid: string | null = null const mergedPRContainsHead = async ( candidate: PullRequestLookupData, candidateRepo: OwnerRepo | null, @@ -146,7 +154,8 @@ export async function resolvePRForBranchOutcome(input: { headRepo, branchName, ghOptions, - executionScope + executionScope, + policy: implicitPRPolicy }) data = branchLookup.data dataRepo = branchLookup.dataRepo @@ -174,13 +183,17 @@ export async function resolvePRForBranchOutcome(input: { upstreamHeadRepo && shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo) ) { - retriedUpstream = { branch: upstreamBranch, headRepo: upstreamHeadRepo } + defaultBranchPRContext.trackedUpstream = { + branch: upstreamBranch, + headRepo: upstreamHeadRepo + } const upstreamLookup = await lookupPRByBranchName({ candidates, headRepo: upstreamHeadRepo, branchName: upstreamBranch.branchName, ghOptions, - executionScope + executionScope, + policy: implicitPRPolicy }) data = upstreamLookup.data dataRepo = upstreamLookup.dataRepo @@ -195,29 +208,6 @@ export async function resolvePRForBranchOutcome(input: { } } } - let rejectedDefaultPRNumber: number | null = null - const hideUnrelatedDefaultPR = async (): Promise => { - if ( - data && - (await shouldHideImplicitDefaultBranchPR({ - data, - prRepo: dataRepo, - originHeadRepo: headRepo, - trackedUpstream: retriedUpstream, - branchName, - linkedPRNumber, - repoPath, - connectionId, - localGitOptions - })) - ) { - rejectedDefaultPRNumber = data.number - data = null - dataRepo = null - dataHeadRepo = headRepo - } - } - await hideUnrelatedDefaultPR() let mergedBranchLookupNumber: number | null = null if (await hideMergedImplicitPR(data, dataRepo)) { mergedBranchLookupNumber = data?.number ?? null @@ -225,32 +215,32 @@ export async function resolvePRForBranchOutcome(input: { dataRepo = null dataHeadRepo = headRepo } - if ( - !data && - typeof linkedPRNumber !== 'number' && - typeof fallbackPRNumber === 'number' && - fallbackPRNumber !== rejectedDefaultPRNumber - ) { + if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') { usedExactNumberLookup = true const fallbackLookup = await lookupPRByNumber({ candidates, number: fallbackPRNumber, ghOptions, - executionScope + executionScope, + policy: implicitPRPolicy }) data = fallbackLookup.data dataRepo = fallbackLookup.dataRepo } - // Recheck exact fallbacks so links cached before #26948 heal without upstream tracking. - await hideUnrelatedDefaultPR() if (!data) { if (hasPendingBranchLookupError) { return prRefreshUpstreamError(pendingBranchLookupError) } - return { kind: 'no-pr', fetchedAt: Date.now() } + return { + kind: 'no-pr', + fetchedAt: Date.now(), + ...(implicitPRPolicy.rejectedPRUrls.size > 0 + ? { rejectedPRUrls: [...implicitPRPolicy.rejectedPRUrls] } + : {}) + } } dataHeadRepo = pullRequestHeadRepository(data, dataRepo) ?? dataHeadRepo - headDivergedFromMergedPRAtOid = await linkedMergedPRDivergedHeadOid( + const headDivergedFromMergedPRAtOid = await linkedMergedPRDivergedHeadOid( data, linkedPRNumber, explicitCurrentHeadOid, diff --git a/src/main/github/client/lookup/implicit-default-branch-pr.ts b/src/main/github/client/lookup/implicit-default-branch-pr.ts index 58f882b34d35..1211330265ab 100644 --- a/src/main/github/client/lookup/implicit-default-branch-pr.ts +++ b/src/main/github/client/lookup/implicit-default-branch-pr.ts @@ -6,11 +6,13 @@ import { } from '../../../source-control/repo-default-branch' import type { HostedReviewLocalGitOptions } from './../github-exec-scope' import type { TrackedUpstreamBranch } from './tracked-upstream-cache' -import { pullRequestHeadRepository, type PullRequestLookupData } from './pull-request-lookup-data' +import { + pullRequestHeadRepository, + type PullRequestLookupData, + type PullRequestLookupPolicy +} from './pull-request-lookup-data' -export async function shouldHideImplicitDefaultBranchPR(input: { - data: PullRequestLookupData - prRepo: OwnerRepo | null +export type ImplicitDefaultBranchPRContext = { originHeadRepo: OwnerRepo | null trackedUpstream: { branch: TrackedUpstreamBranch; headRepo: OwnerRepo } | null branchName: string @@ -18,7 +20,34 @@ export async function shouldHideImplicitDefaultBranchPR(input: { repoPath: string connectionId?: string | null localGitOptions: HostedReviewLocalGitOptions -}): Promise { +} + +export function createImplicitDefaultBranchPRPolicy( + context: ImplicitDefaultBranchPRContext +): PullRequestLookupPolicy { + const rejected = new Set() + const rejectedPRUrls = new Set() + return { + rejectedPRUrls, + async accepts(data, prRepo) { + const hidden = await shouldHideImplicitDefaultBranchPR({ ...context, data, prRepo }) + if (hidden && prRepo) { + rejected.add(`${githubRepoIdentityKey(prRepo)}#${data.number}`) + if (data.url) { + rejectedPRUrls.add(data.url) + } + } + return !hidden + }, + isRejected(prRepo, number) { + return rejected.has(`${githubRepoIdentityKey(prRepo)}#${number}`) + } + } +} + +async function shouldHideImplicitDefaultBranchPR( + input: ImplicitDefaultBranchPRContext & { data: PullRequestLookupData; prRepo: OwnerRepo | null } +): Promise { const { data, branchName, prRepo } = input if ( !branchName || diff --git a/src/main/github/client/lookup/pr-branch-lookup.ts b/src/main/github/client/lookup/pr-branch-lookup.ts index 2ee667ceacc4..82fc4494a7c3 100644 --- a/src/main/github/client/lookup/pr-branch-lookup.ts +++ b/src/main/github/client/lookup/pr-branch-lookup.ts @@ -1,6 +1,10 @@ import { ghExecFileAsync } from '../../gh-utils' import type { OwnerRepo, ghRepoExecOptions } from '../../gh-utils' -import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' +import { + githubHostExecOptions, + githubRepositoryWebHost, + type GitHubApiRepository +} from '../../github-api-repository' import type { GhExecOptions } from './../github-exec-scope' import { isNoPullRequestError } from './../gh-error-predicates' import { @@ -8,7 +12,9 @@ import { PR_BRANCH_LIST_JSON_FIELDS, mapRestPullRequest, normalizePullRequestLookupData, + pullRequestMatchesHeadRepository, type PullRequestLookupData, + type PullRequestLookupPolicy, type RestPullRequest } from './pull-request-lookup-data' import { getPRByNumber } from './pr-number-lookup' @@ -79,6 +85,7 @@ export async function lookupPRByBranchName(args: { branchName: string ghOptions: GhExecOptions executionScope: string + policy?: PullRequestLookupPolicy }): Promise<{ data: PullRequestLookupData | null dataRepo: OwnerRepo | null @@ -88,6 +95,13 @@ export async function lookupPRByBranchName(args: { let pendingError: unknown let hasPendingError = false for (const candidate of args.candidates) { + if ( + args.headRepo && + githubRepositoryWebHost(candidate).trim().toLowerCase() !== + githubRepositoryWebHost(args.headRepo).trim().toLowerCase() + ) { + continue + } try { const branchData = args.headRepo ? await getRestPRForBranch( @@ -104,7 +118,11 @@ export async function lookupPRByBranchName(args: { args.ghOptions, args.executionScope ) - if (data) { + if ( + data && + pullRequestMatchesHeadRepository(data, candidate, args.headRepo) && + (!args.policy || (await args.policy.accepts(data, candidate))) + ) { return { data, dataRepo: candidate } } } catch (err) { @@ -128,7 +146,11 @@ export async function lookupPRByBranchName(args: { args.ghOptions, args.executionScope ) - if (data) { + if ( + data && + pullRequestMatchesHeadRepository(data, candidate, args.headRepo) && + (!args.policy || (await args.policy.accepts(data, candidate))) + ) { return { data, dataRepo: candidate } } } catch (retryErr) { @@ -150,10 +172,13 @@ export async function lookupPRByBranchName(args: { ['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS], args.ghOptions ) - return { + const lookup = { data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), dataRepo: null } + return !args.policy || (await args.policy.accepts(lookup.data, null)) + ? lookup + : { data: null, dataRepo: null } } catch (err) { if (isNoPullRequestError(err)) { return { data: null, dataRepo: null } diff --git a/src/main/github/client/lookup/pr-number-lookup.ts b/src/main/github/client/lookup/pr-number-lookup.ts index 42a605eb295c..3df2bdeaf5bd 100644 --- a/src/main/github/client/lookup/pr-number-lookup.ts +++ b/src/main/github/client/lookup/pr-number-lookup.ts @@ -11,6 +11,7 @@ import { mapRestPullRequest, normalizePullRequestLookupData, type PullRequestLookupData, + type PullRequestLookupPolicy, type RestPullRequest } from './pull-request-lookup-data' import { hydratePullRequestLookupData } from './pull-request-lookup-hydration' @@ -108,8 +109,12 @@ export async function lookupPRByNumber(args: { number: number ghOptions: ReturnType executionScope: string + policy?: PullRequestLookupPolicy }): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { for (const candidate of args.candidates) { + if (args.policy?.isRejected(candidate, args.number)) { + continue + } try { const linkedData = await getPRByNumber( candidate, @@ -117,7 +122,7 @@ export async function lookupPRByNumber(args: { args.ghOptions, args.executionScope ) - if (!linkedData) { + if (!linkedData || (args.policy && !(await args.policy.accepts(linkedData, candidate)))) { continue } return { data: linkedData, dataRepo: candidate } @@ -138,10 +143,13 @@ export async function lookupPRByNumber(args: { ['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS], args.ghOptions ) - return { + const lookup = { data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), dataRepo: null } + return !args.policy || (await args.policy.accepts(lookup.data, null)) + ? lookup + : { data: null, dataRepo: null } } catch (err) { if (isNoPullRequestError(err)) { // Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo. diff --git a/src/main/github/client/lookup/pull-request-lookup-data.ts b/src/main/github/client/lookup/pull-request-lookup-data.ts index 60263b5915bc..8d4a00cea43c 100644 --- a/src/main/github/client/lookup/pull-request-lookup-data.ts +++ b/src/main/github/client/lookup/pull-request-lookup-data.ts @@ -9,6 +9,7 @@ import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-opti import type { HostedReviewExecutionOptions } from '../../../source-control/hosted-review-git-options' import { mapPRState } from '../../mappers' import type { MergedPRCommitMembership } from '../../merged-pr-commit-membership' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { normalizePRMergeable, normalizeReviewDecision, @@ -43,6 +44,12 @@ export type PullRequestLookupData = { stackMetadataChecked?: boolean } +export type PullRequestLookupPolicy = { + accepts: (data: PullRequestLookupData, prRepo: OwnerRepo | null) => Promise + isRejected: (prRepo: OwnerRepo, number: number) => boolean + rejectedPRUrls: ReadonlySet +} + export type RestPullRequest = { number: number title: string @@ -144,6 +151,19 @@ export function pullRequestHeadRepository( return owner && repo ? { owner, repo, ...(prRepo?.host ? { host: prRepo.host } : {}) } : null } +export function pullRequestMatchesHeadRepository( + data: PullRequestLookupData, + prRepo: OwnerRepo | null, + expectedHeadRepo: OwnerRepo | null +): boolean { + const actualHeadRepo = pullRequestHeadRepository(data, prRepo) + return ( + !actualHeadRepo || + !expectedHeadRepo || + githubRepoIdentityKey(actualHeadRepo) === githubRepoIdentityKey(expectedHeadRepo) + ) +} + export function isMergedImplicitPR( data: PullRequestLookupData, linkedPRNumber?: number | null diff --git a/src/main/source-control/repo-default-branch.test.ts b/src/main/source-control/repo-default-branch.test.ts index a2dd56a8cd03..02ea27b1e94b 100644 --- a/src/main/source-control/repo-default-branch.test.ts +++ b/src/main/source-control/repo-default-branch.test.ts @@ -10,7 +10,8 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0) })) import { diff --git a/src/main/source-control/repo-default-branch.ts b/src/main/source-control/repo-default-branch.ts index 61de5b0537de..8a45ba7f13a9 100644 --- a/src/main/source-control/repo-default-branch.ts +++ b/src/main/source-control/repo-default-branch.ts @@ -1,7 +1,9 @@ import { resolveDefaultBaseRefViaExec } from '../git/repo' import { resolveRemoteHeadBranchViaExec, type GitExec } from '../../shared/git-default-base-ref' import { gitExecFileAsync } from '../git/runner' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { hostedReviewRepoScope, scopeGeneration } from './hosted-review-scope-generations' import type { HostedReviewLocalGitOptions } from './hosted-review-git-options' // Why: bounded like TRACKED_UPSTREAM_SNAPSHOT_CACHE in github/client.ts — PR @@ -28,7 +30,13 @@ function getRepoDefaultBranchCacheKey( const runtimeKey = connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` - return [runtimeKey, repoPath].join('\0') + const scope = hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId })) + return [ + runtimeKey, + repoPath, + scopeGeneration(scope), + connectionId ? getSshGitProviderGeneration(connectionId) : 0 + ].join('\0') } function pruneRepoDefaultBranchCache(now: number): void { diff --git a/src/main/source-control/repo-remote-head-branch.test.ts b/src/main/source-control/repo-remote-head-branch.test.ts index b2dfccaad08f..6c223844235b 100644 --- a/src/main/source-control/repo-remote-head-branch.test.ts +++ b/src/main/source-control/repo-remote-head-branch.test.ts @@ -1,12 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() -})) +const { gitExecFileAsyncMock, getSshGitProviderMock, getSshGitProviderGenerationMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getSshGitProviderGenerationMock: vi.fn(() => 0) + }) +) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: getSshGitProviderGenerationMock +})) + +import { bumpScopeGeneration, hostedReviewRepoScope } from './hosted-review-scope-generations' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { getRemoteHeadBranchName, @@ -22,6 +31,7 @@ describe('getRemoteHeadBranchName', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() getSshGitProviderMock.mockReset() + getSshGitProviderGenerationMock.mockReset().mockReturnValue(0) __resetRepoDefaultBranchCacheForTests() }) @@ -120,4 +130,24 @@ describe('getRemoteHeadBranchName', () => { now.mockRestore() } }) + + it('does not reuse defaults from a replaced SSH provider', async () => { + getSshGitProviderGenerationMock.mockReturnValue(1) + const exec = vi.fn().mockResolvedValue(remoteHead('upstream', 'old-default')) + getSshGitProviderMock.mockReturnValue({ exec }) + await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('old-default') + getSshGitProviderGenerationMock.mockReturnValue(2) + exec.mockResolvedValue(remoteHead('upstream', 'new-default')) + await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('new-default') + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('refreshes defaults after the repository review scope is invalidated', async () => { + gitExecFileAsyncMock.mockResolvedValue(remoteHead('upstream', 'old-default')) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('old-default') + bumpScopeGeneration(hostedReviewRepoScope('/repo', LOCAL_EXECUTION_HOST_ID)) + gitExecFileAsyncMock.mockResolvedValue(remoteHead('upstream', 'new-default')) + await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('new-default') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/renderer/src/store/github/pr-result-cache.ts b/src/renderer/src/store/github/pr-result-cache.ts index d041753fe32a..fcf3b346522f 100644 --- a/src/renderer/src/store/github/pr-result-cache.ts +++ b/src/renderer/src/store/github/pr-result-cache.ts @@ -12,6 +12,27 @@ import { findWorktreeById } from './worktree-refresh' const PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX = 128 +type GitHubPRResultCacheArgs = { + prCacheKey: string + repoPath: string + branch: string + settings: AppState['settings'] + repoId?: string + connectionId?: string | null + executionHostId?: string | null + hasRepoOwner?: boolean + pr: PRInfo | null + fetchedAt: number + rejectedPRUrls?: readonly string[] + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + requestStartedAt?: number + fetchedHeadOid?: string | null + requestStartedEntry?: AppState['hostedReviewCache'][string] +} + export function applyPRCacheResult( cache: AppState['prCache'], cacheKey: string, @@ -70,107 +91,29 @@ export function setPRRefreshStartedHostedReviewEntry( export function setGitHubPRResultCaches( state: AppState, - args: { - prCacheKey: string - repoPath: string - branch: string - settings: AppState['settings'] - repoId?: string - connectionId?: string | null - executionHostId?: string | null - hasRepoOwner?: boolean - pr: PRInfo | null - fetchedAt: number - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - requestStartedAt?: number - fetchedHeadOid?: string | null - requestStartedEntry?: AppState['hostedReviewCache'][string] - } + args: GitHubPRResultCacheArgs ): Partial { - const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ - currentPR: state.prCache[args.prCacheKey]?.data, - nextPR: args.pr, + const next = applyGitHubPRResultToCaches({ + ...args, state, - worktreeId: args.worktreeId, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource + prCache: state.prCache, + hostedReviewCache: state.hostedReviewCache }) - const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ - cache: state.hostedReviewCache, - repoPath: args.repoPath, - branch: args.branch, - settings: args.settings, - repoId: args.repoId, - connectionId: args.connectionId, - executionHostId: args.executionHostId, - hasRepoOwner: args.hasRepoOwner, - pr: args.pr, - fetchedAt: args.fetchedAt, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource, - preserveExistingPRForFallbackMiss, - requestStartedAt: args.requestStartedAt, - requestStartedEntry: args.requestStartedEntry - }) - const hostedReviewCacheKey = getHostedReviewCacheKey( - args.repoPath, - args.branch, - args.settings, - args.repoId, - args.connectionId, - args.executionHostId, - args.hasRepoOwner === true - ) - const nextPRCache = applyPRCacheResult( - state.prCache, - args.prCacheKey, - args.pr, - args.fetchedAt, - shouldWritePRCacheForHostedReviewSync({ - hostedReviewSyncAccepted: hostedReviewSync.accepted, - hostedReviewEntry: state.hostedReviewCache[hostedReviewCacheKey], - pr: args.pr, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber - }), - preserveExistingPRForFallbackMiss, - args.fetchedHeadOid - ) return { - ...(nextPRCache === state.prCache ? {} : { prCache: nextPRCache }), - ...(hostedReviewSync.cache === state.hostedReviewCache + ...(next.prCache === state.prCache ? {} : { prCache: next.prCache }), + ...(next.hostedReviewCache === state.hostedReviewCache ? {} - : { hostedReviewCache: hostedReviewSync.cache }) + : { hostedReviewCache: next.hostedReviewCache }) } } -export function applyGitHubPRResultToCaches(args: { - prCache: AppState['prCache'] - hostedReviewCache: AppState['hostedReviewCache'] - prCacheKey: string - repoPath: string - branch: string - settings: AppState['settings'] - repoId?: string - connectionId?: string | null - executionHostId?: string | null - hasRepoOwner?: boolean - pr: PRInfo | null - fetchedAt: number - state: AppState - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - requestStartedAt?: number - fetchedHeadOid?: string | null - requestStartedEntry?: AppState['hostedReviewCache'][string] -}): { +export function applyGitHubPRResultToCaches( + args: GitHubPRResultCacheArgs & { + prCache: AppState['prCache'] + hostedReviewCache: AppState['hostedReviewCache'] + state: AppState + } +): { prCache: AppState['prCache'] hostedReviewCache: AppState['hostedReviewCache'] } { @@ -180,8 +123,7 @@ export function applyGitHubPRResultToCaches(args: { state: args.state, worktreeId: args.worktreeId, linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource + rejectedPRUrls: args.rejectedPRUrls }) const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ cache: args.hostedReviewCache, @@ -235,13 +177,13 @@ export function shouldPreserveExistingPRForFallbackMiss(args: { state: AppState worktreeId?: string linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null + rejectedPRUrls?: readonly string[] }): boolean { if ( args.nextPR !== null || args.linkedPRNumber != null || args.currentPR?.state !== 'merged' || + args.rejectedPRUrls?.includes(args.currentPR.url) || typeof args.currentPR.headSha !== 'string' || args.currentPR.headSha.length === 0 ) { diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 21f48d530d83..40f4d94eff9d 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -171,6 +171,7 @@ export function startPullRequestLookup(args: { hasRepoOwner: repo !== undefined, pr, fetchedAt: outcome.fetchedAt, + rejectedPRUrls: outcome.kind === 'no-pr' ? outcome.rejectedPRUrls : undefined, fetchedHeadOid: requestHeadOid, worktreeId: options?.worktreeId, linkedPRNumber, @@ -270,8 +271,7 @@ export function startPullRequestLookup(args: { state: get(), worktreeId: options?.worktreeId, linkedPRNumber, - fallbackPRNumber, - fallbackPRSource + rejectedPRUrls: outcome.kind === 'no-pr' ? outcome.rejectedPRUrls : undefined }) ) { return get().prCache[cacheKey]?.data ?? null diff --git a/src/renderer/src/store/github/refresh-event-actions.ts b/src/renderer/src/store/github/refresh-event-actions.ts index 8396ff3feca4..c345bb3c41d7 100644 --- a/src/renderer/src/store/github/refresh-event-actions.ts +++ b/src/renderer/src/store/github/refresh-event-actions.ts @@ -177,6 +177,8 @@ export const createRefreshEventActions = ( hasRepoOwner: true, pr: data, fetchedAt: event.outcome.fetchedAt, + rejectedPRUrls: + event.outcome.kind === 'no-pr' ? event.outcome.rejectedPRUrls : undefined, fetchedHeadOid: alias.currentHeadOid, state: s, worktreeId: alias.worktreeId, diff --git a/src/renderer/src/store/slices/github-pr-rejected-cache.test.ts b/src/renderer/src/store/slices/github-pr-rejected-cache.test.ts new file mode 100644 index 000000000000..921828e75c1a --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-rejected-cache.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' +import { _clearGitHubPRRefreshStartedEntriesForTest } from '../github/request-coordination' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' +import { + createTestStore, + makePR, + makePRRefreshWorktree, + mockApi, + resetRemoteRuntimeMocks +} from './github-slice-test-harness' + +describe.each(['manual', 'coordinator'] as const)('%s rejected PR cache results', (source) => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + resetRemoteRuntimeMocks() + mockApi.gh.refreshPRNow.mockReset() + _clearGitHubPRRefreshStartedEntriesForTest() + }) + + afterEach(() => { + _clearGitHubPRRefreshStartedEntriesForTest() + vi.useRealTimers() + }) + + it.each([ + { + name: 'clears a rejected integration PR even at its merged head', + rejectedUrl: 'https://github.com/acme/orca/pull/12', + head: 'merged-head', + preserves: false + }, + { + name: 'clears a rejected integration PR at a confirmed contained commit', + rejectedUrl: 'https://github.com/acme/orca/pull/12', + head: 'contained-head', + preserves: false + }, + { + name: 'preserves a merged PR when the rejected number belongs to another repository', + rejectedUrl: 'https://github.com/other/orca/pull/12', + head: 'merged-head', + preserves: true + }, + { + name: 'preserves a merged PR when the rejected number belongs to another server', + rejectedUrl: 'https://github.example.com/acme/orca/pull/12', + head: 'merged-head', + preserves: true + } + ])('$name', async ({ rejectedUrl, head, preserves }) => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/from-develop' + const worktreeId = 'wt-new-feature' + const cacheKey = `${repoId}::${branch}` + const hostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId) + const cachedPR = makePR({ + number: 12, + state: 'merged', + url: 'https://github.com/acme/orca/pull/12', + headSha: 'merged-head', + confirmedContainedHeadOid: 'contained-head' + }) + const cachedReview = hostedReviewInfoFromGitHubPRInfo(cachedPR) + store.setState({ + repos: [ + { id: repoId, path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 1, kind: 'git' } + ], + worktreesByRepo: { + [repoId]: [makePRRefreshWorktree({ id: worktreeId, repoId, branch, head })] + }, + prCache: { [cacheKey]: { data: cachedPR, fetchedAt: 1 } }, + hostedReviewCache: { + [hostedReviewCacheKey]: { + data: cachedReview, + fetchedAt: 1, + linkedReviewHintKey: 'github:12' + } + } + }) + const outcome = { kind: 'no-pr' as const, fetchedAt: 2, rejectedPRUrls: [rejectedUrl] } + if (source === 'manual') { + mockApi.gh.refreshPRNow.mockResolvedValueOnce(outcome) + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + fallbackPRNumber: 12, + fallbackPRSource: 'pr-cache' + }) + ).resolves.toEqual(preserves ? cachedPR : null) + } else { + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'visible', + aliases: [ + { + cacheKey, + repoId, + repoPath, + branch, + worktreeId, + fallbackPRNumber: 12, + fallbackPRSource: 'pr-cache' + } + ], + outcome + }) + } + + expect(store.getState().prCache[cacheKey]?.data).toEqual(preserves ? cachedPR : null) + expect(store.getState().hostedReviewCache[hostedReviewCacheKey]?.data).toEqual( + preserves ? cachedReview : null + ) + await vi.advanceTimersByTimeAsync(1000) + if (preserves) { + expect(mockApi.cache.setGitHub).not.toHaveBeenCalled() + } else { + expect(mockApi.cache.setGitHub).toHaveBeenCalledWith({ + cache: expect.objectContaining({ + pr: { [cacheKey]: expect.objectContaining({ data: null, fetchedAt: 2 }) } + }) + }) + } + }) +}) diff --git a/src/shared/github/pull-request-for-branch-outcome.test.ts b/src/shared/github/pull-request-for-branch-outcome.test.ts index 7ad79b1f61c4..f3b124a3318d 100644 --- a/src/shared/github/pull-request-for-branch-outcome.test.ts +++ b/src/shared/github/pull-request-for-branch-outcome.test.ts @@ -19,6 +19,13 @@ describe('normalizeGitHubPRForBranchOutcome', () => { expect(normalizeGitHubPRForBranchOutcome(outcome, 20)).toBe(outcome) }) + it.each([ + { kind: 'no-pr' as const, fetchedAt: 10 }, + { kind: 'no-pr' as const, fetchedAt: 10, rejectedPRUrls: [PR.url] } + ])('preserves no-PR responses from older and newer hosts', (outcome) => { + expect(normalizeGitHubPRForBranchOutcome(outcome, 20)).toEqual(outcome) + }) + it('normalizes legacy PRInfo and null responses', () => { expect(normalizeGitHubPRForBranchOutcome(PR, 20)).toEqual({ kind: 'found', diff --git a/src/shared/github/pull-request-refresh-types.ts b/src/shared/github/pull-request-refresh-types.ts index f735a8e0fe2c..b4f61d5fd446 100644 --- a/src/shared/github/pull-request-refresh-types.ts +++ b/src/shared/github/pull-request-refresh-types.ts @@ -22,7 +22,7 @@ export type PRRefreshUpstreamErrorType = PRRefreshErrorType export type PRRefreshOutcome = | { kind: 'found'; pr: PRInfo; fetchedAt: number } - | { kind: 'no-pr'; fetchedAt: number } + | { kind: 'no-pr'; fetchedAt: number; rejectedPRUrls?: string[] } | { kind: 'upstream-error' errorType: PRRefreshErrorType From 884575a797ebfd54d4c76cbd2efbe0bedbf0a404 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 16:48:20 -0700 Subject: [PATCH 059/110] fix(github): verify cached integration PRs after tracking is removed Use the existing REST PR mapping to read the head repository's default branch when no tracked remote or origin can identify it. Require matching repository and head metadata, and preserve the PR if the lookup fails. This clears both closed and merged integration PRs cached by a fork checkout after its upstream tracking is removed. Co-authored-by: petersindex <287557875+petersindex@users.noreply.github.com> --- ...nt-tracked-upstream-default-branch.test.ts | 99 ++++++++++++++++++- .../client/lookup/branch-lookup-resolution.ts | 3 +- .../lookup/implicit-default-branch-pr.ts | 18 +++- 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts index ca01f0a06f98..e92dfc76c48d 100644 --- a/src/main/github/client-tracked-upstream-default-branch.test.ts +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -117,9 +117,20 @@ function primeGh( ) return { stdout: JSON.stringify(list) } } + const exactRestMatch = args[1]?.match(/^repos\/([^/]+\/[^/]+)\/pulls\/(\d+)$/) + if (args[0] === 'api' && exactRestMatch) { + const pr = prsByRepositoryAndNumber.get(`${host}/${exactRestMatch[1]}#${exactRestMatch[2]}`) + if (pr) { + return { stdout: JSON.stringify(pr) } + } + throw new Error('HTTP 404 Not Found') + } const repoIndex = args.indexOf('--repo') const exact = prsByRepositoryAndNumber.get(`${host}/${args[repoIndex + 1]}#${args[2]}`) - if (args[0] === 'pr' && args[1] === 'view' && exact) { + if (args[0] === 'pr' && args[1] === 'view') { + if (!exact) { + throw new Error('HTTP 404 Not Found') + } return { stdout: JSON.stringify({ number: exact.number, @@ -264,6 +275,92 @@ describe('issue #26948: a branch tracking the default branch', () => { ).toBe('no-pr') }) + it.each(['closed', 'merged'] as const)( + 'clears a cached %s integration PR in a fork after upstream tracking is removed', + async (state) => { + primeForkWithUpstream() + primeGit('', { origin: 'main' }) + const integrationPR = restPR('develop', UPSTREAM, UPSTREAM) + primeGh( + {}, + { + ...integrationPR, + merged_at: state === 'merged' ? '2025-01-02T00:00:00Z' : null, + head: { + ...integrationPR.head, + sha: 'feature-head-oid', + repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'develop' } + } + } + ) + + expect( + await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7, { + currentHeadOid: 'feature-head-oid', + acceptMergedFallbackPR: true + }) + ).toMatchObject({ + kind: 'no-pr', + rejectedPRUrls: ['https://github.com/stablyai/widgets/pull/7'] + }) + } + ) + + it('preserves a cached renamed feature PR using its own repository default metadata', async () => { + primeForkWithUpstream() + primeGit('', { origin: 'develop' }) + const featurePR = restPR('develop', UPSTREAM, UPSTREAM) + primeGh( + {}, + { + ...featurePR, + state: 'open', + head: { + ...featurePR.head, + repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'main' } + } + } + ) + + expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ + number: 7, + headRefName: 'develop' + }) + }) + + it.each(['unavailable', 'changed-head'] as const)( + 'preserves an untracked cached PR when REST metadata is %s', + async (metadataState) => { + primeForkWithUpstream() + primeGit('', { origin: 'develop' }) + primeGh({}, { ...restPR('develop', UPSTREAM, UPSTREAM), state: 'open' }) + const ghImplementation = ghExecFileAsyncMock.getMockImplementation() + ghExecFileAsyncMock.mockImplementation(async (args: string[], options) => { + if (args[0] === 'api' && args[1] === 'repos/stablyai/widgets/pulls/7') { + if (metadataState === 'unavailable') { + throw new Error('network unavailable') + } + const pr = restPR('develop', ACME, UPSTREAM) + return { + stdout: JSON.stringify({ + ...pr, + head: { + ...pr.head, + repo: { name: 'widgets', owner: { login: 'acme' }, default_branch: 'develop' } + } + }) + } + } + return ghImplementation?.(args, options) + }) + + expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({ + number: 7, + headRepo: UPSTREAM + }) + } + ) + it('tries the cached feature PR after rejecting an unrelated upstream result', async () => { primeGit('refs/remotes/origin/develop') primeGh( diff --git a/src/main/github/client/lookup/branch-lookup-resolution.ts b/src/main/github/client/lookup/branch-lookup-resolution.ts index 242645abfcb4..4c30a4dad717 100644 --- a/src/main/github/client/lookup/branch-lookup-resolution.ts +++ b/src/main/github/client/lookup/branch-lookup-resolution.ts @@ -87,7 +87,8 @@ export async function resolvePRForBranchOutcome(input: { linkedPRNumber, repoPath, connectionId, - localGitOptions + localGitOptions, + ghOptions } const implicitPRPolicy = createImplicitDefaultBranchPRPolicy(defaultBranchPRContext) diff --git a/src/main/github/client/lookup/implicit-default-branch-pr.ts b/src/main/github/client/lookup/implicit-default-branch-pr.ts index 1211330265ab..ca852798beab 100644 --- a/src/main/github/client/lookup/implicit-default-branch-pr.ts +++ b/src/main/github/client/lookup/implicit-default-branch-pr.ts @@ -4,13 +4,14 @@ import { getRemoteHeadBranchName, getRepoDefaultBranchName } from '../../../source-control/repo-default-branch' -import type { HostedReviewLocalGitOptions } from './../github-exec-scope' +import type { GhExecOptions, HostedReviewLocalGitOptions } from './../github-exec-scope' import type { TrackedUpstreamBranch } from './tracked-upstream-cache' import { pullRequestHeadRepository, type PullRequestLookupData, type PullRequestLookupPolicy } from './pull-request-lookup-data' +import { getRestPRByNumber } from './pr-number-lookup' export type ImplicitDefaultBranchPRContext = { originHeadRepo: OwnerRepo | null @@ -20,6 +21,7 @@ export type ImplicitDefaultBranchPRContext = { repoPath: string connectionId?: string | null localGitOptions: HostedReviewLocalGitOptions + ghOptions: GhExecOptions } export function createImplicitDefaultBranchPRPolicy( @@ -73,7 +75,19 @@ async function shouldHideImplicitDefaultBranchPR( ? 'origin' : null if (!remoteName) { - return false + try { + // Removed tracking can leave a cached integration PR without a matching remote. + const metadata = await getRestPRByNumber(prRepo, data.number, input.ghOptions) + const metadataHeadRepo = pullRequestHeadRepository(metadata, prRepo) + return ( + metadataHeadRepo !== null && + githubRepoIdentityKey(metadataHeadRepo) === headRepoKey && + metadata.headRefName === data.headRefName && + metadata.headDefaultBranchName === data.headRefName + ) + } catch { + return false + } } // An unknown upstream HEAD cannot borrow a different repository's origin default. const defaultBranchName = From b4d8e370dafaa76b7d27c64486ec3f2ae7f9f775 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 17:08:36 -0700 Subject: [PATCH 060/110] fix(orcad): install agent status hooks when a managed host starts (#27319) * fix(orcad): install agent status hooks when a managed host starts * fix(orcad): read login-profile Grok/Kiro homes before installing hooks * refactor(agent-hooks): run the login-shell home probe through process-host --------- Co-authored-by: m4air --- .../process-host-direct-import-baseline.txt | 1 - .../agent-home-login-shell.test.ts | 125 ++++++++++++++++++ .../agent-hooks/agent-home-login-shell.ts | 107 +++++++++++++++ .../agent-hooks/managed-agent-hook-startup.ts | 30 +++++ .../agent-hooks/managed-hook-runtime.test.ts | 110 +-------------- src/main/agent-hooks/managed-hook-runtime.ts | 79 +---------- src/main/orcad/orcad-entry.ts | 13 +- src/main/orcad/orcad-managed-agent-hooks.ts | 54 ++++++++ src/main/orcad/orcad-push-startup.test.ts | 72 +++++++++- .../startup/main-process-ready-runtime.ts | 32 ++--- 10 files changed, 406 insertions(+), 217 deletions(-) create mode 100644 src/main/agent-hooks/agent-home-login-shell.test.ts create mode 100644 src/main/agent-hooks/agent-home-login-shell.ts create mode 100644 src/main/agent-hooks/managed-agent-hook-startup.ts create mode 100644 src/main/orcad/orcad-managed-agent-hooks.ts diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 4b50f1549903..7ff6c9f94caf 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -8,7 +8,6 @@ src/cli/handlers/skills.ts src/cli/runtime/launch.ts src/cli/runtime/serve-update-supervisor.ts src/main/agent-hooks/managed-hook-owner-identity.ts -src/main/agent-hooks/managed-hook-runtime.ts src/main/agent-hooks/wsl-hook-relay-launch.ts src/main/agent-hooks/wsl-hook-relay-link.ts src/main/agent-hooks/wsl-hook-relay-manager.ts diff --git a/src/main/agent-hooks/agent-home-login-shell.test.ts b/src/main/agent-hooks/agent-home-login-shell.test.ts new file mode 100644 index 000000000000..a7d38b809e41 --- /dev/null +++ b/src/main/agent-hooks/agent-home-login-shell.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type ProbeResult = { code: number | null; stdout: string; timedOut: boolean } +type RunProcessStub = (spec: { program: string; args: string[] }) => Promise + +// Why: the probe spawns a real login shell and swallows every spawn failure into its fallback. +// Left unmocked this asserts the runner's scheduling latency, not the parser: on a loaded CI box +// the 8s timeout expires and the first case silently flips to the fallback path. +const probe = vi.hoisted(() => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', async (importOriginal) => ({ + ...(await importOriginal>()), + runProcess: probe.runProcess +})) + +const probeMock = probe.runProcess +const { adoptLoginShellAgentHomes, resolveLoginShellGrokHome, resolveLoginShellKiroHome } = + await import('./agent-home-login-shell') + +function stubProbeOutput(stdout: string | ((script: string) => string)): void { + probeMock.mockImplementation(async ({ args }) => ({ + code: 0, + timedOut: false, + stdout: typeof stdout === 'string' ? stdout : stdout(args[1] ?? '') + })) +} + +function stubProbeFailure(error: Error & { killed?: boolean }): void { + probeMock.mockImplementation(async () => { + if (error.killed) { + return { code: null, timedOut: true, stdout: '' } + } + throw error + }) +} + +beforeEach(() => { + probeMock.mockReset() +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe.runIf(process.platform !== 'win32')('resolveLoginShellGrokHome', () => { + it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('/srv/grok///\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const { program: shell, args } = probeMock.mock.calls[0]?.[0] ?? {} + expect(shell).toBe('/bin/sh') + // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. + expect(args?.[0]).toBe('-c') + }) + + it('passes -lc to a login shell that supports it', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('/srv/grok\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const { program: shell, args } = probeMock.mock.calls[0]?.[0] ?? {} + expect(shell).toBe('/bin/zsh') + expect(args?.[0]).toBe('-lc') + }) + + it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('../relative\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) + + // Why: this is the branch that made the old test flaky — pin it so a probe failure is + // an asserted fallback rather than an invisible substitution for a real answer. + it('falls back when the probe fails or times out', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) +}) + +describe.runIf(process.platform !== 'win32')('resolveLoginShellKiroHome', () => { + it('reads KIRO_HOME from the login shell', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('/srv/kiro/\n') + + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/srv/kiro') + expect(probeMock.mock.calls[0]?.[0].args[1]).toContain('printenv KIRO_HOME') + }) + + it('falls back to ~/.kiro when KIRO_HOME is unset or the probe fails', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('\n') + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') + + stubProbeFailure(new Error('spawn failed')) + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') + }) +}) + +describe.runIf(process.platform !== 'win32')('adoptLoginShellAgentHomes', () => { + it('adopts profile-only agent homes and keeps ones already set', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput((script) => (script.includes('KIRO_HOME') ? '/srv/kiro\n' : '/srv/grok\n')) + const env: NodeJS.ProcessEnv = { GROK_HOME: '/already/grok' } + + await adoptLoginShellAgentHomes(env) + + expect(env).toEqual({ GROK_HOME: '/already/grok', KIRO_HOME: '/srv/kiro' }) + expect(probeMock).toHaveBeenCalledTimes(1) + }) + + it('leaves the environment alone when the login shell has no override', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('\n') + const env: NodeJS.ProcessEnv = {} + + await adoptLoginShellAgentHomes(env) + + expect(env).toEqual({}) + }) +}) diff --git a/src/main/agent-hooks/agent-home-login-shell.ts b/src/main/agent-hooks/agent-home-login-shell.ts new file mode 100644 index 000000000000..73a332d1ad81 --- /dev/null +++ b/src/main/agent-hooks/agent-home-login-shell.ts @@ -0,0 +1,107 @@ +import { basename } from 'node:path' +import { userInfo } from 'node:os' +import { runProcess } from '@orca/process-host' + +const AGENT_HOME_MAX_LENGTH = 4096 +const AGENT_HOME_PROBE_TIMEOUT_MS = 8_000 + +type AgentHomeEnvName = 'GROK_HOME' | 'KIRO_HOME' + +function defaultAgentHome(home: string, dirName: string): string { + return `${home.replace(/\/+$/, '') || home}/${dirName}` +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +function normalizeAgentHome(candidate: string): string | null { + if ( + candidate.length === 0 || + candidate.length > AGENT_HOME_MAX_LENGTH || + candidate !== candidate.trim() || + !candidate.startsWith('/') || + candidate.includes('\\') || + hasControlCharacter(candidate) + ) { + return null + } + return candidate.replace(/\/+$/, '') || '/' +} + +function resolveLoginShell(): string { + const candidate = process.env.SHELL || userInfo().shell || '/bin/sh' + if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) { + return '/bin/sh' + } + return candidate +} + +/** The login shell's value for `envName`, or null when unset, invalid, or unreadable. */ +async function readLoginShellAgentHome( + envName: AgentHomeEnvName, + signal?: AbortSignal +): Promise { + try { + const shell = resolveLoginShell() + const shellName = basename(shell) + const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' + // Why: agent PTYs start login shells, so read the same profile-derived home override. + const result = await runProcess({ + program: shell, + args: [mode, `printenv ${envName} | head -c ${AGENT_HOME_MAX_LENGTH + 1}`], + timeoutMs: AGENT_HOME_PROBE_TIMEOUT_MS, + maxOutputBytes: AGENT_HOME_MAX_LENGTH * 2, + ...(signal ? { signal } : {}) + }) + signal?.throwIfAborted() + if (result.timedOut || result.code !== 0) { + return null + } + return normalizeAgentHome(result.stdout.split(/\r?\n/, 1)[0] ?? '') + } catch { + signal?.throwIfAborted() + return null + } +} + +export async function resolveLoginShellGrokHome( + home: string, + signal?: AbortSignal +): Promise { + return (await readLoginShellAgentHome('GROK_HOME', signal)) ?? defaultAgentHome(home, '.grok') +} + +/** `$KIRO_HOME` replaces `~/.kiro` outright, for agent configs and sessions alike. */ +export async function resolveLoginShellKiroHome( + home: string, + signal?: AbortSignal +): Promise { + return (await readLoginShellAgentHome('KIRO_HOME', signal)) ?? defaultAgentHome(home, '.kiro') +} + +/** + * A process not started from a login shell (orcad over SSH) misses profile-only agent homes, so + * its installers would write where the agents' login-shell panes never look. Never throws. + */ +export async function adoptLoginShellAgentHomes( + env: NodeJS.ProcessEnv = process.env +): Promise { + if (process.platform === 'win32') { + return + } + const names: AgentHomeEnvName[] = ['GROK_HOME', 'KIRO_HOME'] + await Promise.all( + names + .filter((name) => !env[name]) + .map(async (name) => { + const value = await readLoginShellAgentHome(name) + if (value && !env[name]) { + env[name] = value + } + }) + ) +} diff --git a/src/main/agent-hooks/managed-agent-hook-startup.ts b/src/main/agent-hooks/managed-agent-hook-startup.ts new file mode 100644 index 000000000000..c845bf4bd363 --- /dev/null +++ b/src/main/agent-hooks/managed-agent-hook-startup.ts @@ -0,0 +1,30 @@ +import { recordManagedHookInstallFailure } from './install-telemetry' +import { + installManagedAgentHooks, + resolveStartupManagedHookAction, + shouldContinueManagedHookStartup +} from './managed-agent-hook-controls' + +type StartupHookSettings = NonNullable[0]> + +/** Installs or refreshes this host's managed agent hooks once at process start; never removes. */ +export function reconcileStartupManagedAgentHooks(host: { + getSettings: () => StartupHookSettings + isQuitting: () => boolean + shouldHydrateShellPath: boolean +}): void { + // Why skip rather than remove when the off switch is set: the hook files are user-global but this + // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca + // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. + if (resolveStartupManagedHookAction(host.getSettings()) !== 'install') { + return + } + void installManagedAgentHooks(host.getSettings(), { + shouldHydrateShellPath: host.shouldHydrateShellPath, + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => + shouldContinueManagedHookStartup(host.isQuitting(), host.getSettings(), agent) + }).catch((error: unknown) => + console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) + ) +} diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts index f6e55eab1def..281126fae785 100644 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ b/src/main/agent-hooks/managed-hook-runtime.test.ts @@ -1,55 +1,9 @@ import { chmod, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeChildProcess from 'node:child_process' - -// Why: the probe spawns a real login shell, and `resolveRelayGrokHome` swallows every -// spawn failure into its fallback. Left unmocked this asserts the runner's scheduling -// latency, not the parser: on a loaded sharded CI box the 8s timeout expires and the -// first case silently flips to the fallback path. -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, execFile: vi.fn() } -}) - -const { execFile } = await import('node:child_process') -const execFileMock = vi.mocked(execFile) -const { execFile: actualExecFile } = - await vi.importActual('node:child_process') -const { installManagedHooks, resolveRelayGrokHome, resolveRelayKiroHome } = - await import('./managed-hook-runtime') - -type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void - -function stubProbeOutput(stdout: string): void { - execFileMock.mockImplementation(((...args: unknown[]) => { - ;(args.at(-1) as ExecFileCallback)(null, { stdout, stderr: '' }) - return undefined - }) as unknown as typeof execFile) -} - -function stubProbeFailure(error: Error): void { - execFileMock.mockImplementation(((...args: unknown[]) => { - ;(args.at(-1) as ExecFileCallback)(error) - return undefined - }) as unknown as typeof execFile) -} +import { afterEach, describe, expect, it, vi } from 'vitest' -beforeEach(() => { - execFileMock.mockReset() - // Why: `installManagedHooks` proves a skipped probe through the login-shell run log, so the - // probe must really spawn unless a case above stubs it. The mock loses `promisify.custom`, - // so the real `(error, stdout, stderr)` callback is reshaped into the `{ stdout, stderr }` - // that `promisify(execFile)` resolves in production. - execFileMock.mockImplementation(((...args: unknown[]) => { - const callback = args.at(-1) as ExecFileCallback - return (actualExecFile as (...callArgs: unknown[]) => unknown)( - ...args.slice(0, -1), - (error: Error | null, stdout: string, stderr: string) => callback(error, { stdout, stderr }) - ) - }) as unknown as typeof execFile) -}) +const { installManagedHooks } = await import('./managed-hook-runtime') const tempHomes: string[] = [] const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp' @@ -80,66 +34,6 @@ afterEach(async () => { await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) }) -describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => { - it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('/srv/grok///\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') - - const [shell, args] = execFileMock.mock.calls[0] ?? [] - expect(shell).toBe('/bin/sh') - // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. - expect(args?.[0]).toBe('-c') - }) - - it('passes -lc to a login shell that supports it', async () => { - vi.stubEnv('SHELL', '/bin/zsh') - stubProbeOutput('/srv/grok\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') - - const [shell, args] = execFileMock.mock.calls[0] ?? [] - expect(shell).toBe('/bin/zsh') - expect(args?.[0]).toBe('-lc') - }) - - it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('../relative\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') - }) - - // Why: this is the branch that made the old test flaky — pin it so a probe failure is - // an asserted fallback rather than an invisible substitution for a real answer. - it('falls back when the probe fails or times out', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') - }) -}) - -describe.runIf(process.platform !== 'win32')('resolveRelayKiroHome', () => { - it('reads KIRO_HOME from the login shell', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('/srv/kiro/\n') - - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/srv/kiro') - expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('printenv KIRO_HOME') - }) - - it('falls back to ~/.kiro when KIRO_HOME is unset or the probe fails', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('\n') - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') - - stubProbeFailure(new Error('spawn failed')) - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') - }) -}) - describe.runIf(process.platform !== 'win32')('installManagedHooks', () => { it.each([ ['omitted', undefined], diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index e8a32187f8a0..726cc5322c1c 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -1,7 +1,5 @@ -import { execFile } from 'node:child_process' -import { basename } from 'node:path' -import { homedir, userInfo } from 'node:os' -import { promisify } from 'node:util' +import { homedir } from 'node:os' +import { resolveLoginShellGrokHome, resolveLoginShellKiroHome } from './agent-home-login-shell' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import type { AgentHookTarget } from '../../shared/agent-hook-types' import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' @@ -11,80 +9,11 @@ import { scopeManagedHookHostIdentity } from './managed-hook-owner-identity' -const execFileAsync = promisify(execFile) -const AGENT_HOME_MAX_LENGTH = 4096 -const AGENT_HOME_PROBE_TIMEOUT_MS = 8_000 - export type ManagedHookInstallSummary = { installers: number errors: number } -function defaultAgentHome(home: string, dirName: string): string { - return `${home.replace(/\/+$/, '') || home}/${dirName}` -} - -function hasControlCharacter(value: string): boolean { - return Array.from(value).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) -} - -function normalizeAgentHome(candidate: string): string | null { - if ( - candidate.length === 0 || - candidate.length > AGENT_HOME_MAX_LENGTH || - candidate !== candidate.trim() || - !candidate.startsWith('/') || - candidate.includes('\\') || - hasControlCharacter(candidate) - ) { - return null - } - return candidate.replace(/\/+$/, '') || '/' -} - -function resolveLoginShell(): string { - const candidate = process.env.SHELL || userInfo().shell || '/bin/sh' - if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) { - return '/bin/sh' - } - return candidate -} - -async function resolveRelayAgentHome( - envName: 'GROK_HOME' | 'KIRO_HOME', - fallback: string, - signal?: AbortSignal -): Promise { - try { - const shell = resolveLoginShell() - const shellName = basename(shell) - const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' - // Why: agent PTYs start login shells, so read the same profile-derived - // home override without opening two additional SSH exec channels. - const { stdout } = await execFileAsync( - shell, - [mode, `printenv ${envName} | head -c ${AGENT_HOME_MAX_LENGTH + 1}`], - { encoding: 'utf8', timeout: AGENT_HOME_PROBE_TIMEOUT_MS, signal } - ) - return normalizeAgentHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback - } catch { - signal?.throwIfAborted() - return fallback - } -} - -export function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise { - return resolveRelayAgentHome('GROK_HOME', defaultAgentHome(home, '.grok'), signal) -} - -/** `$KIRO_HOME` replaces `~/.kiro` outright, for agent configs and sessions alike. */ -export function resolveRelayKiroHome(home: string, signal?: AbortSignal): Promise { - return resolveRelayAgentHome('KIRO_HOME', defaultAgentHome(home, '.kiro'), signal) -} - export async function installManagedHooks(options?: { signal?: AbortSignal hostKeyFingerprint?: string @@ -100,8 +29,8 @@ export async function installManagedHooks(options?: { const home = homedir() // Why parallel: each probe may wait out its own login-shell timeout. const [grokHomeDir, kiroHomeDir] = await Promise.all([ - agents.includes('grok') ? resolveRelayGrokHome(home, options?.signal) : undefined, - agents.includes('kiro') ? resolveRelayKiroHome(home, options?.signal) : undefined + agents.includes('grok') ? resolveLoginShellGrokHome(home, options?.signal) : undefined, + agents.includes('kiro') ? resolveLoginShellKiroHome(home, options?.signal) : undefined ]) options?.signal?.throwIfAborted() const hostIdentity = scopeManagedHookHostIdentity( diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 5047dd82027b..0f199ef3a9f8 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -177,6 +177,7 @@ async function startOrcadRuntime( const { isAgentStatusHooksEnabled } = await import('../agent-hooks/managed-agent-hook-controls') const { installHookStatusSessionTabsRepublish } = await import('../agent-hooks/hook-status-session-tabs-republish') + const { startOrcadManagedAgentHooks } = await import('./orcad-managed-agent-hooks') const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = await import('../runtime/agent-status-observed-pane-identity') @@ -187,12 +188,10 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} - let removeStatusHookSettingsListener = (): void => {} // Cleanups run in reverse: RPC, then recovery and watchers, then the final flush, then daemon. registerCleanup(() => agentHookServer.stop()) registerCleanup(() => uninstallHookStatusRepublish()) registerCleanup(() => uninstallObservedStatusIdentity()) - registerCleanup(() => removeStatusHookSettingsListener()) // Why disconnect and not shut down: the daemon must outlive this process, or an orcad // restart goes back to killing every running terminal. registerCleanup(() => stopOrcadDaemon()) @@ -227,11 +226,11 @@ async function startOrcadRuntime( statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) }) - removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { - if ('agentStatusHooksEnabled' in updates) { - agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) - } - }) + startOrcadManagedAgentHooks( + profileStore, + (enabled) => agentHookServer.setStatusHooksEnabled(enabled), + registerCleanup + ) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-managed-agent-hooks.ts b/src/main/orcad/orcad-managed-agent-hooks.ts new file mode 100644 index 000000000000..dbdf08695fde --- /dev/null +++ b/src/main/orcad/orcad-managed-agent-hooks.ts @@ -0,0 +1,54 @@ +import { getAppEnvironment } from '../../shared/app-environment' +import { startCodexHooks } from '../codex/codex-hook-reconcile' +import { adoptLoginShellAgentHomes } from '../agent-hooks/agent-home-login-shell' +import { hydrateAgentCliShellPath } from '../agent-hooks/local-agent-cli-presence' +import { + isAgentStatusHooksEnabled, + isAgentStatusHooksEnabledForAgent +} from '../agent-hooks/managed-agent-hook-controls' +import { reconcileStartupManagedAgentHooks } from '../agent-hooks/managed-agent-hook-startup' +import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' +import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' + +/** + * Keeps this host's hook server and its agents' hook configs true to its settings. The startup + * reconcile is the desktop's: without it a freshly managed SSH host only got hooks when its hook + * settings changed, so its agents never reported status. + */ +export function startOrcadManagedAgentHooks( + store: Pick, + setStatusHooksEnabled: (enabled: boolean) => void, + registerCleanup: (cleanup: OrcadRuntimeCleanup) => void +): void { + let stopping = false + const removeSettingsListener = store.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) + registerCleanup(() => { + stopping = true + removeSettingsListener() + }) + const getSettings = () => store.getSettings() + const shouldHydrateShellPath = getAppEnvironment().isPackaged() + startCodexHooks({ + pathReady: shouldHydrateShellPath ? hydrateAgentCliShellPath() : Promise.resolve(), + isEnabled: () => isAgentStatusHooksEnabledForAgent(getSettings(), 'codex'), + // Why null: orcad launches Codex on ~/.codex; Orca-managed account homes are a desktop flow. + resolveLaunchHome: () => null + }) + void adoptLoginShellAgentHomes().then(() => + reconcileStartupManagedAgentHooks({ + getSettings, + isQuitting: () => stopping, + shouldHydrateShellPath + }) + ) + try { + refreshInstalledOpenCodeStatusPlugins(getSettings()) + } catch (error) { + console.warn('[orcad] could not refresh OpenCode status plugins:', error) + } +} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 025dc6667736..b2349b560b19 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' +import type { GlobalSettings } from '../../shared/global-settings-types' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -21,7 +22,34 @@ const state = vi.hoisted(() => ({ startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), - send: vi.fn(async () => ({ ok: true, results: [] })) + send: vi.fn(async () => ({ ok: true, results: [] })), + startCodexHooks: vi.fn(), + installManagedAgentHooks: vi.fn(async () => []), + refreshOpenCodePlugins: vi.fn(), + adoptLoginShellAgentHomes: vi.fn(async () => {}) +})) +const profile = vi.hoisted(() => { + const value: { settings: Partial } = { settings: {} } + return value +}) +// Why: startup reconciles the user-global agent configs; keep it off this machine's real home. +vi.mock('../codex/codex-hook-reconcile', async (importOriginal) => ({ + ...(await importOriginal>()), + startCodexHooks: state.startCodexHooks +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', async (importOriginal) => ({ + ...(await importOriginal>()), + installManagedAgentHooks: state.installManagedAgentHooks +})) +vi.mock('../agent-hooks/local-agent-cli-presence', async (importOriginal) => ({ + ...(await importOriginal>()), + hydrateAgentCliShellPath: async () => {} +})) +vi.mock('../agent-hooks/agent-home-login-shell', () => ({ + adoptLoginShellAgentHomes: state.adoptLoginShellAgentHomes +})) +vi.mock('../opencode/opencode-status-plugin-startup-refresh', () => ({ + refreshInstalledOpenCodeStatusPlugins: state.refreshOpenCodePlugins })) vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => state.root, @@ -65,7 +93,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ } return { store: { - getSettings: () => ({}), + getSettings: () => profile.settings, onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} @@ -157,6 +185,7 @@ beforeEach(() => { afterEach(() => { rmSync(state.root, { recursive: true, force: true }) state.profileStartupErrors.length = 0 + profile.settings = {} vi.clearAllMocks() }) @@ -255,3 +284,42 @@ it('unsubscribes settings when daemon startup fails after hook setup', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +// Why: a freshly managed SSH host starts orcad on connect; without this its agents never report status. +it('installs agent status hooks on the host when it starts', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-hooks-')) + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + try { + expect(state.startCodexHooks).toHaveBeenCalledOnce() + // orcad launches Codex on the real ~/.codex; it has no Orca-managed account homes. + expect(state.startCodexHooks.mock.calls[0]![0].resolveLaunchHome()).toBeNull() + await vi.waitFor(() => + expect(state.installManagedAgentHooks).toHaveBeenCalledWith( + profile.settings, + expect.objectContaining({ shouldHydrateShellPath: true }) + ) + ) + expect(state.refreshOpenCodePlugins).toHaveBeenCalledWith(profile.settings) + // Grok/Kiro homes set only in login profiles must be known before their installers run. + expect(state.adoptLoginShellAgentHomes.mock.invocationCallOrder[0]).toBeLessThan( + state.installManagedAgentHooks.mock.invocationCallOrder[0]! + ) + } finally { + await host.stop() + } +}) + +it('leaves agent configs alone when status hooks are off', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-hooks-off-')) + profile.settings = { agentStatusHooksEnabled: false } + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(state.installManagedAgentHooks).not.toHaveBeenCalled() + expect(state.startCodexHooks.mock.calls[0]![0].isEnabled()).toBe(false) + } finally { + await host.stop() + } +}) diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 4b963730ebf3..e0e93f920e1d 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -15,14 +15,9 @@ import { handleGpuChildCrash } from './gpu-lifecycle' import { isGpuFallbackCrashCandidate } from '../crash-reporting/gpu-crash-fallback-decision' import { startCodexHooks } from '../codex/codex-hook-reconcile' import { hydrateAgentCliShellPath } from '../agent-hooks/local-agent-cli-presence' -import { - installManagedAgentHooks, - isAgentStatusHooksEnabledForAgent, - resolveStartupManagedHookAction, - shouldContinueManagedHookStartup -} from '../agent-hooks/managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' +import { reconcileStartupManagedAgentHooks } from '../agent-hooks/managed-agent-hook-startup' import { shouldInstallManagedHooks } from './configure-process' -import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { mainProcessState as state } from './main-process-state' import { initializeMainProcessObservers } from './main-process-observers' import { initializeMainProcessAccountServices } from './main-process-account-services' @@ -117,23 +112,12 @@ export async function initializeReadyRuntimeServices(): Promise { return state.codexRuntimeHome.resolveHostCodexHomePathForLaunchReadOnly() } }) - const startupManagedHookSettings = store.getSettings() - const shouldReconcileStartupManagedHooks = - shouldInstallManagedHooks(is.dev) && - resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' - // Why skip rather than remove when the off switch is set: the hook files are user-global but this - // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca - // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. - if (shouldReconcileStartupManagedHooks) { - const managedHookStore = store - void installManagedAgentHooks(managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged, - onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => - shouldContinueManagedHookStartup(state.isQuitting, managedHookStore.getSettings(), agent) - }).catch((error: unknown) => - console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) - ) + if (shouldInstallManagedHooks(is.dev)) { + reconcileStartupManagedAgentHooks({ + getSettings: () => store.getSettings(), + isQuitting: () => state.isQuitting, + shouldHydrateShellPath: app.isPackaged + }) } // Why: process-gone metrics only see survivors, and the gone-time host memory // read lands after the corpse released its pages; both need a live pre-gone From ee8cd8e58f9b805e0978191c2e4cab3400b1d644 Mon Sep 17 00:00:00 2001 From: outofplace <69466055+outof-place@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:14:25 +0200 Subject: [PATCH 061/110] perf(ai-vault): pipeline fresh transcript discovery with bounded I/O (#27009) Overlap transcript stat/sidecar reads in ordered batches (4 workers per root, 8 global slots); WSL stays sequential. Fixes #27008 Co-authored-by: outof-place --- src/main/ai-vault/session-newest-files.ts | 14 +- ...session-scanner-discovery-pipeline.test.ts | 273 ++++++++++++++++++ .../ai-vault/session-scanner-discovery.ts | 189 +++++++++--- 3 files changed, 427 insertions(+), 49 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-discovery-pipeline.test.ts diff --git a/src/main/ai-vault/session-newest-files.ts b/src/main/ai-vault/session-newest-files.ts index c315c26dfad6..c5fc252f338a 100644 --- a/src/main/ai-vault/session-newest-files.ts +++ b/src/main/ai-vault/session-newest-files.ts @@ -16,11 +16,7 @@ export class SessionNewestFiles { this.files.push(file) return } - if (this.limit <= 0) { - return - } - const last = this.files.at(-1) - if (this.files.length >= this.limit && last && file.mtimeMs <= last.mtimeMs) { + if (!this.wouldRetain(file.mtimeMs)) { return } let low = 0 @@ -39,6 +35,14 @@ export class SessionNewestFiles { } } + wouldRetain(mtimeMs: number): boolean { + if (this.limit <= 0) { + return false + } + const last = this.files.at(-1) + return !(this.files.length >= this.limit && last && mtimeMs <= last.mtimeMs) + } + get size(): number { return this.files.length } diff --git a/src/main/ai-vault/session-scanner-discovery-pipeline.test.ts b/src/main/ai-vault/session-scanner-discovery-pipeline.test.ts new file mode 100644 index 000000000000..4ec442268202 --- /dev/null +++ b/src/main/ai-vault/session-scanner-discovery-pipeline.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readdir: fsMocks.readdir, + stat: fsMocks.stat +})) + +import { discoverFiles } from './session-scanner-discovery' + +const fs = await vi.importActual('node:fs/promises') +let root: string + +beforeEach(async () => { + fsMocks.readdir.mockReset().mockImplementation(fs.readdir) + fsMocks.stat.mockReset().mockImplementation(fs.stat) + root = await fs.mkdtemp(join(tmpdir(), 'orca-fresh-discovery-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(root, { recursive: true, force: true }) +}) + +async function createFiles(directory: string, count: number): Promise { + await fs.mkdir(directory, { recursive: true }) + await Promise.all( + Array.from({ length: count }, (_, index) => + fs.writeFile(join(directory, `${index.toString().padStart(3, '0')}.jsonl`), '{}\n') + ) + ) + return (await fs.readdir(directory)).map((name) => join(directory, name)) +} + +function scan(scanRoot = root, issues: AiVaultScanIssue[] = [], limit = 100) { + return discoverFiles({ + rootDir: scanRoot, + issues, + limit, + agent: 'claude', + extensions: ['.jsonl'] + }) +} + +describe('fresh discovery pipeline', () => { + it('formats timestamps only for retained files while still reading every candidate', async () => { + await createFiles(root, 35) + fsMocks.stat.mockImplementation(async (path: string) => + Object.assign(await fs.stat(path), { mtimeMs: 1_000 }) + ) + const format = vi.spyOn(Date.prototype, 'toISOString') + expect((await scan(root, [], 3)).files).toHaveLength(3) + expect(format).toHaveBeenCalledTimes(3) + expect(fsMocks.stat).toHaveBeenCalledTimes(35) + format.mockClear() + expect((await scan(root, [], 0)).files).toEqual([]) + expect(format).not.toHaveBeenCalled() + expect(fsMocks.stat).toHaveBeenCalledTimes(70) + expect((await scan(root, [], Infinity)).files).toHaveLength(35) + expect(format).toHaveBeenCalledTimes(35) + }) + + it('reports invalid timestamps even for empty requests, after unavailable sidecar issues', async () => { + const [file] = await createFiles(root, 1) + fsMocks.stat.mockImplementation(async (path: string) => { + if (path.endsWith('.meta')) { + throw Object.assign(new Error('metadata refused'), { code: 'EACCES' }) + } + return Object.assign(await fs.stat(path), { mtimeMs: Infinity }) + }) + const issues: AiVaultScanIssue[] = [] + const discovery = await discoverFiles({ + rootDir: root, + issues, + limit: 0, + agent: 'claude', + extensions: ['.jsonl'], + contentDependencyPath: (path) => `${path}.meta` + }) + expect(discovery.files).toEqual([]) + expect(issues).toEqual([ + { + agent: 'claude', + path: `${file}.meta`, + message: 'Session metadata could not be read this scan.' + }, + { agent: 'claude', path: file, message: expect.stringMatching(/Invalid (?:time value|Date)/) } + ]) + }) + + it('preserves traversal ties when later metadata completes first, including the partial batch', async () => { + const paths = await createFiles(root, 131) + let releaseFirst = () => {} + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve + }) + let reachedLater = () => {} + const laterRead = new Promise((resolve) => { + reachedLater = resolve + }) + fsMocks.stat.mockImplementation(async (path: string) => { + if (path === paths[0]) { + await firstBlocked + } + if (path === paths[15]) { + reachedLater() + } + return Object.assign(await fs.stat(path), { mtimeMs: 1_000 }) + }) + + const discovery = scan(root, [], 130) + try { + await laterRead + } finally { + releaseFirst() + } + expect((await discovery).files.map((file) => file.path)).toEqual(paths.slice(0, 130)) + expect(fsMocks.stat).toHaveBeenCalledTimes(paths.length) + }) + + it('bounds directory and metadata reads across simultaneous roots', async () => { + const roots = Array.from({ length: 8 }, (_, index) => join(root, `root-${index}`)) + await Promise.all(roots.map((directory) => createFiles(directory, 70))) + let active = 0 + let peak = 0 + async function tracked(read: () => Promise): Promise { + active++ + peak = Math.max(peak, active) + try { + await new Promise((resolve) => setImmediate(resolve)) + return await read() + } finally { + active-- + } + } + fsMocks.readdir.mockImplementation((path: string, options: { withFileTypes: true }) => + tracked(() => fs.readdir(path, options)) + ) + fsMocks.stat.mockImplementation((path: string) => tracked(() => fs.stat(path))) + + const results = await Promise.all(roots.map((directory) => scan(directory, [], 3))) + expect(results.every((result) => result.files.length === 3)).toBe(true) + expect(peak).toBeGreaterThan(1) + expect(peak).toBeLessThanOrEqual(8) + expect(active).toBe(0) + }) + + it('sees file, sidecar, and nested membership changes on the next scan without notifications', async () => { + const [file] = await createFiles(root, 1) + if (!file) { + throw new Error('missing fixture') + } + const sidecar = join(root, 'metadata.json') + await fs.writeFile(sidecar, '{}') + const args = { + rootDir: root, + issues: [], + limit: 100, + agent: 'claude' as const, + extensions: ['.jsonl'], + contentDependencyPath: () => sidecar + } + const first = await discoverFiles(args) + await fs.writeFile(file, '{"new":true}\n') + await fs.utimes(file, 2_000, 2_000) + await fs.writeFile(sidecar, '{"new":true}') + await fs.utimes(sidecar, 3_000, 3_000) + const [added] = await createFiles(join(root, 'nested'), 1) + const second = await discoverFiles(args) + expect(second.files.map((entry) => entry.path)).toContain(added) + expect(second.files.find((entry) => entry.path === file)).toMatchObject({ + sizeBytes: 13, + mtimeMs: 2_000_000, + sidecar: { path: sidecar, mtimeMs: 3_000_000, sizeBytes: 12 } + }) + expect(first.files[0]?.sizeBytes).toBe(3) + await fs.rm(file) + expect((await discoverFiles(args)).files.map((entry) => entry.path)).not.toContain(file) + }) + + it('keeps issue ordering at the cap despite reversed metadata failures', async () => { + const paths = await createFiles(root, 65) + let releaseFirst = () => {} + const blocked = new Promise((resolve) => { + releaseFirst = resolve + }) + let reachedLater = () => {} + const later = new Promise((resolve) => { + reachedLater = resolve + }) + fsMocks.stat.mockImplementation(async (path: string) => { + if (path === paths[0]) { + await blocked + } + if (path === paths[15]) { + reachedLater() + } + throw new Error(`unreadable ${path}`) + }) + const issues: AiVaultScanIssue[] = Array.from({ length: 498 }, (_, index) => ({ + agent: 'claude', + path: `previous-${index}`, + message: 'previous issue' + })) + const discovery = scan(root, issues) + try { + await later + } finally { + releaseFirst() + } + expect((await discovery).files).toEqual([]) + expect(issues).toHaveLength(500) + expect(issues[498]?.path).toBe(paths[0]) + expect(issues[499]?.kind).toBe('notice') + }) + + it('reports one unavailable sidecar in traversal order and distinguishes missing metadata', async () => { + const paths = await createFiles(root, 5) + fsMocks.stat.mockImplementation(async (path: string) => { + if (path.endsWith('.meta')) { + throw Object.assign(new Error('metadata refused'), { + code: path === `${paths[0]}.meta` ? 'ENOENT' : 'EACCES' + }) + } + return fs.stat(path) + }) + const issues: AiVaultScanIssue[] = [] + const discovery = await discoverFiles({ + rootDir: root, + issues, + limit: 100, + agent: 'claude', + extensions: ['.jsonl'], + contentDependencyPath: (path) => `${path}.meta` + }) + expect(discovery.files.find((entry) => entry.path === paths[0])?.sidecar).toBe('none') + expect(discovery.files.filter((entry) => entry.sidecar === 'unknown')).toHaveLength(4) + expect(issues).toEqual([ + { + agent: 'claude', + path: `${paths[1]}.meta`, + message: 'Session metadata could not be read this scan.' + } + ]) + }) + + it.skipIf(process.platform === 'win32')( + 'reads retargeted and retargeted-back roots from disk', + async () => { + const [a] = await createFiles(join(root, 'a'), 1) + const [b] = await createFiles(join(root, 'b'), 1) + const alias = join(root, 'alias') + await fs.symlink(join(root, 'a'), alias, 'dir') + expect((await scan(alias)).files[0]?.sizeBytes).toBe(3) + if (!a || !b) { + throw new Error('missing fixture') + } + await fs.writeFile(b, '{"target":"b"}\n') + await fs.unlink(alias) + await fs.symlink(join(root, 'b'), alias, 'dir') + expect((await scan(alias)).files[0]?.sizeBytes).toBe(15) + await fs.writeFile(a, '{"target":"a"}\n') + await fs.unlink(alias) + await fs.symlink(join(root, 'a'), alias, 'dir') + expect((await scan(alias)).files[0]?.sizeBytes).toBe(15) + } + ) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index f7835dbdcc5e..84c2ffdd1e46 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,14 +1,34 @@ import type { Dirent } from 'node:fs' import { extname, join } from 'node:path' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { PrioritySemaphore } from '../../shared/priority-semaphore' +import { isWslUncPath } from '../../shared/wsl-paths' import { SessionNewestFiles } from './session-newest-files' import type { SessionSidecarObservation } from './session-sidecar-stat' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { recordSessionScanIssue } from './session-scan-issues' -import type { SessionFileDiscovery } from './session-scanner-types' +import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +const NATIVE_DISCOVERY_CONCURRENCY = 4 +const NATIVE_DISCOVERY_BATCH_SIZE = 16 +const nativeDiscoverySlots = new PrioritySemaphore(8) + +type SessionFileObservation = + | { file: Omit; sidecarPath?: string } + | { issue: { path: string; message: string } } + +async function withNativeDiscoverySlot(read: () => Promise): Promise { + const release = await nativeDiscoverySlots.acquire(0) + try { + return await read() + } finally { + release() + } +} + export async function discoverFiles(args: { rootDir: string limit: number @@ -21,50 +41,103 @@ export async function discoverFiles(args: { }): Promise { const files = new SessionNewestFiles(args.limit) let refusedSidecar = false - try { - await forEachSessionFile( - args.rootDir, - args.agent, - args.issues, - { - extensions: new Set(args.extensions), - filePredicate: args.filePredicate, - directoryPredicate: args.directoryPredicate - }, - async (path) => { - try { - const fileStat = await wslGatedStat(path, 'scan') - const sidecarPath = await args.contentDependencyPath?.(path) - const sidecar = await observeSessionSidecar(sidecarPath) - if (sidecar === 'unknown' && !refusedSidecar) { - // One issue per root: a refused sibling is a property of the tree, - // not of each transcript that happens to point at it. - refusedSidecar = true - recordSessionScanIssue(args.issues, { - agent: args.agent, - path: sidecarPath ?? args.rootDir, - message: 'Session metadata could not be read this scan.' - }) - } - files.add({ - path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: new Date(fileStat.mtimeMs).toISOString(), - sizeBytes: fileStat.size, - sidecar, - dev: fileStat.dev, - ino: fileStat.ino, - nlink: fileStat.nlink - }) - } catch (err) { - recordSessionScanIssue(args.issues, { - agent: args.agent, - path, - message: errorMessage(err) - }) + let paths: string[] = [] + let pending: Promise | null = null + const native = !isWslUncPath(args.rootDir) + + function consume(observations: readonly SessionFileObservation[]): void { + for (const observation of observations) { + if ('issue' in observation) { + recordSessionScanIssue(args.issues, { agent: args.agent, ...observation.issue }) + continue + } + if (observation.file.sidecar === 'unknown' && !refusedSidecar) { + refusedSidecar = true + recordSessionScanIssue(args.issues, { + agent: args.agent, + path: observation.sidecarPath ?? args.rootDir, + message: 'Session metadata could not be read this scan.' + }) + } + try { + const modifiedAt = new Date(observation.file.mtimeMs) + if (Number.isNaN(modifiedAt.getTime()) || files.wouldRetain(observation.file.mtimeMs)) { + files.add({ ...observation.file, modifiedAt: modifiedAt.toISOString() }) } + } catch (err) { + recordSessionScanIssue(args.issues, { + agent: args.agent, + path: observation.file.path, + message: errorMessage(err) + }) } + } + } + + async function finishPending(): Promise { + if (pending) { + consume(await pending) + pending = null + } + } + + function queueNativeFile(path: string): void | Promise { + paths.push(path) + if (paths.length < NATIVE_DISCOVERY_BATCH_SIZE) { + return + } + return finishPending().then(() => { + pending = readBatch(paths) + paths = [] + }) + } + + async function readBatch(batch: readonly string[]): Promise { + const groups: string[][] = [] + const groupSize = Math.ceil(batch.length / NATIVE_DISCOVERY_CONCURRENCY) + for (let index = 0; index < batch.length; index += groupSize) { + groups.push(batch.slice(index, index + groupSize)) + } + const observations = await mapWithConcurrency(groups, NATIVE_DISCOVERY_CONCURRENCY, (group) => + withNativeDiscoverySlot(async () => { + const files: SessionFileObservation[] = [] + for (const path of group) { + files.push(await observeSessionFile(path, args.contentDependencyPath)) + } + return files + }) ) + return observations.flat() + } + + try { + try { + await forEachSessionFile( + args.rootDir, + args.agent, + args.issues, + { + extensions: new Set(args.extensions), + filePredicate: args.filePredicate, + directoryPredicate: args.directoryPredicate, + ...(native + ? { + readDirectory: (path: string) => + withNativeDiscoverySlot(() => wslGatedReaddir(path, 'scan')) + } + : {}) + }, + native + ? queueNativeFile + : async (path) => consume([await observeSessionFile(path, args.contentDependencyPath)]) + ) + } finally { + // One read batch overlaps directory traversal; commit in traversal order. + await finishPending() + if (paths.length) { + consume(await readBatch(paths)) + } + } } catch (err) { // Why: discoverAiVaultSessionSources fans out with Promise.all, so one // stalled distro would otherwise reject the whole vault scan — including @@ -82,6 +155,31 @@ export async function discoverFiles(args: { return { agent: args.agent, rootDir: args.rootDir, files: files.newest() } } +async function observeSessionFile( + path: string, + contentDependencyPath?: (path: string) => string | undefined | Promise +): Promise { + try { + const fileStat = await wslGatedStat(path, 'scan') + const sidecarPath = contentDependencyPath ? await contentDependencyPath(path) : undefined + const sidecar = sidecarPath ? await observeSessionSidecar(sidecarPath) : 'none' + return { + file: { + path, + mtimeMs: fileStat.mtimeMs, + sizeBytes: fileStat.size, + sidecar, + dev: fileStat.dev, + ino: fileStat.ino, + nlink: fileStat.nlink + }, + sidecarPath + } + } catch (err) { + return { issue: { path, message: errorMessage(err) } } + } +} + /** * A sibling that cannot be statted is not "no sibling": it must not take the * transcript down with it, and it must not read as absent either, or the parse @@ -144,7 +242,7 @@ export async function forEachSessionFile( agent: AiVaultAgent, issues: AiVaultScanIssue[], options: SessionFileWalkOptions, - onFile: (path: string) => Promise, + onFile: (path: string) => void | Promise, depth = 0 ): Promise { options.signal?.throwIfAborted() @@ -179,7 +277,10 @@ export async function forEachSessionFile( options.extensions.has(extname(entry.name).toLowerCase()) && (options.filePredicate?.(fullPath) ?? true) ) { - await onFile(fullPath) + const observation = onFile(fullPath) + if (observation) { + await observation + } } } } From 77d5de1f6c674c14788fcf82907ca3ffd4c4d58f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 17:18:01 -0700 Subject: [PATCH 062/110] fix(orcad): provide the orca CLI in Windows managed-host terminals (#27324) Co-authored-by: m4air --- .../prepare-windows-cli-launcher/action.yml | 39 +++++++++ .github/workflows/e2e.yml | 3 + .github/workflows/node-server-tests.yml | 19 +++++ .github/workflows/pr.yml | 1 + .github/workflows/ssh-windows-hosts.yml | 8 ++ .../workflows/win-orcad-serve-switch-e2e.yml | 3 + config/scripts/build-orcad.mjs | 2 + config/scripts/build-windows-cli-launcher.mjs | 27 ++++++- .../build-windows-cli-launcher.test.mjs | 76 +++++++++++++++++ config/scripts/node-server-change-scope.mjs | 1 + config/scripts/orcad-windows-cli-launcher.mjs | 63 +++++++++++++++ .../orcad-windows-cli-launcher.test.mjs | 66 +++++++++++++++ config/scripts/pr-code-change-scope.mjs | 1 + native/windows-cli-launcher/src/main.rs | 64 ++++++++++++++- src/main/cli/orca-cli-child-path.test.ts | 24 ++++++ src/main/orcad/orcad-cli-launcher.test.ts | 71 ++++++++++++++-- src/main/orcad/orcad-cli-launcher.ts | 81 ++++++++++++++++--- src/main/ssh/orcad-windows-host-lane.test.ts | 10 +++ src/shared/orcad-artifacts.ts | 4 +- 19 files changed, 539 insertions(+), 24 deletions(-) create mode 100644 .github/actions/prepare-windows-cli-launcher/action.yml create mode 100644 config/scripts/orcad-windows-cli-launcher.mjs create mode 100644 config/scripts/orcad-windows-cli-launcher.test.mjs diff --git a/.github/actions/prepare-windows-cli-launcher/action.yml b/.github/actions/prepare-windows-cli-launcher/action.yml new file mode 100644 index 000000000000..8e7ae74f48cf --- /dev/null +++ b/.github/actions/prepare-windows-cli-launcher/action.yml @@ -0,0 +1,39 @@ +name: Prepare the orcad Windows CLI launcher +description: Compiles native/windows-cli-launcher for each requested arch into .build/windows-cli-launcher//orca.exe. + +inputs: + arches: + description: Space-separated Windows architectures (x64, arm64). + required: true + +runs: + using: composite + steps: + - name: Cache the Windows CLI launcher builds + if: runner.os == 'Windows' + uses: actions/cache@v5 + with: + path: | + .build/windows-cli-launcher + native/windows-cli-launcher/target + ~/.cargo/registry + key: orcad-windows-cli-launcher-${{ runner.arch }}-${{ inputs.arches }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs', 'resources/build/icon.ico', 'package.json') }} + + # Why explicit: a runner image without cargo would otherwise fail inside the orcad build. + - name: Build the launcher for each architecture + if: runner.os == 'Windows' + shell: pwsh + env: + ARCHES: ${{ inputs.arches }} + run: | + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + rustup toolchain install stable --profile minimal + rustup default stable + } + $triples = @{ x64 = 'x86_64-pc-windows-msvc'; arm64 = 'aarch64-pc-windows-msvc' } + foreach ($arch in ($env:ARCHES -split ' ' | Where-Object { $_ })) { + rustup target add $triples[$arch] + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node config/scripts/build-windows-cli-launcher.mjs --arch $arch --output ".build/windows-cli-launcher/$arch/orca.exe" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index de4592af128d..a21fb68408c6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -665,6 +665,9 @@ jobs: with: resolve-windows-cache: 'true' restore-windows-cache: 'true' + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: x64 - name: Build the e2e app, CLI, orcad slot and template shell: bash run: | diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 90c3fc68473d..a72e7fbc6fd0 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -192,6 +192,11 @@ jobs: (github.event_name == 'pull_request' || (github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }} restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} + # The template lane cross-compiles arm64 too, as it does for the process-table addon. + - uses: ./.github/actions/prepare-windows-cli-launcher + if: runner.os == 'Windows' + with: + arches: ${{ (inputs.build_template && matrix.os == 'windows-2022') && 'x64 arm64' || (runner.arch == 'ARM64' && 'arm64' || 'x64') }} - run: pnpm build:orcad - wait: bun-orcad - run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }} @@ -216,6 +221,15 @@ jobs: if-no-files-found: error retention-days: 7 overwrite: true + - name: Keep the Windows CLI launchers for the desktop template + if: inputs.build_template && matrix.os == 'windows-2022' + uses: actions/upload-artifact@v7 + with: + name: orcad-windows-cli-launcher + path: .build/windows-cli-launcher/ + if-no-files-found: error + retention-days: 7 + overwrite: true - uses: actions/setup-node@v6 if: runner.arch == 'X64' with: @@ -458,6 +472,11 @@ jobs: with: name: orcad-windows-process-tree path: .build/windows-process-tree + - name: Collect the Windows CLI launchers + uses: actions/download-artifact@v8 + with: + name: orcad-windows-cli-launcher + path: .build/windows-cli-launcher - name: Merge the lanes and gate the full slot matrix shell: bash run: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3751f5f2b289..c98f55d34d09 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1220,6 +1220,7 @@ jobs: config/scripts/ensure-native-runtime-job-ownership.test.mjs config/scripts/verify-packaged-node-pty-job-ownership.test.mjs config/scripts/windows-pe-machine.test.mjs + config/scripts/build-windows-cli-launcher.test.mjs config/scripts/script-module-dependencies.test.mjs src/main/windows-registry-addon.test.ts config/scripts/windows-process-tree-gyp-path.test.mjs diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 752f3ff9a2a0..2b6792b94af3 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -26,6 +26,11 @@ on: - 'config/scripts/build-orcad-*.mjs' - 'config/scripts/orcad-prebuild-*.mjs' - 'config/scripts/orcad-windows-process-tree.mjs' + - 'config/scripts/orcad-windows-cli-launcher.mjs' + - 'config/scripts/build-windows-cli-launcher.mjs' + - 'native/windows-cli-launcher/**' + - 'src/main/orcad/orcad-cli-launcher.ts' + - '.github/actions/prepare-windows-cli-launcher/**' - 'config/scripts/build-relay.mjs' - 'config/patches/node-pty*' - 'config/patches/@vscode__windows-process-tree*' @@ -150,6 +155,9 @@ jobs: with: resolve-windows-cache: ${{ github.event_name == 'pull_request' }} restore-windows-cache: ${{ github.event_name == 'pull_request' }} + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: ${{ matrix.arch }} - name: Build the win32 template and the relay shell: bash env: diff --git a/.github/workflows/win-orcad-serve-switch-e2e.yml b/.github/workflows/win-orcad-serve-switch-e2e.yml index 776b0db72ed8..6e105876008a 100644 --- a/.github/workflows/win-orcad-serve-switch-e2e.yml +++ b/.github/workflows/win-orcad-serve-switch-e2e.yml @@ -78,6 +78,9 @@ jobs: with: resolve-windows-cache: 'true' restore-windows-cache: 'true' + - uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: x64 - name: Build the win32-x64 orcad template shell: bash run: | diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 5099a95994d7..d548e5c65d16 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -31,6 +31,7 @@ import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-s import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' +import { stageOrcadWindowsCliLauncher } from './orcad-windows-cli-launcher.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, ORCAD_CLI_ENTRY_FILENAME, @@ -157,6 +158,7 @@ writeFileSync( stageNodePty(BUILD_TARGET) await stageParcelWatcher(BUILD_TARGET) stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET) +stageOrcadWindowsCliLauncher(ROOT, OUT_DIR, BUILD_TARGET) const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET) mkdirSync(dirname(emojiDatasetOutput), { recursive: true }) copyFileSync( diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index 3d9d93aaed20..a0950170d4ec 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -40,6 +40,20 @@ function defaultOutputPath(projectRoot) { return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe') } +// Why explicit triples: the orcad template ships both, cross-compiled from one x64 runner. +const RUST_TARGETS = { x64: 'x86_64-pc-windows-msvc', arm64: 'aarch64-pc-windows-msvc' } + +export function windowsCliLauncherRustTarget(arch) { + if (arch === undefined) { + return null + } + const target = RUST_TARGETS[arch] + if (!target) { + throw new Error(`Unsupported Windows CLI launcher architecture: ${arch}`) + } + return target +} + function readArg(name) { const index = process.argv.indexOf(name) return index !== -1 ? process.argv[index + 1] : undefined @@ -61,6 +75,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const { version } = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) // Throws on a version the PE cannot represent, before anything is compiled. windowsCliLauncherFileVersion(version) + const rustTarget = windowsCliLauncherRustTarget(readArg('--arch')) const fingerprint = windowsCliLauncherFingerprint( [ join(crateRoot, 'src', 'main.rs'), @@ -71,7 +86,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], - version + rustTarget ? `${version}\0${rustTarget}` : version ) const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot) @@ -92,7 +107,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) '--manifest-path', manifestPath, '--target-dir', - targetDirectory + targetDirectory, + ...(rustTarget ? ['--target', rustTarget] : []) ], { cwd: crateRoot, @@ -119,7 +135,12 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(result.status ?? 1) } - const builtPath = join(targetDirectory, 'release', 'orca.exe') + const builtPath = join( + targetDirectory, + ...(rustTarget ? [rustTarget] : []), + 'release', + 'orca.exe' + ) const vcRuntimeImports = findDynamicVcRuntimeImports( readPeImportedDllNames(readFileSync(builtPath)) ) diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 0e06cafd22eb..236bafe7443a 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -15,6 +15,7 @@ import { describe, expect, it } from 'vitest' import { shouldReuseCompiledWindowsCliLauncher, windowsCliLauncherFileVersion, + windowsCliLauncherRustTarget, windowsCliLauncherFingerprint } from './build-windows-cli-launcher.mjs' import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' @@ -245,6 +246,81 @@ describe('Windows CLI launcher', () => { } }) + it('cross-compiles only the architectures the orcad template ships', () => { + expect(windowsCliLauncherRustTarget(undefined)).toBeNull() + expect(windowsCliLauncherRustTarget('x64')).toBe('x86_64-pc-windows-msvc') + expect(windowsCliLauncherRustTarget('arm64')).toBe('aarch64-pc-windows-msvc') + expect(() => windowsCliLauncherRustTarget('ia32')).toThrow('Unsupported') + }) + + itWindows('runs a server profile CLI on pinned Node with argv and profile intact', () => { + const root = mkdtempSync(join(tmpdir(), 'orca server launcher ')) + try { + const profile = join(root, 'server profile') + const launcherPath = join(profile, 'cli', 'bin', 'orca.exe') + const cliPath = join(root, 'server slot', 'out', 'cli', 'index.js') + mkdirSync(dirname(cliPath), { recursive: true }) + writeFileSync( + cliPath, + `process.stdout.write(JSON.stringify({ + argv: process.argv.slice(2), + profile: process.env.ORCA_USER_DATA_PATH, + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, + nodeOptions: process.env.NODE_OPTIONS ?? null +}))\n`, + 'utf8' + ) + const build = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], + { cwd: projectRoot, encoding: 'utf8' } + ) + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + writeFileSync( + join(dirname(launcherPath), 'orca-launcher.cfg'), + `node=${process.execPath}\ncli=${cliPath}\nuser-data=${profile}\n` + ) + + const inherited = { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + NODE_OPTIONS: '--require C:\\missing\\preload.js', + ORCA_USER_DATA_PATH: 'C:\\another\\owner', + ORCA_TEST_LAUNCHER: launcherPath + } + const expected = (body) => ({ + argv: ['orchestration', 'send', '--body', body, '--json'], + profile, + electronRunAsNode: null, + nodeOptions: null + }) + // Windows PowerShell 5.1 itself drops embedded double quotes, so its body has none. + const shellBody = 'line one & %PATH% ^caret\n\nline three' + const powershell = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json' + ], + { encoding: 'utf8', env: { ...inherited, ORCA_TEST_BODY: shellBody } } + ) + expect(powershell.status, powershell.stderr).toBe(0) + expect(JSON.parse(powershell.stdout)).toEqual(expected(shellBody)) + + const body = 'line one "quoted" \\"escaped\\" & %PATH% ^caret\\\n\nline three\\' + const direct = spawnSync(launcherPath, expected(body).argv, { + encoding: 'utf8', + env: inherited + }) + expect(direct.status, direct.stderr).toBe(0) + expect(JSON.parse(direct.stdout)).toEqual(expected(body)) + } finally { + removeFixtureTree(root) + } + }) + itWindows('survives an inherited environment block containing PATH and Path', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher ')) try { diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index ffaf8f1dce66..17be2b64a089 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -14,6 +14,7 @@ const BUILD_SCRIPTS = [ 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/orcad-prebuild-smoke-child.cjs', 'config/scripts/build-windows-process-tree-relay-addon.mjs', + 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/run-node-server-tests.mjs', 'config/vitest.config.ts', 'config/scripts/happy-dom-offscreen-canvas.ts', diff --git a/config/scripts/orcad-windows-cli-launcher.mjs b/config/scripts/orcad-windows-cli-launcher.mjs new file mode 100644 index 000000000000..14339045317e --- /dev/null +++ b/config/scripts/orcad-windows-cli-launcher.mjs @@ -0,0 +1,63 @@ +import { copyFileSync, existsSync, mkdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { runProcessSync } from '@orca/process-host' +import { ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME } from '../../src/shared/orcad-artifacts.ts' + +const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)( + './windows-pe-machine.cjs' +) + +export function orcadWindowsCliLauncherSource(root, arch) { + return join(root, '.build', 'windows-cli-launcher', arch, 'orca.exe') +} + +/** A Windows slot's `orca` command; a Windows build host compiles a missing one. */ +export function stageOrcadWindowsCliLauncher( + root, + outputDir, + target, + host = { platform: process.platform }, + compile = compileOrcadWindowsCliLauncher +) { + if (!target.startsWith('win32-')) { + return + } + const arch = target.slice('win32-'.length) + const source = orcadWindowsCliLauncherSource(root, arch) + if (!existsSync(source)) { + if (host.platform !== 'win32') { + throw new Error( + `Orcad ${target} requires the native CLI launcher. On Windows, run: ` + + `node config/scripts/build-windows-cli-launcher.mjs --arch ${arch} --output ${source}` + ) + } + compile(root, arch, source) + } + const machine = readPeMachine(source) + if (machine !== PE_MACHINE[arch]) { + throw new Error(`Orcad ${target} CLI launcher has ${describePeMachine(machine)}`) + } + const destination = join(outputDir, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(source, destination) +} + +function compileOrcadWindowsCliLauncher(root, arch, output) { + const result = runProcessSync({ + program: process.execPath, + args: [ + join(root, 'config/scripts/build-windows-cli-launcher.mjs'), + '--arch', + arch, + '--output', + output + ], + cwd: root, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + throw new Error(`Windows CLI launcher build for ${arch} failed with exit ${result.code}`) + } +} diff --git a/config/scripts/orcad-windows-cli-launcher.test.mjs b/config/scripts/orcad-windows-cli-launcher.test.mjs new file mode 100644 index 000000000000..5e12c5597e18 --- /dev/null +++ b/config/scripts/orcad-windows-cli-launcher.test.mjs @@ -0,0 +1,66 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + orcadWindowsCliLauncherSource, + stageOrcadWindowsCliLauncher +} from './orcad-windows-cli-launcher.mjs' + +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function peImage(machine) { + const bytes = Buffer.alloc(0x90) + bytes.write('MZ') + bytes.writeUInt32LE(0x80, 0x3c) + bytes.write('PE\0\0', 0x80) + bytes.writeUInt16LE(machine, 0x84) + return bytes +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orcad-cli-launcher-')) + roots.push(root) + const output = join(root, 'output') + mkdirSync(output) + return { root, output } +} + +function prepare(root, arch, machine) { + const source = orcadWindowsCliLauncherSource(root, arch) + mkdirSync(dirname(source), { recursive: true }) + writeFileSync(source, peImage(machine)) + return source +} + +it('stages the prepared launcher for the requested machine at the slot command path', () => { + const { root, output } = fixture() + const source = prepare(root, 'arm64', 0xaa64) + stageOrcadWindowsCliLauncher(root, output, 'win32-arm64', { platform: 'linux' }) + expect(readFileSync(join(output, 'bin', 'orca.exe'))).toEqual(readFileSync(source)) +}) + +it('rejects a launcher built for the wrong machine', () => { + const { root, output } = fixture() + prepare(root, 'x64', 0xaa64) + expect(() => stageOrcadWindowsCliLauncher(root, output, 'win32-x64')).toThrow('machine 0xaa64') +}) + +it('refuses a missing launcher off Windows and compiles it on Windows', () => { + const { root, output } = fixture() + expect(() => + stageOrcadWindowsCliLauncher(root, output, 'win32-x64', { platform: 'darwin' }) + ).toThrow('native CLI launcher') + const compile = vi.fn((_root, arch, path) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, peImage(arch === 'x64' ? 0x8664 : 0xaa64)) + }) + stageOrcadWindowsCliLauncher(root, output, 'win32-x64', { platform: 'win32' }, compile) + expect(compile).toHaveBeenCalledWith(root, 'x64', orcadWindowsCliLauncherSource(root, 'x64')) + expect(readFileSync(join(output, 'bin', 'orca.exe'))).toEqual(peImage(0x8664)) +}) + +it('ignores POSIX targets', () => { + expect(() => stageOrcadWindowsCliLauncher('absent', 'absent', 'linux-x64-glibc')).not.toThrow() +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 1d1d784db863..632fec09dcdf 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -369,6 +369,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'config/scripts/ensure-native-runtime-job-ownership.test.mjs', 'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs', 'config/scripts/windows-pe-machine.test.mjs', + 'config/scripts/build-windows-cli-launcher.test.mjs', 'config/scripts/script-module-dependencies.test.mjs', 'src/main/windows-registry-addon.test.ts', 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', diff --git a/native/windows-cli-launcher/src/main.rs b/native/windows-cli-launcher/src/main.rs index 00282fe455ee..ab513dea240e 100644 --- a/native/windows-cli-launcher/src/main.rs +++ b/native/windows-cli-launcher/src/main.rs @@ -9,16 +9,29 @@ //! user-writable directory that mutates environment variables and proxies a //! child process is the shape antivirus MSIL heuristics are trained on, and it //! was flagged as exactly that across several vendors (#23383). +//! +//! On a Windows server host, orcad copies this binary into its profile and +//! writes `orca-launcher.cfg` beside it; that file selects the server mode. use std::env; +use std::fs; use std::path::{Path, PathBuf}; use std::process::{exit, Command}; +const SERVER_CONFIG_FILENAME: &str = "orca-launcher.cfg"; + fn main() { let launcher = match env::current_exe() { Ok(path) => path, Err(error) => fail(&format!("Unable to start the Orca CLI: {error}")), }; + if let Some(config) = launcher + .parent() + .map(|directory| directory.join(SERVER_CONFIG_FILENAME)) + .filter(|config| config.is_file()) + { + run_server_cli(&config); + } let Some(resources_directory) = launcher.parent().and_then(Path::parent) else { fail(&format!( @@ -74,10 +87,57 @@ fn main() { }, ); + run_cli(&electron_path, &cli_path) +} + +/// The server mode: the pinned Node runs the active slot's CLI under the +/// server's profile, as the POSIX launcher does. +fn run_server_cli(config_path: &Path) -> ! { + let config = fs::read_to_string(config_path).unwrap_or_else(|error| { + fail(&format!( + "Unable to read the Orca CLI configuration at \"{}\": {error}", + config_path.display() + )) + }); + let node_path = PathBuf::from(config_value(&config, "node", config_path)); + let cli_path = PathBuf::from(config_value(&config, "cli", config_path)); + let user_data_path = config_value(&config, "user-data", config_path); + for path in [&node_path, &cli_path] { + if !path.is_file() { + fail(&format!( + "Unable to locate \"{}\"; reconnect to the Orca server to refresh the CLI", + path.display() + )); + } + } + env::set_var("ORCA_USER_DATA_PATH", user_data_path); + for name in ["NODE_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", "ELECTRON_RUN_AS_NODE"] { + env::remove_var(name); + } + run_cli(&node_path, &cli_path) +} + +fn config_value<'a>(config: &'a str, key: &str, config_path: &Path) -> &'a str { + config + .lines() + .find_map(|line| { + line.split_once('=') + .filter(|(name, value)| *name == key && !value.is_empty()) + .map(|(_, value)| value) + }) + .unwrap_or_else(|| { + fail(&format!( + "The Orca CLI configuration at \"{}\" has no {key}", + config_path.display() + )) + }) +} + +fn run_cli(program: &Path, cli_path: &Path) -> ! { // Each argument stays its own argv entry, so a body holding newlines reaches // the CLI intact. - let mut command = Command::new(&electron_path); - command.arg(&cli_path).args(env::args_os().skip(1)); + let mut command = Command::new(program); + command.arg(cli_path).args(env::args_os().skip(1)); match command.status() { Ok(status) => exit(status.code().unwrap_or(1)), diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index ba9e8c6b7127..30f049bf9956 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -46,6 +46,30 @@ describe('prependOrcaCliDirToChildPath', () => { expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() } ) + it('puts a Windows execution host CLI first on Path without a POSIX restore dir', () => { + const binDir = join(USER_DATA, 'cli', 'bin') + const launcher = join(binDir, 'orca.exe') + installFakeAppEnvironment({ getCliLauncherPath: () => launcher }) + const env: Record = { + Path: `C:\\Windows;${binDir};;C:\\Tools`, + ORCA_USER_DATA_PATH: 'C:\\parent\\profile', + ORCA_CLI_COMMAND: 'C:\\parent\\orca.exe', + ORCA_CLI_BIN_DIR: '/parent/bin' + } + expect( + prependOrcaCliDirToChildPath(env, { + isPackaged: true, + userDataPath: USER_DATA, + resourcesPath: null, + platform: 'win32' + }) + ).toBe(launcher) + expect(env).toEqual({ + Path: `${binDir};C:\\Windows;C:\\Tools`, + ORCA_USER_DATA_PATH: USER_DATA, + ORCA_CLI_COMMAND: launcher + }) + }) it('leads packaged Linux PATH with the bare-orca shim dir', () => { // Why this matters at all: the Linux CLI installs as `orca-ide` so it never claims GNOME // Orca's /usr/bin/orca screen reader, so bare `orca` only works through this shim. diff --git a/src/main/orcad/orcad-cli-launcher.test.ts b/src/main/orcad/orcad-cli-launcher.test.ts index 3df5044d4583..6ae5efeb52d8 100644 --- a/src/main/orcad/orcad-cli-launcher.test.ts +++ b/src/main/orcad/orcad-cli-launcher.test.ts @@ -1,10 +1,17 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' -import { ORCAD_CLI_ENTRY_FILENAME } from '../../shared/orcad-artifacts' -import { getOrcadCliLauncherPath, prepareOrcadCliLauncher } from './orcad-cli-launcher' +import { + ORCAD_CLI_ENTRY_FILENAME, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME +} from '../../shared/orcad-artifacts' +import { + ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME, + getOrcadCliLauncherPath, + prepareOrcadCliLauncher +} from './orcad-cli-launcher' const roots = vi.hoisted(() => ({ install: '', profile: '' })) vi.mock('./orcad-app-paths', () => ({ @@ -87,10 +94,58 @@ describe('orcad profile CLI launcher', () => { expect(getOrcadCliLauncherPath()).toBeNull() }) - it('does not create a cmd.exe message proxy on Windows', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - await writeCli() - await prepareOrcadCliLauncher() - expect(getOrcadCliLauncherPath()).toBeNull() + describe('on a Windows host', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + async function writeLauncher(contents: string): Promise { + const path = join(roots.install, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, contents) + } + + const binDir = (): string => join(roots.profile, 'cli', 'bin') + const readConfig = (): Promise => + readFile(join(binDir(), ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME), 'utf8') + + it('installs the native launcher and points it at this slot, runtime and profile', async () => { + await writeCli() + await writeLauncher('native launcher v1') + await prepareOrcadCliLauncher() + const launcher = getOrcadCliLauncherPath() + expect(launcher).toBe(join(binDir(), 'orca.exe')) + expect(await readFile(launcher!, 'utf8')).toBe('native launcher v1') + expect(await readConfig()).toBe( + [ + `node=${process.execPath}`, + `cli=${join(roots.install, ...ORCAD_CLI_ENTRY_FILENAME.split('/'))}`, + `user-data=${roots.profile}`, + '' + ].join('\n') + ) + // cmd.exe reparses `%*`, so no batch or PowerShell proxy may shadow the exe. + expect((await readdir(binDir())).sort()).toEqual( + [ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME, 'orca.exe'].sort() + ) + }) + + it('refreshes the launcher and its target when the host switches server slots', async () => { + await writeCli() + await writeLauncher('native launcher v1') + await prepareOrcadCliLauncher() + roots.install = join(directory, 'updated server') + await writeCli() + await writeLauncher('native launcher v2') + await prepareOrcadCliLauncher() + expect(await readFile(join(binDir(), 'orca.exe'), 'utf8')).toBe('native launcher v2') + expect(await readConfig()).toContain(`cli=${join(roots.install, 'out', 'cli', 'index.js')}`) + }) + + it('leaves a slot without the native launcher with no orca command', async () => { + await writeCli() + await prepareOrcadCliLauncher() + expect(getOrcadCliLauncherPath()).toBeNull() + }) }) }) diff --git a/src/main/orcad/orcad-cli-launcher.ts b/src/main/orcad/orcad-cli-launcher.ts index 01660164e0a2..7bb351a37391 100644 --- a/src/main/orcad/orcad-cli-launcher.ts +++ b/src/main/orcad/orcad-cli-launcher.ts @@ -1,11 +1,20 @@ -import { chmod, mkdir, readFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { chmod, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { ORCAD_CLI_ENTRY_FILENAME } from '../../shared/orcad-artifacts' +import { join } from 'node:path' +import { + ORCAD_CLI_ENTRY_FILENAME, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME +} from '../../shared/orcad-artifacts' +import { nodeFileContentsEqual } from '../../shared/node-file-content-equality' import { buildUnixCliLauncher } from '../cli/cli-dev-launcher' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths' +// Read by native/windows-cli-launcher: its presence beside orca.exe selects the server mode. +export const ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME = 'orca-launcher.cfg' +const RETIRED_LAUNCHER_SUFFIX = '.retired' + let launcherPath: string | null = null export function getOrcadCliLauncherPath(): string | null { @@ -14,19 +23,21 @@ export function getOrcadCliLauncherPath(): string | null { export async function prepareOrcadCliLauncher(): Promise { launcherPath = null - // Windows needs a native argv-preserving launcher; never proxy message bodies through cmd.exe. - if (process.platform === 'win32') { - return - } - const entry = join(resolveOrcadInstallRoot(), ...ORCAD_CLI_ENTRY_FILENAME.split('/')) + const installRoot = resolveOrcadInstallRoot() + const entry = join(installRoot, ...ORCAD_CLI_ENTRY_FILENAME.split('/')) // Older server slots and source-only runs may not include the CLI yet. if (!existsSync(entry)) { return } const userDataPath = resolveUserDataPath() - const path = join(userDataPath, 'cli', 'bin', 'orca') + const binDir = join(userDataPath, 'cli', 'bin') + if (process.platform === 'win32') { + launcherPath = await prepareWindowsLauncher(installRoot, entry, userDataPath, binDir) + return + } + const path = join(binDir, 'orca') const script = buildUnixCliLauncher(process.execPath, entry, userDataPath, 'node') - await mkdir(dirname(path), { recursive: true }) + await mkdir(binDir, { recursive: true }) const current = await readFile(path, 'utf8').catch(() => null) if (current !== script) { writeFileAtomically(path, script, { mode: 0o700 }) @@ -34,3 +45,53 @@ export async function prepareOrcadCliLauncher(): Promise { await chmod(path, 0o700) launcherPath = path } + +export function buildWindowsCliLauncherConfig( + nodePath: string, + cliEntryPath: string, + userDataPath: string +): string { + return `node=${nodePath}\ncli=${cliEntryPath}\nuser-data=${userDataPath}\n` +} + +/** A native exe, never a .cmd: cmd.exe reparses `%*` and cannot carry a multi-line body. */ +async function prepareWindowsLauncher( + installRoot: string, + entry: string, + userDataPath: string, + binDir: string +): Promise { + const source = join(installRoot, ...ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME.split('/')) + if (!existsSync(source)) { + return null + } + const path = join(binDir, 'orca.exe') + await mkdir(binDir, { recursive: true }) + await removeRetiredLaunchers(binDir) + const binary = await readFile(source) + if (!(await nodeFileContentsEqual(path, binary).catch(() => false))) { + try { + writeFileAtomically(path, binary) + } catch { + // A running `orca` keeps its image mapped; Windows still lets it be renamed away. + await rename(path, `${path}.${randomUUID()}${RETIRED_LAUNCHER_SUFFIX}`) + writeFileAtomically(path, binary) + } + } + const configPath = join(binDir, ORCAD_WINDOWS_CLI_LAUNCHER_CONFIG_FILENAME) + const config = buildWindowsCliLauncherConfig(process.execPath, entry, userDataPath) + if ((await readFile(configPath, 'utf8').catch(() => null)) !== config) { + writeFileAtomically(configPath, config) + } + return path +} + +async function removeRetiredLaunchers(binDir: string): Promise { + const names = await readdir(binDir).catch(() => []) + await Promise.all( + names + .filter((name) => name.startsWith('orca.exe.') && name.endsWith(RETIRED_LAUNCHER_SUFFIX)) + // One still running stays until a later start. + .map((name) => rm(join(binDir, name), { force: true }).catch(() => {})) + ) +} diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index deaa320d8b38..0acabed675b0 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -27,6 +27,8 @@ import { decommissionRemoteOrcad } from './orcad-remote-decommission' import { readOrcadActivationRecord } from './orcad-activation-record-store' import { gcOldOrcadVersions } from './orcad-remote-gc' import { execOrcadRemote } from './orcad-remote-runtime-control' +import { orcadWindowsNodeCommandLine } from './orcad-remote-windows-node' +import { joinRemotePath } from './ssh-remote-platform' import { isWindowsOrcadCellId, readWindowsHostCellDescriptor, @@ -97,6 +99,14 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, slotDir)) ) expect(await liveness()).toBe('LIVE') + // Managed terminals' `orca`: the native launcher orcad installed in its profile. + const cli = joinRemotePath(options.host, context.userDataDir, 'cli', 'bin', 'orca.exe') + const status = JSON.parse( + await execOrcadRemote(options, orcadWindowsNodeCommandLine(cli, ['status', '--json'])) + ) + receipt.cliStatus = status.result + expect(status.result.runtime).toMatchObject({ reachable: true }) + expect(status.result.app.desktopWindowStatus).toBe('blocked') // The managed tunnel picks the stdio bridge on this account and reaches orcad through it. receipt.stdioBridge = await proveWindowsStdioBridge(conn, options.host, slotDir) expect(receipt.stdioBridge).toMatchObject({ diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 4e34b4f34dc4..4b2a44e205a8 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -13,6 +13,8 @@ export const ORCAD_BUN_RUNTIME_FILENAME = 'bun-runtime' export const ORCAD_WINDOWS_BUN_RUNTIME_FILENAME = 'bun-runtime.exe' export const ORCAD_BUILD_TARGET_FILENAME = '.build-target' export const ORCAD_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' +/** native/windows-cli-launcher: cmd.exe and .ps1 shims cannot carry every argv intact. */ +export const ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME = 'bin/orca.exe' export function orcadBunRuntimeFilename(target: string): string { return isWindowsTarget(target) ? ORCAD_WINDOWS_BUN_RUNTIME_FILENAME : ORCAD_BUN_RUNTIME_FILENAME @@ -205,7 +207,7 @@ export function orcadArtifactFilenames(target: string): string[] { ) filenames.push(...orcadNodePtyNativeArtifacts(target), orcadRipgrepArtifact(target)) if (isWindowsTarget(target)) { - filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME) + filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME, ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME) } return filenames } From 7ecc61a8a77814aac7e5cfe04013e92c31479259 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:34:28 -0400 Subject: [PATCH 063/110] infra(relay): pin staging c1, c2 and c4 to the step-5 image for the staging e2e (#27339) --- .../scripts/relay-staging-c4-refresh-workflow.test.mjs | 6 ++++-- cloud/infra/terraform/environments/staging.tfvars | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 67225f4d8690..71e5e7aa9219 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -42,6 +42,8 @@ const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' // C31 launches on the digest the director served when it was declared. const c31Digest = 'f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269' +// Staging C4 runs the step-5 image D for the staging e2e. +const stagingC4Digest = 'ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02' function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) @@ -52,8 +54,8 @@ function cellBlock(tfvars, cellId) { const productionCell = (cellId) => cellBlock(productionTfvars, cellId) // Scoped to C4 by name: staging C3 serves this digest too since its 2026-09-03 re-pin. -test('pins staging C4 and the launch Asia cells to one image, and C30/C31 to director images', () => { - assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${launchDigest}"`)) +test('pins staging C4 to the step-5 image, the launch Asia cells to one image, and C30/C31 to director images', () => { + assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${stagingC4Digest}"`)) for (const cellId of asiaCells) { assert.match(productionCell(cellId), new RegExp(`relay@sha256:${launchDigest}"`), cellId) } diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index 8c095bcb9cec..d26a0ee23f53 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -39,8 +39,8 @@ relay_gce_cells = { boot_disk_gb = 30 boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-7" capacity_requests = 4000 - # c2's image: the legacy 2d0f6e6 cannot report capped telemetry, so a cap needs this one. - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:1239830d0946dc92ded3c9edde1c0b827f584a7a2be5c177beed900056d76f69" + # Step-5 image D (#27049-#27107). Never 2d0f6e6: it cannot report capped telemetry. + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" # Step-5 staging e2e: c1 is the second US reserve cell, so it needs a cap and rehome trust. connection_hard_cap = 600 connection_unobserved_bound = 60 @@ -52,7 +52,7 @@ relay_gce_cells = { boot_disk_gb = 30 boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-7" capacity_requests = 4000 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:1239830d0946dc92ded3c9edde1c0b827f584a7a2be5c177beed900056d76f69" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" connection_hard_cap = 600 connection_unobserved_bound = 60 } @@ -77,7 +77,7 @@ relay_gce_cells = { boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" capacity_requests = 6000 database_pool_max = 10 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" initially_enabled = false connection_hard_cap = 3000 connection_unobserved_bound = 60 From 91f9744d3a1286c3edf25111f6e0c515fa0ffae0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:36:49 -0700 Subject: [PATCH 064/110] fix(settings): return current preferences after slow updates (#27199) * fix(settings): return current preferences after reconciliation * test(settings): run SQLite reply regressions in Node --- config/scripts/vitest-node-runtime-files.mjs | 1 + .../ipc/settings-delayed-response.test.ts | 157 ++++++++++++++++++ src/main/ipc/settings.ts | 2 +- 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/main/ipc/settings-delayed-response.test.ts diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 36763a4a3a99..fb184c456bb2 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -86,6 +86,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/relay/relay-primary-channel.test.ts', 'src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts', 'src/main/ipc/orca-profiles-switch-persistence.test.ts', + 'src/main/ipc/settings-delayed-response.test.ts', 'src/main/runtime/structured-agent-session-startup-tab-restore.test.ts', 'src/shared/remote-runtime-shared-control-connection.test.ts', 'src/main/runtime/relay/relay-control-client.test.ts', diff --git a/src/main/ipc/settings-delayed-response.test.ts b/src/main/ipc/settings-delayed-response.test.ts new file mode 100644 index 000000000000..f1d79107913d --- /dev/null +++ b/src/main/ipc/settings-delayed-response.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../persistence/loading-store/profile-state-maintenance-fixture' + +const { + handleMock, + setMainUiLanguageMock, + applyElectronProxySettingsMock, + applyAgentStatusHooksEnabledMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + setMainUiLanguageMock: vi.fn(), + applyElectronProxySettingsMock: vi.fn(), + applyAgentStatusHooksEnabledMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/test/user-data') }, + BrowserWindow: { getAllWindows: vi.fn(() => []) }, + ipcMain: { handle: handleMock, on: vi.fn() }, + nativeTheme: { themeSource: 'system' } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) +vi.mock('../ghostty/index', () => ({ previewGhosttyImport: vi.fn() })) +vi.mock('../warp-themes', () => ({ previewWarpThemeImport: vi.fn() })) +vi.mock('../network/proxy-settings', () => ({ + applyElectronProxySettings: applyElectronProxySettingsMock +})) +vi.mock('../browser/browser-session-proxy', () => ({ + applyBrowserSessionProxies: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('../browser/browser-session-registry', () => ({ + browserSessionRegistry: { listProfiles: vi.fn(() => []) } +})) +vi.mock('../app-icon', () => ({ applyAppIcon: vi.fn() })) +vi.mock('../ai-vault-search/session-search-enablement', () => ({ + applySessionSearchSettingsChange: vi.fn() +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock +})) +vi.mock('../worktree-root-preparation', () => ({ + prepareLocalWorktreeRootsForRepos: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: vi.fn() })) +vi.mock('../i18n/main-i18n', () => ({ setMainUiLanguage: setMainUiLanguageMock })) + +import { registerSettingsHandlers } from './settings' + +beforeEach(() => { + handleMock.mockClear() + setMainUiLanguageMock.mockReset().mockResolvedValue('en') + applyElectronProxySettingsMock.mockReset().mockResolvedValue({ source: 'settings' }) + applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([]) +}) + +const event = { sender: { id: 1 } } + +function settingsWriter() { + const handler = handleMock.mock.calls.find(([channel]) => channel === 'settings:set')?.[1] + if (typeof handler !== 'function') { + throw new Error('Missing settings:set handler') + } + return handler +} + +it('returns current settings when a language change finishes after a newer theme write', async () => { + const { store, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'system', uiLanguage: 'en' }) + await store.flushPendingOrThrowAsync() + registerSettingsHandlers(store) + const write = settingsWriter() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + setMainUiLanguageMock.mockImplementationOnce(async () => { + started.resolve() + await release.promise + return 'ko' + }) + + const first = write(event, { theme: 'dark', uiLanguage: 'ko' }) + await started.promise + await write(event, { theme: 'light' }) + release.resolve() + const reply = await first + await store.flushPendingOrThrowAsync() + + expect(store.getSettings().theme).toBe('light') + expect(readState().settings.theme).toBe('light') + expect(reply.theme).toBe('light') +}) + +it('returns current settings when hook reconciliation finishes after a newer theme write', async () => { + const { store, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'system', agentStatusHooksEnabled: false }) + await store.flushPendingOrThrowAsync() + registerSettingsHandlers(store) + const write = settingsWriter() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + applyAgentStatusHooksEnabledMock.mockImplementationOnce(async () => { + started.resolve() + await release.promise + return [] + }) + + const first = write(event, { theme: 'dark', agentStatusHooksEnabled: true }) + await started.promise + await write(event, { theme: 'light' }) + release.resolve() + const reply = await first + await store.flushPendingOrThrowAsync() + + expect(store.getSettings().theme).toBe('light') + expect(readState().settings.theme).toBe('light') + expect(reply.theme).toBe('light') + expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledWith( + true, + expect.objectContaining({ theme: 'dark' }), + expect.any(Object) + ) +}) + +it('returns current settings when proxy reconciliation finishes after a newer theme write', async () => { + const { store, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'system', httpProxyUrl: '' }) + await store.flushPendingOrThrowAsync() + registerSettingsHandlers(store) + const write = settingsWriter() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + applyElectronProxySettingsMock.mockImplementationOnce(async () => { + started.resolve() + await release.promise + return { source: 'settings' } + }) + + const first = write(event, { theme: 'dark', httpProxyUrl: 'http://proxy.example:8080' }) + await started.promise + await write(event, { theme: 'light' }) + release.resolve() + const reply = await first + await store.flushPendingOrThrowAsync() + + expect(store.getSettings().theme).toBe('light') + expect(readState().settings.theme).toBe('light') + expect(reply.theme).toBe('light') +}) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 1200a1803d7c..cd9fd873e0a3 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -302,7 +302,7 @@ export function registerSettingsHandlers( }) } - return result + return store.getSettings() }) ipcMain.handle( From 43fbd2389f33ba661bb76309136993aca65a90cf Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 17:49:32 -0700 Subject: [PATCH 065/110] test(e2e): pass the catalog owner to the remaining catalog fetches and creates (#27338) * test(e2e): pass the catalog owner to the remaining project-group and folder fetches * test(e2e): pass the owner to folder-workspace and project-group creates --------- Co-authored-by: m4air --- tests/e2e/pdf-find-counter.spec.ts | 15 ++++++---- .../e2e/pr11346-selected-runtime-add.spec.ts | 4 +-- .../project-group-creation-visibility.spec.ts | 6 ++-- tests/e2e/project-group-manual-sort.spec.ts | 4 +-- .../resource-manager-folder-labels.spec.ts | 28 +++++++++++-------- ...sidebar-folder-keyboard-navigation.spec.ts | 15 ++++++---- tests/e2e/sidebar-measured-row-scroll.spec.ts | 2 +- tests/e2e/workspace-layout-oracle-ssh.spec.ts | 17 ++++++----- .../worktree-lineage-toggle-dialog.spec.ts | 2 +- 9 files changed, 55 insertions(+), 38 deletions(-) diff --git a/tests/e2e/pdf-find-counter.spec.ts b/tests/e2e/pdf-find-counter.spec.ts index 7a8d4b13b049..56f525a34842 100644 --- a/tests/e2e/pdf-find-counter.spec.ts +++ b/tests/e2e/pdf-find-counter.spec.ts @@ -129,12 +129,15 @@ test.describe('PDF in a folder workspace', () => { parentPath: folderPath, createdFrom: 'folder-scan' }) - await state.fetchProjectGroups() - const folder = await state.createFolderWorkspace({ - projectGroupId: group.id, - name: 'PDF documents', - folderPath - }) + await state.fetchProjectGroups({ runtimeEnvironmentId: null }) + const folder = await state.createFolderWorkspace( + { + projectGroupId: group.id, + name: 'PDF documents', + folderPath + }, + { runtimeEnvironmentId: null } + ) if (!folder) { throw new Error('Missing fixture folder') } diff --git a/tests/e2e/pr11346-selected-runtime-add.spec.ts b/tests/e2e/pr11346-selected-runtime-add.spec.ts index 09630d277b3c..7547e90012a7 100644 --- a/tests/e2e/pr11346-selected-runtime-add.spec.ts +++ b/tests/e2e/pr11346-selected-runtime-add.spec.ts @@ -563,8 +563,8 @@ async function runSelectedRuntimeAddJourney( if (!store) { throw new Error('Renderer store unavailable') } - await store.getState().fetchProjectGroups() - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + await store.getState().fetchFolderWorkspaces({ runtimeEnvironmentId: null }) return { folderWorkspaces: store.getState().folderWorkspaces, projectGroups: store.getState().projectGroups diff --git a/tests/e2e/project-group-creation-visibility.spec.ts b/tests/e2e/project-group-creation-visibility.spec.ts index 8524beb58c68..8f08bb5be698 100644 --- a/tests/e2e/project-group-creation-visibility.spec.ts +++ b/tests/e2e/project-group-creation-visibility.spec.ts @@ -87,7 +87,7 @@ for (const delayCreateResponse of [false, true]) { }) } const creation = orcaPage.evaluate(() => - window.__store!.getState().createProjectGroup('Crowded group') + window.__store!.getState().createProjectGroup('Crowded group', 'local') ) if (delayCreateResponse) { try { @@ -163,7 +163,9 @@ for (const delayCreateResponse of [false, true]) { } } await expect(group).toHaveCount(1) - await orcaPage.evaluate(() => window.__store!.getState().fetchProjectGroups()) + await orcaPage.evaluate(() => + window.__store!.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + ) await expect(group).toHaveCount(1) await group.click() for (const repo of groupedRepos) { diff --git a/tests/e2e/project-group-manual-sort.spec.ts b/tests/e2e/project-group-manual-sort.spec.ts index e402ee35461b..05c372314ac0 100644 --- a/tests/e2e/project-group-manual-sort.spec.ts +++ b/tests/e2e/project-group-manual-sort.spec.ts @@ -92,7 +92,7 @@ async function seedProjectHeaderSortScenario( let repos = findSeededRepos() const deadline = Date.now() + 10_000 while (repos.some((repo) => !repo) && Date.now() < deadline) { - await state.fetchRepos() + await state.fetchRepos({ runtimeEnvironmentId: null }) repos = findSeededRepos() if (repos.every((repo) => repo)) { break @@ -136,7 +136,7 @@ async function seedDuplicateTabOrderProjectGroups( const groups = [] for (const name of groupNames) { - const created = await state.createProjectGroup(name) + const created = await state.createProjectGroup(name, 'local') if (!created) { throw new Error(`Failed to create Project Group: ${name}`) } diff --git a/tests/e2e/resource-manager-folder-labels.spec.ts b/tests/e2e/resource-manager-folder-labels.spec.ts index fb4f37a02681..36ef5d4d8549 100644 --- a/tests/e2e/resource-manager-folder-labels.spec.ts +++ b/tests/e2e/resource-manager-folder-labels.spec.ts @@ -31,14 +31,17 @@ for (const theme of ['dark', 'light'] as const) { parentPath: folder.folderPath, createdFrom: 'folder-scan' }) - await state.fetchProjectGroups() + await state.fetchProjectGroups({ runtimeEnvironmentId: null }) if (!group) { throw new Error('Could not create project group') } - const workspace = await state.createFolderWorkspace({ - projectGroupId: group.id, - ...folder - }) + const workspace = await state.createFolderWorkspace( + { + projectGroupId: group.id, + ...folder + }, + { runtimeEnvironmentId: null } + ) if (!workspace) { throw new Error('Could not create folder workspace') } @@ -93,15 +96,18 @@ test('names a folder terminal recovered from the daemon after restart without an parentPath: folderPath, createdFrom: 'folder-scan' }) - await state.fetchProjectGroups() + await state.fetchProjectGroups({ runtimeEnvironmentId: null }) if (!group) { throw new Error('Could not create project group') } - const folder = await state.createFolderWorkspace({ - projectGroupId: group.id, - name: 'Recovered notes', - folderPath - }) + const folder = await state.createFolderWorkspace( + { + projectGroupId: group.id, + name: 'Recovered notes', + folderPath + }, + { runtimeEnvironmentId: null } + ) if (!folder) { throw new Error('Could not create folder workspace') } diff --git a/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts b/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts index 9c036e994df4..25c2ebb6fed1 100644 --- a/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts +++ b/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts @@ -23,12 +23,15 @@ test('cycles visible folder workspaces with shortcuts and focused list arrows', parentPath, createdFrom: 'folder-scan' }) - await store.getState().fetchProjectGroups() - const folder = await store.getState().createFolderWorkspace({ - projectGroupId: group.id, - name: 'Folder workspace', - folderPath: parentPath - }) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + const folder = await store.getState().createFolderWorkspace( + { + projectGroupId: group.id, + name: 'Folder workspace', + folderPath: parentPath + }, + { runtimeEnvironmentId: null } + ) const repo = store.getState().repos[0] const [first, last] = repo ? (store.getState().worktreesByRepo[repo.id] ?? []) : [] if (!folder || !repo || !first || !last) { diff --git a/tests/e2e/sidebar-measured-row-scroll.spec.ts b/tests/e2e/sidebar-measured-row-scroll.spec.ts index 3d9d412af99d..bc0cc10a0c18 100644 --- a/tests/e2e/sidebar-measured-row-scroll.spec.ts +++ b/tests/e2e/sidebar-measured-row-scroll.spec.ts @@ -123,7 +123,7 @@ test('keeps a collapsed group visible when an idle card below it grows', async ( if (!member || !collapsed || !expanded) { throw new Error('Expected three fixture repos') } - const group = await store.getState().createProjectGroup('Research gang') + const group = await store.getState().createProjectGroup('Research gang', 'local') if (!group) { throw new Error('Fixture group was not created') } diff --git a/tests/e2e/workspace-layout-oracle-ssh.spec.ts b/tests/e2e/workspace-layout-oracle-ssh.spec.ts index 2362b6e29737..b5d2db6c3094 100644 --- a/tests/e2e/workspace-layout-oracle-ssh.spec.ts +++ b/tests/e2e/workspace-layout-oracle-ssh.spec.ts @@ -136,13 +136,16 @@ async function connectFolderOnlyTarget( parentPath: folderPath, connectionId: added.target.id }) - await store.getState().fetchProjectGroups() - const workspace = await store.getState().createFolderWorkspace({ - projectGroupId: group.id, - name: 'Layout oracle SSH folder', - folderPath, - connectionId: added.target.id - }) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + const workspace = await store.getState().createFolderWorkspace( + { + projectGroupId: group.id, + name: 'Layout oracle SSH folder', + folderPath, + connectionId: added.target.id + }, + { runtimeEnvironmentId: null } + ) if (!workspace) { throw new Error('Folder workspace was not created') } diff --git a/tests/e2e/worktree-lineage-toggle-dialog.spec.ts b/tests/e2e/worktree-lineage-toggle-dialog.spec.ts index 9edb3a7182b8..76ec5c7a153c 100644 --- a/tests/e2e/worktree-lineage-toggle-dialog.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-dialog.spec.ts @@ -27,7 +27,7 @@ test('locally controlled project-group modal keeps child-toggle input', async ({ if (!parent) { throw new Error('Missing parent') } - const group = await store.getState().createProjectGroup('Shortcut dialog group') + const group = await store.getState().createProjectGroup('Shortcut dialog group', 'local') if (!group) { throw new Error('Missing project group') } From db309a57772b42c0d968fafafe73648a6ce99ef0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 18:07:50 -0700 Subject: [PATCH 066/110] test(e2e): cover SSH user journeys on an orcad-managed host (#27323) * test(e2e): cover SSH terminal and workspace journeys on a managed orcad host * test(e2e): port the provisioned-root recipe journey; read ports from the host scan * test(e2e): fix the provisioned-root spec's catalog refresh call * test(e2e): wait for the shell prompt, open file links directly, and mark forward restore fixme * test(e2e): record launches for teardown, match the prompt robustly, park the link test * test(ci): list the journeys job in the workflow permission matrix * test(e2e): run the terminal image-link journey again * test(e2e): park the external-path link journey on its product gap --------- Co-authored-by: m4air --- .github/workflows/e2e.yml | 53 ++++- config/scripts/ci-e2e-job-selection.mjs | 12 +- config/scripts/orcad-e2e-routing.test.mjs | 18 ++ config/scripts/pr-e2e-source-routing.mjs | 17 ++ .../release-cut-token-permissions.test.mjs | 1 + tests/e2e/helpers/orcad-convert-host.ts | 28 ++- tests/e2e/helpers/orcad-managed-workspace.ts | 208 +++++++++++++++++ .../helpers/terminal-workspace-root-link.ts | 20 +- tests/e2e/ssh-orcad-terminal-journeys.spec.ts | 216 ++++++++++++++++++ .../e2e/ssh-orcad-vm-provisioned-root.spec.ts | 213 +++++++++++++++++ tests/e2e/ssh-orcad-workspace-tools.spec.ts | 198 ++++++++++++++++ 11 files changed, 977 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/helpers/orcad-managed-workspace.ts create mode 100644 tests/e2e/ssh-orcad-terminal-journeys.spec.ts create mode 100644 tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts create mode 100644 tests/e2e/ssh-orcad-workspace-tools.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a21fb68408c6..0f051a32ca8e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -363,7 +363,10 @@ jobs: . != "tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts" and . != "tests/e2e/ssh-orcad-explorer-selected-host.spec.ts" and . != "tests/e2e/ssh-orcad-browser-drop-owner.spec.ts" and - . != "tests/e2e/ssh-orcad-terminal-root-owner.spec.ts" + . != "tests/e2e/ssh-orcad-terminal-root-owner.spec.ts" and + . != "tests/e2e/ssh-orcad-terminal-journeys.spec.ts" and + . != "tests/e2e/ssh-orcad-workspace-tools.spec.ts" and + . != "tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -642,6 +645,54 @@ jobs: retention-days: 7 if-no-files-found: ignore + # SSH user journeys (terminal reconnect/restore/faults, Quick Open, ports, links, agent status) on a + # Docker host served by managed orcad. Its own runner so the convert lane keeps its time budget. + orcad-journeys-docker: + name: ssh journeys on managed orcad (Docker) + needs: [build, prepare-native-cache] + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-workspace-tools.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts') + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + - name: Install headless tools + run: sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb openbox x11-utils + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + - uses: actions/download-artifact@v8 + with: + name: e2e-build-out + path: out/ + - name: Build the linux-x64-glibc orcad template + run: | + pnpm build:orcad-prebuilds + pnpm build:orcad-prebuilds --require-slots linux-x64-glibc + pnpm build:orcad + node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc + mv out/orcad-template "$RUNNER_TEMP/orcad-convert-template" + - name: Run SSH journeys on a managed Docker host + env: + SKIP_BUILD: '1' + ORCA_E2E_SSH_DOCKER: '1' + ORCA_E2E_ORCAD_CONVERT_HOST: docker + ORCA_E2E_FORWARD_APP_LOGS: '1' + run: | + export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-terminal-journeys.spec.ts tests/e2e/ssh-orcad-workspace-tools.spec.ts tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts --project=electron-headless --workers=1 + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: orcad-journeys-docker-traces + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + # D7 on Windows: both hosts share \daemon and so one daemon pipe. Built here rather than # from the Linux e2e build because the slot, template and addons are Windows-native. Also runs the # missing-AppData startup check, which needs the same Windows e2e build. diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index d19d5562bcae..7f94ec1eb413 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -71,6 +71,12 @@ export const ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC = export const ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC = 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts' export const ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC = 'tests/e2e/ssh-orcad-terminal-root-owner.spec.ts' +// Run in their own job, which builds the template they need. +export const ORCAD_JOURNEY_E2E_SPECS = [ + 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts', + 'tests/e2e/ssh-orcad-workspace-tools.spec.ts', + 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts' +] export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, @@ -93,7 +99,8 @@ export const DEDICATED_E2E_SPECS = [ ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC, ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC, ORCAD_BROWSER_DROP_OWNER_E2E_SPEC, - ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC + ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC, + ...ORCAD_JOURNEY_E2E_SPECS ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -148,7 +155,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_EXPLORER_WATCH_RECOVERY_E2E_SPEC || spec === ORCAD_EXPLORER_SELECTED_HOST_E2E_SPEC || spec === ORCAD_BROWSER_DROP_OWNER_E2E_SPEC || - spec === ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC + spec === ORCAD_TERMINAL_ROOT_OWNER_E2E_SPEC || + ORCAD_JOURNEY_E2E_SPECS.includes(spec) ) } } diff --git a/config/scripts/orcad-e2e-routing.test.mjs b/config/scripts/orcad-e2e-routing.test.mjs index 066cc2ee6ca1..d2ec99491dd3 100644 --- a/config/scripts/orcad-e2e-routing.test.mjs +++ b/config/scripts/orcad-e2e-routing.test.mjs @@ -6,6 +6,7 @@ import { classifyE2eJobs, ORCAD_AUTO_CONVERT_E2E_SPEC, ORCAD_IDLE_EXIT_E2E_SPEC, + ORCAD_JOURNEY_E2E_SPECS, ORCAD_OPEN_IN_OWNER_E2E_SPEC, ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, WINDOWS_MISSING_APPDATA_E2E_SPEC @@ -137,3 +138,20 @@ it('routes managed target-owner guards and menus to the real launch regression', ) ).toBe(true) }) + +it('runs the managed-host journey specs in their own job when routed', () => { + const job = jobs['orcad-journeys-docker'] + expect(job.needs).toEqual(['build', 'prepare-native-cache']) + for (const spec of ORCAD_JOURNEY_E2E_SPECS) { + expectRouted(['tests/e2e/helpers/orcad-managed-workspace.ts'], spec) + expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`) + expect( + job.steps.some((step) => step.run?.includes(spec)), + spec + ).toBe(true) + expect(classifyE2eJobs(JSON.stringify([spec])), spec).toEqual({ + e2e_run_changed: false, + e2e_needs_build: true + }) + } +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 7db5a8cfebe3..7d7d0813fdb1 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -186,6 +186,23 @@ export const PR_E2E_SOURCE_ROUTES = [ file )) }, + { + // Why: managed SSH hosts are now the only SSH path, so these specs carry its user journeys. + id: 'ssh.orcad-journeys', + specs: [ + 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts', + 'tests/e2e/ssh-orcad-workspace-tools.spec.ts', + 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts' + ], + matches: (file) => + /^tests\/e2e\/helpers\/(?:orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|paired-host-terminal|terminal-workspace-root-link)\.ts$/.test( + file + ) || + (isProductSource(file) && + /^src\/(?:main\/ssh\/orcad-managed-|main\/ipc\/ssh-port-forward|renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts$|renderer\/src\/runtime\/web-runtime-session|renderer\/src\/components\/terminal-pane\/remote-runtime-)/.test( + file + )) + }, { id: 'ssh.localhost-agent-hooks', specs: ['tests/e2e/ssh-localhost.spec.ts'], diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index d4a6d36bc4ed..883c82cfd9fb 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -12,6 +12,7 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#changed-e2e': { contents: 'read' }, '.github/workflows/e2e.yml#e2e': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-auto-convert-docker': { contents: 'read' }, + '.github/workflows/e2e.yml#orcad-journeys-docker': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-serve-mode-switch': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-serve-mode-switch-windows': { contents: 'read' }, '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, diff --git a/tests/e2e/helpers/orcad-convert-host.ts b/tests/e2e/helpers/orcad-convert-host.ts index 9568c6eea380..0d8c4eeea6df 100644 --- a/tests/e2e/helpers/orcad-convert-host.ts +++ b/tests/e2e/helpers/orcad-convert-host.ts @@ -2,6 +2,7 @@ * The real SSH host a conversion cell runs against: the Linux Docker fixture, or a Windows * OpenSSH host the ssh-windows-hosts lane provisioned and described in a descriptor file. */ +import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import type { TestInfo } from '@stablyai/playwright-test' import type { SshTargetCreateInput } from '../../../src/shared/ssh-types' @@ -11,6 +12,7 @@ import { cleanupDockerSshRelayTarget, DOCKER_SSH_RELAY_REMOTE_REPO_PATH, execDockerSshRelayTargetCommand, + execDockerSshRelayTargetControlCommand, startDockerSshRelayTarget } from './docker-ssh-relay-target' @@ -27,8 +29,23 @@ export type OrcadConvertHost = { blockTcpForwarding?: () => void /** Docker only: runs a shell command on the host as the SSH user and returns its stdout. */ exec?: (command: string) => string + /** Docker only: kills every live SSH connection, leaving sshd listening; returns how many. */ + dropSshConnections?: () => number + /** Docker only: freezes or thaws the whole host; TCP stays up but nothing answers. */ + setFrozen?: (frozen: boolean) => void } +// The listener is the oldest sshd; every other sshd/sshd-session is a live connection. +const DROP_SSH_CONNECTIONS = ` +daemon="$(pgrep -x sshd | sort -n | head -1)" +killed=0 +for pid in $(pgrep -x sshd; pgrep -x sshd-session); do + [ "$pid" = "$daemon" ] && continue + kill -9 "$pid" 2>/dev/null && killed=$((killed+1)) +done +echo "$killed" +` + /** `docker`, or the path of a Windows host-cell descriptor. */ export function startOrcadConvertHost(source: string, testInfo: TestInfo): OrcadConvertHost { const label = `orcad convert E2E ${Date.now()}` @@ -48,7 +65,16 @@ export function startOrcadConvertHost(source: string, testInfo: TestInfo): Orcad remoteFolderPath: '/tmp', cleanup: () => cleanupDockerSshRelayTarget(target), blockTcpForwarding: () => blockDockerSshRelayTargetTcpForwarding(target), - exec: (command) => execDockerSshRelayTargetCommand(target, command) + exec: (command) => execDockerSshRelayTargetCommand(target, command), + dropSshConnections: () => + Number( + execDockerSshRelayTargetControlCommand(target, DROP_SSH_CONNECTIONS).split('\n').at(-1) + ), + setFrozen: (frozen) => + execFileSync('docker', [frozen ? 'pause' : 'unpause', target.containerName], { + stdio: 'ignore', + timeout: 30_000 + }) } } const descriptor = parseWindowsHostCellDescriptor(readFileSync(source, 'utf8')) diff --git a/tests/e2e/helpers/orcad-managed-workspace.ts b/tests/e2e/helpers/orcad-managed-workspace.ts new file mode 100644 index 000000000000..70943839d311 --- /dev/null +++ b/tests/e2e/helpers/orcad-managed-workspace.ts @@ -0,0 +1,208 @@ +/** + * A repository workspace on an orcad-managed Docker SSH host, reached the way users reach one: + * a saved SSH profile whose first connect deploys the managed server. Plus the terminal moves the + * managed-host journey specs share, driven through the tab bar and the keyboard. + */ +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import { expect } from './orca-app' +import { createRestartSession } from './orca-restart' +import { startOrcadConvertHost, type OrcadConvertHost } from './orcad-convert-host' +import { seedRelayEraProfile, type RelayEraProfile } from './orcad-upgrade-profile' +import { convertAndRetain } from './orcad-convert-flow' +import { readPairedPaneContent } from './paired-host-terminal' +import { waitForSessionReady } from './store' +import { dismissTransientAnnouncement } from './ssh-config-host-picker' +import { focusActiveTerminalInput } from './terminal' +import { toRuntimeExecutionHostId } from '../../../src/shared/execution-host' + +export const ORCAD_TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE + +export type ManagedHostFixture = { + host: OrcadConvertHost + session: ReturnType + /** Set by the body to whichever launch is live, so teardown closes it. */ + app: ElectronApplication | null +} + +/** A fresh Docker SSH host and profile for `body`, torn down whatever it throws. */ +export async function withManagedHost( + testInfo: TestInfo, + body: (fixture: ManagedHostFixture) => Promise +): Promise { + const host = startOrcadConvertHost('docker', testInfo) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE! }) + const fixture: ManagedHostFixture = { host, session, app: null } + try { + await body(fixture) + } finally { + try { + if (fixture.app) { + await session.close(fixture.app) + } + await session.dispose() + } finally { + host.cleanup() + } + } +} + +export type ManagedWorkspace = { + app: ElectronApplication + page: Page + seeded: RelayEraProfile + environmentId: string +} + +/** Leaves the managed repository active; each launch is recorded on `fixture` for teardown. */ +export async function launchManagedWorkspace( + fixture: ManagedHostFixture +): Promise { + const { host, session } = fixture + // The first launch only creates the profile the saved SSH host is written into. + const first = await session.launch() + fixture.app = first.app + await waitForSessionReady(first.page) + await session.close(first.app) + fixture.app = null + const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + repoPath: host.remoteRepoPath, + folderPath: host.remoteFolderPath + }) + const { app, page } = await session.launch() + fixture.app = app + await waitForSessionReady(page) + await convertAndRetain(page, session.userDataDir, seeded) + const environmentId = await managedEnvironmentId(page, seeded.targetId) + await activateManagedWorkspace(page, seeded.worktreeId, environmentId) + return { app, page, seeded, environmentId } +} + +export async function managedEnvironmentId(page: Page, targetId: string): Promise { + const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( + (entry) => entry.orcadDeployment?.sshTargetId === targetId + ) + if (!environment) { + throw new Error(`No managed server registered for ${targetId}`) + } + return environment.id +} + +export async function activateManagedWorkspace( + page: Page, + worktreeId: string, + environmentId: string +): Promise { + await page.evaluate( + ({ worktreeId, hostId }) => { + const state = window.__store!.getState() + state.setActiveView('terminal') + state.setActiveWorktree(worktreeId, hostId) + }, + { worktreeId, hostId: toRuntimeExecutionHostId(environmentId) } + ) + await dismissTransientAnnouncement(page) +} + +export function terminalTabIds(page: Page, worktreeId: string): Promise { + return page.evaluate( + (id) => (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id), + worktreeId + ) +} + +/** Opens a terminal the way the tab bar's + does and waits for its pane to mount. */ +export async function openTerminalTab(page: Page, worktreeId: string): Promise { + const before = new Set(await terminalTabIds(page, worktreeId)) + await page.evaluate(async (worktreeId) => { + const state = window.__store!.getState() + const groupId = state.activeGroupIdByWorktree[worktreeId] + if (!groupId) { + throw new Error(`No active tab group for ${worktreeId}`) + } + await state.openNewTerminalTabInActiveWorkspace(groupId) + }, worktreeId) + let created: string | undefined + await expect + .poll( + async () => { + created = (await terminalTabIds(page, worktreeId)).find((id) => !before.has(id)) + return created ?? null + }, + { timeout: 60_000, message: 'the new terminal tab never appeared' } + ) + .not.toBeNull() + await waitForPane(page, created!) + // Keys typed before the shell's first prompt can be lost, as they would be for a user too. + await expect + .poll(() => readPairedPaneContent(page, created!), { + timeout: 60_000, + message: `the shell in ${created} never printed a prompt` + }) + .toMatch(/[#$] /) + return created! +} + +export async function waitForPane(page: Page, tabId: string): Promise { + await expect + .poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, tabId), { + timeout: 60_000, + message: `pane for ${tabId} did not mount` + }) + .toBe(true) +} + +export function selectTerminalTab(page: Page, tabId: string): Promise { + return page.evaluate((id) => { + const state = window.__store!.getState() + state.setActiveTab(id) + state.setActiveTabType('terminal', state.activeWorktreeId) + }, tabId) +} + +/** Types `command` into the tab's pane and presses Enter, as a user at the keyboard would. */ +export async function typeInTerminal(page: Page, tabId: string, command: string): Promise { + await selectTerminalTab(page, tabId) + await waitForPane(page, tabId) + await focusActiveTerminalInput(page) + await page.keyboard.insertText(command) + await page.keyboard.press('Enter') +} + +export async function expectPaneText( + page: Page, + tabId: string, + text: string, + timeout = 60_000 +): Promise { + await expect + .poll(() => readPairedPaneContent(page, tabId), { + timeout, + message: `pane ${tabId} never showed ${text}` + }) + .toContain(text) +} + +/** Echoes a unique marker and waits for the output; returns the marker. */ +export async function echoMarker( + page: Page, + tabId: string, + prefix: string, + timeout = 60_000 +): Promise { + const marker = `${prefix}_${Date.now()}` + // Quoted apart so the typed command line itself can never satisfy the check. + await typeInTerminal(page, tabId, `echo ${prefix}''_${marker.slice(prefix.length + 1)}`) + await expectPaneText(page, tabId, marker, timeout) + return marker +} + +/** Proves the shell reads input again: retypes a fresh marker until one is echoed back. */ +export async function expectTerminalAnswers( + page: Page, + tabId: string, + timeout = 120_000 +): Promise { + await expect(async () => { + await echoMarker(page, tabId, 'ANSWER', 10_000) + }).toPass({ timeout }) +} diff --git a/tests/e2e/helpers/terminal-workspace-root-link.ts b/tests/e2e/helpers/terminal-workspace-root-link.ts index 05df6f6f3b16..2798cd458fa4 100644 --- a/tests/e2e/helpers/terminal-workspace-root-link.ts +++ b/tests/e2e/helpers/terminal-workspace-root-link.ts @@ -6,7 +6,9 @@ import { focusActiveTerminalInput, getTerminalContent } from './terminal' export async function openTerminalWorkspaceRootLink( page: Page, testInfo: TestInfo, - rootPath: string + rootPath: string, + /** `open` Ctrl/Cmd-clicks, which opens a file link directly instead of its actions menu. */ + click: 'actions' | 'open' = 'actions' ): Promise<{ x: number; y: number }> { await focusActiveTerminalInput(page) const readyMarker = `ROOT_LINK_PRINTED_${Date.now()}` @@ -63,9 +65,21 @@ export async function openTerminalWorkspaceRootLink( } await page.mouse.move(target.current.x, target.current.y) await expect( - page.locator('.pane-link-tooltip:visible').filter({ hasText: `${rootPath} (Click for actions` }) + page + .locator('.pane-link-tooltip:visible') + .filter({ hasText: click === 'actions' ? `${rootPath} (Click for actions` : `${rootPath} (` }) ).toBeVisible() await page.screenshot({ path: testInfo.outputPath('managed-terminal-root-link-hover.png') }) - await page.mouse.click(target.current.x, target.current.y) + if (click === 'actions') { + await page.mouse.click(target.current.x, target.current.y) + return target.current + } + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' + await page.keyboard.down(modifier) + try { + await page.mouse.click(target.current.x, target.current.y) + } finally { + await page.keyboard.up(modifier) + } return target.current } diff --git a/tests/e2e/ssh-orcad-terminal-journeys.spec.ts b/tests/e2e/ssh-orcad-terminal-journeys.spec.ts new file mode 100644 index 000000000000..c9cc0f982e01 --- /dev/null +++ b/tests/e2e/ssh-orcad-terminal-journeys.spec.ts @@ -0,0 +1,216 @@ +/** + * Terminal journeys on an orcad-managed SSH host, read from what each pane actually shows: + * + * 1. Reconnect: a pane keeps its scrollback and a running full-screen app, and a tab opened after + * a reconnect gets a fresh shell that survives the next reconnect. + * 2. Restore: tabs come back exactly once across relaunches and a renderer reload, and a tab + * closed while the host is disconnected stays closed. + * 3. Faults: a pane outlives a dropped SSH link and a frozen host, and answers input after both. + */ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { reconnect } from './helpers/orcad-convert-flow' +import { readPairedPaneContent } from './helpers/paired-host-terminal' +import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' +import { + activateManagedWorkspace, + echoMarker, + expectPaneText, + expectTerminalAnswers, + launchManagedWorkspace, + openTerminalTab, + ORCAD_TEMPLATE, + selectTerminalTab, + terminalTabIds, + typeInTerminal, + waitForPane, + withManagedHost +} from './helpers/orcad-managed-workspace' + +test.skip( + !ORCAD_TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', + 'Needs Docker and server template' +) +test.skip(process.platform === 'win32', 'The Docker SSH host uses POSIX tooling') + +function relaunchable(app: ElectronApplication | null): ElectronApplication { + if (!app) { + throw new Error('No live launch to close') + } + return app +} + +function sshStatus(page: Page, targetId: string): Promise { + return page.evaluate( + (id) => window.__store?.getState().sshConnectionStates.get(id)?.status ?? null, + targetId + ) +} + +/** A live full-screen app redraws; a frozen or replayed frame does not. */ +async function expectTopRedrawing(page: Page, tabId: string): Promise { + await expectPaneText(page, tabId, 'load average') + const frame = await readPairedPaneContent(page, tabId) + await expect.poll(() => readPairedPaneContent(page, tabId), { timeout: 30_000 }).not.toBe(frame) + expect(await readPairedPaneContent(page, tabId)).toContain('PID') +} + +test('a managed pane keeps its scrollback and a running TUI across reconnects', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. +{}, testInfo) => { + test.setTimeout(12 * 60_000) + await withManagedHost(testInfo, async (fixture) => { + // The fixture shell emits no OSC title; a real user's shell does, and the new-tab check reads it. + fixture.host.exec!(`printf '%s\\n' 'PS1="\\[\\e]0;\\u@\\h: \\w\\a\\]$PS1"' >> /root/.bashrc`) + const { page, seeded } = await launchManagedWorkspace(fixture) + const first = await openTerminalTab(page, seeded.worktreeId) + const marker = await echoMarker(page, first, 'RECONNECT_MARKER') + + await reconnect(page, seeded.targetId) + await selectTerminalTab(page, first) + await waitForPane(page, first) + await expectPaneText(page, first, marker) + + await typeInTerminal(page, first, 'top') + await expectTopRedrawing(page, first) + for (let round = 0; round < 2; round += 1) { + await reconnect(page, seeded.targetId) + await selectTerminalTab(page, first) + await waitForPane(page, first) + await expectTopRedrawing(page, first) + } + + // A tab opened after a reconnect gets its own shell, and survives the next reconnect. + const fresh = await openTerminalTab(page, seeded.worktreeId) + expect(fresh).not.toBe(first) + await expectTerminalAnswers(page, fresh) + expect(await readPairedPaneContent(page, fresh)).not.toContain(marker) + await expect + .poll( + () => + page.evaluate( + ({ worktreeId, tabId }) => + window.__store?.getState().tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) + ?.title ?? null, + { worktreeId: seeded.worktreeId, tabId: fresh } + ), + { timeout: 60_000, message: 'the new tab kept its placeholder title' } + ) + .not.toMatch(/^Terminal \d+$/) + await reconnect(page, seeded.targetId) + expect(await terminalTabIds(page, seeded.worktreeId)).toEqual( + expect.arrayContaining([first, fresh]) + ) + await expectTerminalAnswers(page, fresh) + await page.screenshot({ path: testInfo.outputPath('managed-reconnect-panes.png') }) + }) +}) + +/** Samples until the tab set agrees three times running, so a late restore cannot slip past. */ +async function settledTabIds(page: Page, worktreeId: string): Promise { + let previous = '' + let agreements = 0 + let latest: string[] = [] + await expect + .poll( + async () => { + latest = (await terminalTabIds(page, worktreeId)).sort() + const key = JSON.stringify(latest) + agreements = key === previous && latest.length > 0 ? agreements + 1 : 0 + previous = key + return agreements >= 2 + }, + { timeout: 120_000, intervals: [2_000] } + ) + .toBe(true) + return latest +} + +test('managed tabs restore exactly once, and a tab closed while disconnected stays closed', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. +{}, testInfo) => { + test.setTimeout(15 * 60_000) + await withManagedHost(testInfo, async (fixture) => { + const launched = await launchManagedWorkspace(fixture) + const { seeded, environmentId } = launched + let page = launched.page + const opened = [ + await openTerminalTab(page, seeded.worktreeId), + await openTerminalTab(page, seeded.worktreeId), + await openTerminalTab(page, seeded.worktreeId) + ] + const marker = await echoMarker(page, opened[0], 'RESTORE_MARKER') + const expected = await settledTabIds(page, seeded.worktreeId) + expect(expected).toEqual(expect.arrayContaining(opened)) + + for (let cycle = 0; cycle < 2; cycle += 1) { + await fixture.session.close(relaunchable(fixture.app)) + fixture.app = null + const relaunched = await fixture.session.launch() + fixture.app = relaunched.app + page = relaunched.page + await waitForSessionReady(page) + await waitForStartupWorktreeRefresh(page) + await activateManagedWorkspace(page, seeded.worktreeId, environmentId) + expect(await settledTabIds(page, seeded.worktreeId), `relaunch ${cycle + 1}`).toEqual( + expected + ) + await selectTerminalTab(page, opened[0]) + await expectPaneText(page, opened[0], marker) + } + + // A renderer reload remounts every restored tab onto its own shell. + await page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await page.reload() + await waitForSessionReady(page, 60_000) + await activateManagedWorkspace(page, seeded.worktreeId, environmentId) + expect(await settledTabIds(page, seeded.worktreeId)).toEqual(expected) + for (const tabId of opened) { + await expectTerminalAnswers(page, tabId) + } + await expectPaneText(page, opened[0], marker) + + // The lid-closed shape: close a tab while disconnected, then reconnect. + const closed = opened[2] + await page.evaluate((targetId) => window.api.ssh.disconnect({ targetId }), seeded.targetId) + await page.evaluate((tabId) => window.__store!.getState().closeTab(tabId), closed) + await reconnect(page, seeded.targetId) + const remaining = expected.filter((id) => id !== closed) + expect(await settledTabIds(page, seeded.worktreeId)).toEqual(remaining) + await fixture.session.close(relaunchable(fixture.app)) + fixture.app = null + const final = await fixture.session.launch() + fixture.app = final.app + await waitForSessionReady(final.page) + await waitForStartupWorktreeRefresh(final.page) + await activateManagedWorkspace(final.page, seeded.worktreeId, environmentId) + expect(await settledTabIds(final.page, seeded.worktreeId)).toEqual(remaining) + }) +}) + +test('a managed pane outlives a dropped SSH link and a frozen host', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. +{}, testInfo) => { + test.setTimeout(15 * 60_000) + await withManagedHost(testInfo, async (fixture) => { + const { page, seeded } = await launchManagedWorkspace(fixture) + const tabId = await openTerminalTab(page, seeded.worktreeId) + const marker = await echoMarker(page, tabId, 'FAULT_MARKER') + + expect(fixture.host.dropSshConnections!()).toBeGreaterThan(0) + await expectTerminalAnswers(page, tabId, 180_000) + await expectPaneText(page, tabId, marker) + + // Silence, not a reset: the client must call the host lost instead of wedging on it. + fixture.host.setFrozen!(true) + try { + await expect + .poll(() => sshStatus(page, seeded.targetId), { timeout: 120_000, intervals: [2_000] }) + .not.toBe('connected') + } finally { + fixture.host.setFrozen!(false) + } + await reconnect(page, seeded.targetId) + await selectTerminalTab(page, tabId) + await expectTerminalAnswers(page, tabId, 180_000) + await expectPaneText(page, tabId, marker) + await page.screenshot({ path: testInfo.outputPath('managed-fault-recovery.png') }) + }) +}) diff --git a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts new file mode 100644 index 000000000000..bea66842227b --- /dev/null +++ b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts @@ -0,0 +1,213 @@ +import { openSidebarWorkspaceComposer } from './helpers/sidebar-project-dialog' +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { ensureDockerSshRelayImage } from './helpers/docker-ssh-relay-image' +import { + cleanupDockerSshRelayTarget, + execDockerSshRelayTargetCommand, + shellQuote, + startDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { ORCAD_TEMPLATE } from './helpers/orcad-managed-workspace' + +/** A recipe VM's SSH root, served by a managed orcad the first connect deploys. */ +test.skip( + !ORCAD_TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', + 'Needs Docker and server template' +) +test.skip(process.platform === 'win32', 'The recipe scripts are POSIX shell') +test.use({ + seedTestRepo: false, + orcaAppExtraEnv: { ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE ?? '' } +}) + +test('adopts a recipe-provisioned SSH root without creating a linked worktree', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(240_000) + let target: DockerSshRelayTarget | null = null + const sourceRepo = mkdtempSync(path.join(tmpdir(), 'orca-provisioned-root-source-')) + try { + ensureDockerSshRelayImage(process.cwd()) + target = startDockerSshRelayTarget(testInfo) + const expectedRefHead = seedRecipeRepo(sourceRepo, target) + await waitForSessionReady(orcaPage) + const sourceRepoId = await addRecipeRepo(orcaPage, sourceRepo) + + await openSidebarWorkspaceComposer(orcaPage) + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + await expect(dialog).toBeVisible() + await dialog.getByRole('combobox', { name: 'Run on' }).click() + await orcaPage.getByRole('option', { name: /Per-Workspace Environment/ }).click() + await orcaPage + .getByRole('listbox', { name: 'Per-Workspace Environment' }) + .getByText('Docker provisioned root', { exact: true }) + .click() + + const workspaceName = `provisioned-root-${Date.now()}` + await dialog.getByPlaceholder(/Type a name/i).fill(workspaceName) + await dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click() + const trustDialog = orcaPage.getByRole('dialog', { name: /Run VM recipe/ }) + await expect(trustDialog).toBeVisible() + await trustDialog.getByRole('button', { name: 'Run hooks' }).click() + + await expect(dialog).toBeHidden({ timeout: 60_000 }) + await expect(orcaPage.getByRole('option', { name: new RegExp(workspaceName) })).toBeVisible({ + timeout: 60_000 + }) + await ensureTerminalVisible(orcaPage) + + const adopted = await orcaPage.evaluate( + ({ sourceRepoId, workspaceName }) => { + const state = window.__store!.getState() + return Object.values(state.worktreesByRepo) + .flat() + .find( + (worktree) => worktree.displayName === workspaceName && worktree.repoId !== sourceRepoId + ) + }, + { sourceRepoId, workspaceName } + ) + expect(adopted).toMatchObject({ + path: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + isMainWorktree: true, + ephemeralVmCheckoutMode: 'provisioned-root' + }) + expect(adopted?.hostId).toMatch(/^(?:ssh:runtime-ssh-|runtime:)/) + expect( + execDockerSshRelayTargetCommand( + target, + `git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} worktree list --porcelain | grep -c '^worktree '` + ) + ).toBe('1') + expect( + execDockerSshRelayTargetCommand( + target, + `git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} branch --show-current` + ) + ).toBe(workspaceName) + expect( + execDockerSshRelayTargetCommand( + target, + `git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} rev-parse HEAD` + ) + ).toBe(expectedRefHead) + + const removeDialog = orcaPage.getByRole('dialog', { name: 'Remove Project' }) + const removeMenuItem = orcaPage.getByRole('menuitem', { name: 'Remove Project from Orca' }) + await expect(async () => { + await orcaPage + .getByRole('option', { name: new RegExp(workspaceName) }) + .click({ button: 'right' }) + await expect(removeMenuItem).toBeVisible({ timeout: 1_000 }) + await removeMenuItem.click({ force: true, timeout: 1_000 }) + await expect(removeDialog).toBeVisible({ timeout: 1_000 }) + }).toPass({ timeout: 10_000 }) + await expect(removeDialog).toContainText( + 'Its VM recipe determines whether the environment and its files are permanently deleted.' + ) + await removeDialog.getByRole('button', { name: 'Remove', exact: true }).click() + await expect + .poll( + () => + orcaPage.evaluate( + (repoId) => window.__store!.getState().repos.some((repo) => repo.id === repoId), + adopted!.repoId + ), + { timeout: 30_000 } + ) + .toBe(false) + const destroyed = target + expect(() => execDockerSshRelayTargetCommand(destroyed, 'true')).toThrow() + } finally { + cleanupDockerSshRelayTarget(target) + rmSync(sourceRepo, { recursive: true, force: true }) + } +}) + +async function addRecipeRepo(page: Parameters[0], repoPath: string) { + return page.evaluate(async (pathValue) => { + const result = await window.api.repos.add({ path: pathValue }) + if ('error' in result) { + throw new Error(result.error) + } + const store = window.__store! + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) + await store.getState().updateSettings({ experimentalEphemeralVms: true }) + store.getState().setActiveRepo(result.repo.id) + return result.repo.id + }, repoPath) +} + +function seedRecipeRepo(repoPath: string, target: DockerSshRelayTarget): string { + const createScript = path.join(repoPath, 'create.sh') + const destroyScript = path.join(repoPath, 'destroy.sh') + // The recipe's isolated HOME must still address the engine that owns the fixture container. + const docker = `docker --config ${shellQuote(process.env.DOCKER_CONFIG ?? path.join(homedir(), '.docker'))}` + writeFileSync( + createScript, + `#!/usr/bin/env bash +set -euo pipefail +[ "\${ORCA_RECIPE_RESULT_SCHEMA_VERSION:-}" = 2 ] +[ -n "\${ORCA_REPO_URL:-}" ] +[ -n "\${ORCA_REPO_REF:-}" ] +[ -n "\${ORCA_REPO_REF_HEAD:-}" ] +[ -n "\${ORCA_REPO_BRANCH:-}" ] +${docker} exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} cat-file -e "$ORCA_REPO_REF_HEAD^{commit}" +${docker} exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD" >&2 +node -e 'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",connection:{type:"ssh",projectRoot:process.argv[1],target:{label:"Docker provisioned root",host:process.argv[2],port:Number(process.argv[3]),username:"root",identityFile:process.argv[4],identitiesOnly:true}}}))' ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} ${shellQuote(target.host)} ${target.port} ${shellQuote(target.identityFile)} +` + ) + writeFileSync( + destroyScript, + `#!/usr/bin/env bash +set -euo pipefail +cat >/dev/null +${docker} rm -f ${shellQuote(target.containerName)} >/dev/null +` + ) + chmodSync(createScript, 0o755) + chmodSync(destroyScript, 0o755) + writeFileSync( + path.join(repoPath, 'orca.yaml'), + `environmentRecipes: + - id: docker-provisioned-root + name: Docker provisioned root + checkoutMode: provisioned-root + create: ./create.sh + destroy: ./destroy.sh +` + ) + execFileSync('git', ['init'], { cwd: repoPath }) + execFileSync('git', ['config', 'user.email', 'e2e@test.local'], { cwd: repoPath }) + execFileSync('git', ['config', 'user.name', 'Orca E2E'], { cwd: repoPath }) + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/stablyai/orca.git'], { + cwd: repoPath + }) + execFileSync('git', ['add', '.'], { cwd: repoPath }) + execFileSync('git', ['commit', '-m', 'seed recipe'], { cwd: repoPath }) + const expectedRefHead = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoPath, + encoding: 'utf8' + }).trim() + execDockerSshRelayTargetCommand( + target, + `rm -rf ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} && mkdir -p ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)}` + ) + execFileSync('docker', [ + 'cp', + `${repoPath}${path.sep}.`, + `${target.containerName}:${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}` + ]) + execDockerSshRelayTargetCommand( + target, + `chown -R root:root ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)}` + ) + return expectedRefHead +} diff --git a/tests/e2e/ssh-orcad-workspace-tools.spec.ts b/tests/e2e/ssh-orcad-workspace-tools.spec.ts new file mode 100644 index 000000000000..4edb8dfec116 --- /dev/null +++ b/tests/e2e/ssh-orcad-workspace-tools.spec.ts @@ -0,0 +1,198 @@ +/** + * Workspace tools on an orcad-managed SSH host: Quick Open over a monorepo-sized tree, the host's + * port scan and an SSH port forward, an image outside the workspace opened from a terminal link, and + * agent status reported from a remote terminal. + */ +import { expect, test } from './helpers/orca-app' +import { reconnect } from './helpers/orcad-convert-flow' +import { shellQuote } from './helpers/docker-ssh-relay-target' +import { callEnvironment, createPairedHostTerminal } from './helpers/paired-host-terminal' +import { reserveLocalPort } from './helpers/ssh-port-forward-snapshot-barrier' +import { openTerminalWorkspaceRootLink } from './helpers/terminal-workspace-root-link' +import { + echoMarker, + expectTerminalAnswers, + launchManagedWorkspace, + openTerminalTab, + ORCAD_TEMPLATE, + typeInTerminal, + withManagedHost +} from './helpers/orcad-managed-workspace' + +test.skip( + !ORCAD_TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', + 'Needs Docker and server template' +) +test.skip(process.platform === 'win32', 'The Docker SSH host uses POSIX tooling') + +test('Quick Open finds a file in a monorepo-sized managed workspace', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. +{}, testInfo) => { + test.setTimeout(10 * 60_000) + await withManagedHost(testInfo, async (fixture) => { + const repo = shellQuote(fixture.host.remoteRepoPath) + // ~22.6k tracked paths: past the size whose single reply once overflowed the transport (#12547). + fixture.host.exec!( + [ + `cd ${repo}`, + 'for d in $(seq 1 226); do mkdir -p packages/pkg-$d/src && (cd packages/pkg-$d/src && ' + + 'touch $(seq -f "module-with-a-long-descriptive-name-%05g.ts" 1 100)); done', + 'touch packages/pkg-200/src/quick-open-needle-target.ts', + 'git add -A', + 'git -c user.email=e2e@test.local -c user.name=e2e commit -q -m "monorepo-sized tree"' + ].join(' && ') + ) + const { page } = await launchManagedWorkspace(fixture) + await page.evaluate(() => window.__store?.getState().openModal('quick-open')) + const dialog = page.getByRole('dialog', { name: 'Go to file' }) + await expect(dialog).toBeVisible() + const input = dialog.getByPlaceholder('Go to file...') + await input.fill('quick-open-needle-target') + await expect( + dialog.getByRole('option').filter({ hasText: 'quick-open-needle-target.ts' }) + ).toHaveCount(1, { timeout: 90_000 }) + await input.fill('module-with-a-long-descriptive-name-00042') + await expect(dialog.getByRole('option').first()).toBeVisible({ timeout: 60_000 }) + await expect(dialog.getByText('Loading files...')).toHaveCount(0) + await expect(dialog).not.toContainText('Remote Orca runtime closed the connection') + await page.screenshot({ path: testInfo.outputPath('managed-quick-open.png') }) + }) +}) + +for (const acrossReconnect of [false, true]) { + test(`a managed host reports its listener, and an SSH forward reaches it${acrossReconnect ? ' across a reconnect' : ''}`, async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. + {}, testInfo) => { + // Why fixme: saved forwards are re-established only by the relay session's connect callbacks. + test.fixme(acrossReconnect, 'A managed host reconnect never calls restorePortForwards') + test.setTimeout(10 * 60_000) + const remotePort = 7860 + const marker = `ORCA_FORWARD_${Date.now()}` + const reservation = await reserveLocalPort() + await withManagedHost(testInfo, async (fixture) => { + const { page, seeded, environmentId } = await launchManagedWorkspace(fixture) + const script = `require('node:http').createServer((_q, r) => r.end(${JSON.stringify(marker)})).listen(${remotePort}, '127.0.0.1')` + // Started from the workspace so the scan attributes it to this workspace. + fixture.host.exec!( + `cd ${shellQuote(fixture.host.remoteRepoPath)} && nohup node -e ${shellQuote(script)} >/tmp/orca-http.log 2>&1 < /dev/null &` + ) + // The scan the Ports panel shows for a workspace on this host. + await expect + .poll( + async () => + JSON.stringify(await callEnvironment(page, environmentId, 'workspacePorts.scan', {})), + { timeout: 60_000, message: 'the managed host never reported the listener' } + ) + .toContain(`"port":${remotePort}`) + + const localPort = reservation.port + await reservation.release() + const forward = await page.evaluate( + (args) => window.api.ssh.addPortForward({ ...args, remoteHost: '127.0.0.1' }), + { targetId: seeded.targetId, localPort, remotePort, label: 'managed e2e' } + ) + const fetchMarker = async (): Promise => { + try { + return await (await fetch(`http://127.0.0.1:${localPort}/`)).text() + } catch (error) { + return String(error) + } + } + await expect.poll(fetchMarker, { timeout: 30_000 }).toBe(marker) + if (acrossReconnect) { + await reconnect(page, seeded.targetId) + await expect.poll(fetchMarker, { timeout: 60_000 }).toBe(marker) + } + await page.evaluate((id) => window.api.ssh.removePortForward({ id }), forward.id) + await expect.poll(fetchMarker, { timeout: 30_000 }).not.toBe(marker) + }).finally(() => reservation.release()) + }) +} + +test('a terminal link opens an image outside the managed workspace', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. +{}, testInfo) => { + // Why fixme: in-workspace links work (ssh-orcad-terminal-root-owner); this path gets no link at all. + test.fixme(true, 'A managed terminal does not linkify a path outside its workspace') + test.setTimeout(10 * 60_000) + const imagePath = '/tmp/orca-ssh-external-preview.png' + const imageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVR4AWN8z8DwnwEJMDGgAcICAO2mBAXmO4drAAAAAElFTkSuQmCC' + await withManagedHost(testInfo, async (fixture) => { + fixture.host.exec!( + `printf '%s' ${shellQuote(imageBase64)} | base64 -d > ${shellQuote(imagePath)}` + ) + const { page, seeded } = await launchManagedWorkspace(fixture) + // A link is only clickable once the shell has printed it, so wait for one that answers. + await expectTerminalAnswers(page, await openTerminalTab(page, seeded.worktreeId)) + await openTerminalWorkspaceRootLink(page, testInfo, imagePath, 'open') + const preview = page.locator(`img[alt="${imagePath.split('/').at(-1)}"]`) + await expect(preview).toBeVisible({ timeout: 30_000 }) + expect(await preview.evaluate((element: HTMLImageElement) => element.naturalWidth)).toBe(2) + await expect(page.getByText('Unable to load file', { exact: true })).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath('managed-external-image.png') }) + }) +}) + +test('a managed terminal reports agent status from hooks and from Pi-style titles', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. +{}, testInfo) => { + test.setTimeout(10 * 60_000) + await withManagedHost(testInfo, async (fixture) => { + const { page, seeded, environmentId } = await launchManagedWorkspace(fixture) + const tabId = await openTerminalTab(page, seeded.worktreeId) + await echoMarker(page, tabId, 'HOOK_READY') + // Backgrounded: a foreground post ends the shell command, which rightly clears the turn. + const prompt = `orca managed hook ${Date.now()}` + const payload = JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }) + await typeInTerminal( + page, + tabId, + [ + '( sleep 0.2; curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex"', + '-H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}"', + '--data-urlencode "paneKey=${ORCA_PANE_KEY}" --data-urlencode "tabId=${ORCA_TAB_ID}"', + '--data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}"', + '--data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}"', + `--data-urlencode ${shellQuote(`payload=${payload}`)} >/tmp/orca-hook.log 2>&1 ) &` + ].join(' ') + ) + await expect + .poll( + () => + page.evaluate( + (prompt) => + Object.values(window.__store?.getState().agentStatusByPaneKey ?? {}).some( + (entry) => + entry.prompt === prompt && + entry.agentType === 'codex' && + entry.state === 'working' + ), + prompt + ), + { timeout: 30_000, message: 'the host hook never reached the agent status store' } + ) + .toBe(true) + + // The host classifies agent titles a remote terminal emits. + const terminal = await createPairedHostTerminal(page, environmentId, seeded.worktreeId, 'bash') + const status = async (): Promise => { + const result = await callEnvironment(page, environmentId, 'terminal.agentStatus', { + terminal: terminal.terminal + }) + return typeof result === 'object' && result !== null && 'agentStatus' in result + ? result.agentStatus + : result + } + for (const [title, expected] of [ + ['⠋ OMP', 'working'], + ['OMP ready', 'idle'], + ['⠋ Pi', 'working'] + ] as const) { + await callEnvironment(page, environmentId, 'terminal.send', { + terminal: terminal.terminal, + text: `printf '\\033]0;${title}\\007'`, + enter: true + }) + await expect + .poll(status, { timeout: 15_000, message: `title ${title}` }) + .toMatchObject({ isRunningAgent: true, status: expected }) + } + }) +}) From be787967f7d98f5daa64a0439311787ca14b3a69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:12:37 +0000 Subject: [PATCH 067/110] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 302611ab2bc6..9c0e49f6455c 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 102m + + downloads: 103m @@ -15,7 +15,7 @@ downloads downloads - 102m - 102m + 103m + 103m From f6d536dcda8b8761b001f8f0a6d43cd75afbcfd2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:22:45 -0400 Subject: [PATCH 068/110] Save runtime-started tabs in the tab bar before their terminal starts (#27083) * Write runtime-started panes with their tab bar entry and group before they spawn Co-Authored-By: Claude * List the headless reorder's row-order finding and mark the headless group writer for the switch Co-Authored-By: Claude * Admit runtime-started tabs into the targeted or focused tab group A phone, CLI or orchestration create on a headless host with two tab groups was written into the first group, so after a restart the tab moved groups. The phone's targetGroupId, else the host's focused group, now reaches the pane admission. Co-Authored-By: Claude * Admit an untargeted runtime-started tab into the first tab group Defaulting to the host's focused group moved a CLI-created tab to the right group after a restart and in the desktop window, while main and the live view keep it in the first group. Only the phone's explicit targetGroupId now picks the group. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../ipc/pty-pane-claim-arbitration.test.ts | 4 +- .../pty-pane-reservation-settlement.test.ts | 2 +- ...ty-runtime-ssh-binding-persistence.test.ts | 2 +- .../ipc/pty-spawn-placement-threading.test.ts | 2 +- src/main/ipc/pty/runtime/spawn-commit.ts | 3 +- ...time-admitted-terminal-membership.test.ts} | 131 +++++++++++++++--- ...profile-state-delayed-authority-fixture.ts | 18 +++ .../loading-store/pty-binding-persistence.ts | 58 ++++++-- .../pty-binding-placement-inertness.test.ts | 6 +- .../pty-binding-session-update.ts | 10 +- .../pty-reattach-failure-routing.test.ts | 3 +- .../pty-spawn-exit-durability.test.ts | 3 +- .../pty-spawn-handle-publication.test.ts | 3 +- .../pty-spawn-replacement-durability.test.ts | 4 +- .../pty-spawn-restore-durability.test.ts | 3 +- .../session-owner-fields.ts | 3 +- .../terminal-topology/terminal-leaf-move.ts | 4 +- .../terminal-pane-admission.ts | 66 +++++++++ .../terminal-stale-pane-tree-client.test.ts | 14 +- .../terminal-topology-commit.ts | 75 +++++++++- .../terminal-topology-model.test.ts | 39 ++++-- .../runtime/headless-terminal-split-layout.ts | 2 +- ...ime-apply-mobile-session-tab-navigation.ts | 33 ----- ...ld-headless-mobile-session-browser-tabs.ts | 23 --- ...e-runtime-owned-mobile-session-terminal.ts | 5 +- ...ca-runtime-create-terminal-dependencies.ts | 3 +- .../runtime/orca-runtime-create-terminal.ts | 4 +- ...untime-move-headless-mobile-session-tab.ts | 46 ++++-- .../orca-runtime-split-pty-backed-terminal.ts | 18 +-- ...a-runtime-terminal-split-authority.test.ts | 56 +++----- .../orca-runtime-test-fixtures.spec.ts | 76 +++++----- ...le-session-tabs-cold-serve-hydrate.spec.ts | 51 +++++++ .../mobile-session-tabs-part-03.spec.ts | 24 ++-- ...worktree-setup-and-startup-part-02.spec.ts | 9 +- ...worktree-setup-and-startup-part-03.spec.ts | 12 +- ...worktree-setup-and-startup-part-04.spec.ts | 10 +- .../runtime/runtime-durable-store-fixture.ts | 35 +++++ src/main/runtime/runtime-pane-admission.ts | 87 ++++++++++++ src/main/runtime/runtime-store-contract.ts | 2 + .../runtime/runtime-terminal-contracts.ts | 2 + ...untime-terminal-orphan-session-adoption.ts | 3 +- .../runtime-terminal-spawn-placement.ts | 16 ++- .../workspace-layout-command-types.ts | 5 + .../workspace-layout-disk-fields.ts | 4 +- .../workspace-layout-pane-commands.ts | 2 +- .../workspace-layout-round-trip-check.ts | 2 +- .../workspace-layout-tab-commands.ts | 4 +- .../workspace-layout-oracle-headless.spec.ts | 40 +++++- .../workspace-layout-oracle-known-on-main.ts | 33 ++--- 49 files changed, 768 insertions(+), 292 deletions(-) rename src/main/{persistence-host-admitted-terminal-membership.test.ts => persistence-runtime-admitted-terminal-membership.test.ts} (55%) create mode 100644 src/main/persistence/terminal-topology/terminal-pane-admission.ts create mode 100644 src/main/runtime/runtime-pane-admission.ts diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 86fe3381f2bf..50516ec3398c 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -159,7 +159,7 @@ describe('registerPtyHandlers', () => { leafId, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true, + mayCreate: false, origin: 'spawn' }) }) @@ -519,7 +519,7 @@ describe('registerPtyHandlers', () => { leafId, ptyId: 'pty-shared', startupCwd: '/tmp', - hostAdmittedMembership: true, + mayCreate: false, origin: 'spawn' }) }) diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 30ef457a81d3..0cee922e8091 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -542,7 +542,7 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-1@@relay-pty', - hostAdmittedMembership: true, + mayCreate: false, origin: 'spawn' }, 'ssh:ssh-1' diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index 6af9334293a1..d273b4566ab8 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -202,7 +202,7 @@ describe('registerPtyHandlers', () => { tabId: 'tab-remote', leafId, ptyId: 'ssh:ssh-reattach-ok@@relay-pty', - hostAdmittedMembership: true, + mayCreate: false, origin: 'reattach' }, 'ssh:ssh-reattach-ok' diff --git a/src/main/ipc/pty-spawn-placement-threading.test.ts b/src/main/ipc/pty-spawn-placement-threading.test.ts index 76b6dd9ce87b..386e74a8fccd 100644 --- a/src/main/ipc/pty-spawn-placement-threading.test.ts +++ b/src/main/ipc/pty-spawn-placement-threading.test.ts @@ -170,7 +170,7 @@ describe('pty spawn placement threading', () => { leafId: LEAF, ptyId: expect.any(String), incarnationId: expect.any(String), - hostAdmittedMembership: true, + mayCreate: false, placement: SPLIT, origin: 'spawn' }) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 71a9a42bf518..deb361e2ab23 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -150,7 +150,8 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { tabId, leafId, ptyId: ctx.result.id, - hostAdmittedMembership: true, + // The runtime wrote the pane before spawning; a pane gone by now was closed meanwhile. + mayCreate: false, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), ...(expectedSourceBinding ? { expectedSourceBinding } : {}), diff --git a/src/main/persistence-host-admitted-terminal-membership.test.ts b/src/main/persistence-runtime-admitted-terminal-membership.test.ts similarity index 55% rename from src/main/persistence-host-admitted-terminal-membership.test.ts rename to src/main/persistence-runtime-admitted-terminal-membership.test.ts index 83a6898c6671..82d748a70d04 100644 --- a/src/main/persistence-host-admitted-terminal-membership.test.ts +++ b/src/main/persistence-runtime-admitted-terminal-membership.test.ts @@ -47,7 +47,23 @@ function persistedTabIds(session: WorkspaceSessionState, worktreeId: string): st return (session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) } -describe('host-admitted terminal membership survives a stale renderer replay', () => { +/** `orca terminal create`: the runtime writes the pane, then binds the terminal it started. */ +async function startRuntimePane( + store: Awaited>, + pane: { worktreeId: string; tabId: string; leafId: string; ptyId: string; incarnationId?: string } +): Promise { + expect( + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + }) + ).toBe('admitted') + return store.persistPtyBinding({ ...pane, mayCreate: false }) +} + +describe('runtime-admitted terminal membership survives a stale renderer replay', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-')) }) @@ -57,21 +73,26 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( rmSync(testState.dir, { recursive: true, force: true }) }) - it('keeps the first host-admitted tab when the renderer replays its pre-create tab list', async () => { + it('keeps the first runtime-admitted tab when the renderer replays its pre-create tab list', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - // `orca terminal create`: the host mints a tab the renderer has never seen. expect( - await store.persistPtyBinding({ + await startRuntimePane(store, { worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, - ptyId: 'host-pty', - hostAdmittedMembership: true + ptyId: 'host-pty' }) ).toBe(true) - expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab') + const session = store.getWorkspaceSession() + expect(persistedTabIds(session, WORKTREE)).toContain('host-tab') + // Written with its tab-bar entry and group, so no view reads a row the tab bar lacks. + expect(session.unifiedTabs?.[WORKTREE]?.map((tab) => tab.entityId)).toContain('host-tab') + expect(session.tabGroups?.[WORKTREE]?.flatMap((group) => group.tabOrder)).toContain('host-tab') + expect(session.terminalLayoutsByTabId['host-tab']?.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_2]: 'host-pty' + }) // The renderer's debounced writer flushes a snapshot taken before the create. store.setWorkspaceSession(rendererSession()) @@ -79,16 +100,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab') }) - it('keeps a host-admitted tab in a second worktree of the same repo', async () => { + it('keeps a runtime-admitted tab in a second worktree of the same repo', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - await store.persistPtyBinding({ + await startRuntimePane(store, { worktreeId: OTHER_WORKTREE, tabId: 'host-tab-other', leafId: TEST_LEAF_2, - ptyId: 'host-pty-other', - hostAdmittedMembership: true + ptyId: 'host-pty-other' }) store.setWorkspaceSession(rendererSession()) @@ -100,12 +120,11 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( store.setWorkspaceSession(rendererSession()) expect( - await store.persistPtyBinding({ + await startRuntimePane(store, { worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, - ptyId: 'host-pty', - hostAdmittedMembership: true + ptyId: 'host-pty' }) ).toBe(true) expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe( @@ -118,9 +137,8 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( ) }) - // Polarity: without the flag the renderer still owns membership, so a renderer - // spawn racing its own writer must not freeze the tab list. - it('leaves renderer-owned membership alone when the binding is not host-admitted', async () => { + // Polarity: a renderer spawn racing its own writer must not freeze the tab list. + it('leaves renderer-owned membership alone when the runtime did not admit the pane', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) @@ -138,16 +156,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( // Closing must still work afterwards. Closes are host-driven: the retirement is // computed from the store's own session (see stageTerminalSurfaceRetirements), // which is what outranks the fence this create just raised. - it('still lets the authoritative retirement path close the host-admitted tab', async () => { + it('still lets the authoritative retirement path close the runtime-admitted tab', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - await store.persistPtyBinding({ + await startRuntimePane(store, { worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, ptyId: 'host-pty', - incarnationId: 'host-incarnation', - hostAdmittedMembership: true + incarnationId: 'host-incarnation' }) store.setWorkspaceSession( @@ -164,4 +181,76 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) }) + + it('refuses to bind a pane closed while its terminal started, and mints no tab for it', async () => { + const store = await createStore() + store.setWorkspaceSession(rendererSession()) + expect( + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: WORKTREE, + tabId: 'host-tab', + leafId: TEST_LEAF_2 + }) + ).toBe('admitted') + await store.withdrawTerminalPane({ + type: 'closePane', + workspace: WORKTREE, + tabId: 'host-tab', + leafId: TEST_LEAF_2 + }) + + expect( + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: 'host-tab', + leafId: TEST_LEAF_2, + ptyId: 'host-pty', + mayCreate: false + }) + ).toBe(false) + expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab']) + }) + + it('refuses to bind a split pane that moved to another tab while its terminal started', async () => { + const store = await createStore() + store.setWorkspaceSession(rendererSession()) + expect( + await store.admitTerminalPane({ + type: 'splitPane', + workspace: WORKTREE, + tabId: 'renderer-tab', + leafId: TEST_LEAF_1, + direction: 'vertical', + newLeafId: TEST_LEAF_2 + }) + ).toBe('admitted') + await expect( + store.moveTerminalLeafToNewTab({ + worktreeId: WORKTREE, + sourceTabId: 'renderer-tab', + targetTabId: 'moved-tab', + leafId: TEST_LEAF_2, + ptyId: null + }) + ).resolves.toMatchObject({ status: 'moved' }) + + expect( + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: 'renderer-tab', + leafId: TEST_LEAF_2, + ptyId: 'late-pty', + mayCreate: false + }) + ).toBe(false) + const session = store.getWorkspaceSession() + expect(session.terminalLayoutsByTabId['renderer-tab']?.root).toEqual({ + type: 'leaf', + leafId: TEST_LEAF_1 + }) + expect( + session.terminalLayoutsByTabId['moved-tab']?.ptyIdsByLeafId?.[TEST_LEAF_2] + ).toBeUndefined() + }) }) diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts index e51ec92b5ec0..e2078f8b9182 100644 --- a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -11,6 +11,7 @@ import type { ProfileStateDomainReplacement } from './profile-state-authority' import { Store } from './store' +import { toSshExecutionHostId } from '../../../shared/execution-host' export function deferred() { let resolve!: (value: T) => void @@ -147,3 +148,20 @@ export async function fixture(legacyOpenCodeGoApiKey?: string) { } return { store, authority, readState } } + +/** The runtime writes a pane before it spawns; a runtime spawn commit binds only that pane. */ +export async function admitRuntimeSpawnPane( + store: Store, + pane: { worktreeId: string; tabId: string; leafId: string }, + connectionId?: string | null +): Promise { + await store.admitTerminalPane( + { + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + }, + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index f96624d8cb1a..bc27b7f86a33 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -22,7 +22,20 @@ import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../../shared/terminal-leaf-move' -import { moveLeaf } from '../terminal-topology/terminal-topology-commit' +import { + admitPane, + moveLeaf, + withdrawPane, + type TerminalTopologyCommitContext +} from '../terminal-topology/terminal-topology-commit' +import type { + TerminalPaneAdmission, + TerminalPaneAdmissionOutcome +} from '../terminal-topology/terminal-pane-admission' +import type { + CommandOf, + LayoutRefusalCode +} from '../../../shared/workspace-layout/workspace-layout-command-types' import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants' type PtyBindingPersistenceOperationsRuntime = Pick< @@ -46,8 +59,6 @@ export type PersistPtyBindingArgs = { startupCwd?: string expectedBinding?: { ptyId: string; incarnationId?: string } expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean /** * Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer * and must be able to mint the surface it is binding. A reattach is the opposite: the pane @@ -217,16 +228,41 @@ export class PtyBindingPersistenceOperations { * the binding domain only for its runtime and partition access; the commit module owns the write. */ moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise { - const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] - return runtime.runDurableMutation( - moveLeaf(request, { - state: runtime.state, - hostIds: () => sessions.getWorkspaceSessionHostIds(), - getSession: (hostId) => sessions.getWorkspaceSession(hostId), - markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) - }) + return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( + moveLeaf(request, topologyCommitContext(this)) + ) + } + + /** Writes a runtime-started pane before its terminal starts; the commit module owns the write. */ + admitTerminalPane( + admission: TerminalPaneAdmission, + hostId?: string | null + ): Promise { + return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( + admitPane(resolveHostId(hostId), admission, topologyCommitContext(this)) ) } + + withdrawTerminalPane( + pane: CommandOf<'closePane'>, + hostId?: string | null + ): Promise { + return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( + withdrawPane(resolveHostId(hostId), pane, topologyCommitContext(this)) + ) + } +} + +function topologyCommitContext( + owner: PtyBindingPersistenceOperations +): TerminalTopologyCommitContext { + const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] + return { + state: runtime.state, + hostIds: () => sessions.getWorkspaceSessionHostIds(), + getSession: (hostId) => sessions.getWorkspaceSession(hostId), + markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain) + } } function writePtyBinding( diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index ce412cf4633d..95007aaa380f 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -64,8 +64,7 @@ const SCENARIOS: Scenario[] = [ leafId: TEST_LEAF_1, ptyId: 'pty-new', incarnationId: 'inc-new', - startupCwd: '/fixture/local/sub', - hostAdmittedMembership: true + startupCwd: '/fixture/local/sub' }, placements: (leaf) => [ [NEW_TAB, 'agrees'], @@ -110,8 +109,7 @@ const SCENARIOS: Scenario[] = [ worktreeId: FOLDER_WORKTREE, tabId: 'tab-folder', leafId: TEST_LEAF_1, - ptyId: 'pty-folder', - hostAdmittedMembership: true + ptyId: 'pty-folder' }, placements: () => [ [NEW_TAB, 'agrees'], diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 3024543505a6..dd7ca9e1275f 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -18,15 +18,12 @@ export function applyPtyBinding( const reconciledIncarnation = args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false - let hostAdmittedTabCreated = false const advanceTopologyFence = (): void => { const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + // Why: a split is itself the authority — with no fence the renderer's pre-split + // pane list replays over it and the pane is lost even on the repo's first split. + const establishesMembershipAuthority = args.expectedSourceBinding !== undefined if ( !reconciledIncarnation && (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) @@ -62,7 +59,6 @@ export function applyPtyBinding( ) } else { terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. const nextTabs = [ ...(tabs ?? []), diff --git a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts index 7a1d95c91cb9..75eeef8061d2 100644 --- a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts +++ b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -71,6 +71,7 @@ describe.each(['ipc', 'runtime'])('%s failed reattach routing', (controller) => }) ctx.result = result ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding, connectionId) ctx.hostSessionBinding = { store, ...binding } shutdown = vi.spyOn(ctx.provider, 'shutdown') commit = () => commitRuntimePtySpawn(ctx) diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts index ef146779b4bf..b39a5eb01c96 100644 --- a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -1,5 +1,5 @@ import { expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' @@ -78,6 +78,7 @@ it.each( const ctx = createRuntimePtySpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.stablePaneOwner = owner + await admitRuntimeSpawnPane(store, binding, connectionId) ctx.hostSessionBinding = { store, ...binding } ctx.metadataLeafId = binding.leafId commit = () => commitRuntimePtySpawn(ctx) diff --git a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts index 9fb0d65a2cb8..e8cad7828eec 100644 --- a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts @@ -1,5 +1,5 @@ import { expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -62,6 +62,7 @@ it.each(['ipc', 'runtime'])( }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding) ctx.hostSessionBinding = { store, ...binding } commit = () => commitRuntimePtySpawn(ctx) } diff --git a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts index 19bb64ff3693..75f154074e27 100644 --- a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts @@ -1,6 +1,6 @@ import { isTerminalSessionStorageCapacityFailure } from '../../../shared/terminal-session-state-save-failure' import { afterEach, expect, it, vi } from 'vitest' -import { deferred, fixture } from './profile-state-delayed-authority-fixture' +import { deferred, fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -72,6 +72,7 @@ it.each( const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding) ctx.hostSessionBinding = { store, ...binding } shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown) commit = () => commitRuntimePtySpawn(ctx) @@ -137,6 +138,7 @@ it.each(['ipc', 'runtime'])( const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding) ctx.hostSessionBinding = { store, ...binding } commit = () => commitRuntimePtySpawn(ctx) } diff --git a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts index 26737df9dd0d..714cf99c1155 100644 --- a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' +import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' @@ -56,6 +56,7 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) ctx.result = result ctx.metadataLeafId = binding.leafId + await admitRuntimeSpawnPane(store, binding) ctx.hostSessionBinding = { store, ...binding } commit = () => commitRuntimePtySpawn(ctx) } diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index 1ac9fec8935b..88920f660f31 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -38,8 +38,7 @@ export function createMinimalPersistedTerminalTab(args: { color: null, sortOrder: args.existingTabCount, createdAt: Date.now(), - ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - pendingActivationSpawn: true + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}) } } diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts index 627e4a705e72..5248ca7c0d90 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -68,7 +68,7 @@ function moveLeafInPartition( const boundHere = sourceLayout.ptyIdsByLeafId?.[leafId] const remainingLayout = retireLeavesFromTerminalLayout(sourceLayout, new Set([leafId])) const remainingPtyIds = Object.values(remainingLayout?.ptyIdsByLeafId ?? {}) - const { pendingActivationSpawn, ...row } = createMinimalPersistedTerminalTab({ + const row = createMinimalPersistedTerminalTab({ worktreeId, tabId: targetTabId, ptyId: ptyId ?? '', @@ -78,8 +78,6 @@ function moveLeafInPartition( const targetTab = { ...row, ptyId, - // A moved live pane reattaches; only an unbound one still spawns on activation. - ...(ptyId ? {} : { pendingActivationSpawn }), ...(sourceTab.shellOverride ? { shellOverride: sourceTab.shellOverride } : {}) } const nextTabs = tabs.map((tab) => diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts new file mode 100644 index 000000000000..4e3ab537f446 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -0,0 +1,66 @@ +// A runtime-started pane, written through the layout module before its terminal starts (design +// 4.1): Loader, then the command, then the Serializer, so the partition is rewritten from one model. + +import { randomUUID } from 'node:crypto' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { locatePane } from '../../../shared/workspace-layout/workspace-layout-command-steps' +import type { + CommandOf, + LayoutCommand, + LayoutRefusalCode +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' +import { loadWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load' +import type { LoadedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load-types' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { paneKeyOf } from '../../../shared/workspace-layout/workspace-layout-model' +import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' + +/** A new tab, or a split of an existing pane, with the ids its terminal will run under. */ +export type TerminalPaneAdmission = + | (CommandOf<'createTerminalTab'> & { tabId: string; leafId: string }) + | (CommandOf<'splitPane'> & { newLeafId: string }) + +/** 'exists': the start attaches to a pane already in the layout, so nothing is written. */ +export type TerminalPaneAdmissionOutcome = 'admitted' | 'exists' | LayoutRefusalCode + +/** The partition to write, or null when nothing changes. */ +export type PaneLayoutChange = { value: T; session: WorkspaceSessionState | null } + +export function admitPaneToSession( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + admission: TerminalPaneAdmission +): PaneLayoutChange { + const leafId = admission.type === 'createTerminalTab' ? admission.leafId : admission.newLeafId + const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) + const workspace = loaded.layout.workspaces[admission.workspace] + if (workspace && locatePane(workspace, paneKeyOf(admission.tabId, leafId))) { + return { value: 'exists', session: null } + } + return applyToSession(loaded, admission, 'admitted') +} + +export function withdrawPaneFromSession( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + pane: CommandOf<'closePane'> +): PaneLayoutChange { + return applyToSession(loadWorkspaceLayout(hostId, session, nameBasedLoadContext()), pane, null) +} + +function applyToSession( + loaded: LoadedWorkspaceLayout, + command: LayoutCommand, + committed: T +): PaneLayoutChange { + const applied = applyLayoutCommand(loaded.layout, command, { + mintId: randomUUID, + mintLeafId: randomUUID, + now: Date.now + }) + return applied.ok + ? { value: committed, session: saveWorkspaceLayout({ ...loaded, layout: applied.model }) } + : { value: applied.code, session: null } +} diff --git a/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts b/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts index b11a2a244699..9e10e984b391 100644 --- a/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts +++ b/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts @@ -100,14 +100,24 @@ describe('an old client pane tree that predates a split or close', () => { it('keeps a pane another client split since the tree was built', async () => { const store = await openWithPanes([TEST_LEAF_1]) - // `terminal.split` from the CLI: a host-created pane bound against its source pane. + // `terminal.split` from the CLI: the runtime writes the pane, then binds it against its source. + await expect( + store.admitTerminalPane({ + type: 'splitPane', + workspace: WT, + tabId: 'tab-a', + leafId: TEST_LEAF_1, + direction: 'vertical', + newLeafId: NEW_LEAF + }) + ).resolves.toBe('admitted') await expect( store.persistPtyBinding({ worktreeId: WT, tabId: 'tab-a', leafId: NEW_LEAF, ptyId: 'pty-new', - hostAdmittedMembership: true, + mayCreate: false, expectedSourceBinding: { tabId: 'tab-a', leafId: TEST_LEAF_1, ptyId: 'pty-1' } }) ).resolves.toBe(true) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index dc1b562a51da..756869d08d79 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -5,6 +5,17 @@ import type { TerminalLeafMoveResult } from '../../../shared/terminal-leaf-move' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { + CommandOf, + LayoutRefusalCode +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import { + admitPaneToSession, + withdrawPaneFromSession, + type PaneLayoutChange, + type TerminalPaneAdmission, + type TerminalPaneAdmissionOutcome +} from './terminal-pane-admission' import { startSpan } from '../../observability/tracer' import { terminalSurfaceCloseMutation, @@ -18,7 +29,12 @@ import { assignWorkspaceSessionPartition } from './terminal-topology-membership' // the pane move; the close transform still lives in runtime/ and other writers move here later. /** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ -type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf' +type TerminalTopologyCommitKind = + | 'close_leaf' + | 'close_tab' + | 'move_leaf' + | 'admit_pane' + | 'withdraw_pane' export function closeLeafOrTab( commit: TerminalSurfaceCloseCommit @@ -43,6 +59,45 @@ export function moveLeaf( ) } +/** + * Writes the pane, its tab-bar entry and group before its terminal starts (design 4.1), through the + * layout module. + */ +export function admitPane( + hostId: ExecutionHostId, + admission: TerminalPaneAdmission, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return traced( + 'admit_pane', + () => + commitPaneChange( + hostId, + admitPaneToSession(hostId, context.getSession(hostId), admission), + context + ), + (outcome) => (outcome === 'admitted' || outcome === 'exists' ? undefined : outcome) + ) +} + +/** Removes a pane `admitPane` wrote whose start then failed, so a failed create leaves no tab. */ +export function withdrawPane( + hostId: ExecutionHostId, + pane: CommandOf<'closePane'>, + context: TerminalTopologyCommitContext +): () => DurableProfileStateMutation { + return traced( + 'withdraw_pane', + () => + commitPaneChange( + hostId, + withdrawPaneFromSession(hostId, context.getSession(hostId), pane), + context + ), + (code) => code ?? undefined + ) +} + /** * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. * Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code. @@ -82,7 +137,7 @@ type TopologyState = Pick< 'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases' > -type TerminalTopologyCommitContext = { +export type TerminalTopologyCommitContext = { state: TopologyState hostIds: () => ExecutionHostId[] getSession: (hostId: ExecutionHostId) => WorkspaceSessionState @@ -147,3 +202,19 @@ function commitLeafMove( rollback: () => restores.forEach((restore) => restore()) } } + +function commitPaneChange( + hostId: ExecutionHostId, + change: PaneLayoutChange, + context: TerminalTopologyCommitContext +): DurableProfileStateMutation { + if (!change.session) { + return { value: change.value, persist: false } + } + const restore = writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), + change.session + ) + return { value: change.value, rollback: restore } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts index 4b44bf6fdb44..f68b7595dbb8 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -553,24 +553,33 @@ async function runSeed(seed: number): Promise { const ptyId = ids.pty(worktreeId) const ticks = Math.floor(random() * 4) log.push(`${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId} stale-save+${ticks}`) - // `terminal.split` from a client or the CLI: main mints the pane while the window's - // in-flight save lacks it. + // `terminal.split` from a client or the CLI: main writes the pane, then binds it, while + // the window's in-flight save lacks it. + writer = 'host split' const [bound] = await Promise.all([ - store.persistPtyBinding(() => { - writer = 'host split' - return { - worktreeId, + store + .admitTerminalPane({ + type: 'splitPane', + workspace: worktreeId, tabId: target.tabId, - leafId, - ptyId, - hostAdmittedMembership: true, - expectedSourceBinding: { + leafId: target.leafId, + direction: 'vertical', + newLeafId: leafId + }) + .then(() => + store.persistPtyBinding({ + worktreeId, tabId: target.tabId, - leafId: target.leafId, - ptyId: target.pane.ptyId! - } - } - }), + leafId, + ptyId, + mayCreate: false, + expectedSourceBinding: { + tabId: target.tabId, + leafId: target.leafId, + ptyId: target.pane.ptyId! + } + }) + ), staleSave(window.snapshot(), ticks) ]) expect(bound).toBe(true) diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts index 946fd05b6c51..871371ee1999 100644 --- a/src/main/runtime/headless-terminal-split-layout.ts +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -24,7 +24,7 @@ export function buildHeadlessTerminalSplitLayout( direction: 'horizontal' | 'vertical' } ): TerminalLayoutSnapshot { - // Why: PTY admission durably appends a fallback vertical leaf before this exact-direction commit. + // Why: the stored tree may already hold the new leaf; it is placed again beside its source. const currentRoot = existing?.root ? removeLayoutLeaf(existing.root, args.leafId) : null const existingRoot: TerminalPaneLayoutNode = currentRoot ?? { type: 'leaf', diff --git a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts index 055e64c4622f..a9f46fca1dab 100644 --- a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts @@ -18,7 +18,6 @@ import { buildHeadlessMobileSessionTabGroups, cloneTerminalLayoutSnapshot } from './mobile-session-layout-projection' -import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout' export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh { protected applyMobileSessionTabNavigation( @@ -171,38 +170,6 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW this.emitMobileSessionTabsSnapshot(nextSnapshot) } - // Why: a headless split only updated the LIVE session snapshot, never the - // persisted workspace session layout. So a later snapshot rebuild (e.g. on the - // next terminal create) re-derived from the stale single-leaf persisted layout - // and collapsed the split. Persist the new split leaf into the workspace - // session's terminalLayoutsByTabId so the split survives rebuilds. - protected persistHeadlessTerminalSplit(args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - splitFromLeafId: string - direction: 'horizontal' | 'vertical' - }): boolean { - const session = this.getWorkspaceSessionForWorktree(args.worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return false - } - const existing = session.terminalLayoutsByTabId?.[args.tabId] - const nextLayout = buildHeadlessTerminalSplitLayout( - existing ? cloneTerminalLayoutSnapshot(existing) : undefined, - args - ) - this.setWorkspaceSessionForWorktree(args.worktreeId, { - ...session, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: nextLayout - } - }) - return true - } - protected persistHeadlessTerminalActiveLeaf( worktreeId: string, tab: RuntimeMobileSessionTerminalTab diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 301645aad303..99d11f4a5739 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -259,29 +259,6 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu } } - protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return - } - const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) - const tabs = session.tabsByWorktree[worktreeId] ?? [] - const reordered = [...tabs] - .sort((a, b) => { - const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER - const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER - return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt - }) - .map((tab, index) => ({ ...tab, sortOrder: index })) - this.setWorkspaceSessionForWorktree(worktreeId, { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [worktreeId]: reordered - } - }) - } - protected emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { if (this.mobileSessionTabListeners.size === 0) { return diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index dac1c0e199d5..07375833d7f0 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -111,6 +111,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + ...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}), startupCommandDelivery: opts.startupCommandDelivery, ...(opts.identity ? { @@ -181,10 +182,6 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() - if (opts.viewMode) { - // Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default. - this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) - } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const existingSurface = existing?.tabs.find( diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 3be610ed18fb..97fdc3de949d 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -29,7 +29,8 @@ export { getTerminalViewerColors, setPairedViewerColors } from './terminal-view- export { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors' export type { RuntimePtyController } from './runtime-pty-controller-contract' export { getRuntimeDesktopSurface } from './runtime-desktop-surface' -export { runtimeNewTabPlacement } from './runtime-terminal-spawn-placement' +export { newTabIn } from './runtime-terminal-spawn-placement' +export { spawnInAdmittedPane } from './runtime-pane-admission' export type { IpcMainEvent } from 'electron' // Why initiallyHidden: no renderer pane exists yet, so main must answer startup queries — Muse diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index dcc80914b6f4..03d7583e6c84 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -123,7 +123,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate try { const { launchAgent } = launchOpts launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent }) - result = await this.ptyController.spawn({ + result = await dependencies.spawnInAdmittedPane(this.store, this.ptyController, { cols: 120, rows: 40, cwd, @@ -169,7 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), - placement: dependencies.runtimeNewTabPlacement(), + ...dependencies.newTabIn(launchOpts), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts index 8caa6bbd9de4..8e010c916500 100644 --- a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts @@ -69,10 +69,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith tabGroups: nextGroups, tabs: nextTabs } - this.persistHeadlessTerminalTabOrder(worktreeId, tabOrder) - if (nextGroups.length > 1 && snapshot.tabGroupLayout) { - this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) - } + this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } @@ -155,18 +152,50 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith return { moved: true } } - // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split. + // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split; the tab bar and rows + // follow the groups. Deleted by the switch (PR 6), when moves are layout commands. protected persistHeadlessTabGroups( worktreeId: string, groups: readonly RuntimeMobileSessionTabGroup[], - layout: TabGroupLayoutNode + layout: TabGroupLayoutNode | undefined ): void { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } + const order = groups.flatMap((group) => group.tabOrder) + const groupIdByTabId = new Map( + groups.flatMap((group) => group.tabOrder.map((tabId) => [tabId, group.id] as const)) + ) + const position = (tabId: string): number => { + const index = order.indexOf(tabId) + return index === -1 ? order.length : index + } + const entries = session.unifiedTabs?.[worktreeId] + const rows = session.tabsByWorktree[worktreeId] this.setWorkspaceSessionForWorktree(worktreeId, { ...session, + ...(rows + ? { + tabsByWorktree: { + ...session.tabsByWorktree, + [worktreeId]: [...rows] + .sort((a, b) => position(a.id) - position(b.id) || a.sortOrder - b.sortOrder) + .map((row, sortOrder) => ({ ...row, sortOrder })) + } + } + : {}), + ...(entries + ? { + unifiedTabs: { + ...session.unifiedTabs, + [worktreeId]: entries.map((entry) => ({ + ...entry, + groupId: groupIdByTabId.get(entry.id) ?? entry.groupId + })) + } + } + : {}), tabGroups: { ...session.tabGroups, [worktreeId]: groups.map((group) => ({ @@ -177,10 +206,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) })) }, - tabGroupLayouts: { - ...session.tabGroupLayouts, - [worktreeId]: layout - } + ...(layout ? { tabGroupLayouts: { ...session.tabGroupLayouts, [worktreeId]: layout } } : {}) }) } } diff --git a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts index de9890862abd..c87488dd92d9 100644 --- a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts +++ b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import { runtimeSplitPlacement } from './runtime-terminal-spawn-placement' import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core' import type { Worktree } from '../../shared/worktree/types' +import { spawnInAdmittedPane, withdrawRuntimePane } from './runtime-pane-admission' export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitTerminal { protected async splitPtyBackedTerminal( @@ -56,7 +57,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT const leafId = randomUUID() const preAllocatedHandle = this.createPreAllocatedTerminalHandle() const paneKey = makePaneKey(parentTabId, leafId) - const result = await this.ptyController.spawn({ + const splitArgs = { cols: 120, rows: 40, cwd: workspace.path, @@ -89,7 +90,8 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT } } : {}) - }) + } + const result = await spawnInAdmittedPane(this.store, this.ptyController, splitArgs) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) @@ -150,17 +152,6 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT revalidateSourceAuthority() } if (createdPty) { - const persisted = this.persistHeadlessTerminalSplit({ - worktreeId: workspace.id, - tabId: parentTabId, - leafId, - ptyId: createdPty.ptyId, - splitFromLeafId: parsedPaneKey.leafId, - direction - }) - if (sourceAuthority.persisted && !persisted) { - throw new Error('workspace_session_unavailable') - } this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { tabId: parentTabId, leafId, @@ -192,6 +183,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT } catch { // Best-effort cleanup; preserve the original split authority error. } + await withdrawRuntimePane(this.store, splitArgs) throw error } const committedSourceAuthority = sourceAuthority.persisted diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index 8369a219331e..a59074a82dc5 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -1,3 +1,4 @@ +import { withRuntimePaneAdmission } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' @@ -96,7 +97,7 @@ function createHarness( addedAt: 1, ...(connectionId ? { connectionId } : {}) } - const store = { + const store = withRuntimePaneAdmission({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getWorkspaceSession: (hostId?: string) => { @@ -107,7 +108,7 @@ function createHarness( session = next }, persistPtyBinding: () => true - } + }) let resolveSpawn: ((result: { id: string }) => void) | undefined const spawn = options.deferSpawn ? vi.fn( @@ -319,7 +320,6 @@ describe('remote runtime terminal split authority', () => { [SOURCE_LEAF_ID]: SOURCE_PTY_ID } }) - expect(Object.values(persistedLayout!.ptyIdsByLeafId!)).toContain(SPLIT_PTY_ID) const siblingSurfaces = harness .getSnapshot()! .tabs.filter( @@ -432,37 +432,20 @@ describe('remote runtime terminal split authority', () => { expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true) }) - it('revalidates a projected paired-runtime source after renderer adoption', async () => { - const harness = createHarness(false, { - deferReveal: true, - includePairedSnapshot: true, - sourceIncarnationId: 'projected-before', - stopAndWaitResult: false - }) - - const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) - await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce()) - expect(harness.spawn).toHaveBeenCalledWith( - expect.not.objectContaining({ expectedSourceBinding: expect.anything() }) - ) - - harness.replaceSourceIncarnation('projected-after') - harness.resolveReveal() + it('refuses a split whose source is only projected, before spawning', async () => { + const harness = createHarness(false, { includePairedSnapshot: true }) - await expect(split).rejects.toThrow('terminal_split_source_not_found') - expect(harness.stopAndWait).toHaveBeenCalledWith( - SPLIT_PTY_ID, - expect.objectContaining({ deadlineMs: expect.any(Number) }) - ) - expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) - expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false) + await expect( + harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) + ).rejects.toThrow('terminal_split_source_not_found') + expect(harness.spawn).not.toHaveBeenCalled() + expect(harness.revealTerminalSession).not.toHaveBeenCalled() }) - it('preserves the split error when kill and retirement throw', async () => { - const harness = createHarness(false, { - deferReveal: true, - includePairedSnapshot: true, - sourceIncarnationId: 'projected-before', + it('preserves the split error and takes back the pane when kill and retirement throw', async () => { + const harness = createHarness(true, { + deferSpawn: true, + includePairedSnapshot: false, stopAndWaitResult: false }) harness.kill.mockImplementation(() => { @@ -471,14 +454,19 @@ describe('remote runtime terminal split authority', () => { harness.retireRejectedPty.mockImplementation(() => { throw new Error('retire failed') }) + harness.replacePersistedSourceIncarnation('persisted-before') const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) - await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce()) - harness.replaceSourceIncarnation('projected-after') - harness.resolveReveal() + await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce()) + harness.replacePersistedSourceIncarnation('persisted-after') + harness.resolveSpawn() await expect(split).rejects.toThrow('terminal_split_source_not_found') expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false) + expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: SOURCE_LEAF_ID + }) }) }) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index 63a29dbc717f..fe61bcbb7da4 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -1,4 +1,4 @@ -import { withDurableRuntimeStore } from './runtime-durable-store-fixture' +import { withDurableRuntimeStore, withRuntimePaneAdmission } from './runtime-durable-store-fixture' import { expect, vi } from 'vitest' import { createHash } from 'node:crypto' import { HeadlessEmulator } from '../daemon/headless-emulator' @@ -580,46 +580,48 @@ function makeRuntimeStoreWithWorkspaceSession( const setSession = (next: WorkspaceSessionState): void => { session = next } - const runtimeStore = withDurableRuntimeStore({ - ...store, - getWorkspaceSession: (hostId?: string) => - hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), - setWorkspaceSession: vi.fn(setSession), - // Headless close is a durable transaction; keep the in-memory fixture's - // persistence contract equivalent to the production store. - flushOrThrow: vi.fn(), - persistPtyBinding: vi.fn(async (input) => { - const args = typeof input === 'function' ? input() : input - if (!args) { - return false - } - const tabs = session.tabsByWorktree[args.worktreeId] ?? [] - session = { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [args.worktreeId]: tabs.map((tab) => - tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab - ) - }, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...(session.terminalLayoutsByTabId[args.tabId] ?? { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null - }), - ptyIdsByLeafId: { - ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, - [args.leafId]: args.ptyId + const runtimeStore = withRuntimePaneAdmission( + withDurableRuntimeStore({ + ...store, + getWorkspaceSession: (hostId?: string) => + hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), + setWorkspaceSession: vi.fn(setSession), + // Headless close is a durable transaction; keep the in-memory fixture's + // persistence contract equivalent to the production store. + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(async (input) => { + const args = typeof input === 'function' ? input() : input + if (!args) { + return false + } + const tabs = session.tabsByWorktree[args.worktreeId] ?? [] + session = { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [args.worktreeId]: tabs.map((tab) => + tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab + ) + }, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + ...(session.terminalLayoutsByTabId[args.tabId] ?? { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null + }), + ptyIdsByLeafId: { + ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } } } } - } - return true + return true + }) }) - }) + ) return { runtimeStore, getSession: () => session, setSession } } diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts index 612efbb77b82..3b4c48eeddb7 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts @@ -176,4 +176,55 @@ describe('OrcaRuntimeService', () => { expect(listed.tabGroups).toHaveLength(1) expect(storedActiveGroupId(runtime)).toBe(listed.tabGroups?.[0]?.id) }) + + it('keeps phone and CLI creates in the group they joined across a cold restart', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeTabId: 'serve-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'serve-tab' }, + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-left' }, + tabGroups: { [TEST_WORKTREE_ID]: SPLIT_GROUPS }, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + makeTerminalTab('serve-tab', SERVE_PTY_ID, 0), + makeTerminalTab('daemon-tab', DAEMON_PTY_ID, 1) + ] + }, + terminalLayoutsByTabId: { + 'serve-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: SERVE_PTY_ID }), + 'daemon-tab': makeHeadlessTerminalLayout({ [HEADLESS_SECOND_LEAF_ID]: DAEMON_PTY_ID }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore) + let ptys = 0 + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `created-pty-${++ptys}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + // The phone targets the right group; an untargeted CLI create joins the first, as on main. + const phone = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + targetGroupId: 'group-right' + }) + const cli = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + const tabOrderOf = (session: WorkspaceSessionState, groupId: string) => + session.tabGroups?.[TEST_WORKTREE_ID]?.find((group) => group.id === groupId)?.tabOrder + expect(tabOrderOf(getSession(), 'group-right')).toContain(phone.tab.parentTabId) + expect(tabOrderOf(getSession(), 'group-left')).toContain(cli.tabId) + + const restarted = new OrcaRuntimeService( + makeRuntimeStoreWithWorkspaceSession(getSession()).runtimeStore + ) + const listed = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(listed.tabGroups?.map((group) => group.tabOrder)).toEqual([ + ['serve-tab', cli.tabId], + ['daemon-tab', phone.tab.parentTabId] + ]) + }) }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts index 1b28f86bd0de..22a822b47881 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts @@ -13,8 +13,10 @@ import { TEST_WORKTREE_PATH, UUID_RE, deferred, + makeRuntimeStoreWithWorkspaceSession, store } from '../orca-runtime-test-fixtures.spec' +import { getDefaultWorkspaceSession } from '../../../shared/constants' describe('OrcaRuntimeService', () => { it('keeps client selection when a renderer session-tab close cannot commit', async () => { @@ -249,17 +251,10 @@ describe('OrcaRuntimeService', () => { it('creates mobile session terminals in a headless runtime server', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) - const runtime = new OrcaRuntimeService(store) - const persistViewMode = vi.spyOn( - runtime as unknown as { - persistHeadlessSessionTabProps: ( - worktreeId: string, - tabId: string, - props: { viewMode: 'terminal' | 'chat' } - ) => void - }, - 'persistHeadlessSessionTabProps' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + getDefaultWorkspaceSession() ) + const runtime = new OrcaRuntimeService(runtimeStore) runtime.setPtyController({ spawn, write: () => true, @@ -289,9 +284,12 @@ describe('OrcaRuntimeService', () => { viewMode: 'chat', isActive: true }) - expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, { - viewMode: 'chat' - }) + // Written with the pane before spawn, so a serve restart keeps the initial mode. + expect( + getSession().tabsByWorktree[TEST_WORKTREE_ID]?.find( + (tab) => tab.id === result.tab.parentTabId + )?.viewMode + ).toBe('chat') const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(listed.tabs).toEqual([ diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts index eca4bd125140..0f121cc56d55 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts @@ -1,3 +1,4 @@ +import { getDefaultWorkspaceSession } from '../../../shared/constants' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -10,7 +11,11 @@ import { runHook, shouldRunSetupForCreate } from '../orca-runtime-test-mocks.spec' -import { expectStablePaneKeyEnv, store } from '../orca-runtime-test-fixtures.spec' +import { + expectStablePaneKeyEnv, + store, + makeRuntimeStoreWithWorkspaceSession +} from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { it('sequences setup before startup for opted-in local headless worktree creates', async () => { @@ -517,7 +522,7 @@ describe('OrcaRuntimeService', () => { it('honors split setup placement for CLI-created worktrees without startup agents', async () => { const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), setupScriptLaunchMode: 'split-vertical' as const diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index f40f25da7e48..0b804598cb4a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -1,3 +1,4 @@ +import { getDefaultWorkspaceSession } from '../../../shared/constants' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { OrcaRuntimeService, @@ -14,12 +15,17 @@ import { shouldRunSetupForCreate } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' -import { TEST_REPO_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + TEST_REPO_ID, + makeWorktreeMeta, + store, + makeRuntimeStoreWithWorkspaceSession +} from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { it('does not surface the new workspace when a background create splits its setup pane', async () => { const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), setupScriptLaunchMode: 'split-horizontal' as const @@ -102,7 +108,7 @@ describe('OrcaRuntimeService', () => { it('still surfaces the new workspace when an activating create splits its setup pane', async () => { const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), setupScriptLaunchMode: 'split-horizontal' as const diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index c883db499c59..63ba48a6611a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -1,3 +1,4 @@ +import { getDefaultWorkspaceSession } from '../../../shared/constants' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -14,7 +15,12 @@ import { shouldRunSetupForCreate } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' -import { TEST_REPO_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + TEST_REPO_ID, + makeWorktreeMeta, + store, + makeRuntimeStoreWithWorkspaceSession +} from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { it('sends follow-up prompts for CLI-created stdin-after-start startup agents', async () => { @@ -233,7 +239,7 @@ describe('OrcaRuntimeService', () => { it('honors split setup placement for opted-in local startup-draft worktrees', async () => { const metaById: Record = {} const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), defaultTuiAgent: 'codex' as const, diff --git a/src/main/runtime/runtime-durable-store-fixture.ts b/src/main/runtime/runtime-durable-store-fixture.ts index a5f2dc82793a..cbc7f4e3d1f7 100644 --- a/src/main/runtime/runtime-durable-store-fixture.ts +++ b/src/main/runtime/runtime-durable-store-fixture.ts @@ -1,5 +1,14 @@ import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors' +import { resolveHostId } from '../persistence/loading-store/session-host-partitions' +import { + admitPaneToSession, + withdrawPaneFromSession, + type PaneLayoutChange, + type TerminalPaneAdmission +} from '../persistence/terminal-topology/terminal-pane-admission' +import type { CommandOf } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' /** Keep runtime fakes on the same reserved, durable-before-ack contract as Store. */ export function withDurableRuntimeStore< @@ -38,3 +47,29 @@ export function withDurableRuntimeStore< } }) } + +/** Gives a runtime fake the Store's pane admission, over the fake's own session accessors. */ +export function withRuntimePaneAdmission< + T extends { + getWorkspaceSession?: (hostId?: string) => WorkspaceSessionState + setWorkspaceSession?: (session: WorkspaceSessionState, hostId?: string) => void + } +>(store: T) { + const commit = ( + hostId: string | undefined, + change: (session: WorkspaceSessionState) => PaneLayoutChange + ): V => { + const resolved = resolveHostId(hostId) + const { value, session } = change(store.getWorkspaceSession!(resolved)) + if (session) { + store.setWorkspaceSession!(session, resolved) + } + return value + } + return Object.assign(store, { + admitTerminalPane: async (admission: TerminalPaneAdmission, hostId?: string) => + commit(hostId, (session) => admitPaneToSession(resolveHostId(hostId), session, admission)), + withdrawTerminalPane: async (pane: CommandOf<'closePane'>, hostId?: string) => + commit(hostId, (session) => withdrawPaneFromSession(resolveHostId(hostId), session, pane)) + }) +} diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts new file mode 100644 index 000000000000..16ca7e224059 --- /dev/null +++ b/src/main/runtime/runtime-pane-admission.ts @@ -0,0 +1,87 @@ +import { toSshExecutionHostId } from '../../shared/execution-host' +import type { TerminalPaneAdmission } from '../persistence/terminal-topology/terminal-pane-admission' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import type { RuntimeStore } from './runtime-store-contract' + +type Spawn = NonNullable +type AdmittedSpawnArgs = Parameters[0] & { + worktreeId: string + tabId: string + leafId: string + /** The group a new tab joins; a split stays in its parent's. */ + groupId?: string +} + +/** The pane its placement names: a new tab, or a split beside its parent pane. */ +function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission { + const { worktreeId: workspace, tabId, leafId, placement } = args + if (placement?.kind === 'split') { + const { parentLeafId, direction } = placement + return { + type: 'splitPane', + workspace, + tabId, + leafId: parentLeafId, + direction, + newLeafId: leafId + } + } + const viewMode = placement?.kind === 'new-tab' ? placement.row?.viewMode : undefined + return { + type: 'createTerminalTab', + workspace, + tabId, + leafId, + ...(args.groupId ? { groupId: args.groupId } : {}), + ...(viewMode ? { viewMode } : {}), + ...(args.cwd ? { creation: { startupCwd: args.cwd } } : {}) + } +} + +function hostIdOf(args: AdmittedSpawnArgs): string | undefined { + return args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined +} + +/** Takes back a pane the runtime wrote for a start that then failed, so it leaves no tab. */ +export async function withdrawRuntimePane( + store: RuntimeStore | null | undefined, + args: AdmittedSpawnArgs +): Promise { + await store?.withdrawTerminalPane?.( + { type: 'closePane', workspace: args.worktreeId, tabId: args.tabId, leafId: args.leafId }, + hostIdOf(args) + ) +} + +/** + * Starts a runtime-originated terminal (design 4.1): its pane, tab-bar entry and group are written + * first, then the process starts and binds to that pane. A start that fails, or that attaches to a + * session already running in another pane, takes back the pane it wrote. + */ +export async function spawnInAdmittedPane( + store: RuntimeStore | null | undefined, + controller: Required>, + args: AdmittedSpawnArgs +): ReturnType { + const outcome = await store?.admitTerminalPane?.(admissionFor(args), hostIdOf(args)) + if (outcome !== undefined && outcome !== 'admitted' && outcome !== 'exists') { + // Only a split is refused: its parent pane left the layout. + throw new Error('terminal_split_source_not_found') + } + const { groupId: _groupId, ...spawnArgs } = args + let result: Awaited> + try { + result = await controller.spawn(spawnArgs) + } catch (error) { + if (outcome === 'admitted') { + await withdrawRuntimePane(store, args) + } + throw error + } + const started = result.agentSessionEnsure?.owner.surface ?? result.stablePaneOwner + const elsewhere = started && (started.tabId !== args.tabId || started.leafId !== args.leafId) + if (outcome === 'admitted' && elsewhere) { + await withdrawRuntimePane(store, args) + } + return result +} diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 27ea24c367ca..acf457c50138 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -58,6 +58,8 @@ export type RuntimeStore = { abortStagedOrcadMigrationCatalog?: Store['abortStagedOrcadMigrationCatalog'] getOrcadMigrationCatalogState?: Store['getOrcadMigrationCatalogState'] persistPtyBinding?: Store['persistPtyBinding'] + admitTerminalPane?: Store['admitTerminalPane'] + withdrawTerminalPane?: Store['withdrawTerminalPane'] getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index c243d21fe03a..3bee3fc6ed0f 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -67,6 +67,8 @@ export type TerminalCreateOptions = { terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' + /** The tab group a background create joins; defaults to the first group. */ + targetGroupId?: string startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] /** The surface that asked for this `startupAgent` launch; the runtime attributes every one it diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index e2a4cd1df5d0..f95c28a4e122 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -47,8 +47,7 @@ export function buildRuntimeTerminalOrphanSession(args: { customTitle: null, color: null, sortOrder: tabsById.size, - createdAt: Date.now(), - pendingActivationSpawn: true + createdAt: Date.now() } tabsById.set(claim.tabId, tab) } diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts index ae7503c712bf..3141cdf813b9 100644 --- a/src/main/runtime/runtime-terminal-spawn-placement.ts +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -2,8 +2,20 @@ import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types' /** A main-side create always opens its own tab. */ -export function runtimeNewTabPlacement(): TerminalPanePlacement { - return { kind: 'new-tab' } +export function runtimeNewTabPlacement(viewMode?: 'terminal' | 'chat'): TerminalPanePlacement { + return viewMode ? { kind: 'new-tab', row: { viewMode } } : { kind: 'new-tab' } +} + +/** A runtime-started tab opens in the group asked for, else the first group (as the live view). */ +export function newTabIn(opts: { viewMode?: 'terminal' | 'chat'; targetGroupId?: string }): { + placement: TerminalPanePlacement + groupId?: string +} { + const { targetGroupId } = opts + return { + placement: runtimeNewTabPlacement(opts.viewMode), + ...(targetGroupId ? { groupId: targetGroupId } : {}) + } } export function runtimeSplitPlacement( diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index c9899da303b9..83874caaf40a 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -17,6 +17,9 @@ type On = { workspace: string } export type LayoutCommand = | (On & { type: 'createTerminalTab' + /** Ids the runtime minted before applying, e.g. a retried create's. */ + tabId?: string + leafId?: string groupId?: string afterTabId?: string index?: number @@ -31,6 +34,8 @@ export type LayoutCommand = leafId: string direction: TerminalPaneSplitDirection ratio?: number + /** The new pane's id, when the runtime minted it before applying. */ + newLeafId?: string }) | (On & { type: 'closePane'; tabId: string; leafId: string }) | (On & { diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index ece0eab9ef76..c29e058d0a6b 100644 --- a/src/shared/workspace-layout/workspace-layout-disk-fields.ts +++ b/src/shared/workspace-layout/workspace-layout-disk-fields.ts @@ -123,8 +123,8 @@ const ROW = { startupCwd: own('tab.terminal', 'startupCwd'), launchAgent: own('tab.terminal', 'launchAgent'), agentLaunchPane: own('tab.terminal', 'agentLaunchPane'), - // Transient handoffs, never restored; the window strips them, but main's minimal row mint - // stores `pendingActivationSpawn`, so the Loader drops it. + // Transient handoffs, never restored; the window strips them, but older builds' minimal row mint + // stored `pendingActivationSpawn`, so the Loader drops it. pendingActivationSpawn: 'unwritten', recovery: 'unwritten', restoredFromSession: 'unwritten' diff --git a/src/shared/workspace-layout/workspace-layout-pane-commands.ts b/src/shared/workspace-layout/workspace-layout-pane-commands.ts index 1c57d32752d9..1f7002cca713 100644 --- a/src/shared/workspace-layout/workspace-layout-pane-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-pane-commands.ts @@ -46,7 +46,7 @@ export function splitPane( if (!layoutContainsLeafId(tab.panes.root, command.leafId)) { return refuse('pane_not_found') } - const leafId = context.mintLeafId() + const leafId = command.newLeafId ?? context.mintLeafId() const side = command.direction === 'vertical' ? 'right' : 'bottom' const root = insertLeafBeside(tab.panes.root!, command.leafId, leafId, side, command.ratio) const next = updateTab(model, command.workspace, { ...tab, panes: { ...tab.panes, root } }) diff --git a/src/shared/workspace-layout/workspace-layout-round-trip-check.ts b/src/shared/workspace-layout/workspace-layout-round-trip-check.ts index 2f3882b0856c..b18e595022ea 100644 --- a/src/shared/workspace-layout/workspace-layout-round-trip-check.ts +++ b/src/shared/workspace-layout/workspace-layout-round-trip-check.ts @@ -42,7 +42,7 @@ export const KNOWN_LOAD_CHANGES: ReadonlySet = new Set([ 'group.recentTabIds', 'row.title', 'row.ptyId', - // Transient handoffs main's minimal row mint stores; never restored. + // Transient handoffs older builds' minimal row mint stored; never restored. 'row.pendingActivationSpawn', 'row.recovery', 'row.customTitle', diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index 2d96a35a1d1e..d0493b01cba7 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -40,8 +40,8 @@ export function createTerminalTab( const ordinal = getNextTerminalOrdinal( terminals.map((tab) => ({ defaultTitle: tab.terminal.defaultTitle, title: '' })) ) - const id = context.mintId() - const leafId = context.mintLeafId() + const id = command.tabId ?? context.mintId() + const leafId = command.leafId ?? context.mintLeafId() const tab: LayoutTerminalTab = { id, entityId: id, diff --git a/tests/e2e/workspace-layout-oracle-headless.spec.ts b/tests/e2e/workspace-layout-oracle-headless.spec.ts index c97d1af77a68..9d54de0a1c27 100644 --- a/tests/e2e/workspace-layout-oracle-headless.spec.ts +++ b/tests/e2e/workspace-layout-oracle-headless.spec.ts @@ -97,10 +97,10 @@ async function handleForTab(run: Run, tabId: string): Promise { return handle! } -async function createTab(run: Run, client: RuntimeClient): Promise { +async function createTab(run: Run, client: RuntimeClient, targetGroupId?: string): Promise { const created = await client.call<{ tab: { parentTabId: string } }>( 'session.tabs.createTerminal', - { worktree: run.worktree } + { worktree: run.worktree, ...(targetGroupId ? { targetGroupId } : {}) } ) return created.result.tab.parentTabId } @@ -304,6 +304,42 @@ const SCENARIOS: Scenario[] = [ await run.restart('warm restart', [1, 1]) } }, + { + // #27083 review: a phone create joins the group it targets, and an untargeted CLI create the + // first group, across a cold restart. + id: 'create-in-second-group-cold-restart', + journey: async (run) => { + const first = await createTab(run, run.a) + const second = await createTab(run, run.a) + await run.check('two tabs', [1, 1]) + const [source] = await groupsOf(run) + const [, target] = await moveTab( + run, + 'split second tab right', + { kind: 'split', tabId: second, targetGroupId: source!.id, splitDirection: 'right' }, + [[first], [second]] + ) + const phone = await createTab(run, run.a, target!.id) + await run.check('phone create in second group', [1, 1, 1]) + const membership = async () => + JSON.stringify((await groupsOf(run)).map((group) => group.tabOrder.toSorted())) + const joined = await membership() + const phoneExpected = JSON.stringify([[first], [second, phone].toSorted()]) + run.expectNone( + 'phone create joins the second group', + joined === phoneExpected ? [] : [`groups ${joined}, expected ${phoneExpected}`] + ) + const cli = (await createTerminal(run)).tabId! + await run.check('CLI create in first group', [1, 1, 1, 1]) + await run.restart('cold restart', [1, 1, 1, 1], true) + const after = await membership() + const expected = JSON.stringify([[first, cli].toSorted(), [second, phone].toSorted()]) + run.expectNone( + 'created tabs stay in their groups after a cold restart', + after === expected ? [] : [`groups ${after}, expected ${expected}`] + ) + } + }, { // Moves between groups and a reorder; a group emptied by a move closes. id: 'move-tab-between-groups', diff --git a/tests/e2e/workspace-layout-oracle-known-on-main.ts b/tests/e2e/workspace-layout-oracle-known-on-main.ts index 212a2399a55c..7bfd83a2251e 100644 --- a/tests/e2e/workspace-layout-oracle-known-on-main.ts +++ b/tests/e2e/workspace-layout-oracle-known-on-main.ts @@ -84,20 +84,6 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [ step: 'relaunch', cause: 'tab bar not saved after wake' }, - // Headless runtimes save tab rows and no tab bar; a close then saves a tab bar that lacks the - // tabs still open. - { - scenario: 'headless-*', - check: 'rules', - rule: 'tab_bar_missing', - cause: 'headless saves no tab bar' - }, - { - scenario: 'headless-*', - check: 'rules', - rule: 'tab_lists_disagree', - cause: 'headless close saves a partial tab bar' - }, // After a relaunch main holds the tab rows but no tab bar for the worktree until the window // saves again; slow enough on Linux CI to outlast the settle wait. ...(['tab_lists_disagree', 'tab_bar_missing'] as const).flatMap((rule) => @@ -132,16 +118,15 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [ step: 'rename after restart', cause: 'orcad title after restart' }, - // Headless group moves save the groups but still no tab bar, so the groups list tabs the tab bar - // lacks. - ...(['orcad', 'electron'] as const).flatMap((kind) => - ['split-tab-into-new-group', 'move-tab-between-groups'].map((id) => ({ - scenario: `headless-${kind}-${id}`, - check: 'rules' as const, - rule: 'group_lists_missing_tab', - cause: 'headless saves groups but no tab bar' - })) - ), + // A headless reorder saves rows through the session merge door, which keeps the stored row order, + // so rows and the group disagree; hidden until creates saved a tab bar. Fixed in PR 6 with + // client-reorder-tabs. + ...(['orcad', 'electron'] as const).map((kind) => ({ + scenario: `headless-${kind}-move-tab-between-groups`, + check: 'rules' as const, + rule: 'tab_order_disagrees', + cause: 'one tab order' + })), // After a headless move into an existing group, paired clients still see that group's previous // tab selected, not the moved one. A split into a new group selects the moved tab. ...(['orcad', 'electron'] as const).map((kind) => ({ From e6af89741f3afbc5118ac69284df6b6d17a88db2 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 18:25:54 -0700 Subject: [PATCH 069/110] feat(ssh): a paired client can manage the server's own SSH hosts (ssh.target-management.v1) (#27329) * feat(ssh): manage a paired server's own SSH targets over RPC (ssh.target-management.v1) * fix(ssh): keep a browsed remote path untrimmed --------- Co-authored-by: m4air --- .../ipc/repos/repos-changed-notification.ts | 4 +- src/main/ipc/ssh-browse.ts | 274 +--------------- ...ssh-target-crud-handlers-ownership.test.ts | 12 +- src/main/ipc/ssh-target-crud-handlers.ts | 97 +++--- src/main/ipc/ssh.ts | 2 + src/main/runtime/rpc/methods/repo.test.ts | 44 +++ src/main/runtime/rpc/methods/repo.ts | 11 + .../rpc/methods/ssh-target-management.test.ts | 304 ++++++++++++++++++ src/main/runtime/rpc/methods/ssh.ts | 127 +++++++- ...time-project-host-setup-controller.test.ts | 14 + .../runtime-project-host-setup-controller.ts | 13 + .../runtime-repository-command-surface.ts | 2 + src/main/ssh/ssh-remote-directory-browse.ts | 276 ++++++++++++++++ src/main/ssh/ssh-target-registry.ts | 28 +- .../runtime-ssh-target-management.test.ts | 86 +++++ .../runtime/runtime-ssh-target-management.ts | 107 ++++++ src/shared/protocol-version.ts | 16 +- .../rpc-params-catalog.generated.ts | 8 +- src/shared/rpc-contract/ssh-params.ts | 57 ++++ src/shared/ssh-target-management.ts | 46 +++ 20 files changed, 1183 insertions(+), 345 deletions(-) create mode 100644 src/main/runtime/rpc/methods/ssh-target-management.test.ts create mode 100644 src/main/ssh/ssh-remote-directory-browse.ts create mode 100644 src/renderer/src/runtime/runtime-ssh-target-management.test.ts create mode 100644 src/renderer/src/runtime/runtime-ssh-target-management.ts create mode 100644 src/shared/ssh-target-management.ts diff --git a/src/main/ipc/repos/repos-changed-notification.ts b/src/main/ipc/repos/repos-changed-notification.ts index 3428fc87eb4a..43aa0ae35a2a 100644 --- a/src/main/ipc/repos/repos-changed-notification.ts +++ b/src/main/ipc/repos/repos-changed-notification.ts @@ -12,9 +12,9 @@ export function setRepoRemoteClientNotifier(notifier: RepoRemoteClientNotifier): repoRemoteClientNotifier = notifier } -export function notifyReposChanged(mainWindow: BrowserWindow): void { +export function notifyReposChanged(mainWindow: BrowserWindow | null): void { wakeFolderRepoGitUpgradeWatch() - if (!mainWindow.isDestroyed()) { + if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('repos:changed') } // Why: paired clients only refetch a remote catalog on this event; without it a diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index e626968ea062..d0a2d8ccf107 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -1,39 +1,7 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection-manager' -import type { SshExecOptions } from '../ssh/ssh-connection-utils' -import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' -import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' -import { sortDirEntries } from '../../shared/file-name-sort' -import { buildPosixStdoutFence } from '../../shared/posix-stdout-fence' +import { browseSshDirectory, type RemoteBrowseResult } from '../ssh/ssh-remote-directory-browse' -export type RemoteDirEntry = { - name: string - isDirectory: boolean -} - -type RemoteBrowseResult = { - entries: RemoteDirEntry[] - resolvedPath: string - pathFlavor: FilesystemPathFlavor -} - -const SSH_BROWSE_TIMEOUT_MS = 15_000 - -// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one. -const POSIX_COMMAND_NOT_FOUND_EXIT = 127 - -// Carries the raw exit code so the fallback can distinguish 127 (no powershell.exe → not Windows) from a genuine PowerShell error. -class RemoteBrowseError extends Error { - constructor( - message: string, - readonly exitCode: number | null - ) { - super(message) - this.name = 'RemoteBrowseError' - } -} - -// Why: relay fs.readDir needs workspace-root ACLs that don't exist until a repo is added, so browse over raw SSH exec. export function registerSshBrowseHandler( getConnectionManager: () => SshConnectionManager | null ): void { @@ -41,243 +9,7 @@ export function registerSshBrowseHandler( ipcMain.handle( 'ssh:browseDir', - async (_event, args: { targetId: string; dirPath: string }): Promise => { - const mgr = getConnectionManager() - if (!mgr) { - throw new Error('SSH connection manager not initialized') - } - const conn = mgr.getConnection(args.targetId) - if (!conn) { - throw new Error(`SSH connection "${args.targetId}" not found`) - } - - try { - return await browseWithPosixShell(conn, args.dirPath) - } catch (posixError) { - // Why: only a RemoteBrowseError (ran, non-zero exit) signals a Windows shell; don't retry transport errors/timeouts as Windows. - if (!(posixError instanceof RemoteBrowseError)) { - throw posixError - } - try { - return await browseWithWindowsPowerShell(conn, args.dirPath) - } catch (fallbackError) { - // Why: exit 127 (no powershell.exe) → host isn't Windows, surface the original POSIX failure; otherwise PowerShell's own error is the real cause. - throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError - } - } - } + (_event, args: { targetId: string; dirPath: string }): Promise => + browseSshDirectory(getConnectionManager(), args.targetId, args.dirPath) ) } - -type SshBrowseConnection = NonNullable> - -function browseWithPosixShell( - conn: SshBrowseConnection, - dirPath: string -): Promise { - // Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs. - return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`, 'posix') -} - -function browseWithWindowsPowerShell( - conn: SshBrowseConnection, - dirPath: string -): Promise { - if (/^[\\/]+$/.test(dirPath.trim())) { - const script = [ - "$ErrorActionPreference = 'Stop'", - '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - "Write-Output '/'", - "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Name -match '^[A-Za-z]$' } | Sort-Object Name | ForEach-Object { Write-Output (($_.Name.ToUpperInvariant() + ':\\') + '/') }" - ].join('; ') - return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) - } - - const script = [ - "$ErrorActionPreference = 'Stop'", - // Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake. - '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - `$dir = ${powerShellPathExpression(dirPath)}`, - 'Set-Location -LiteralPath $dir', - '$resolved = (Get-Location).ProviderPath', - // Why: the renderer's parentPath/joinPath split only on `/`, so emit a forward-slash resolvedPath while keeping native $resolved for Get-ChildItem. - "Write-Output ($resolved -replace '\\\\', '/')", - 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', - " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", - '}' - ].join('; ') - - return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) -} - -async function runBrowseCommand( - conn: SshBrowseConnection, - command: string, - pathFlavor: FilesystemPathFlavor, - options?: SshExecOptions -): Promise { - // Why: login-shell rc output would otherwise be read as the resolved path. - const fence = options?.wrapCommand === false ? null : buildPosixStdoutFence(command, 'SSH') - const remoteCommand = fence?.command ?? command - const channel = options ? await conn.exec(remoteCommand, options) : await conn.exec(remoteCommand) - - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let exitCode: number | null = null - let settled = false - let timeout: ReturnType | null = null - - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - channel.off('data', onStdoutData) - channel.stderr.off('data', onStderrData) - channel.off('exit', onExit) - channel.off('close', onClose) - channel.off('error', onError) - channel.stderr.off('error', onError) - } - const rejectOnce = (error: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - } - const closeChannel = (): void => { - const closable = channel as { close?: () => void; destroy?: () => void } - try { - if (typeof closable.close === 'function') { - closable.close() - } else if (typeof closable.destroy === 'function') { - closable.destroy() - } - } catch { - /* best effort */ - } - } - const onTimeout = (): void => { - // Why: no relay deadline exists during add-project browsing, so bound this raw exec channel or Add Remote Project hangs forever. - rejectOnce(new Error('Remote directory listing timed out')) - closeChannel() - } - const resolveOnce = (result: RemoteBrowseResult): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve(result) - } - - const onStdoutData = (data: Buffer): void => { - stdout += data.toString() - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString() - } - // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. - const onExit = (code: number | null): void => { - exitCode = code - } - const onError = (error: Error): void => { - rejectOnce(error) - } - const onClose = (): void => { - stdout = fence?.readStdout(stdout) ?? stdout - // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. - if (exitCode !== 0) { - const msg = - stderr.trim() || - (exitCode === null - ? 'Remote listing failed (channel closed without exit status)' - : `Remote listing failed (exit ${exitCode})`) - rejectOnce(new RemoteBrowseError(msg, exitCode)) - return - } - if (stderr.trim() && !stdout.trim()) { - rejectOnce(new Error(stderr.trim())) - return - } - - // Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names. - const lines = stdout.trim().split(/\r?\n/) - if (lines.length === 0) { - rejectOnce(new Error('Empty response from remote')) - return - } - - const resolvedPath = lines[0] - const entries: RemoteDirEntry[] = [] - - for (let i = 1; i < lines.length; i++) { - const line = lines[i] - if (!line || line === './' || line === '../') { - continue - } - if (line.endsWith('/')) { - entries.push({ name: line.slice(0, -1), isDirectory: true }) - } else { - entries.push({ name: line, isDirectory: false }) - } - } - - // Sort: directories first, then natural name order (matches the Explorer) - sortDirEntries(entries) - - resolveOnce({ entries, resolvedPath, pathFlavor }) - } - - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('exit', onExit) - channel.on('close', onClose) - // Why: SSH exec streams emit `error` on transport loss; without a scoped listener a disappearing remote can become process-fatal. - channel.on('error', onError) - channel.stderr.on('error', onError) - timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) -} - -// Why: exit 127 means powershell.exe wasn't found — the host isn't Windows, so surface the original POSIX failure instead. -function isPosixCommandNotFound(error: unknown): boolean { - return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT -} - -// Why: single-quote to block shell injection; ~ needs $HOME since single quotes suppress tilde expansion. -function shellEscape(s: string): string { - if (s === '~') { - return '"$HOME"' - } - if (s.startsWith('~/')) { - return `"$HOME"/${shellEscapeRaw(s.slice(2))}` - } - return shellEscapeRaw(s) -} - -function shellEscapeRaw(s: string): string { - return `'${s.replace(/'/g, "'\\''")}'` -} - -function powerShellPathExpression(s: string): string { - if (s === '~') { - return '$HOME' - } - if (s.startsWith('~/') || s.startsWith('~\\')) { - return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` - } - return powerShellLiteral(normalizeWindowsDrivePath(s)) -} - -// Why: renderer's POSIX path rebuild yields '/C:/…' or bare 'C:' (drive-relative), both mis-resolved by Set-Location; re-root to a proper drive path. -function normalizeWindowsDrivePath(s: string): string { - const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') - return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped -} diff --git a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts index 630615f001cb..15d2c53fd918 100644 --- a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts +++ b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => { state, addTarget: vi.fn(), updateTarget: vi.fn(), + setMutations: vi.fn(), closeTunnel: vi.fn(async () => {}), disconnect: vi.fn(async () => {}) } @@ -21,7 +22,8 @@ vi.mock('../ssh/ssh-target-registry', () => ({ addTarget: mocks.addTarget, updateTarget: mocks.updateTarget, lastRepoReadoptions: [] - }) + }), + setSshTargetMutations: mocks.setMutations })) vi.mock('./ssh-session-teardown', () => ({ removeRegisteredSshTarget: mocks.remove })) vi.mock('../ssh/orcad-managed-tunnel', () => ({ closeOrcadManagedTunnel: mocks.closeTunnel })) @@ -92,6 +94,14 @@ describe('SSH target CRUD against managed orcad targets', () => { expect(mocks.remove).not.toHaveBeenCalled() }) + it('gives paired clients the same rules: a managed host is refused there too', async () => { + const mutations = mocks.setMutations.mock.calls.at(-1)?.[0] + await expect(mutations.remove('ssh-1')).rejects.toThrow('Settings › Managed servers') + mutations.update('ssh-1', { host: 'elsewhere', generation: 9 }) + expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { host: 'elsewhere' }) + expect(mocks.remove).not.toHaveBeenCalled() + }) + it('never lets the renderer write a provisioning intent', () => { mocks.state.target = target handler('ssh:updateTarget')(null, { diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 010bc6635907..2a842b943d7e 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -2,6 +2,8 @@ import { ipcMain } from 'electron' import type { SshConfigHostListArgs, SshRepoReadoption, + SshTarget, + SshTargetAddResult, SshTargetCreateInput, SshTargetUpdateInput } from '../../shared/ssh-types' @@ -12,7 +14,7 @@ import { import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' import { closeOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel' import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' +import { getSshTargetRegistryStore, setSshTargetMutations } from '../ssh/ssh-target-registry' import { allowsDirectSshRelay, isManagedOrcadSshTarget, @@ -22,6 +24,7 @@ import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' import { fingerprintRuntimeSshTarget } from '../ssh/runtime-ssh-access' import { removeRegisteredSshTarget } from './ssh-session-teardown' +import { notifyReposChanged } from './repos/repos-changed-notification' // Why: add/import can re-adopt workspaces orphaned on a removed target id (see ssh-target-readoption); the renderer must refresh its repo list to surface them. function takeRepoReadoptions(): SshRepoReadoption[] { @@ -36,10 +39,8 @@ function takeRepoReadoptions(): SshRepoReadoption[] { )) { rotateSshProviderAuthority(targetId) } - const win = getCurrentMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('repos:changed') - } + // Why: paired clients refetch repos only on this event, and they may have made the change. + notifyReposChanged(getCurrentMainWindow()) return repoReadoptions } @@ -97,7 +98,49 @@ export function assertNotManagedServerHost(targetId: string): void { } } +function addSshTarget(input: SshTargetCreateInput): SshTargetAddResult { + const target = getSshTargetRegistryStore()!.addTarget(omitRendererSshTargetGeneration(input)) + // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. + const repoReadoptions = takeRepoReadoptions() + return { target, repoReadoptions } +} + +function updateSshTarget(id: string, updates: SshTargetUpdateInput): SshTarget | null { + assertNotRuntimeOwned(id, 'edited') + const before = getSshTargetRegistryStore()!.getTarget(id) + // The fence and generation are stripped, so a managed host keeps its server binding. + const updated = getSshTargetRegistryStore()!.updateTarget( + id, + omitRendererSshTargetGeneration(updates) + ) + const environmentId = getManagedOrcadFenceEnvironmentId(updated ?? undefined) + if (environmentId && updated) { + // Why: the tunnel and the SSH transport under it were built from the old fields, so both go + // and the next use dials the edited target. Only a host reached through its managed server: + // one an older build changed runs on the relay directly, whose session owns the transport. + const redial = + !allowsDirectSshRelay(updated) && + (!before || fingerprintRuntimeSshTarget(before) !== fingerprintRuntimeSshTarget(updated)) + void runTargetLifecycle(id, async () => { + await closeOrcadManagedTunnel(environmentId) + if (redial) { + await connectionManager?.disconnect(id) + } + }).catch(() => undefined) + } + return updated +} + +async function removeSshTarget(id: string): Promise { + assertNotRuntimeOwned(id, 'removed') + assertNotManagedServerHost(id) + await removeRegisteredSshTarget(id) +} + export function registerSshTargetCrudHandlers(): void { + // Why: paired clients manage this host's targets over RPC with the same rules as SSH settings. + setSshTargetMutations({ add: addSshTarget, update: updateSshTarget, remove: removeSshTarget }) + ipcMain.handle('ssh:listTargets', () => { return getSshTargetRegistryStore()!.listTargets() }) @@ -106,49 +149,17 @@ export function registerSshTargetCrudHandlers(): void { return getSshTargetRegistryStore()!.listRemovedTargetLabels() }) - ipcMain.handle('ssh:addTarget', (_event, args: { target: SshTargetCreateInput }) => { - const target = getSshTargetRegistryStore()!.addTarget( - omitRendererSshTargetGeneration(args.target) - ) - // Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host. - const repoReadoptions = takeRepoReadoptions() - return { target, repoReadoptions } - }) + ipcMain.handle('ssh:addTarget', (_event, args: { target: SshTargetCreateInput }) => + addSshTarget(args.target) + ) ipcMain.handle( 'ssh:updateTarget', - (_event, args: { id: string; updates: SshTargetUpdateInput }) => { - assertNotRuntimeOwned(args.id, 'edited') - const before = getSshTargetRegistryStore()!.getTarget(args.id) - // The fence and generation are stripped, so a managed host keeps its server binding. - const updated = getSshTargetRegistryStore()!.updateTarget( - args.id, - omitRendererSshTargetGeneration(args.updates) - ) - const environmentId = getManagedOrcadFenceEnvironmentId(updated ?? undefined) - if (environmentId && updated) { - // Why: the tunnel and the SSH transport under it were built from the old fields, so both go - // and the next use dials the edited target. Only a host reached through its managed server: - // one an older build changed runs on the relay directly, whose session owns the transport. - const redial = - !allowsDirectSshRelay(updated) && - (!before || fingerprintRuntimeSshTarget(before) !== fingerprintRuntimeSshTarget(updated)) - void runTargetLifecycle(args.id, async () => { - await closeOrcadManagedTunnel(environmentId) - if (redial) { - await connectionManager?.disconnect(args.id) - } - }).catch(() => undefined) - } - return updated - } + (_event, args: { id: string; updates: SshTargetUpdateInput }) => + updateSshTarget(args.id, args.updates) ) - ipcMain.handle('ssh:removeTarget', async (_event, args: { id: string }) => { - assertNotRuntimeOwned(args.id, 'removed') - assertNotManagedServerHost(args.id) - await removeRegisteredSshTarget(args.id) - }) + ipcMain.handle('ssh:removeTarget', (_event, args: { id: string }) => removeSshTarget(args.id)) ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { const targets = getSshTargetRegistryStore()!.importFromSshConfig(args) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index cb993f03e2ad..816423e1aa6d 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -17,6 +17,7 @@ import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { getSshTargetRegistryStore, setSshConnectionManagerResolver, + setSshTargetMutations, setSshTargetRegistryHandlers, setSshTargetRegistryStore } from '../ssh/ssh-target-registry' @@ -270,6 +271,7 @@ export async function resetSshHandlerStateForTests(): Promise { setSshTargetRegistryStore(null) setPersistedStore(null) setSshTargetRegistryHandlers({ connect: null, getState: null }) + setSshTargetMutations(null) setCurrentGetMainWindow(() => null) setCurrentRuntime(undefined) } diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 687b7ba50306..3c629c58aaa0 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -12,6 +12,7 @@ import { } from '../../../../shared/protocol-version' import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../../../shared/repo-search-limits' +import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from '../../../../shared/ssh-target-management' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -160,6 +161,49 @@ describe('repo RPC methods', () => { ) }) + it('advertises server SSH target management so clients can gate the remote project flow', () => { + expect(RUNTIME_CAPABILITIES).toContain(SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY) + }) + + // Harvested from the community PR #8492 by @jae-heo. + it('adds an existing folder on one of the server SSH targets as a project', async () => { + const repo = { + id: 'repo-p8', + path: '/srv/project', + displayName: 'project', + badgeColor: '#737373', + addedAt: 1, + connectionId: 'ssh-p8', + kind: 'git' as const + } + const runtime = new OrcaRuntimeService(null) + const addSshRepo = vi.spyOn(runtime, 'addSshRepo').mockResolvedValue(repo) + const addRepo = vi.spyOn(runtime, 'addRepo') + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.addRemote', { connectionId: 'ssh-p8', remotePath: '/srv/project' }), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + + expect(addSshRepo).toHaveBeenCalledWith({ connectionId: 'ssh-p8', remotePath: '/srv/project' }) + expect(addRepo).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: true, result: { repo: { connectionId: 'ssh-p8' } } }) + }) + + it('refuses a remote project without an SSH target rather than adding a server path', async () => { + const runtime = new OrcaRuntimeService(null) + const addSshRepo = vi.spyOn(runtime, 'addSshRepo') + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.addRemote', { remotePath: '/srv/project' }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(addSshRepo).not.toHaveBeenCalled() + }) + it('updates project runtime preferences on the runtime server', async () => { const project = { id: 'project-1', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 42563096896e..ffb9e0c6d742 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -27,6 +27,7 @@ import { RepoSparsePresetSave, RepoUpdate } from '../../../../shared/rpc-contract/repo-params' +import { RepoAddRemote } from '../../../../shared/rpc-contract/ssh-params' export const REPO_METHODS = [ defineMethod({ @@ -126,6 +127,16 @@ export const REPO_METHODS = [ ) }) }), + // Why a new method, not a field on repo.add: an old host would strip the field and register the + // path on itself instead of on its SSH target. Gated by ssh.target-management.v1. + defineMethod({ + name: 'repo.addRemote', + permission: 'workspace', + params: RepoAddRemote, + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient(await context.runtime.addSshRepo(params), context) + }) + }), defineMethod({ name: 'repo.create', permission: 'workspace', diff --git a/src/main/runtime/rpc/methods/ssh-target-management.test.ts b/src/main/runtime/rpc/methods/ssh-target-management.test.ts new file mode 100644 index 000000000000..ddba0166d402 --- /dev/null +++ b/src/main/runtime/rpc/methods/ssh-target-management.test.ts @@ -0,0 +1,304 @@ +import '../unused-default-rpc-methods.test-fixture' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { RpcCallerScope } from '../rpc-caller-scope' +import { OrcaRuntimeService } from '../../orca-runtime' +import { SSH_EDITABLE_TARGET_FIELDS } from '../../../../shared/ssh-target-management' +import { SSH_METHODS } from './ssh' + +const mocks = vi.hoisted(() => ({ + getRegisteredSshState: vi.fn(), + listRegisteredSshTargets: vi.fn(), + mutations: { add: vi.fn(), update: vi.fn(), remove: vi.fn() }, + getSshConnectionManager: vi.fn(), + browseSshDirectory: vi.fn() +})) + +vi.mock('../../../ssh/ssh-target-registry', () => ({ + connectRegisteredSshTarget: vi.fn(), + getRegisteredSshState: mocks.getRegisteredSshState, + getRegisteredSshTargetMutations: () => mocks.mutations, + getSshConnectionManager: mocks.getSshConnectionManager, + listRegisteredRemovedSshTargetLabels: vi.fn(() => ({})), + listRegisteredSshTargets: mocks.listRegisteredSshTargets +})) + +vi.mock('../../../ssh/ssh-remote-directory-browse', () => ({ + browseSshDirectory: mocks.browseSshDirectory +})) + +const SECRET_PASSWORD = 'hunter2-password' +const SECRET_PASSPHRASE = 'key-passphrase-value' + +// A stored row carrying keys no paired client may see: secrets (as a malformed or future profile +// might hold) and main-owned fields. +const storedTarget = { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.internal', + port: 2222, + username: 'me', + identityFile: '~/.ssh/id_ed25519', + jumpHost: 'bastion', + source: 'manual', + generation: 4, + password: SECRET_PASSWORD, + passphrase: SECRET_PASSPHRASE, + lastRequiredPassphrase: true, + remoteRuntimeResolution: { rung: 'A' }, + owner: undefined +} + +const validInput = { label: 'Build', host: 'build.internal', port: 22, username: 'ci' } + +const runtime = new OrcaRuntimeService() + +function dispatch(method: string, params?: unknown, callerScope?: RpcCallerScope) { + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS, callerScope }) + const request: RpcRequest = { id: 'req-1', authToken: 'tok', method, params } + return dispatcher.dispatch(request) +} + +function expectNoSecrets(value: unknown): void { + const serialized = JSON.stringify(value) + expect(serialized).not.toContain(SECRET_PASSWORD) + expect(serialized).not.toContain(SECRET_PASSPHRASE) + expect(serialized).not.toContain('lastRequiredPassphrase') + expect(serialized).not.toContain('remoteRuntimeResolution') +} + +function listedTargetKeys(response: unknown): string[] { + const parsed: unknown = JSON.parse(JSON.stringify(response)) + if (typeof parsed !== 'object' || parsed === null || !('result' in parsed)) { + return [] + } + const { result } = parsed + if (typeof result !== 'object' || result === null || !('targets' in result)) { + return [] + } + const [first] = Array.isArray(result.targets) ? result.targets : [] + return typeof first === 'object' && first !== null ? Object.keys(first) : [] +} + +const ALLOWED_VIEW_KEYS = new Set([ + ...SSH_EDITABLE_TARGET_FIELDS, + 'id', + 'generation', + 'connected', + 'connectionStatus', + 'remotePlatform', + 'source', + 'managedServer' +]) + +describe('server SSH target management RPC', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getRegisteredSshState.mockReturnValue({ status: 'connected' }) + }) + + it('lists editable fields only; secrets and main-owned fields never leave the server', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([storedTarget]) + + const response = await dispatch('ssh.listEditableTargets') + + expect(response).toMatchObject({ + ok: true, + result: { + targets: [ + { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.internal', + port: 2222, + username: 'me', + identityFile: '~/.ssh/id_ed25519', + jumpHost: 'bastion', + generation: 4, + connected: true, + connectionStatus: 'connected', + source: 'manual' + } + ] + } + }) + expectNoSecrets(response) + expect(listedTargetKeys(response).filter((key) => !ALLOWED_VIEW_KEYS.has(key))).toEqual([]) + }) + + it('marks a managed-server host so the client does not offer removal', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([ + { ...validInput, id: 'ssh-2', orcadFence: { environmentId: 'env-1' } } + ]) + + const response = await dispatch('ssh.listEditableTargets') + + expect(response).toMatchObject({ ok: true, result: { targets: [{ managedServer: true }] } }) + expect(JSON.stringify(response)).not.toContain('orcadFence') + }) + + it.each([ + ['password', SECRET_PASSWORD], + ['passphrase', SECRET_PASSPHRASE] + ])('refuses a %s on add or update instead of dropping it silently', async (key, value) => { + const add = await dispatch('ssh.addTarget', { target: { ...validInput, [key]: value } }) + const update = await dispatch('ssh.updateTarget', { + targetId: 'ssh-1', + target: { ...validInput, [key]: value } + }) + + expect(add).toMatchObject({ ok: false }) + expect(update).toMatchObject({ ok: false }) + expect(mocks.mutations.add).not.toHaveBeenCalled() + expect(mocks.mutations.update).not.toHaveBeenCalled() + expectNoSecrets([add, update]) + }) + + it('refuses main-owned fields a client must not forge', async () => { + const response = await dispatch('ssh.addTarget', { + target: { ...validInput, generation: 99, orcadFence: { environmentId: 'env-1' } } + }) + + expect(response).toMatchObject({ ok: false }) + expect(mocks.mutations.add).not.toHaveBeenCalled() + }) + + it('adds through the shared mutation and returns the redacted view', async () => { + mocks.mutations.add.mockReturnValue({ + target: { ...storedTarget, ...validInput, id: 'ssh-new' }, + repoReadoptions: [{ oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: ['r1'] }] + }) + + const response = await dispatch('ssh.addTarget', { target: validInput }) + + expect(mocks.mutations.add).toHaveBeenCalledWith({ ...validInput, source: 'manual' }) + expect(response).toMatchObject({ + ok: true, + result: { + target: { id: 'ssh-new', host: 'build.internal' }, + repoReadoptions: [{ newTargetId: 'ssh-new', repoIds: ['r1'] }] + } + }) + expectNoSecrets(response) + }) + + it('updates with replace semantics so an omitted optional field is cleared', async () => { + mocks.mutations.update.mockReturnValue({ ...storedTarget, ...validInput }) + + const response = await dispatch('ssh.updateTarget', { targetId: 'ssh-1', target: validInput }) + + const [targetId, updates] = mocks.mutations.update.mock.calls[0] + expect(targetId).toBe('ssh-1') + expect(updates).toMatchObject({ ...validInput, source: 'manual' }) + expect(Object.keys(updates)).toEqual(expect.arrayContaining(['identityFile', 'jumpHost'])) + expect(updates.identityFile).toBeUndefined() + expect(updates.jumpHost).toBeUndefined() + expect(response).toMatchObject({ ok: true }) + expectNoSecrets(response) + }) + + it('reports an unknown target instead of answering success', async () => { + mocks.mutations.update.mockReturnValue(null) + + const response = await dispatch('ssh.updateTarget', { targetId: 'gone', target: validInput }) + + expect(response).toMatchObject({ ok: false, error: { message: 'SSH target not found: gone' } }) + }) + + it('removes through the shared mutation and surfaces its refusal', async () => { + mocks.mutations.remove.mockResolvedValueOnce(undefined) + await expect(dispatch('ssh.removeTarget', { targetId: 'ssh-1' })).resolves.toMatchObject({ + ok: true, + result: { removed: true } + }) + mocks.mutations.remove.mockRejectedValueOnce(new Error('This host runs a managed Orca server.')) + await expect(dispatch('ssh.removeTarget', { targetId: 'ssh-2' })).resolves.toMatchObject({ + ok: false + }) + }) + + it('browses a server SSH target over the server connection manager', async () => { + const manager = { getConnection: vi.fn() } + mocks.getSshConnectionManager.mockReturnValue(manager) + mocks.browseSshDirectory.mockResolvedValue({ + entries: [{ name: 'src', isDirectory: true }], + resolvedPath: '/home/me', + pathFlavor: 'posix' + }) + + const response = await dispatch('ssh.browseDir', { targetId: 'ssh-1', dirPath: '~' }) + + expect(mocks.browseSshDirectory).toHaveBeenCalledWith(manager, 'ssh-1', '~') + expect(response).toMatchObject({ ok: true, result: { resolvedPath: '/home/me' } }) + }) + + it('publishes no client event carrying target data', async () => { + mocks.listRegisteredSshTargets.mockReturnValue([storedTarget]) + mocks.mutations.add.mockReturnValue({ target: storedTarget, repoReadoptions: [] }) + mocks.mutations.update.mockReturnValue(storedTarget) + mocks.mutations.remove.mockResolvedValue(undefined) + + const events: unknown[] = [] + const unsubscribe = runtime.onClientEvent((event) => events.push(event)) + + await dispatch('ssh.listEditableTargets') + await dispatch('ssh.addTarget', { target: validInput }) + await dispatch('ssh.updateTarget', { targetId: 'ssh-1', target: validInput }) + await dispatch('ssh.removeTarget', { targetId: 'ssh-1' }) + unsubscribe() + + expectNoSecrets(events) + expect(JSON.stringify(events)).not.toContain('dev.internal') + }) + + describe('who may manage', () => { + const MANAGEMENT_CALLS: [string, unknown][] = [ + ['ssh.listEditableTargets', undefined], + ['ssh.addTarget', { target: validInput }], + ['ssh.updateTarget', { targetId: 'ssh-1', target: validInput }], + ['ssh.removeTarget', { targetId: 'ssh-1' }], + ['ssh.browseDir', { targetId: 'ssh-1', dirPath: '~' }] + ] + + beforeEach(() => { + mocks.listRegisteredSshTargets.mockReturnValue([]) + mocks.mutations.add.mockReturnValue({ target: storedTarget, repoReadoptions: [] }) + mocks.mutations.update.mockReturnValue(storedTarget) + mocks.mutations.remove.mockResolvedValue(undefined) + mocks.browseSshDirectory.mockResolvedValue({ + entries: [], + resolvedPath: '/', + pathFlavor: 'posix' + }) + }) + + it.each(MANAGEMENT_CALLS)( + 'a paired desktop or web client may call %s', + async (method, params) => { + await expect( + dispatch(method, params, { kind: 'runtime-paired', grants: [] }) + ).resolves.toMatchObject({ ok: true }) + } + ) + + it.each(MANAGEMENT_CALLS)('a phone may not call %s', async (method, params) => { + await expect(dispatch(method, params, { kind: 'mobile' })).resolves.toMatchObject({ + ok: false + }) + }) + + it.each(MANAGEMENT_CALLS)( + "an SSH host's CLI without remote control may not call %s", + async (method, params) => { + await expect( + dispatch(method, params, { + kind: 'ssh-bridge', + targetId: 'ssh-1', + remoteCliControl: false + }) + ).resolves.toMatchObject({ ok: false }) + } + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 0f2e0b606c9d..e2bce335a98b 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -1,28 +1,75 @@ import { connectRegisteredSshTarget, getRegisteredSshState, + getRegisteredSshTargetMutations, + getSshConnectionManager, listRegisteredRemovedSshTargetLabels, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' +import { isManagedOrcadSshTarget } from '../../../ssh/ssh-connection-store' +import { browseSshDirectory } from '../../../ssh/ssh-remote-directory-browse' import { defineMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' -import type { SshTargetSummary } from '../../../../shared/ssh-types' -import { SshTarget } from '../../../../shared/rpc-contract/ssh-params' +import type { SshTarget as SshTargetRecord, SshTargetSummary } from '../../../../shared/ssh-types' +import type { + SshEditableTarget, + SshEditableTargetField, + SshEditableTargetInput +} from '../../../../shared/ssh-target-management' +import { + SshBrowseDir, + SshTarget, + SshTargetAdd, + SshTargetUpdate +} from '../../../../shared/rpc-contract/ssh-params' // Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone. +function toSshTargetSummary({ id, label, generation }: SshTargetRecord): SshTargetSummary { + const state = getRegisteredSshState(id) + const remotePlatform = state?.remotePlatform + return { + id, + label, + ...(generation === undefined ? {} : { generation }), + connected: state?.status === 'connected', + ...(state?.status === undefined ? {} : { connectionStatus: state.status }), + ...(remotePlatform === undefined ? {} : { remotePlatform }) + } +} + function listRegisteredSshTargetSummaries(): SshTargetSummary[] { - return listRegisteredSshTargets().map(({ id, label, generation }) => { - const state = getRegisteredSshState(id) - const remotePlatform = state?.remotePlatform - return { - id, - label, - ...(generation === undefined ? {} : { generation }), - connected: state?.status === 'connected', - ...(state?.status === undefined ? {} : { connectionStatus: state.status }), - ...(remotePlatform === undefined ? {} : { remotePlatform }) - } - }) + return listRegisteredSshTargets().map(toSshTargetSummary) +} + +// Why an allowlist: a field added to SshTarget later (or a stray key in an old profile) must not +// reach paired clients by default. `satisfies` keeps it in step with SSH_EDITABLE_TARGET_FIELDS. +function pickSshEditableFields(source: SshEditableTargetInput): SshEditableTargetInput { + return { + label: source.label, + configHost: source.configHost, + host: source.host, + port: source.port, + username: source.username, + identityFile: source.identityFile, + identityAgent: source.identityAgent, + identitiesOnly: source.identitiesOnly, + gssapiAuthentication: source.gssapiAuthentication, + proxyCommand: source.proxyCommand, + jumpHost: source.jumpHost, + relayGracePeriodSeconds: source.relayGracePeriodSeconds, + systemSshConnectionReuse: source.systemSshConnectionReuse, + remoteRuntime: source.remoteRuntime, + allowRemoteCliControl: source.allowRemoteCliControl + } satisfies Record +} + +function toSshEditableTarget(target: SshTargetRecord): SshEditableTarget { + return { + ...pickSshEditableFields(target), + ...toSshTargetSummary(target), + ...(target.source === undefined ? {} : { source: target.source }), + ...(isManagedOrcadSshTarget(target) ? { managedServer: true as const } : {}) + } } export const SSH_METHODS = [ @@ -66,5 +113,57 @@ export const SSH_METHODS = [ permission: 'workspace', params: null, handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() }) + }), + // Why `workspace`: a paired client that can open terminals here can already edit ~/.ssh/config, + // so managing this host's SSH targets is the same power (rpc-method-permission.ts). + defineMethod({ + name: 'ssh.listEditableTargets', + permission: 'workspace', + params: null, + handler: () => ({ targets: listRegisteredSshTargets().map(toSshEditableTarget) }) + }), + defineMethod({ + name: 'ssh.addTarget', + permission: 'workspace', + params: SshTargetAdd, + handler: (params) => { + const result = getRegisteredSshTargetMutations().add({ ...params.target, source: 'manual' }) + return { + target: toSshEditableTarget(result.target), + repoReadoptions: result.repoReadoptions + } + } + }), + defineMethod({ + name: 'ssh.updateTarget', + permission: 'workspace', + params: SshTargetUpdate, + handler: (params) => { + // Why: replace semantics, so an omitted optional field is cleared, as SSH settings do. + const updated = getRegisteredSshTargetMutations().update(params.targetId, { + ...pickSshEditableFields(params.target), + source: 'manual' + }) + if (!updated) { + throw new Error(`SSH target not found: ${params.targetId}`) + } + return { target: toSshEditableTarget(updated) } + } + }), + defineMethod({ + name: 'ssh.removeTarget', + permission: 'workspace', + params: SshTarget, + handler: async (params) => { + await getRegisteredSshTargetMutations().remove(params.targetId) + return { removed: true } + } + }), + defineMethod({ + name: 'ssh.browseDir', + permission: 'workspace', + params: SshBrowseDir, + handler: (params) => + browseSshDirectory(getSshConnectionManager(), params.targetId, params.dirPath) }) ] diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index 6defe84800bb..114f00699045 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -76,6 +76,20 @@ describe('RuntimeProjectHostSetupController host routing', () => { expect(result.repo.id).toBe(remoteRepo.id) }) + it('adds a new project from a folder on an SSH target, never as a local path', async () => { + const { controller, addRepo, addRemoteRepo } = makeController() + + const repo = await controller.addSshRepo({ connectionId: TARGET_ID, remotePath: REMOTE_PATH }) + + expect(addRemoteRepo).toHaveBeenCalledWith({ + connectionId: TARGET_ID, + remotePath: REMOTE_PATH, + kind: 'git' + }) + expect(addRepo).not.toHaveBeenCalled() + expect(repo.connectionId).toBe(TARGET_ID) + }) + it('decodes a percent-encoded SSH target back to its connection id', async () => { const { controller, addRemoteRepo, projectId } = makeController() diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index c3661f06acd2..6dff6bae9848 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -121,6 +121,19 @@ export class RuntimeProjectHostSetupController { return this.completeSetup(args, repo, !knownRepoIds.has(repo.id)) } + /** Registers an existing folder on one of this host's SSH targets as a new project. */ + addSshRepo(args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'folder' | 'git' + }): Promise { + if (!this.deps.getStore()) { + throw new Error('runtime_unavailable') + } + return this.deps.addRemoteRepo({ ...args, kind: args.kind === 'folder' ? 'folder' : 'git' }) + } + async setupClone(args: ProjectHostSetupCloneArgs): Promise { assertCloneHostIsSupported(args.hostId) const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id)) diff --git a/src/main/runtime/runtime-repository-command-surface.ts b/src/main/runtime/runtime-repository-command-surface.ts index 8820e4e945bd..78d6a38abaa3 100644 --- a/src/main/runtime/runtime-repository-command-surface.ts +++ b/src/main/runtime/runtime-repository-command-surface.ts @@ -40,6 +40,7 @@ export type RuntimeRepositoryCommandSurface = { createProjectHostSetup: RuntimeProjectHostSetupController['createSetup'] setupProjectExistingFolder: RuntimeProjectHostSetupController['setupExistingFolder'] setupProjectClone: RuntimeProjectHostSetupController['setupClone'] + addSshRepo: RuntimeProjectHostSetupController['addSshRepo'] updateProjectHostSetup: RuntimeProjectHostSetupController['updateSetup'] deleteProjectHostSetup: RuntimeProjectHostSetupController['deleteSetup'] listProjectGroups: RuntimeProjectGroupController['listGroups'] @@ -115,6 +116,7 @@ export function installRuntimeRepositoryCommandSurface( createProjectHostSetup: host.createSetup.bind(host), setupProjectExistingFolder: host.setupExistingFolder.bind(host), setupProjectClone: host.setupClone.bind(host), + addSshRepo: host.addSshRepo.bind(host), updateProjectHostSetup: host.updateSetup.bind(host), deleteProjectHostSetup: host.deleteSetup.bind(host), listProjectGroups: groups.listGroups.bind(groups), diff --git a/src/main/ssh/ssh-remote-directory-browse.ts b/src/main/ssh/ssh-remote-directory-browse.ts new file mode 100644 index 000000000000..b1fd912abbbd --- /dev/null +++ b/src/main/ssh/ssh-remote-directory-browse.ts @@ -0,0 +1,276 @@ +import type { SshConnectionManager } from './ssh-connection-manager' +import type { SshExecOptions } from './ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import { sortDirEntries } from '../../shared/file-name-sort' +import { buildPosixStdoutFence } from '../../shared/posix-stdout-fence' + +export type RemoteDirEntry = { + name: string + isDirectory: boolean +} + +export type RemoteBrowseResult = { + entries: RemoteDirEntry[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor +} + +const SSH_BROWSE_TIMEOUT_MS = 15_000 + +// Why: 127 = POSIX "command not found" (locale-independent) — the Windows fallback never ran, so the original POSIX error is the real one. +const POSIX_COMMAND_NOT_FOUND_EXIT = 127 + +// Carries the raw exit code so the fallback can distinguish 127 (no powershell.exe → not Windows) from a genuine PowerShell error. +class RemoteBrowseError extends Error { + constructor( + message: string, + readonly exitCode: number | null + ) { + super(message) + this.name = 'RemoteBrowseError' + } +} + +// Why: relay fs.readDir needs workspace-root ACLs that don't exist until a repo is added, so browse over raw SSH exec. +export async function browseSshDirectory( + manager: SshConnectionManager | null, + targetId: string, + dirPath: string +): Promise { + if (!manager) { + throw new Error('SSH connection manager not initialized') + } + const conn = manager.getConnection(targetId) + if (!conn) { + throw new Error(`SSH connection "${targetId}" not found`) + } + + try { + return await browseWithPosixShell(conn, dirPath) + } catch (posixError) { + // Why: only a RemoteBrowseError (ran, non-zero exit) signals a Windows shell; don't retry transport errors/timeouts as Windows. + if (!(posixError instanceof RemoteBrowseError)) { + throw posixError + } + try { + return await browseWithWindowsPowerShell(conn, dirPath) + } catch (fallbackError) { + // Why: exit 127 (no powershell.exe) → host isn't Windows, surface the original POSIX failure; otherwise PowerShell's own error is the real cause. + throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError + } + } +} + +type SshBrowseConnection = NonNullable> + +function browseWithPosixShell( + conn: SshBrowseConnection, + dirPath: string +): Promise { + // Why: `command ls` skips aliases; `&&` makes a failing ls exit non-zero (not look empty); -1Ap = one-per-line + trailing / on dirs. + return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`, 'posix') +} + +function browseWithWindowsPowerShell( + conn: SshBrowseConnection, + dirPath: string +): Promise { + if (/^[\\/]+$/.test(dirPath.trim())) { + const script = [ + "$ErrorActionPreference = 'Stop'", + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + "Write-Output '/'", + "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Name -match '^[A-Za-z]$' } | Sort-Object Name | ForEach-Object { Write-Output (($_.Name.ToUpperInvariant() + ':\\') + '/') }" + ].join('; ') + return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) + } + + const script = [ + "$ErrorActionPreference = 'Stop'", + // Why: PowerShell 5.1 emits redirected stdout in the OEM code page; pin UTF-8 so non-ASCII names aren't mojibake. + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + `$dir = ${powerShellPathExpression(dirPath)}`, + 'Set-Location -LiteralPath $dir', + '$resolved = (Get-Location).ProviderPath', + // Why: the renderer's parentPath/joinPath split only on `/`, so emit a forward-slash resolvedPath while keeping native $resolved for Get-ChildItem. + "Write-Output ($resolved -replace '\\\\', '/')", + 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', + " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", + '}' + ].join('; ') + + return runBrowseCommand(conn, powerShellCommand(script), 'win32', { wrapCommand: false }) +} + +async function runBrowseCommand( + conn: SshBrowseConnection, + command: string, + pathFlavor: FilesystemPathFlavor, + options?: SshExecOptions +): Promise { + // Why: login-shell rc output would otherwise be read as the resolved path. + const fence = options?.wrapCommand === false ? null : buildPosixStdoutFence(command, 'SSH') + const remoteCommand = fence?.command ?? command + const channel = options ? await conn.exec(remoteCommand, options) : await conn.exec(remoteCommand) + + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let exitCode: number | null = null + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('exit', onExit) + channel.off('close', onClose) + channel.off('error', onError) + channel.stderr.off('error', onError) + } + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const closeChannel = (): void => { + const closable = channel as { close?: () => void; destroy?: () => void } + try { + if (typeof closable.close === 'function') { + closable.close() + } else if (typeof closable.destroy === 'function') { + closable.destroy() + } + } catch { + /* best effort */ + } + } + const onTimeout = (): void => { + // Why: no relay deadline exists during add-project browsing, so bound this raw exec channel or Add Remote Project hangs forever. + rejectOnce(new Error('Remote directory listing timed out')) + closeChannel() + } + const resolveOnce = (result: RemoteBrowseResult): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + + const onStdoutData = (data: Buffer): void => { + stdout += data.toString() + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString() + } + // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. + const onExit = (code: number | null): void => { + exitCode = code + } + const onError = (error: Error): void => { + rejectOnce(error) + } + const onClose = (): void => { + stdout = fence?.readStdout(stdout) ?? stdout + // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. + if (exitCode !== 0) { + const msg = + stderr.trim() || + (exitCode === null + ? 'Remote listing failed (channel closed without exit status)' + : `Remote listing failed (exit ${exitCode})`) + rejectOnce(new RemoteBrowseError(msg, exitCode)) + return + } + if (stderr.trim() && !stdout.trim()) { + rejectOnce(new Error(stderr.trim())) + return + } + + // Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names. + const lines = stdout.trim().split(/\r?\n/) + if (lines.length === 0) { + rejectOnce(new Error('Empty response from remote')) + return + } + + const resolvedPath = lines[0] + const entries: RemoteDirEntry[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line || line === './' || line === '../') { + continue + } + if (line.endsWith('/')) { + entries.push({ name: line.slice(0, -1), isDirectory: true }) + } else { + entries.push({ name: line, isDirectory: false }) + } + } + + // Sort: directories first, then natural name order (matches the Explorer) + sortDirEntries(entries) + + resolveOnce({ entries, resolvedPath, pathFlavor }) + } + + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('exit', onExit) + channel.on('close', onClose) + // Why: SSH exec streams emit `error` on transport loss; without a scoped listener a disappearing remote can become process-fatal. + channel.on('error', onError) + channel.stderr.on('error', onError) + timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) +} + +// Why: exit 127 means powershell.exe wasn't found — the host isn't Windows, so surface the original POSIX failure instead. +function isPosixCommandNotFound(error: unknown): boolean { + return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT +} + +// Why: single-quote to block shell injection; ~ needs $HOME since single quotes suppress tilde expansion. +function shellEscape(s: string): string { + if (s === '~') { + return '"$HOME"' + } + if (s.startsWith('~/')) { + return `"$HOME"/${shellEscapeRaw(s.slice(2))}` + } + return shellEscapeRaw(s) +} + +function shellEscapeRaw(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'` +} + +function powerShellPathExpression(s: string): string { + if (s === '~') { + return '$HOME' + } + if (s.startsWith('~/') || s.startsWith('~\\')) { + return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` + } + return powerShellLiteral(normalizeWindowsDrivePath(s)) +} + +// Why: renderer's POSIX path rebuild yields '/C:/…' or bare 'C:' (drive-relative), both mis-resolved by Set-Location; re-root to a proper drive path. +function normalizeWindowsDrivePath(s: string): string { + const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') + return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped +} diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts index aef003025ccc..90636a993104 100644 --- a/src/main/ssh/ssh-target-registry.ts +++ b/src/main/ssh/ssh-target-registry.ts @@ -1,7 +1,13 @@ import type { SshConnectionStore } from './ssh-connection-store' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { SshConnectionManager } from './ssh-connection-manager' -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' +import type { + SshConnectionState, + SshTarget, + SshTargetAddResult, + SshTargetCreateInput, + SshTargetUpdateInput +} from '../../shared/ssh-types' /** * The SSH target/state registry, split out of `ipc/ssh.ts`. @@ -61,6 +67,26 @@ export function listRegisteredRemovedSshTargetLabels(): Record { return sshStore?.listRemovedTargetLabels() ?? {} } +/** Add/update/remove with the same rules the desktop's SSH settings use. */ +export type SshTargetMutations = { + add: (target: SshTargetCreateInput) => SshTargetAddResult + update: (targetId: string, updates: SshTargetUpdateInput) => SshTarget | null + remove: (targetId: string) => Promise +} + +let registeredSshTargetMutations: SshTargetMutations | null = null + +export function setSshTargetMutations(mutations: SshTargetMutations | null): void { + registeredSshTargetMutations = mutations +} + +export function getRegisteredSshTargetMutations(): SshTargetMutations { + if (!registeredSshTargetMutations) { + throw new Error('ssh_handlers_not_registered') + } + return registeredSshTargetMutations +} + let registeredGetActiveMultiplexer: | ((connectionId: string) => SshChannelMultiplexer | undefined) | null = null diff --git a/src/renderer/src/runtime/runtime-ssh-target-management.test.ts b/src/renderer/src/runtime/runtime-ssh-target-management.test.ts new file mode 100644 index 000000000000..7fc0e2dcc359 --- /dev/null +++ b/src/renderer/src/runtime/runtime-ssh-target-management.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from '../../../shared/ssh-target-management' + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + runtimeEnvironmentSupportsCapability: vi.fn() +})) + +vi.mock('./runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + runtimeEnvironmentSupportsCapability: mocks.runtimeEnvironmentSupportsCapability +})) + +const { + addRuntimeSshRepo, + addRuntimeSshTarget, + browseRuntimeSshDirectory, + listRuntimeEditableSshTargets, + removeRuntimeSshTarget, + RuntimeSshTargetManagementUnsupportedError, + updateRuntimeSshTarget +} = await import('./runtime-ssh-target-management') + +const input = { label: 'Build', host: 'build.internal', port: 22, username: 'ci' } + +const CALLS: [string, () => Promise][] = [ + ['list', () => listRuntimeEditableSshTargets('env-1')], + ['add', () => addRuntimeSshTarget('env-1', input)], + ['update', () => updateRuntimeSshTarget('env-1', 'ssh-1', input)], + ['remove', () => removeRuntimeSshTarget('env-1', 'ssh-1')], + ['browse', () => browseRuntimeSshDirectory('env-1', 'ssh-1', '~')], + ['add project', () => addRuntimeSshRepo('env-1', { connectionId: 'ssh-1', remotePath: '~/app' })] +] + +describe('managing a paired server SSH targets across versions', () => { + const sshApi = { addTarget: vi.fn(), listTargets: vi.fn(), browseDir: vi.fn() } + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { api: { ssh: sshApi, repos: { addRemote: vi.fn() } } }) + }) + + it.each(CALLS)( + 'an old server without the capability refuses %s and nothing falls back to this client', + async (_name, call) => { + mocks.runtimeEnvironmentSupportsCapability.mockResolvedValue(false) + + await expect(call()).rejects.toBeInstanceOf(RuntimeSshTargetManagementUnsupportedError) + + expect(mocks.runtimeEnvironmentSupportsCapability).toHaveBeenCalledWith( + 'env-1', + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY + ) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(sshApi.addTarget).not.toHaveBeenCalled() + expect(sshApi.listTargets).not.toHaveBeenCalled() + expect(sshApi.browseDir).not.toHaveBeenCalled() + } + ) + + it.each(CALLS)('a current server is called for %s', async (_name, call) => { + mocks.runtimeEnvironmentSupportsCapability.mockResolvedValue(true) + mocks.callRuntimeRpc.mockResolvedValue({ targets: [], target: {}, repo: {} }) + + await call() + + expect(mocks.callRuntimeRpc.mock.calls[0]?.[0]).toEqual({ + kind: 'environment', + environmentId: 'env-1' + }) + }) + + it('sends a whole target so the server can clear omitted fields', async () => { + mocks.runtimeEnvironmentSupportsCapability.mockResolvedValue(true) + mocks.callRuntimeRpc.mockResolvedValue({ target: { id: 'ssh-1', ...input } }) + + await updateRuntimeSshTarget('env-1', 'ssh-1', input) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'ssh.updateTarget', + { targetId: 'ssh-1', target: input }, + {} + ) + }) +}) diff --git a/src/renderer/src/runtime/runtime-ssh-target-management.ts b/src/renderer/src/runtime/runtime-ssh-target-management.ts new file mode 100644 index 000000000000..39fb3df97a77 --- /dev/null +++ b/src/renderer/src/runtime/runtime-ssh-target-management.ts @@ -0,0 +1,107 @@ +import type { Repo } from '../../../shared/repo-types' +import { + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY, + type SshEditableTarget, + type SshEditableTargetAddResult, + type SshEditableTargetInput +} from '../../../shared/ssh-target-management' +import type { FilesystemPathFlavor } from '../../../shared/filesystem-entry-types' +import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' + +/** The paired server predates server SSH management; the caller shows "update the server". */ +export class RuntimeSshTargetManagementUnsupportedError extends Error { + constructor() { + super('Update this Orca server to manage its SSH hosts from here.') + this.name = 'RuntimeSshTargetManagementUnsupportedError' + } +} + +export function runtimeSupportsSshTargetManagement(environmentId: string): Promise { + return runtimeEnvironmentSupportsCapability( + environmentId, + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY + ) +} + +// Why every call checks: an old server would answer method_not_found at best; there is never a +// local fallback, because this client's own SSH hosts are a different machine's. +async function callSshTargetManagement( + environmentId: string, + method: string, + params?: unknown, + timeoutMs?: number +): Promise { + if (!(await runtimeSupportsSshTargetManagement(environmentId))) { + throw new RuntimeSshTargetManagementUnsupportedError() + } + return callRuntimeRpc( + { kind: 'environment', environmentId }, + method, + params, + timeoutMs === undefined ? {} : { timeoutMs } + ) +} + +export async function listRuntimeEditableSshTargets( + environmentId: string +): Promise { + const result = await callSshTargetManagement<{ targets: SshEditableTarget[] }>( + environmentId, + 'ssh.listEditableTargets' + ) + return result.targets +} + +export function addRuntimeSshTarget( + environmentId: string, + target: SshEditableTargetInput +): Promise { + return callSshTargetManagement(environmentId, 'ssh.addTarget', { target }) +} + +export async function updateRuntimeSshTarget( + environmentId: string, + targetId: string, + target: SshEditableTargetInput +): Promise { + const result = await callSshTargetManagement<{ target: SshEditableTarget }>( + environmentId, + 'ssh.updateTarget', + { targetId, target } + ) + return result.target +} + +export async function removeRuntimeSshTarget( + environmentId: string, + targetId: string +): Promise { + await callSshTargetManagement(environmentId, 'ssh.removeTarget', { targetId }) +} + +export type RuntimeSshDirectoryListing = { + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor +} + +export function browseRuntimeSshDirectory( + environmentId: string, + targetId: string, + dirPath: string +): Promise { + // Why 20s: the server bounds its own SSH listing at 15s. + return callSshTargetManagement(environmentId, 'ssh.browseDir', { targetId, dirPath }, 20_000) +} + +export async function addRuntimeSshRepo( + environmentId: string, + args: { connectionId: string; remotePath: string; displayName?: string; kind?: 'git' | 'folder' } +): Promise { + const result = await callSshTargetManagement<{ repo: Repo }>( + environmentId, + 'repo.addRemote', + args + ) + return result.repo +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d9a580cdcf26..c87fc2d09887 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,4 +1,5 @@ import { WORKSPACE_ATTACHMENT_RUNTIME_CAPABILITIES } from './workspace-attachment-capabilities' +import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from './ssh-target-management' import { AUTOMATION_RUNTIME_CAPABILITIES, AUTOMATION_RUNTIME_CLIENT_CAPABILITIES @@ -44,18 +45,8 @@ import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resum import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' -export { - AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, - AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY -} from './agent-session-resume-runtime-capabilities' +// Why a star: every resume capability is part of the runtime contract this module re-exports. +export * from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' import { AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES } from './agent-session-create-capabilities' @@ -447,6 +438,7 @@ export const RUNTIME_CAPABILITIES = [ PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY, RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY, + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY, ...AGENT_LAUNCH_RUNTIME_CAPABILITIES ] as const diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 15be72576630..33463e3a7adf 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -491,7 +491,7 @@ import { DictationStart, SpeechModelAction } from './speech-params' -import { SshTarget } from './ssh-params' +import { RepoAddRemote, SshBrowseDir, SshTarget, SshTargetAdd, SshTargetUpdate } from './ssh-params' import { ContinueInterruptedParams } from './structured-agent-session-continue-params' import { AcknowledgeAttentionParams, @@ -1140,6 +1140,7 @@ export const RPC_PARAMS_BY_METHOD = { 'reference.find': ReferenceFind, 'reference.list': ReferenceList, 'repo.add': RepoPath, + 'repo.addRemote': RepoAddRemote, 'repo.baseRefDefault': RepoSelector, 'repo.clone': RepoClone, 'repo.create': RepoCreate, @@ -1203,11 +1204,16 @@ export const RPC_PARAMS_BY_METHOD = { 'speech.models.delete': SpeechModelAction, 'speech.models.download': SpeechModelAction, 'speech.models.list': null, + 'ssh.addTarget': SshTargetAdd, + 'ssh.browseDir': SshBrowseDir, 'ssh.connect': SshTarget, 'ssh.getState': SshTarget, + 'ssh.listEditableTargets': null, 'ssh.listRemovedTargetLabels': null, 'ssh.listTargetSummaries': null, 'ssh.listTargets': null, + 'ssh.removeTarget': SshTarget, + 'ssh.updateTarget': SshTargetUpdate, 'stats.summary': null, 'status.get': null, 'terminal.adoptOrphans': TerminalAdoptOrphans, diff --git a/src/shared/rpc-contract/ssh-params.ts b/src/shared/rpc-contract/ssh-params.ts index 77f336c991b9..c64481b45999 100644 --- a/src/shared/rpc-contract/ssh-params.ts +++ b/src/shared/rpc-contract/ssh-params.ts @@ -1,5 +1,62 @@ import { z } from 'zod' +import { + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + SSH_REMOTE_RUNTIMES +} from '../ssh-types' +import { OptionalString, requiredString } from './rpc-param-primitives' export const SshTarget = z.object({ targetId: z.string().min(1) }) + +const boundedText = (max: number) => z.string().trim().min(1).max(max) + +// Why strict: an unknown key (a password, a passphrase, or a main-owned field such as a +// generation or managed-server fence) is refused rather than silently dropped, so a client can +// never believe the server kept a secret. Credentials are asked for when the server connects. +export const SshEditableTargetInput = z + .object({ + label: boundedText(200), + configHost: boundedText(512).optional(), + host: boundedText(512), + port: z.number().int().min(1).max(65_535), + username: z.string().trim().max(200), + identityFile: boundedText(4096).optional(), + identityAgent: boundedText(4096).optional(), + identitiesOnly: z.boolean().optional(), + gssapiAuthentication: z.boolean().optional(), + proxyCommand: boundedText(4096).optional(), + jumpHost: boundedText(1024).optional(), + relayGracePeriodSeconds: z + .number() + .int() + .min(0) + .max(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS) + .refine( + (value) => value === 0 || value >= MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + `Terminal timeout must be 0 or at least ${MIN_SSH_RELAY_GRACE_PERIOD_SECONDS} seconds` + ) + .optional(), + systemSshConnectionReuse: z.boolean().optional(), + remoteRuntime: z.enum(SSH_REMOTE_RUNTIMES).optional(), + allowRemoteCliControl: z.boolean().optional() + }) + .strict() + +export const SshTargetAdd = z.object({ target: SshEditableTargetInput }) + +/** Replaces the target's editable fields; an omitted optional field is cleared. */ +export const SshTargetUpdate = SshTarget.extend({ target: SshEditableTargetInput }) + +// Why untrimmed: a remote folder name may legitimately end in a space. +export const SshBrowseDir = SshTarget.extend({ + dirPath: z.string().min(1).max(4096) +}) + +export const RepoAddRemote = z.object({ + connectionId: requiredString('Missing SSH target'), + remotePath: requiredString('Missing remote path'), + displayName: OptionalString, + kind: z.enum(['git', 'folder']).optional() +}) diff --git a/src/shared/ssh-target-management.ts b/src/shared/ssh-target-management.ts new file mode 100644 index 000000000000..c61304705d3e --- /dev/null +++ b/src/shared/ssh-target-management.ts @@ -0,0 +1,46 @@ +import type { SshRepoReadoption, SshTarget, SshTargetSummary } from './ssh-types' + +// Why: hosts without this have no ssh.listEditableTargets/addTarget/updateTarget/removeTarget/ +// browseDir or repo.addRemote. A client must disable server SSH management, never manage its own +// SSH hosts in their place. +export const SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY = 'ssh.target-management.v1' as const + +/** + * The SSH target fields a paired client reads and writes on a server. There is deliberately no + * password or key passphrase: the server stores none and asks for them when it connects. + */ +export const SSH_EDITABLE_TARGET_FIELDS = [ + 'label', + 'configHost', + 'host', + 'port', + 'username', + 'identityFile', + 'identityAgent', + 'identitiesOnly', + 'gssapiAuthentication', + 'proxyCommand', + 'jumpHost', + 'relayGracePeriodSeconds', + 'systemSshConnectionReuse', + 'remoteRuntime', + 'allowRemoteCliControl' +] as const satisfies readonly (keyof SshTarget)[] + +export type SshEditableTargetField = (typeof SSH_EDITABLE_TARGET_FIELDS)[number] + +/** What a client sends to add a target or to replace an existing target's editable fields. */ +export type SshEditableTargetInput = Pick & + Partial>> + +export type SshEditableTarget = SshTargetSummary & + SshEditableTargetInput & { + source?: SshTarget['source'] + /** The host serves a managed Orca server; it can be edited but not removed from here. */ + managedServer?: true + } + +export type SshEditableTargetAddResult = { + target: SshEditableTarget + repoReadoptions: SshRepoReadoption[] +} From f30631cddbbe9ad0898556fa2e4026593a64cac7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:39:54 -0400 Subject: [PATCH 070/110] infra(relay): keep staging c4 on the serving director image until the director identity bootstrap (#27351) --- cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs | 4 ++-- cloud/infra/terraform/environments/staging.tfvars | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 71e5e7aa9219..277164e6747d 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -42,8 +42,8 @@ const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' // C31 launches on the digest the director served when it was declared. const c31Digest = 'f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269' -// Staging C4 runs the step-5 image D for the staging e2e. -const stagingC4Digest = 'ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02' +// Staging C4 stays on the serving director's image until the director's identity bootstrap deploy. +const stagingC4Digest = launchDigest function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index d26a0ee23f53..53001e607f8f 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -77,7 +77,7 @@ relay_gce_cells = { boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" capacity_requests = 6000 database_pool_max = 10 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563" initially_enabled = false connection_hard_cap = 3000 connection_unobserved_bound = 60 From 1dde4c0049dbc52f4569081746218499f8c6a464 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 18:41:46 -0700 Subject: [PATCH 071/110] fix(projects): create on a server's SSH project; Add Project SSH lists the server's targets (#27331) * fix(projects): create on a server's SSH project; Add Project lists the server's SSH hosts * fix(projects): derive server SSH states; drop redundant casts * fix(composer): a server's SSH project gets its agents from the server * fix(composer): only agent detection treats a server's SSH project as the server's --------- Co-authored-by: m4air --- .../settings/RepositoryHostSetupsSection.tsx | 9 +- .../sidebar/AddRemoteHostSshFormPanel.tsx | 58 ++++-- .../src/components/sidebar/AddRepoDialog.tsx | 3 +- .../sidebar/AddRepoDialogStepContent.tsx | 11 +- .../components/sidebar/AddRepoRemoteStep.tsx | 57 +++++- .../sidebar/AddRepoStartSteps.test.tsx | 27 +++ .../components/sidebar/AddRepoStartSteps.tsx | 5 +- .../AddRepoSteps.default-checkout.test.ts | 136 ++++++++++++- .../src/components/sidebar/AddRepoSteps.tsx | 184 +++++++++++------- .../sidebar/AddServerSshHostDialog.tsx | 81 ++++++++ .../components/sidebar/NonGitFolderDialog.tsx | 48 ++++- .../components/sidebar/RemoteFileBrowser.tsx | 3 +- .../sidebar/add-remote-host-ssh-actions.ts | 14 +- .../sidebar/add-repo-local-start-actions.ts | 53 +++-- .../sidebar/add-repo-server-ssh-targets.ts | 49 +++++ .../sidebar/repo-header-create-state.test.ts | 8 +- .../sidebar/repo-header-create-state.ts | 8 +- .../use-remote-file-browser-listing.test.ts | 53 +++++ .../use-remote-file-browser-listing.ts | 17 +- .../sidebar/use-server-ssh-projects.ts | 39 ++++ .../worktree-list/rows/SectionHeader.tsx | 8 +- .../viewport/VirtualizedWorktreeViewport.tsx | 4 +- .../viewport/virtual-row-context.ts | 6 +- .../composer-state/host-runtime-effects.ts | 12 +- .../runtime-target-selection.ts | 13 +- .../server-ssh-repo-connect.test.ts | 122 ++++++++++++ .../workspace-identity-state.ts | 9 +- .../src/hooks/use-repo-ssh-gate-resolver.ts | 21 ++ src/renderer/src/i18n/locales/en.json | 8 + src/renderer/src/i18n/locales/es.json | 8 + src/renderer/src/i18n/locales/fr.json | 8 + src/renderer/src/i18n/locales/ja.json | 8 + src/renderer/src/i18n/locales/ko.json | 8 + src/renderer/src/i18n/locales/zh.json | 8 + .../src/lib/repo-ssh-connection.test.ts | 112 +++++++++++ src/renderer/src/lib/repo-ssh-connection.ts | 60 ++++++ .../runtime-environment-ssh-selectors.ts | 13 +- 37 files changed, 1125 insertions(+), 166 deletions(-) create mode 100644 src/renderer/src/components/sidebar/AddServerSshHostDialog.tsx create mode 100644 src/renderer/src/components/sidebar/add-repo-server-ssh-targets.ts create mode 100644 src/renderer/src/components/sidebar/use-remote-file-browser-listing.test.ts create mode 100644 src/renderer/src/components/sidebar/use-server-ssh-projects.ts create mode 100644 src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts create mode 100644 src/renderer/src/hooks/use-repo-ssh-gate-resolver.ts create mode 100644 src/renderer/src/lib/repo-ssh-connection.test.ts create mode 100644 src/renderer/src/lib/repo-ssh-connection.ts diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index 108efe65798c..d00508656db7 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -254,14 +254,7 @@ export function RepositoryHostSetupsSection({ const nestedSshStatus = runtimeOwnerEnvironmentId && executionHost?.kind === 'ssh' ? selectRuntimeAwareSshStatus( - { - sshConnectionStates, - sshTargetLabels, - removedSshTargetLabels, - sshTargetsHydrated, - sshStateByEnvironment, - runtimeStatusByEnvironmentId - }, + { sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId }, runtimeOwnerEnvironmentId, executionHost.targetId ) diff --git a/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx b/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx index fe93ba105ae1..12fe84d39128 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostSshFormPanel.tsx @@ -12,7 +12,8 @@ export function AddRemoteHostSshFormPanel({ onFormChange, onSubmit, onCancel, - onFillFromConfig + onFillFromConfig, + serverLabel }: { form: EditingTarget disabled: boolean @@ -21,19 +22,34 @@ export function AddRemoteHostSshFormPanel({ onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void onSubmit: () => void onCancel: () => void - onFillFromConfig: () => void + /** Absent for a server's host: this client's ~/.ssh/config says nothing about the server's. */ + onFillFromConfig?: () => void + /** Set when the host is saved on a paired server rather than on this client. */ + serverLabel?: string }): React.JSX.Element { return ( <> - {translate('auto.components.sidebar.AddRemoteHostDialog.sshTitle', 'Add SSH host')} + {serverLabel + ? translate( + 'auto.components.sidebar.AddRemoteHostDialog.serverSshTitle', + 'Add SSH host to {{server}}', + { server: serverLabel } + ) + : translate('auto.components.sidebar.AddRemoteHostDialog.sshTitle', 'Add SSH host')} - {translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshDescription', - 'Add a persistent machine you can log into over SSH.' - )} + {serverLabel + ? translate( + 'auto.components.sidebar.AddRemoteHostDialog.serverSshDescription', + '{{server}} saves this host and connects to it. Passwords and passphrases are asked for when it connects.', + { server: serverLabel } + ) + : translate( + 'auto.components.sidebar.AddRemoteHostDialog.sshDescription', + 'Add a persistent machine you can log into over SSH.' + )} @@ -47,18 +63,22 @@ export function AddRemoteHostSshFormPanel({ /> - + {onFillFromConfig ? ( + + ) : ( + + )}
+ + + ) : null}
) : selectedTarget && !selectedTargetConnected ? (
diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx index 2e66953e9c57..67af0bc60ac5 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -214,6 +214,33 @@ describe('AddRepoLocalStartStep', () => { expect(model.description).toBe('Existing Git repository or folder on this host') }) + it.each([ + ['supported', false, 'Open a project folder from an SSH host this server connects to'], + ['unsupported', true, 'Update this server to add projects on its SSH hosts'], + ['unknown', true, 'Open a project folder from an SSH host this server connects to'] + ] as const)( + "offers a server's own SSH hosts only when the server supports it (%s)", + (serverSshProjects, disabled, description) => { + const { secondaryActions } = getAddRepoLocalStartActions({ + isSshLikely: false, + showRemoteAction: true, + browseHostKind: 'runtime', + serverSshProjects, + onBrowse: vi.fn(), + onOpenCloneStep: vi.fn(), + onOpenRemoteStep: vi.fn(), + onOpenCreateStep: vi.fn() + }) + const remote = secondaryActions.find((action) => action.kind === 'remote') + + expect(remote).toMatchObject({ + title: "Project on this server's SSH host", + description, + disabled + }) + } + ) + it('focuses Browse folder when the default Add Project step opens', async () => { const { container, root } = await renderLocalStartStepDom(false) const browseButton = findButton(container, 'Browse folder') diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx index e5e4b409abc2..e71da74c77ab 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx @@ -71,6 +71,7 @@ type AddRepoLocalStartStepProps = { canCreateProject?: boolean actionsDisabled?: boolean browseHostKind?: 'local' | 'ssh' | 'runtime' + serverSshProjects?: 'supported' | 'unsupported' | 'unknown' onBrowse: () => void onOpenCloneStep: () => void onOpenRemoteStep: () => void @@ -90,6 +91,7 @@ export function AddRepoLocalStartStep({ canCreateProject = true, actionsDisabled = false, browseHostKind = 'local', + serverSshProjects, onBrowse, onOpenCloneStep, onOpenRemoteStep, @@ -107,7 +109,8 @@ export function AddRepoLocalStartStep({ onOpenCreateStep, showRemoteAction, canCreateProject, - browseHostKind + browseHostKind, + serverSshProjects }) // The white fill + ⏎ chip is a roving selection indicator, not a fixed "primary" badge: diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts index 6ca5d28f16fc..708517804d46 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts @@ -12,8 +12,12 @@ const mocks = vi.hoisted(() => ({ projectHostSetups: [], clearOrcaHookTrustForRepo: vi.fn(), openModal: vi.fn(), - cancelNestedRepoScan: vi.fn() + cancelNestedRepoScan: vi.fn(), + sshStateByEnvironment: new Map() }, + listServerTargets: vi.fn(), + addServerRepo: vi.fn(), + connectServerTarget: vi.fn(), addRemote: vi.fn(), listTargets: vi.fn(), getState: vi.fn(), @@ -27,6 +31,7 @@ vi.mock('react', async (importOriginal) => { return { ...actual, useCallback: unknown>(fn: T) => fn, + useMemo: (factory: () => T) => factory(), useEffect: (effect: () => void | (() => void)) => { effect() }, @@ -63,6 +68,15 @@ vi.mock('@/store', () => { return { useAppStore } }) +vi.mock('@/runtime/runtime-ssh-target-management', () => ({ + listRuntimeEditableSshTargets: mocks.listServerTargets, + addRuntimeSshRepo: mocks.addServerRepo +})) + +vi.mock('@/runtime/runtime-environment-ssh-state', () => ({ + connectRuntimeEnvironmentSshTarget: mocks.connectServerTarget +})) + vi.mock('../../../../shared/nested-repo-telemetry', () => ({ createNestedRepoTelemetryAttemptId: () => 'attempt-1' })) @@ -226,4 +240,124 @@ describe('useRemoteRepo default-checkout handoff', () => { runtimeEnvironmentId: null }) }) + + // Adapted from the community PR #8492 by @jae-heo. + describe("a paired server's own SSH hosts (#8489)", () => { + function serverRemoteRepo(scanNestedRepos = vi.fn().mockResolvedValue(null)) { + return import('./AddRepoSteps').then(({ useRemoteRepo }) => + useRemoteRepo( + mocks.fetchWorktrees, + vi.fn(), + vi.fn(), + mocks.onGitRepoReady, + scanNestedRepos, + undefined, + undefined, + 'env-linux' + ) + ) + } + + it("lists the server's hosts, never this client's", async () => { + mocks.stateValues = [[], null, '~/', null, false, null] + mocks.listServerTargets.mockResolvedValue([ + { + id: 'ssh-p8', + label: 'p8', + host: 'p8', + port: 22, + username: 'me', + connectionStatus: 'connected' + } + ]) + + const result = await serverRemoteRepo() + await result.handleOpenRemoteStep('ssh-p8') + + expect(mocks.listServerTargets).toHaveBeenCalledWith('env-linux') + expect(mocks.listTargets).not.toHaveBeenCalled() + expect(mocks.getState).not.toHaveBeenCalled() + expect(mocks.stateSetters[0]).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'ssh-p8', + state: expect.objectContaining({ status: 'connected' }) + }) + ]) + expect(mocks.stateSetters[1]).toHaveBeenCalledWith('ssh-p8') + }) + + it('shows why when the server cannot list them, with no local fallback', async () => { + mocks.stateValues = [[], null, '~/', null, false, null] + mocks.listServerTargets.mockRejectedValue( + new Error('Update this Orca server to manage its SSH hosts from here.') + ) + + const result = await serverRemoteRepo() + await result.handleOpenRemoteStep() + + expect(mocks.listTargets).not.toHaveBeenCalled() + expect(mocks.stateSetters[3]).toHaveBeenCalledWith( + 'Update this Orca server to manage its SSH hosts from here.' + ) + }) + + it('adds the project through the server and refreshes it as server-owned', async () => { + const repo = makeRepo({ connectionId: 'ssh-p8', executionHostId: 'ssh:ssh-p8' }) + mocks.stateValues = [[], 'ssh-p8', '/srv/repo', null, false, null] + mocks.addServerRepo.mockResolvedValue(repo) + mocks.fetchWorktrees.mockResolvedValue(true) + const scanNestedRepos = vi.fn() + + const result = await serverRemoteRepo(scanNestedRepos) + await result.handleAddRemoteRepo() + + expect(mocks.addServerRepo).toHaveBeenCalledWith('env-linux', { + connectionId: 'ssh-p8', + remotePath: '/srv/repo' + }) + expect(mocks.addRemote).not.toHaveBeenCalled() + expect(scanNestedRepos).not.toHaveBeenCalled() + expect(mocks.storeState.repos).toContainEqual({ + ...repo, + executionHostId: 'runtime:env-linux' + }) + expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true, + executionHostId: 'runtime:env-linux' + }) + expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'runtime:env-linux') + }) + + it('hands a non-git folder to the confirm dialog bound to the server', async () => { + mocks.stateValues = [[], 'ssh-p8', '/srv/notes', null, false, null] + mocks.addServerRepo.mockRejectedValue(new Error('Not a valid git repository: /srv/notes')) + + const result = await serverRemoteRepo() + await result.handleAddRemoteRepo() + + expect(mocks.storeState.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { + folderPath: '/srv/notes', + connectionId: 'ssh-p8', + runtimeEnvironmentId: 'env-linux' + }) + }) + + it('connects the host through the server', async () => { + mocks.stateValues = [[], null, '~/', null, false, null] + mocks.connectServerTarget.mockResolvedValue({ + targetId: 'ssh-p8', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + const sshConnect = vi.fn() + Object.assign(window.api.ssh, { connect: sshConnect }) + + const result = await serverRemoteRepo() + await result.handleConnectTarget('ssh-p8') + + expect(mocks.connectServerTarget).toHaveBeenCalledWith('env-linux', 'ssh-p8') + expect(sshConnect).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index c17386073de1..3d942886fe7f 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -1,9 +1,16 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' import type { NestedRepoScanResult } from '../../../../shared/project-group-types' -import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' +import type { Repo } from '../../../../shared/repo-types' +import { addRuntimeSshRepo } from '@/runtime/runtime-ssh-target-management' +import { connectRuntimeEnvironmentSshTarget } from '@/runtime/runtime-environment-ssh-state' +import { + loadServerSshTargets, + withServerSshStates, + type RemoteRepoTarget +} from './add-repo-server-ssh-targets' import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry' import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' @@ -13,6 +20,23 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' // ── SSH host project hook ─────────────────────────────────────────── +async function loadLocalSshTargets(): Promise { + const targets = await window.api.ssh.listTargets() + return Promise.all( + targets.map(async (t) => { + const state = await window.api.ssh.getState({ targetId: t.id }) + return { ...t, state: state ?? undefined } + }) + ) +} + +function unwrapAddedRepo(result: { repo: Repo } | { error: string }): Repo { + if ('error' in result) { + throw new Error(result.error) + } + return result.repo +} + export function useRemoteRepo( fetchWorktrees: ( repoId: string, @@ -38,9 +62,11 @@ export function useRemoteRepo( inProgress: boolean, scanId: string | null ) => void, - onNestedScanResult?: (scan: NestedRepoScanResult | null, attemptId: string) => void + onNestedScanResult?: (scan: NestedRepoScanResult | null, attemptId: string) => void, + /** A paired server: its own SSH hosts are listed, browsed and added by that server only. */ + serverEnvironmentId: string | null = null ) { - const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([]) + const [sshTargets, setSshTargets] = useState([]) const [selectedTargetId, setSelectedTargetId] = useState(null) const [remotePath, setRemotePath] = useState('~/') const [remoteError, setRemoteError] = useState(null) @@ -75,18 +101,9 @@ export function useRemoteRepo( const gen = ++remoteGenRef.current setStep('remote') try { - const targets = (await window.api.ssh.listTargets()) as SshTarget[] - if (gen !== remoteGenRef.current) { - return - } - const withState = await Promise.all( - targets.map(async (t) => { - const state = (await window.api.ssh.getState({ - targetId: t.id - })) as SshConnectionState | null - return { ...t, state: state ?? undefined } - }) - ) + const withState = serverEnvironmentId + ? await loadServerSshTargets(serverEnvironmentId) + : await loadLocalSshTargets() if (gen !== remoteGenRef.current) { return } @@ -102,20 +119,37 @@ export function useRemoteRepo( if (connected) { setSelectedTargetId(connected.id) } - } catch { + } catch (error) { if (gen !== remoteGenRef.current) { return } setSshTargets([]) + if (serverEnvironmentId) { + setRemoteError(extractIpcErrorMessage(error, String(error))) + } } }, - [setStep] + [serverEnvironmentId, setStep] ) // Why: keep the target list's connection state in sync while the dialog is // open, so clicking the inline Connect button below updates the dot/label // live without the user reopening the step. + const serverSshStates = useAppStore((s) => + serverEnvironmentId + ? s.sshStateByEnvironment.get(serverEnvironmentId)?.connectionStates + : undefined + ) + // Why derived: the server pushes SSH state changes into the store while this step is open. + const displayedSshTargets = useMemo( + () => withServerSshStates(sshTargets, serverSshStates), + [serverSshStates, sshTargets] + ) + useEffect(() => { + if (serverEnvironmentId) { + return + } const unsubscribe = window.api.ssh.onStateChanged(({ targetId, state }) => { setSshTargets((prev) => prev.map((t) => (t.id === targetId ? { ...t, state } : t))) if (state.status === 'connected') { @@ -123,19 +157,32 @@ export function useRemoteRepo( } }) return unsubscribe - }, []) + }, [serverEnvironmentId]) - const handleConnectTarget = useCallback(async (targetId: string) => { - try { - await window.api.ssh.connect({ targetId }) - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.sidebar.AddRepoSteps.3e64e8a70d', 'Connection failed') - ) - } - }, []) + const handleConnectTarget = useCallback( + async (targetId: string) => { + try { + if (serverEnvironmentId) { + const state = await connectRuntimeEnvironmentSshTarget(serverEnvironmentId, targetId) + if (state) { + setSshTargets((prev) => prev.map((t) => (t.id === targetId ? { ...t, state } : t))) + setSelectedTargetId((curr) => + state.status === 'connected' ? (curr ?? targetId) : curr + ) + } + return + } + await window.api.ssh.connect({ targetId }) + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.sidebar.AddRepoSteps.3e64e8a70d', 'Connection failed') + ) + } + }, + [serverEnvironmentId] + ) const handleAddRemoteRepo = useCallback(async () => { if (!selectedTargetId || !remotePath.trim()) { @@ -150,28 +197,31 @@ export function useRemoteRepo( const attemptId = createNestedRepoTelemetryAttemptId() const scanId = `nested-repo-scan-${Date.now()}-${Math.random().toString(36).slice(2)}` setRemoteNestedScanId(scanId) - const scan = await scanNestedRepos?.(trimmedRemotePath, selectedTargetId, { - scanId, - runtimeEnvironmentId: null, - onProgress: (progressScan) => { - if ( - gen !== remoteGenRef.current || - !mountedRef.current || - progressScan.selectedPathKind !== 'non_git_folder' || - progressScan.repos.length === 0 - ) { - return - } - showNestedRepoReview?.( - progressScan, - trimmedRemotePath, - selectedTargetId, - attemptId, - true, - scanId - ) - } - }) + // Why no nested scan on a server's host: that server's scan cannot reach its SSH targets. + const scan = serverEnvironmentId + ? null + : await scanNestedRepos?.(trimmedRemotePath, selectedTargetId, { + scanId, + runtimeEnvironmentId: null, + onProgress: (progressScan) => { + if ( + gen !== remoteGenRef.current || + !mountedRef.current || + progressScan.selectedPathKind !== 'non_git_folder' || + progressScan.repos.length === 0 + ) { + return + } + showNestedRepoReview?.( + progressScan, + trimmedRemotePath, + selectedTargetId, + attemptId, + true, + scanId + ) + } + }) if (!mountedRef.current || gen !== remoteGenRef.current) { return } @@ -182,16 +232,16 @@ export function useRemoteRepo( return } setRemoteNestedScanId(null) - const result = await window.api.repos.addRemote({ - connectionId: selectedTargetId, - remotePath: trimmedRemotePath - }) - if ('error' in result) { - throw new Error(result.error) - } - const { alreadyPresent, repo } = upsertAddedRepoWithProjectHostSetup(result.repo, { - sshConnectionId: selectedTargetId - }) + const request = { connectionId: selectedTargetId, remotePath: trimmedRemotePath } + const addedRepo = serverEnvironmentId + ? await addRuntimeSshRepo(serverEnvironmentId, request) + : unwrapAddedRepo(await window.api.repos.addRemote(request)) + const { alreadyPresent, repo } = upsertAddedRepoWithProjectHostSetup( + addedRepo, + serverEnvironmentId + ? { runtimeEnvironmentId: serverEnvironmentId } + : { sshConnectionId: selectedTargetId } + ) if (alreadyPresent) { useAppStore.getState().clearOrcaHookTrustForRepo(repo.id) @@ -206,7 +256,9 @@ export function useRemoteRepo( ) // Why: the repo is already persisted here; if SSH refresh is temporarily // non-authoritative, finish onto the project row instead of stranding the dialog. - const ownerOptions = worktreeRefreshOptions(undefined, selectedTargetId) + const ownerOptions = serverEnvironmentId + ? worktreeRefreshOptions(serverEnvironmentId) + : worktreeRefreshOptions(undefined, selectedTargetId) await fetchWorktrees(repo.id, ownerOptions) if (!mountedRef.current || gen !== remoteGenRef.current) { return @@ -221,7 +273,8 @@ export function useRemoteRepo( closeModal() useAppStore.getState().openModal('confirm-non-git-folder', { folderPath: trimmedRemotePath, - connectionId: selectedTargetId + connectionId: selectedTargetId, + ...(serverEnvironmentId ? { runtimeEnvironmentId: serverEnvironmentId } : {}) }) return } @@ -235,6 +288,7 @@ export function useRemoteRepo( } } }, [ + serverEnvironmentId, selectedTargetId, remotePath, scanNestedRepos, @@ -247,7 +301,7 @@ export function useRemoteRepo( ]) return { - sshTargets, + sshTargets: displayedSshTargets, selectedTargetId, remotePath, remoteError, diff --git a/src/renderer/src/components/sidebar/AddServerSshHostDialog.tsx b/src/renderer/src/components/sidebar/AddServerSshHostDialog.tsx new file mode 100644 index 000000000000..3f3157a977d4 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddServerSshHostDialog.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { useAppStore } from '@/store' +import { EMPTY_FORM, type EditingTarget } from '../settings/ssh-target-draft' +import { + addRuntimeSshTarget, + listRuntimeEditableSshTargets +} from '@/runtime/runtime-ssh-target-management' +import { refreshRuntimeEnvironmentSshTargetMetadata } from '@/runtime/runtime-environment-ssh-state' +import { AddRemoteHostSshFormPanel } from './AddRemoteHostSshFormPanel' +import { saveNewSshHostFromForm } from './add-remote-host-ssh-actions' + +/** Adds an SSH host to a paired server's own list; nothing is saved on this client. */ +export function AddServerSshHostDialog({ + environmentId, + serverLabel, + open, + onOpenChange, + onSaved +}: { + environmentId: string + serverLabel: string + open: boolean + onOpenChange: (open: boolean) => void + onSaved: () => void +}): React.JSX.Element { + const [form, setForm] = useState(EMPTY_FORM) + const [isSaving, setIsSaving] = useState(false) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + + const close = (): void => { + if (isSaving) { + return + } + setForm(EMPTY_FORM) + onOpenChange(false) + } + + const save = async (): Promise => { + setIsSaving(true) + try { + const outcome = await saveNewSshHostFromForm({ + form, + ssh: { + listTargets: () => listRuntimeEditableSshTargets(environmentId), + addTarget: ({ target }) => addRuntimeSshTarget(environmentId, target) + }, + // Why: re-adopted projects live on the server, which announces its own repo change. + recordSshRepoReadoptions: () => {}, + setSshTargetsMetadata: () => { + void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {}) + }, + recordFeatureInteraction + }) + if (outcome === 'saved') { + setForm(EMPTY_FORM) + onOpenChange(false) + onSaved() + } + } finally { + setIsSaving(false) + } + } + + return ( + (next ? onOpenChange(true) : close())}> + + void save()} + onCancel={close} + serverLabel={serverLabel} + /> + + + ) +} diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 8ae03f120d87..1b2c5159a9cf 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -18,6 +18,41 @@ import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' import { worktreeRefreshOptions } from './add-repo-runtime-owner' +import { addRuntimeSshRepo } from '@/runtime/runtime-ssh-target-management' +import type { Repo } from '../../../../shared/repo-types' + +// Why: with a server set, the SSH target is that server's own host and only the server adds it. +async function addSshFolderProject(args: { + connectionId: string + runtimeEnvironmentId: string | null + remotePath: string + displayName: string +}): Promise<{ repo: Repo; ownerOptions: ReturnType }> { + const request = { + connectionId: args.connectionId, + remotePath: args.remotePath, + kind: 'folder' as const, + ...(args.displayName ? { displayName: args.displayName } : {}) + } + if (args.runtimeEnvironmentId) { + const added = await addRuntimeSshRepo(args.runtimeEnvironmentId, request) + return { + repo: upsertAddedRepoWithProjectHostSetup(added, { + runtimeEnvironmentId: args.runtimeEnvironmentId + }).repo, + ownerOptions: worktreeRefreshOptions(args.runtimeEnvironmentId) + } + } + const result = await window.api.repos.addRemote(request) + if ('error' in result) { + throw new Error(result.error) + } + return { + repo: upsertAddedRepoWithProjectHostSetup(result.repo, { sshConnectionId: args.connectionId }) + .repo, + ownerOptions: worktreeRefreshOptions(undefined, args.connectionId) + } +} const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -57,22 +92,15 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { void (async () => { try { const stateBeforeAdd = useAppStore.getState() - const result = await window.api.repos.addRemote({ + const { repo, ownerOptions } = await addSshFolderProject({ connectionId, + runtimeEnvironmentId: runtimeEnvironmentId || null, remotePath: folderPath, - kind: 'folder', - ...(displayName ? { displayName } : {}) - }) - if ('error' in result) { - throw new Error(result.error) - } - const { repo } = upsertAddedRepoWithProjectHostSetup(result.repo, { - sshConnectionId: connectionId + displayName }) const state = useAppStore.getState() const hadProjectBeforeAdd = stateBeforeAdd.repos.length > 0 await markOnboardingProjectAdded('addedFolder') - const ownerOptions = worktreeRefreshOptions(undefined, connectionId) await state.fetchWorktrees(repo.id, ownerOptions) // Why: mirror the local non-git folder flow — without this the // dialog closes and the UI shows no visible change, making the diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx index 423c72d2f510..7723b2fdd1cd 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx @@ -13,8 +13,9 @@ import { useRemoteFileBrowserPathPreview } from './use-remote-file-browser-path- import { translate } from '@/i18n/i18n' import type { FilesystemPathFlavor } from '../../../../shared/filesystem-entry-types' +// Both set: an SSH host the paired server owns, listed by that server. type RemoteFileBrowserProps = ( - | { targetId: string; runtimeEnvironmentId?: never } + | { targetId: string; runtimeEnvironmentId?: string } | { runtimeEnvironmentId: string; targetId?: never } ) & { initialPath?: string diff --git a/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts index e9037ba0c6ec..81fe6d9211ed 100644 --- a/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts +++ b/src/renderer/src/components/sidebar/add-remote-host-ssh-actions.ts @@ -35,7 +35,10 @@ type SshApi = { }> } -export async function saveNewSshHostFromForm({ +type SavedSshHost = Pick + +/** `ssh` is this client's own API or a paired server's (whose hosts never touch local metadata). */ +export async function saveNewSshHostFromForm({ form, ssh, recordSshRepoReadoptions, @@ -43,9 +46,14 @@ export async function saveNewSshHostFromForm({ recordFeatureInteraction }: { form: EditingTarget - ssh: SshApi + ssh: { + listTargets: () => Promise + addTarget: (args: { + target: SshTargetCreateInput + }) => Promise<{ repoReadoptions: SshRepoReadoption[] }> + } recordSshRepoReadoptions: (readoptions: readonly SshRepoReadoption[]) => void - setSshTargetsMetadata: (targets: SshTarget[]) => void + setSshTargetsMetadata: (targets: T[]) => void recordFeatureInteraction: (feature: 'ssh') => void }): Promise<'saved' | 'validation-failed' | 'failed'> { const { host, configHost, username, port } = getSshTargetDraftConnectionFields(form) diff --git a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts index fbe525470dae..626639aba3f1 100644 --- a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts +++ b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts @@ -10,6 +10,8 @@ export type AddRepoLocalStartActionHandlers = { showRemoteAction?: boolean canCreateProject?: boolean browseHostKind?: 'local' | 'ssh' | 'runtime' + /** A paired server's own SSH hosts; `unsupported` when it predates managing them. */ + serverSshProjects?: 'supported' | 'unsupported' | 'unknown' } export type AddRepoLocalStartAction = { @@ -29,7 +31,8 @@ export function getAddRepoLocalStartActions({ onOpenCreateStep, showRemoteAction = true, canCreateProject = true, - browseHostKind = 'local' + browseHostKind = 'local', + serverSshProjects }: { isSshLikely: boolean } & AddRepoLocalStartActionHandlers): { primaryAction: AddRepoLocalStartAction secondaryActions: AddRepoLocalStartAction[] @@ -65,19 +68,41 @@ export function getAddRepoLocalStartActions({ onClick: onBrowse } - const remote = { - kind: 'remote' as const, - icon: Monitor, - title: translate( - 'auto.components.sidebar.add.repo.local.start.actions.3d162cc76f', - 'Project on SSH host' - ), - description: translate( - 'auto.components.sidebar.add.repo.local.start.actions.a6c20dca96', - 'Open a project folder from an SSH host' - ), - onClick: onOpenRemoteStep - } + const remote = serverSshProjects + ? { + kind: 'remote' as const, + icon: Monitor, + title: translate( + 'auto.components.sidebar.add.repo.local.start.actions.serverSshTitle', + "Project on this server's SSH host" + ), + description: + serverSshProjects === 'unsupported' + ? translate( + 'auto.components.sidebar.add.repo.local.start.actions.serverSshUpdate', + 'Update this server to add projects on its SSH hosts' + ) + : translate( + 'auto.components.sidebar.add.repo.local.start.actions.serverSshDescription', + 'Open a project folder from an SSH host this server connects to' + ), + // Why: an old server has no way to do this, and this client must not do it locally. + disabled: serverSshProjects !== 'supported', + onClick: onOpenRemoteStep + } + : { + kind: 'remote' as const, + icon: Monitor, + title: translate( + 'auto.components.sidebar.add.repo.local.start.actions.3d162cc76f', + 'Project on SSH host' + ), + description: translate( + 'auto.components.sidebar.add.repo.local.start.actions.a6c20dca96', + 'Open a project folder from an SSH host' + ), + onClick: onOpenRemoteStep + } const clone = { kind: 'clone' as const, icon: Globe, diff --git a/src/renderer/src/components/sidebar/add-repo-server-ssh-targets.ts b/src/renderer/src/components/sidebar/add-repo-server-ssh-targets.ts new file mode 100644 index 000000000000..c640e1998534 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-server-ssh-targets.ts @@ -0,0 +1,49 @@ +import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' +import type { SshEditableTarget } from '../../../../shared/ssh-target-management' +import { listRuntimeEditableSshTargets } from '@/runtime/runtime-ssh-target-management' + +export type RemoteRepoTarget = SshTarget & { state?: SshConnectionState } + +function toRemoteRepoTarget(target: SshEditableTarget): RemoteRepoTarget { + const { + connectionStatus, + connected: _connected, + remotePlatform: _remotePlatform, + managedServer: _managedServer, + ...rest + } = target + return { + ...rest, + // Why no state when absent: the server has none for it, which is not "disconnected" proof. + ...(connectionStatus + ? { + state: { targetId: target.id, status: connectionStatus, error: null, reconnectAttempt: 0 } + } + : {}) + } +} + +/** The SSH hosts a paired server owns, for its Add Project flow. */ +export async function loadServerSshTargets(environmentId: string): Promise { + return (await listRuntimeEditableSshTargets(environmentId)).map(toRemoteRepoTarget) +} + +/** Overlays the server's pushed SSH states (mirrored in the store) onto the listed targets. */ +export function withServerSshStates( + targets: RemoteRepoTarget[], + states: ReadonlyMap | undefined +): RemoteRepoTarget[] { + if (!states) { + return targets + } + let changed = false + const next = targets.map((target) => { + const state = states.get(target.id) + if (!state || state === target.state) { + return target + } + changed = true + return { ...target, state } + }) + return changed ? next : targets +} diff --git a/src/renderer/src/components/sidebar/repo-header-create-state.test.ts b/src/renderer/src/components/sidebar/repo-header-create-state.test.ts index 6f4a121cb4cc..dc08fa3bc4e6 100644 --- a/src/renderer/src/components/sidebar/repo-header-create-state.test.ts +++ b/src/renderer/src/components/sidebar/repo-header-create-state.test.ts @@ -20,7 +20,7 @@ describe('repo header create state', () => { getRepoHeaderCreateState({ repo: makeRepo(), label: 'orca', - sshStatus: null + sshGate: { connectionId: null, status: null } }) ).toEqual({ disabled: false, @@ -35,7 +35,7 @@ describe('repo header create state', () => { getRepoHeaderCreateState({ repo: makeRepo({ kind: 'folder' }), label: 'docs', - sshStatus: null + sshGate: { connectionId: null, status: null } }) ).toMatchObject({ disabled: false, @@ -49,7 +49,7 @@ describe('repo header create state', () => { getRepoHeaderCreateState({ repo: makeRepo({ connectionId: 'ssh-1' }), label: 'remote', - sshStatus: 'connected' + sshGate: { connectionId: 'ssh-1', status: 'connected' } }) ).toMatchObject({ disabled: false, @@ -71,7 +71,7 @@ describe('repo header create state', () => { getRepoHeaderCreateState({ repo: makeRepo({ connectionId: 'ssh-1' }), label: 'remote', - sshStatus + sshGate: { connectionId: 'ssh-1', status: sshStatus } }) ).toEqual({ disabled: true, diff --git a/src/renderer/src/components/sidebar/repo-header-create-state.ts b/src/renderer/src/components/sidebar/repo-header-create-state.ts index 6b061932a9ec..73657ecd411f 100644 --- a/src/renderer/src/components/sidebar/repo-header-create-state.ts +++ b/src/renderer/src/components/sidebar/repo-header-create-state.ts @@ -14,7 +14,8 @@ export type RepoHeaderCreateState = { export function getRepoHeaderCreateState(input: { repo: Repo label: string - sshStatus: SshConnectionStatus | null + /** From selectRepoSshGateInput, so a paired server's own target reads that server's state. */ + sshGate: { connectionId: string | null; status: SshConnectionStatus | null } }): RepoHeaderCreateState { if (!isGitRepoKind(input.repo)) { return { @@ -33,10 +34,7 @@ export function getRepoHeaderCreateState(input: { } } - const sshGate = getSelectedRepoSshGate({ - connectionId: input.repo.connectionId, - status: input.repo.connectionId ? input.sshStatus : null - }) + const sshGate = getSelectedRepoSshGate(input.sshGate) if (sshGate.selectedRepoRequiresConnection) { return { disabled: true, diff --git a/src/renderer/src/components/sidebar/use-remote-file-browser-listing.test.ts b/src/renderer/src/components/sidebar/use-remote-file-browser-listing.test.ts new file mode 100644 index 000000000000..aafa98f7929a --- /dev/null +++ b/src/renderer/src/components/sidebar/use-remote-file-browser-listing.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + browseRuntimeSshDirectory: vi.fn(), + browseRuntimeServerDirectory: vi.fn(), + browseDir: vi.fn() +})) + +vi.mock('@/runtime/runtime-ssh-target-management', () => ({ + browseRuntimeSshDirectory: mocks.browseRuntimeSshDirectory +})) +vi.mock('@/runtime/runtime-server-directory-browser', () => ({ + browseRuntimeServerDirectory: mocks.browseRuntimeServerDirectory +})) + +const { useRemoteFileBrowserListing } = await import('./use-remote-file-browser-listing') + +const listing = { resolvedPath: '/home/me', entries: [], pathFlavor: 'posix' as const } + +describe('remote folder listing', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(window, { api: { ssh: { browseDir: mocks.browseDir } } }) + for (const mock of Object.values(mocks)) { + mock.mockResolvedValue(listing) + } + }) + + it("asks the server to list its own SSH host's folders", async () => { + const { result } = renderHook(() => useRemoteFileBrowserListing('ssh-p8', 'env-1')) + + await result.current.fetchListing('~') + + expect(mocks.browseRuntimeSshDirectory).toHaveBeenCalledWith('env-1', 'ssh-p8', '~') + expect(mocks.browseDir).not.toHaveBeenCalled() + expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled() + }) + + it("lists this client's SSH host and the server's own disk as before", async () => { + await renderHook(() => + useRemoteFileBrowserListing('ssh-mine', undefined) + ).result.current.fetchListing('~') + await renderHook(() => + useRemoteFileBrowserListing(undefined, 'env-1') + ).result.current.fetchListing('~') + + expect(mocks.browseDir).toHaveBeenCalledWith({ targetId: 'ssh-mine', dirPath: '~' }) + expect(mocks.browseRuntimeServerDirectory).toHaveBeenCalledWith('env-1', '~') + expect(mocks.browseRuntimeSshDirectory).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/use-remote-file-browser-listing.ts b/src/renderer/src/components/sidebar/use-remote-file-browser-listing.ts index fc2f736620a4..c8d952907093 100644 --- a/src/renderer/src/components/sidebar/use-remote-file-browser-listing.ts +++ b/src/renderer/src/components/sidebar/use-remote-file-browser-listing.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, type RefObject } from 'react' import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser' +import { browseRuntimeSshDirectory } from '@/runtime/runtime-ssh-target-management' import type { DirEntry } from './remote-file-browser-helpers' import type { FilesystemPathFlavor } from '../../../../shared/filesystem-entry-types' @@ -31,12 +32,16 @@ export function useRemoteFileBrowserListing( if (cached) { return cached } - const result = targetId - ? await window.api.ssh.browseDir({ targetId, dirPath }) - : await browseRuntimeServerDirectory( - requireRuntimeEnvironmentId(runtimeEnvironmentId), - dirPath - ) + // Why: with both set, the target is the server's own SSH host and only the server dials it. + const result = + targetId && runtimeEnvironmentId + ? await browseRuntimeSshDirectory(runtimeEnvironmentId, targetId, dirPath) + : targetId + ? await window.api.ssh.browseDir({ targetId, dirPath }) + : await browseRuntimeServerDirectory( + requireRuntimeEnvironmentId(runtimeEnvironmentId), + dirPath + ) listingCacheRef.current.set(result.resolvedPath, result) // Also key by the requested dirPath (e.g. `~`, relative) so an identical request doesn't re-hit the SSH backend. if (dirPath !== result.resolvedPath) { diff --git a/src/renderer/src/components/sidebar/use-server-ssh-projects.ts b/src/renderer/src/components/sidebar/use-server-ssh-projects.ts new file mode 100644 index 000000000000..729f70dd919e --- /dev/null +++ b/src/renderer/src/components/sidebar/use-server-ssh-projects.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react' +import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from '../../../../shared/ssh-target-management' +import { + useStructuredAgentSessionHostCapabilityState, + type StructuredAgentSessionHostCapabilityState +} from '@/runtime/structured-agent-session-host-capability' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' + +export type ServerSshProjects = { + state: StructuredAgentSessionHostCapabilityState + environmentId: string + label: string + onTargetsChanged: () => void +} + +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } + +/** Whether the selected paired server can add projects on its own SSH hosts; null for no server. */ +export function useServerSshProjects( + environmentId: string | null, + label: string | null, + onTargetsChanged: () => void +): ServerSshProjects | null { + const target = useMemo( + () => (environmentId ? { kind: 'environment', environmentId } : LOCAL_TARGET), + [environmentId] + ) + const state = useStructuredAgentSessionHostCapabilityState( + target, + SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY + ) + return useMemo( + () => + environmentId + ? { state, environmentId, label: label ?? environmentId, onTargetsChanged } + : null, + [environmentId, label, onTargetsChanged, state] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index e189fa18f763..b474f103108e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -2,7 +2,7 @@ import React from 'react' import { ChevronDown } from 'lucide-react' import type { VirtualItem } from '@tanstack/react-virtual' import { cn } from '@/lib/utils' -import type { AppState } from '@/store/types' +import type { RepoSshGateResolver } from '@/hooks/use-repo-ssh-gate-resolver' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status' @@ -48,7 +48,7 @@ export type SectionHeaderRowContext = { collapsedGroups: Set workspaceStatuses: readonly WorkspaceStatusDefinition[] projectGroups: readonly ProjectGroup[] - sshConnectionStates: AppState['sshConnectionStates'] + getRepoSshGate: RepoSshGateResolver highlightedRevealRowKey: string | null dragOverStatus: WorkspaceStatus | null pinDragOver: boolean @@ -152,9 +152,7 @@ export function renderWorktreeSectionHeaderRow(args: { ? getRepoHeaderCreateState({ repo: row.repo, label: row.label, - sshStatus: row.repo.connectionId - ? (ctx.sshConnectionStates.get(row.repo.connectionId)?.status ?? null) - : null + sshGate: ctx.getRepoSshGate(row.repo) }) : null const folderBackedProjectGroup = diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx index 56f5f8fe50ce..a72692d885a9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx @@ -4,6 +4,7 @@ import { translate } from '@/i18n/i18n' import { WorktreeListScrollToTopButton } from '../../WorktreeListScrollToTopButton' import { renderWorktreeSidebarDropIndicators } from './drop-indicators' import { useWorktreeListScrollToTop } from './use-scroll-to-top' +import { useRepoSshGateResolver } from '@/hooks/use-repo-ssh-gate-resolver' import { getActiveDescendantOptionId } from '../navigation/active-descendant-option' import { buildRenderableRows } from '../listing/renderable-rows' import { useFolderWorkspacePathStatusRows } from '../listing/use-folder-path-statuses' @@ -58,6 +59,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr const settings = useAppStore((s) => s.settings) const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const getRepoSshGate = useRepoSshGateResolver() const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true const reveal = useSidebarRevealHighlight() @@ -301,7 +303,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr measureVirtualRowElement, settings, worktreeVisibilityDefaultsByHost, - sshConnectionStates, + getRepoSshGate, newCardStyle, folderBackedProjectGroupIds, projectGroups, diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index 5219db4cfa20..b03ba6f15790 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -17,6 +17,8 @@ import type { VirtualizedWorktreeViewportProps } from './viewport-props' import type { WorktreeVirtualRowContext } from '../rows/virtual-row-dispatch' import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../../store/worktree-visibility-defaults-by-host' +import type { RepoSshGateResolver } from '@/hooks/use-repo-ssh-gate-resolver' + type BuildArgs = { props: VirtualizedWorktreeViewportProps projectGroups: readonly ProjectGroup[] @@ -26,7 +28,7 @@ type BuildArgs = { measureVirtualRowElement: (element: HTMLDivElement | null) => void settings: AppState['settings'] worktreeVisibilityDefaultsByHost: AppState['worktreeVisibilityDefaultsByHost'] - sshConnectionStates: AppState['sshConnectionStates'] + getRepoSshGate: RepoSshGateResolver newCardStyle: boolean folderBackedProjectGroupIds: ReadonlySet session: WorktreeDragSession @@ -78,7 +80,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual collapsedGroups: props.collapsedGroups, workspaceStatuses: props.workspaceStatuses, projectGroups: args.projectGroups, - sshConnectionStates: args.sshConnectionStates, + getRepoSshGate: args.getRepoSshGate, highlightedRevealRowKey: reveal.highlightedRevealRowKey, dragOverStatus: runtime.dragOverStatus, pinDragOver: runtime.pinDragOver, diff --git a/src/renderer/src/hooks/composer-state/host-runtime-effects.ts b/src/renderer/src/hooks/composer-state/host-runtime-effects.ts index 24a5402433be..3679e79b982a 100644 --- a/src/renderer/src/hooks/composer-state/host-runtime-effects.ts +++ b/src/renderer/src/hooks/composer-state/host-runtime-effects.ts @@ -30,6 +30,11 @@ import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tu import { getAgentCatalog } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate' +import { + connectRepoSshConnection, + getRepoSshConnection, + selectRepoSshGateInput +} from '@/lib/repo-ssh-connection' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -137,16 +142,17 @@ export function useHostRuntimeEffects(input: HostRuntimeEffectsInput) { } const liveState = useAppStore.getState() const liveRepo = liveState.repos.find((repo) => repo.id === repoIdRef.current) - if (liveRepo?.connectionId !== targetId) { + const connection = liveRepo ? getRepoSshConnection(liveRepo) : null + if (!liveRepo || connection?.targetId !== targetId) { return } - const liveStatus = liveState.sshConnectionStates.get(targetId)?.status ?? null + const liveStatus = selectRepoSshGateInput(liveState, liveRepo).status if (liveStatus === 'connected' || isSshConnectInProgress(liveStatus)) { return } try { - await window.api.ssh.connect({ targetId }) + await connectRepoSshConnection(connection) } catch (error) { toast.error( error instanceof Error diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts index ba6224009e0e..5aa0a191c103 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef } from 'react' import { getFolderSourceRepos } from '@/components/sidebar/folder-workspace-composer-helpers' import { parseExecutionHostId, getRepoExecutionHostId } from '../../../../shared/execution-host' import { getSelectedRepoSshGate } from '@/lib/new-workspace-ssh-gate' +import { selectRepoSshGateInput } from '@/lib/repo-ssh-connection' import { useFolderWorkspaceComposerPathStatus } from '@/components/sidebar/folder-workspace-composer-path-status' import { useDetectedAgents } from '@/hooks/useDetectedAgents' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -250,8 +251,14 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe initialRecipeId: initialEphemeralVmRecipeId }) - const selectedRepoConnectionId = selectedRepo?.connectionId ?? null - + // Why the shared gate: a paired server's own SSH target is read from that server (#25887). + const selectedRepoSshGateInput = selectedRepo + ? selectRepoSshGateInput( + { sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId }, + selectedRepo + ) + : null + const selectedRepoConnectionId = selectedRepoSshGateInput?.connectionId ?? null const selectedRepoSshState = selectedRepoConnectionId ? (sshConnectionStates.get(selectedRepoConnectionId) ?? null) : null @@ -259,7 +266,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe const { selectedRepoSshStatus, selectedRepoRequiresConnection, selectedRepoConnectInProgress } = getSelectedRepoSshGate({ connectionId: selectedRepoConnectionId, - status: selectedRepoSshState?.status ?? null + status: selectedRepoSshGateInput?.status ?? null }) const repoIdRef = useRef(repoId) diff --git a/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts b/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts new file mode 100644 index 000000000000..490fe7c25a87 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom +// #25887: a workspace on a paired server's own SSH target must be connected by that server. +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const state: Record = {} + return { state, connectRuntimeEnvironmentSshTarget: vi.fn() } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((select: (state: unknown) => unknown) => select(mocks.state), { + getState: () => mocks.state + }) +})) +vi.mock('@/runtime/runtime-environment-ssh-state', () => ({ + connectRuntimeEnvironmentSshTarget: mocks.connectRuntimeEnvironmentSshTarget, + hydrateRuntimeEnvironmentSshState: vi.fn(async () => {}) +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +const { useHostRuntimeEffects } = await import('./host-runtime-effects') + +const sshConnect = vi.fn() + +function renderEffects(repoId: string, targetId: string) { + return renderHook(() => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the connect path reads only the refs and store set here; the rest are inert stubs. + useHostRuntimeEffects({ + commitHookCheckIfCurrent: vi.fn(), + connectionId: null, + disabledTuiAgents: [], + ensureDetectedAgents: vi.fn(async () => []), + ensureRemoteDetectedAgents: vi.fn(async () => []), + ensureRuntimeDetectedAgents: vi.fn(async () => []), + fallbackDefaultAgent: null, + folderTargetConnectionId: null, + isRemote: false, + loadHookCheckForRepo: vi.fn(), + newWorkspaceDraft: null, + repoId: null, + repoIdRef: { current: repoId }, + runtimeEnvironmentId: null, + selectedRepoConnectionIdRef: { current: targetId }, + selectedRepoHookContextKey: null, + selectedRepoIsGit: false, + selectedRepoSshStatus: null, + setTuiAgent: vi.fn(), + settings: null, + tuiAgent: 'claude' + } as unknown as Parameters[0]) + ) +} + +describe('connecting the SSH target of the selected project', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(window, { api: { ssh: { connect: sshConnect } } }) + }) + + it("asks the server to connect its own SSH target, never this client's", async () => { + mocks.state = { + repos: [ + { + id: 'repo-dike', + connectionId: 'ssh-dike', + executionHostId: 'runtime:env-1' + } + ], + sshConnectionStates: new Map() + } + const { result } = renderEffects('repo-dike', 'ssh-dike') + + await act(() => result.current.onConnectSelectedRepo()) + + expect(mocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith('env-1', 'ssh-dike') + expect(sshConnect).not.toHaveBeenCalled() + }) + + it("connects this client's own SSH target locally, as before", async () => { + mocks.state = { + repos: [{ id: 'repo-local', connectionId: 'ssh-mine', executionHostId: 'ssh:ssh-mine' }], + sshConnectionStates: new Map() + } + const { result } = renderEffects('repo-local', 'ssh-mine') + + await act(() => result.current.onConnectSelectedRepo()) + + expect(sshConnect).toHaveBeenCalledWith({ targetId: 'ssh-mine' }) + expect(mocks.connectRuntimeEnvironmentSshTarget).not.toHaveBeenCalled() + }) +}) + +describe("agents for a project on a paired server's SSH host", () => { + it("are detected by the server, never by probing this client's SSH connections", async () => { + mocks.state = { + remoteDetectedAgentIds: {}, + runtimeDetectedAgentIds: {}, + detectedAgentIds: null, + ensureDetectedAgents: vi.fn(), + ensureRemoteDetectedAgents: vi.fn(), + ensureRuntimeDetectedAgents: vi.fn() + } + const { useWorkspaceIdentityState } = await import('./workspace-identity-state') + + const { result } = renderHook(() => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: identity state reads only these fields; the rest are inert. + useWorkspaceIdentityState({ + persistDraft: false, + newWorkspaceDraft: null, + selectedRepoConnectionId: 'ssh-dike', + selectedRepoSettings: { activeRuntimeEnvironmentId: 'env-1' }, + settings: null + } as unknown as Parameters[0]) + ) + + expect(result.current.isRemote).toBe(false) + // Attachment uploads still name the worktree's real host. + expect(result.current.connectionId).toBe('ssh-dike') + expect(result.current.runtimeEnvironmentId).toBe('env-1') + }) +}) diff --git a/src/renderer/src/hooks/composer-state/workspace-identity-state.ts b/src/renderer/src/hooks/composer-state/workspace-identity-state.ts index 37606e8cd7f3..c11400ee3617 100644 --- a/src/renderer/src/hooks/composer-state/workspace-identity-state.ts +++ b/src/renderer/src/hooks/composer-state/workspace-identity-state.ts @@ -176,11 +176,14 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) { ) // Why: for a repo on an SSH host or runtime env, read the per-host agent list so the dialog shows the host's installed agents, not local. - const connectionId = selectedRepoConnectionId + const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null - const isRemote = typeof connectionId === 'string' + // Kept as-is: uploads fence on the worktree's real host, which for a server repo is its target. + const connectionId = selectedRepoConnectionId - const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null + // Why: a server-owned repo's connectionId names that server's own SSH target, which this client + // cannot probe for agents; the server answers for it. + const isRemote = typeof connectionId === 'string' && !runtimeEnvironmentId const detectedAgentList = useAppStore((s) => { if (isRemote) { diff --git a/src/renderer/src/hooks/use-repo-ssh-gate-resolver.ts b/src/renderer/src/hooks/use-repo-ssh-gate-resolver.ts new file mode 100644 index 000000000000..02803e63be50 --- /dev/null +++ b/src/renderer/src/hooks/use-repo-ssh-gate-resolver.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' +import { useAppStore } from '@/store' +import { selectRepoSshGateInput } from '@/lib/repo-ssh-connection' +import type { Repo } from '../../../shared/repo-types' + +export type RepoSshGateResolver = (repo: Repo) => ReturnType + +/** Re-renders when either this client's or a paired server's SSH state changes. */ +export function useRepoSshGateResolver(): RepoSshGateResolver { + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + return useCallback( + (repo: Repo) => + selectRepoSshGateInput( + { sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId }, + repo + ), + [sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId] + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8cc7e3335818..dd2c22f7e31c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5388,6 +5388,9 @@ "df6fbcf880": "No SSH targets configured.", "44637f43bd": "SSH target", "80557be85a": "Choose a connected SSH target and enter the path to a Git repository.", + "serverDescription": "Choose an SSH host that {{server}} connects to, and enter the path to a Git repository on it.", + "serverEmpty": "{{server}} has no SSH hosts yet.", + "serverAddHost": "Add SSH host", "91b93a90a4": "Open project on SSH host", "007651bdf9": "Navigate to a directory and click Select to choose it.", "dd3ff65486": "Browse remote filesystem", @@ -6124,6 +6127,9 @@ "5f9ffac036": "Clone a remote Git repository", "7edb8ebe24": "Clone from URL", "a6c20dca96": "Open a project from an SSH target", + "serverSshTitle": "Project on this server's SSH host", + "serverSshDescription": "Open a project folder from an SSH host this server connects to", + "serverSshUpdate": "Update this server to add projects on its SSH hosts", "sshCreateUnavailable": "Not available for SSH hosts yet", "3d162cc76f": "Project on SSH host", "fb4fc5380e": "Local project, Git repo, or folder with many repos", @@ -6454,6 +6460,8 @@ "serverDescription": "Pair with Orca running on another computer.", "serverRunsAsYou": "This device will be able to run commands as you on this server.", "sshDescription": "Add a persistent machine you can log into over SSH.", + "serverSshTitle": "Add SSH host to {{server}}", + "serverSshDescription": "{{server}} saves this host and connects to it. Passwords and passphrases are asked for when it connects.", "cancel": "Cancel", "saving": "Saving...", "save": "Save", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ac8fb85b72da..4d311c8899fe 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5130,6 +5130,9 @@ "df6fbcf880": "No hay destinos SSH configurados.", "44637f43bd": "Destino SSH", "80557be85a": "Elige un destino SSH conectado e introduce la ruta a un repositorio Git.", + "serverDescription": "Elige un host SSH al que se conecta {{server}} e introduce la ruta a un repositorio Git en él.", + "serverEmpty": "{{server}} aún no tiene hosts SSH.", + "serverAddHost": "Añadir host SSH", "91b93a90a4": "Abrir proyecto en host SSH", "007651bdf9": "Navega hasta un directorio y haz clic en Seleccionar para elegirlo.", "dd3ff65486": "Explorar el sistema de archivos remoto", @@ -5884,6 +5887,9 @@ "5f9ffac036": "Clonar un repositorio Git remoto", "7edb8ebe24": "Clonar desde URL", "a6c20dca96": "Abrir un proyecto desde un destino SSH", + "serverSshTitle": "Proyecto en un host SSH de este servidor", + "serverSshDescription": "Abre una carpeta de proyecto de un host SSH al que se conecta este servidor", + "serverSshUpdate": "Actualiza este servidor para añadir proyectos en sus hosts SSH", "3d162cc76f": "Proyecto remoto", "fb4fc5380e": "Proyecto local, repositorio de Git o carpeta con muchos repositorios", "2281fdc8c7": "Explorar carpeta", @@ -6237,6 +6243,8 @@ "serverDescription": "Empareja con Orca ejecutándose en otro equipo.", "serverRunsAsYou": "Este dispositivo podrá ejecutar comandos como tú en este servidor.", "sshDescription": "Agrega una máquina persistente donde puedas iniciar sesión por SSH.", + "serverSshTitle": "Añadir host SSH a {{server}}", + "serverSshDescription": "{{server}} guarda este host y se conecta a él. Las contraseñas y frases de contraseña se piden al conectar.", "cancel": "Cancelar", "saving": "Guardando...", "save": "Guardar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ed257372afb1..06a61d670088 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -5251,6 +5251,9 @@ "df6fbcf880": "Aucune cible SSH configurée.", "44637f43bd": "Cible SSH", "80557be85a": "Choisissez une cible SSH connectée et saisissez le chemin vers un dépôt Git.", + "serverDescription": "Choisissez un hôte SSH auquel {{server}} se connecte, puis saisissez le chemin d’un dépôt Git sur cet hôte.", + "serverEmpty": "{{server}} n’a pas encore d’hôte SSH.", + "serverAddHost": "Ajouter un hôte SSH", "91b93a90a4": "Ouvrir un projet sur l'hôte SSH", "007651bdf9": "Naviguez jusqu'à un répertoire et cliquez sur Sélectionner pour le choisir.", "dd3ff65486": "Parcourir le système de fichiers distant", @@ -5987,6 +5990,9 @@ "5f9ffac036": "Cloner un dépôt Git distant", "7edb8ebe24": "Cloner depuis une URL", "a6c20dca96": "Ouvrir un projet depuis une cible SSH", + "serverSshTitle": "Projet sur un hôte SSH de ce serveur", + "serverSshDescription": "Ouvrir un dossier de projet depuis un hôte SSH auquel ce serveur se connecte", + "serverSshUpdate": "Mettez ce serveur à jour pour ajouter des projets sur ses hôtes SSH", "sshCreateUnavailable": "Pas encore disponible pour les hôtes SSH", "3d162cc76f": "Projet sur hôte SSH", "fb4fc5380e": "Projet local, dépôt Git ou dossier contenant plusieurs dépôts", @@ -6317,6 +6323,8 @@ "serverDescription": "Se jumeler avec Orca exécuté sur un autre ordinateur.", "serverRunsAsYou": "Cet appareil pourra exécuter des commandes en votre nom sur ce serveur.", "sshDescription": "Ajoutez une machine permanente sur laquelle vous pouvez vous connecter en SSH.", + "serverSshTitle": "Ajouter un hôte SSH à {{server}}", + "serverSshDescription": "{{server}} enregistre cet hôte et s’y connecte. Les mots de passe et phrases secrètes sont demandés à la connexion.", "cancel": "Annuler", "saving": "Enregistrement...", "save": "Enregistrer", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 227575da986d..d8029419cc2f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5111,6 +5111,9 @@ "df6fbcf880": "SSH ターゲットが設定されていません。", "44637f43bd": "SSH ターゲット", "80557be85a": "接続されている SSH ターゲットを選択し、Git リポジトリへのパスを入力します。", + "serverDescription": "{{server}} が接続する SSH ホストを選び、そのホスト上の Git リポジトリのパスを入力してください。", + "serverEmpty": "{{server}} にはまだ SSH ホストがありません。", + "serverAddHost": "SSH ホストを追加", "91b93a90a4": "SSH ホスト上のプロジェクトを開く", "007651bdf9": "ディレクトリに移動し、「選択」をクリックして選択します。", "dd3ff65486": "リモートファイルシステムを参照する", @@ -5865,6 +5868,9 @@ "5f9ffac036": "リモート Git リポジトリのクローンを作成する", "7edb8ebe24": "URL からクローン", "a6c20dca96": "SSH ターゲットからプロジェクトを開く", + "serverSshTitle": "このサーバーの SSH ホスト上のプロジェクト", + "serverSshDescription": "このサーバーが接続する SSH ホストのプロジェクトフォルダーを開きます", + "serverSshUpdate": "SSH ホストにプロジェクトを追加するには、このサーバーを更新してください", "3d162cc76f": "SSH ホスト上のプロジェクト", "fb4fc5380e": "ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー", "2281fdc8c7": "フォルダーを参照", @@ -6237,6 +6243,8 @@ "serverDescription": "別のコンピュータで実行されている Orca とペアリングします。", "serverRunsAsYou": "このデバイスは、このサーバー上であなたとしてコマンドを実行できるようになります。", "sshDescription": "SSH でログインできる永続的なマシンを追加します。", + "serverSshTitle": "{{server}} に SSH ホストを追加", + "serverSshDescription": "{{server}} がこのホストを保存して接続します。パスワードとパスフレーズは接続時に尋ねられます。", "cancel": "キャンセル", "saving": "保存中…", "save": "保存", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 62d63f3bf3ab..3c15aa5fde10 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5111,6 +5111,9 @@ "df6fbcf880": "구성된 SSH 대상이 없습니다.", "44637f43bd": "SSH 대상", "80557be85a": "연결된 SSH 대상을 선택하고 Git 리포지토리 경로를 입력하세요.", + "serverDescription": "{{server}}이(가) 연결하는 SSH 호스트를 선택하고, 그 호스트의 Git 저장소 경로를 입력하세요.", + "serverEmpty": "{{server}}에 아직 SSH 호스트가 없습니다.", + "serverAddHost": "SSH 호스트 추가", "91b93a90a4": "원격 프로젝트 열기", "007651bdf9": "디렉터리로 이동한 후 선택을 클릭하여 선택합니다.", "dd3ff65486": "원격 파일 시스템 찾아보기", @@ -5865,6 +5868,9 @@ "5f9ffac036": "원격 Git repos 복제", "7edb8ebe24": "URL에서 복제", "a6c20dca96": "SSH 대상에서 프로젝트 열기", + "serverSshTitle": "이 서버의 SSH 호스트에 있는 프로젝트", + "serverSshDescription": "이 서버가 연결하는 SSH 호스트의 프로젝트 폴더를 엽니다", + "serverSshUpdate": "SSH 호스트에 프로젝트를 추가하려면 이 서버를 업데이트하세요", "3d162cc76f": "원격 프로젝트", "fb4fc5380e": "로컬 프로젝트, Git repo 또는 repos가 많은 폴더", "2281fdc8c7": "폴더 찾아보기", @@ -6237,6 +6243,8 @@ "serverDescription": "다른 컴퓨터에서 실행 중인 Orca와 페어링합니다.", "serverRunsAsYou": "이 기기는 이 서버에서 사용자 권한으로 명령을 실행할 수 있게 됩니다.", "sshDescription": "SSH를 통해 로그인할 수 있는 영구 머신을 추가합니다.", + "serverSshTitle": "{{server}}에 SSH 호스트 추가", + "serverSshDescription": "{{server}}이(가) 이 호스트를 저장하고 연결합니다. 비밀번호와 암호 문구는 연결할 때 묻습니다.", "cancel": "취소", "saving": "저장 중...", "save": "저장", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dd536ad76536..e74128ea8637 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5111,6 +5111,9 @@ "df6fbcf880": "未配置 SSH 目标。", "44637f43bd": "SSH 目标", "80557be85a": "选择已连接的 SSH 目标并输入 Git 存储库的路径。", + "serverDescription": "选择 {{server}} 连接的 SSH 主机,并输入该主机上 Git 仓库的路径。", + "serverEmpty": "{{server}} 还没有 SSH 主机。", + "serverAddHost": "添加 SSH 主机", "91b93a90a4": "打开远程项目", "007651bdf9": "导航到目录并单击“选择”以选择它。", "dd3ff65486": "浏览远程文件系统", @@ -5865,6 +5868,9 @@ "5f9ffac036": "克隆远程 Git 存储库", "7edb8ebe24": "从 URL 克隆", "a6c20dca96": "从 SSH 目标打开项目", + "serverSshTitle": "此服务器 SSH 主机上的项目", + "serverSshDescription": "从此服务器连接的 SSH 主机打开项目文件夹", + "serverSshUpdate": "更新此服务器后才能在其 SSH 主机上添加项目", "3d162cc76f": "远程项目", "fb4fc5380e": "本地项目、Git 仓库或包含多个仓库的文件夹", "2281fdc8c7": "浏览文件夹", @@ -6237,6 +6243,8 @@ "serverDescription": "与另一台电脑上运行的 Orca 配对。", "serverRunsAsYou": "此设备将能够在此服务器上以你的身份运行命令。", "sshDescription": "添加一台可通过 SSH 登录并持久保存的机器。", + "serverSshTitle": "向 {{server}} 添加 SSH 主机", + "serverSshDescription": "{{server}} 会保存此主机并连接到它。密码和密钥口令会在连接时询问。", "cancel": "取消", "saving": "正在保存...", "save": "保存", diff --git a/src/renderer/src/lib/repo-ssh-connection.test.ts b/src/renderer/src/lib/repo-ssh-connection.test.ts new file mode 100644 index 000000000000..bdb58db8aa74 --- /dev/null +++ b/src/renderer/src/lib/repo-ssh-connection.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import type { SshConnectionState, SshConnectionStatus } from '../../../shared/ssh-types' +import { getRepoHeaderCreateState } from '@/components/sidebar/repo-header-create-state' + +vi.mock('@/runtime/runtime-environment-ssh-state', () => ({ + connectRuntimeEnvironmentSshTarget: vi.fn() +})) + +const { getRepoSshConnection, selectRepoSshGateInput } = await import('./repo-ssh-connection') + +function repo(overrides: Partial): Repo { + return { + id: 'r', + path: '/srv/dike', + displayName: 'dike', + badgeColor: '#999', + addedAt: 1, + ...overrides + } +} + +function sshState(status: SshConnectionStatus): SshConnectionState { + return { targetId: 'ssh-dike', status, error: null, reconnectAttempt: 0 } +} + +// The server's target `ssh-dike` is never in this client's own map. +function state(options: { + serverStatus?: SshConnectionStatus + hydrated?: boolean + reachable?: boolean +}) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store fixture; the selector reads only the SSH maps and the status entry built here. + return { + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + removedSshTargetLabels: new Map(), + sshTargetsHydrated: true, + sshStateByEnvironment: new Map([ + [ + 'env-1', + { + connectionStates: new Map( + options.serverStatus ? [['ssh-dike', sshState(options.serverStatus)]] : [] + ), + targetLabels: new Map([['ssh-dike', 'dike']]), + targetGenerations: new Map(), + removedTargetLabels: new Map(), + targetsHydrated: options.hydrated ?? true + } + ] + ]), + runtimeStatusByEnvironmentId: new Map([ + ['env-1', options.reachable === false ? { status: null } : { checkedAt: 1, status: {} }] + ]) + } as unknown as Parameters[0] +} + +const serverRepo = repo({ connectionId: 'ssh-dike', executionHostId: 'runtime:env-1' }) + +describe("a project on a paired server's own SSH target (#25887)", () => { + it('names the server and its target', () => { + expect(getRepoSshConnection(serverRepo)).toEqual({ + environmentId: 'env-1', + targetId: 'ssh-dike' + }) + expect(getRepoSshConnection(repo({ connectionId: 'ssh-mine' }))).toEqual({ + environmentId: null, + targetId: 'ssh-mine' + }) + expect(getRepoSshConnection(repo({ executionHostId: 'runtime:env-1' }))).toBeNull() + }) + + it('allows creating a workspace when the server reports its target connected', () => { + const sshGate = selectRepoSshGateInput(state({ serverStatus: 'connected' }), serverRepo) + + expect(getRepoHeaderCreateState({ repo: serverRepo, label: 'dike', sshGate })).toMatchObject({ + disabled: false + }) + }) + + it('asks to reconnect when the server reports its target disconnected', () => { + const sshGate = selectRepoSshGateInput(state({ serverStatus: 'disconnected' }), serverRepo) + + expect(sshGate).toEqual({ connectionId: 'ssh-dike', status: 'disconnected' }) + expect(getRepoHeaderCreateState({ repo: serverRepo, label: 'dike', sshGate })).toMatchObject({ + disabled: true, + requiresSshReconnect: true + }) + }) + + it('leaves an unverifiable target to the server instead of blocking with a dead reconnect', () => { + for (const unverifiable of [{ hydrated: false }, { reachable: false }]) { + const sshGate = selectRepoSshGateInput( + state({ serverStatus: 'disconnected', ...unverifiable }), + serverRepo + ) + expect(sshGate).toEqual({ connectionId: null, status: null }) + } + }) + + it("keeps reading this client's own map for its own SSH projects", () => { + const local = repo({ connectionId: 'ssh-mine' }) + const base = state({}) + base.sshConnectionStates.set('ssh-mine', { ...sshState('connected'), targetId: 'ssh-mine' }) + + expect(selectRepoSshGateInput(base, local)).toEqual({ + connectionId: 'ssh-mine', + status: 'connected' + }) + }) +}) diff --git a/src/renderer/src/lib/repo-ssh-connection.ts b/src/renderer/src/lib/repo-ssh-connection.ts new file mode 100644 index 000000000000..4d6ddb75288c --- /dev/null +++ b/src/renderer/src/lib/repo-ssh-connection.ts @@ -0,0 +1,60 @@ +import { + selectRuntimeAwareSshStatus, + type RuntimeAwareSshStatusState +} from '@/store/slices/runtime-environment-ssh-selectors' +import { connectRuntimeEnvironmentSshTarget } from '@/runtime/runtime-environment-ssh-state' +import { + getRepoExecutionHostId, + getRepoSshConnectionId, + parseExecutionHostId +} from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' +import type { SshConnectionStatus } from '../../../shared/ssh-types' + +/** The SSH target holding a repo's files; `environmentId` is the paired server that owns it. */ +export type RepoSshConnection = { environmentId: string | null; targetId: string } + +type RepoSshFields = Pick + +export function getRepoSshConnection(repo: RepoSshFields): RepoSshConnection | null { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (host?.kind === 'runtime') { + const targetId = getRepoSshConnectionId(repo) + return targetId ? { environmentId: host.environmentId, targetId } : null + } + return repo.connectionId ? { environmentId: null, targetId: repo.connectionId } : null +} + +/** + * The target and status that gate creating a workspace in a repo. A server's own target is read + * from that server's state, never this client's map (#25887). When this client cannot verify it + * (server unreachable or not hydrated) nothing is gated here: the server owns the answer. + */ +export function selectRepoSshGateInput( + state: RuntimeAwareSshStatusState, + repo: RepoSshFields +): { connectionId: string | null; status: SshConnectionStatus | null } { + const connection = getRepoSshConnection(repo) + if (!connection) { + return { connectionId: null, status: null } + } + if (connection.environmentId === null) { + return { + connectionId: connection.targetId, + status: state.sshConnectionStates.get(connection.targetId)?.status ?? null + } + } + const status = selectRuntimeAwareSshStatus(state, connection.environmentId, connection.targetId) + return status === null + ? { connectionId: null, status: null } + : { connectionId: connection.targetId, status } +} + +/** Connects the target on whichever machine owns it; a server's target is never dialed here. */ +export async function connectRepoSshConnection(connection: RepoSshConnection): Promise { + if (connection.environmentId !== null) { + await connectRuntimeEnvironmentSshTarget(connection.environmentId, connection.targetId) + return + } + await window.api.ssh.connect({ targetId: connection.targetId }) +} diff --git a/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts b/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts index 90f092220997..20b728fd9c23 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts @@ -15,17 +15,24 @@ type RuntimeAwareSshReadState = Pick< > & Partial> +/** What a target's status reads; narrower so callers needn't carry the label maps. */ +export type RuntimeAwareSshStatusState = Pick< + AppState, + 'sshConnectionStates' | 'sshStateByEnvironment' +> & + Partial> + // Why the shared verdict and not `entry.status`: an unverifiable probe nulls it while the // transport is still up, and blanking the mirrored SSH rows of a host that never went away // reads as "the targets vanished" (docs/reference/ssh-execution-boundary.md). -function isEnvironmentReachable(state: RuntimeAwareSshReadState, environmentId: string): boolean { +function isEnvironmentReachable(state: RuntimeAwareSshStatusState, environmentId: string): boolean { return isConnectedRuntimeHostState( runtimeHostConnectionStateForEntry(state.runtimeStatusByEnvironmentId?.get(environmentId)) ) } export function selectRuntimeAwareSshStatus( - state: RuntimeAwareSshReadState, + state: RuntimeAwareSshStatusState, environmentId: string | null, targetId: string ): SshConnectionStatus | null { @@ -43,7 +50,7 @@ export function selectRuntimeAwareSshStatus( } export function selectRuntimeAwareSshError( - state: RuntimeAwareSshReadState, + state: RuntimeAwareSshStatusState, environmentId: string | null, targetId: string ): string | null { From 8f24508bb66aecc3c2e36bf67f02c17ace6573e3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:54:54 -0400 Subject: [PATCH 072/110] infra(relay): pin staging c4 to the step-5 image again after the director identity bootstrap (#27352) --- cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs | 4 ++-- cloud/infra/terraform/environments/staging.tfvars | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 277164e6747d..71e5e7aa9219 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -42,8 +42,8 @@ const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' // C31 launches on the digest the director served when it was declared. const c31Digest = 'f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269' -// Staging C4 stays on the serving director's image until the director's identity bootstrap deploy. -const stagingC4Digest = launchDigest +// Staging C4 runs the step-5 image D for the staging e2e. +const stagingC4Digest = 'ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02' function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index 53001e607f8f..d26a0ee23f53 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -77,7 +77,7 @@ relay_gce_cells = { boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" capacity_requests = 6000 database_pool_max = 10 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" initially_enabled = false connection_hard_cap = 3000 connection_unobserved_bound = 60 From f69bbe8dca9387e351ddaf5ecb5429768a3fc2b8 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:15:50 -0400 Subject: [PATCH 073/110] List the cold-relaunch tab-order swap as known on main (#27353) * List the cold-relaunch tab-order swap as known on main The cold-daemon-restart oracle scenario intermittently finds the tab rows in swapped order after the relaunch. The relaunch's exits for the killed daemon's terminals retire both tab rows, the respawns re-add them in the order they bind, and the window's next save keeps main's row order. Co-Authored-By: Claude * Wrap the known-on-main comment to the line limit Co-Authored-By: Claude --------- Co-authored-by: Claude --- tests/e2e/AGENTS.md | 2 +- tests/e2e/workspace-layout-oracle-known-on-main.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index 88908539f2a8..7865a3f9e255 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -58,4 +58,4 @@ Terminal-topology refactor PRs must be inert or fix a named bug. `terminal-layou `workspace-layout-oracle-latency.spec.ts` records command-to-drawn-update p50/p95 for split, close pane, new tab, rename, tab reorder and close tab, idle and while another tab floods output. It never fails on a number. -A step waits up to `ORCA_LAYOUT_ORACLE_SETTLE_MS` (12 s) for every view to agree before recording. Findings main still has are listed in `workspace-layout-oracle-known-on-main.ts` with their bug; anything else fails. `ORCA_LAYOUT_ORACLE_RECORD=1` records without failing, `ORCA_LAYOUT_ORACLE_REPEAT=` repeats each scenario, and each run writes a JSON report (with the evidence each side held) to `ORCA_LAYOUT_ORACLE_REPORT_DIR`. `node config/scripts/check-workspace-layout-rules.mjs ` runs the rules over any profile, including a running dev app's. +A step waits up to `ORCA_LAYOUT_ORACLE_SETTLE_MS` (12 s) for every view to agree before recording. Findings main still has are listed in `workspace-layout-oracle-known-on-main.ts` with their bug; anything else fails. A new entry must cite, in its PR, a run on `main` that reproduced the finding; for an intermittent finding, rerun until one does. `ORCA_LAYOUT_ORACLE_RECORD=1` records without failing, `ORCA_LAYOUT_ORACLE_REPEAT=` repeats each scenario, and each run writes a JSON report (with the evidence each side held) to `ORCA_LAYOUT_ORACLE_REPORT_DIR`. `node config/scripts/check-workspace-layout-rules.mjs ` runs the rules over any profile, including a running dev app's. diff --git a/tests/e2e/workspace-layout-oracle-known-on-main.ts b/tests/e2e/workspace-layout-oracle-known-on-main.ts index 7bfd83a2251e..25cfb71fc63e 100644 --- a/tests/e2e/workspace-layout-oracle-known-on-main.ts +++ b/tests/e2e/workspace-layout-oracle-known-on-main.ts @@ -58,6 +58,16 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [ rule: 'tab_order_disagrees', cause: 'one tab order' }, + // Intermittent (about 1 in 30 on main): the relaunch's exits for the killed daemon's terminals + // retire both tab rows, the respawns re-add them in whichever order they bind, and the window's + // next save keeps main's row order. Fixed in PR 6, where rows follow the group's tab order. + { + scenario: 'cold-daemon-restart', + check: 'expected', + step: 'after cold relaunch', + detail: /: panes per tab \[1,2\], expected \[2,1\]$/, + cause: 'one tab order' + }, // After a cold relaunch the paired-client tab list stays empty while the window shows the tabs. { scenario: 'cold-daemon-restart', From 73575f4a0ab12298e53c6945f015b816305037f9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:26:27 -0400 Subject: [PATCH 074/110] infra(relay): point the staging deploy at the pre-step-5 image for the reserve guard drill (#27357) --- cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs | 4 ++-- cloud/infra/terraform/environments/staging.tfvars | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 71e5e7aa9219..277164e6747d 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -42,8 +42,8 @@ const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' // C31 launches on the digest the director served when it was declared. const c31Digest = 'f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269' -// Staging C4 runs the step-5 image D for the staging e2e. -const stagingC4Digest = 'ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02' +// Staging C4 stays on the serving director's image until the director's identity bootstrap deploy. +const stagingC4Digest = launchDigest function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index d26a0ee23f53..53001e607f8f 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -77,7 +77,7 @@ relay_gce_cells = { boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" capacity_requests = 6000 database_pool_max = 10 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563" initially_enabled = false connection_hard_cap = 3000 connection_unobserved_bound = 60 From 24ea55a5119ba3d67facae1d0c87ba0394055aa3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:42:16 -0400 Subject: [PATCH 075/110] infra(relay): pin staging c4 to the step-5 image again after the reserve guard drill (#27360) --- cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs | 4 ++-- cloud/infra/terraform/environments/staging.tfvars | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 277164e6747d..71e5e7aa9219 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -42,8 +42,8 @@ const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' // C31 launches on the digest the director served when it was declared. const c31Digest = 'f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269' -// Staging C4 stays on the serving director's image until the director's identity bootstrap deploy. -const stagingC4Digest = launchDigest +// Staging C4 runs the step-5 image D for the staging e2e. +const stagingC4Digest = 'ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02' function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index 53001e607f8f..d26a0ee23f53 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -77,7 +77,7 @@ relay_gce_cells = { boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" capacity_requests = 6000 database_pool_max = 10 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:ac2ab357d3ed9f104ed3d5c440ef3d1ad493d9661e86d1c404d8dfc6b7c59c02" initially_enabled = false connection_hard_cap = 3000 connection_unobserved_bound = 60 From cf36928a694fae0bcbe61a93ee583fd8f3b86f57 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 19:44:23 -0700 Subject: [PATCH 076/110] test(native-chat): unmount the cut-turn notice hooks after each test (#27358) A rendered hook left mounted can commit after happy-dom is torn down, which fails the shard with "window is not defined" from React's scheduler. Co-authored-by: m4air Co-authored-by: Claude --- .../use-structured-agent-session.cut-turn-notice.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx index f4cd4c927ad7..29c2253dddfa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -import { renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } @@ -74,6 +74,9 @@ vi.mock('./use-structured-agent-session-sends', () => ({ import { useStructuredAgentSession } from './use-structured-agent-session' describe('useStructuredAgentSession transcript', () => { + // A root left mounted can commit after happy-dom is torn down ("window is not defined"). + afterEach(cleanup) + it('shows a cut turn no row explains with its one notice, placed in that turn', () => { const { result } = renderHook(() => useStructuredAgentSession({ From 5f350a37864a3a25efb1041b341c4552fbd7c3c0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 20:01:11 -0700 Subject: [PATCH 077/110] fix(ssh): restore saved port forwards on managed hosts and show their Ports tab (#27361) * fix(ssh): restore saved port forwards on managed hosts; show Ports for managed workspaces * test: annotate the mock connection casts --------- Co-authored-by: m4air --- .../ipc/ssh-connection-state-callbacks.ts | 5 + src/main/ipc/ssh-host-server-connect.ts | 2 + ...anaged-host-port-forward-lifecycle.test.ts | 69 ++++++++++++ .../ipc/ssh-managed-port-forward-restore.ts | 26 +++++ src/main/ipc/ssh-port-forward-persistence.ts | 16 ++- src/main/ipc/ssh-port-forward-restore.test.ts | 100 ++++++++++++++++++ src/main/ipc/ssh-session-teardown.ts | 5 +- src/main/ssh/ssh-port-forward-provider.ts | 2 + src/main/ssh/ssh-port-forward.test.ts | 16 +++ src/main/ssh/ssh-port-forward.ts | 9 ++ src/main/ssh/ssh2-port-forward-provider.ts | 4 +- .../right-sidebar/activity-bar-buttons.tsx | 2 +- .../right-sidebar/ports-panel-host.test.ts | 39 +++++++ .../right-sidebar/ports-panel-host.ts | 24 +++++ .../right-sidebar-activity-visibility.test.ts | 10 +- .../right-sidebar-activity-visibility.ts | 7 +- .../use-right-sidebar-activity-items.ts | 17 ++- tests/e2e/ssh-orcad-workspace-tools.spec.ts | 11 +- 18 files changed, 343 insertions(+), 21 deletions(-) create mode 100644 src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts create mode 100644 src/main/ipc/ssh-managed-port-forward-restore.ts create mode 100644 src/main/ipc/ssh-port-forward-restore.test.ts diff --git a/src/main/ipc/ssh-connection-state-callbacks.ts b/src/main/ipc/ssh-connection-state-callbacks.ts index 51c68b948323..3d190c9f7306 100644 --- a/src/main/ipc/ssh-connection-state-callbacks.ts +++ b/src/main/ipc/ssh-connection-state-callbacks.ts @@ -11,6 +11,7 @@ import { testingTargets } from './ssh-connect-attempt-registry' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' import { requestCredential } from './ssh-passphrase' import { clearRelayLostBackoff } from './ssh-relay-lost-backoff' import { @@ -108,6 +109,10 @@ export function handleSshConnectionStateChange(targetId: string, state: SshConne } if (!session) { + // An explicit connect restores through its own publish; this covers a transport that recovered. + if (state.status === 'connected' && !connectInFlight.has(targetId)) { + void restoreManagedHostPortForwards(targetId) + } return } // Why: allow reconnect from both 'ready' and 'reconnecting'; without the latter, a failed relay deploy would permanently brick the session. diff --git a/src/main/ipc/ssh-host-server-connect.ts b/src/main/ipc/ssh-host-server-connect.ts index 4d1ff5ba22cf..e15c60e4bd98 100644 --- a/src/main/ipc/ssh-host-server-connect.ts +++ b/src/main/ipc/ssh-host-server-connect.ts @@ -18,6 +18,7 @@ import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { allowsDirectSshRelay } from '../ssh/ssh-connection-store' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' import { broadcastSshState, clearRelayStateOverride, @@ -101,6 +102,7 @@ export function publishManagedServerConnect( ...(serving ? { serving } : {}) } setSshHostServerStatus(targetId, managedServer) + void restoreManagedHostPortForwards(targetId) const state: SshConnectionState = { ...(connectionManager!.getState(targetId) ?? { targetId, reconnectAttempt: 0 }), targetId, diff --git a/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts b/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts new file mode 100644 index 000000000000..022472d7d2ad --- /dev/null +++ b/src/main/ipc/ssh-managed-host-port-forward-lifecycle.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + restore: vi.fn(async () => {}), + removeAllForwards: vi.fn(async () => {}), + disconnect: vi.fn(async () => {}), + getState: vi.fn(() => ({ targetId: 't1', status: 'connected', error: null, reconnectAttempt: 0 })) +})) + +vi.mock('./ssh-managed-port-forward-restore', () => ({ restoreManagedHostPortForwards: h.restore })) +vi.mock('./ssh-ipc-context', () => ({ + connectionManager: { getState: h.getState, disconnect: h.disconnect, getConnection: vi.fn() }, + currentRuntime: null, + getCurrentMainWindow: () => null, + persistedStore: null, + portForwardManager: { removeAllForwards: h.removeAllForwards } +})) +vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + getSshTargetRegistryStore: () => null +})) + +const { handleSshConnectionStateChange } = await import('./ssh-connection-state-callbacks') +const { publishManagedServerConnect } = await import('./ssh-host-server-connect') +const { disconnectRegisteredSshTarget } = await import('./ssh-session-teardown') +const { connectInFlight } = await import('./ssh-connect-attempt-registry') +const { getSshProviderAuthority } = await import('../ssh/ssh-provider-authority') +const { clearSshHostServerStatus, setSshHostServerStatus } = + await import('../ssh/ssh-host-server-status') + +const connected = { targetId: 't1', status: 'connected', error: null, reconnectAttempt: 0 } as const + +describe('saved port forwards on a managed host', () => { + beforeEach(() => { + vi.clearAllMocks() + clearSshHostServerStatus('t1') + }) + + it('restores them when a managed connect is published', () => { + publishManagedServerConnect('t1', 'env-1') + expect(h.restore).toHaveBeenCalledWith('t1') + }) + + it('restores them when the managed host transport reconnects on its own', () => { + setSshHostServerStatus('t1', { kind: 'managed', environmentId: 'env-1' }) + handleSshConnectionStateChange('t1', { ...connected, status: 'reconnecting' }) + expect(h.restore).not.toHaveBeenCalled() + handleSshConnectionStateChange('t1', connected) + expect(h.restore).toHaveBeenCalledWith('t1') + }) + + it('leaves an explicit connect to restore through its own publish', () => { + connectInFlight.set('t1', { + authority: getSshProviderAuthority('t1'), + promise: new Promise(() => {}) + }) + try { + handleSshConnectionStateChange('t1', connected) + expect(h.restore).not.toHaveBeenCalled() + } finally { + connectInFlight.delete('t1') + } + }) + + it('closes the local listeners on disconnect even though no relay session exists', async () => { + await disconnectRegisteredSshTarget('t1') + expect(h.removeAllForwards).toHaveBeenCalledWith('t1') + }) +}) diff --git a/src/main/ipc/ssh-managed-port-forward-restore.ts b/src/main/ipc/ssh-managed-port-forward-restore.ts new file mode 100644 index 000000000000..41b868c45cce --- /dev/null +++ b/src/main/ipc/ssh-managed-port-forward-restore.ts @@ -0,0 +1,26 @@ +import { getSshHostServerStatus } from '../ssh/ssh-host-server-status' +import { getCurrentMainWindow } from './ssh-ipc-context' +import { restorePortForwards } from './ssh-port-forward-persistence' + +const restoring = new Map>() + +/** A managed host has no relay session to restore its saved forwards, so its connects do. */ +export function restoreManagedHostPortForwards(targetId: string): Promise { + if (getSshHostServerStatus(targetId)?.kind !== 'managed') { + return Promise.resolve() + } + // Why chained: overlapping connects would otherwise both bind the same saved local port. + const previous = restoring.get(targetId) ?? Promise.resolve() + const next = previous + .then(() => restorePortForwards(targetId, getCurrentMainWindow)) + .catch((error: unknown) => { + console.warn(`[ssh] Could not restore port forwards for ${targetId}:`, error) + }) + .finally(() => { + if (restoring.get(targetId) === next) { + restoring.delete(targetId) + } + }) + restoring.set(targetId, next) + return next +} diff --git a/src/main/ipc/ssh-port-forward-persistence.ts b/src/main/ipc/ssh-port-forward-persistence.ts index e0407f859f5c..7addd6b7dde3 100644 --- a/src/main/ipc/ssh-port-forward-persistence.ts +++ b/src/main/ipc/ssh-port-forward-persistence.ts @@ -4,6 +4,10 @@ import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { connectionManager, portForwardManager } from './ssh-ipc-context' import { broadcastPortForwards } from './ssh-renderer-broadcast' +function forwardKey(forward: SavedPortForward): string { + return `${forward.localPort}:${forward.remoteHost}:${forward.remotePort}` +} + // Why: after user add/remove/update the runtime manager is the source of truth — persist exactly its entries (unrestored ones handled by a separate helper). export function persistPortForwards(targetId: string): void { const active = portForwardManager!.listForwards(targetId) @@ -21,12 +25,10 @@ export function persistPortForwards(targetId: string): void { // Why: keep forwards that failed to restore in the persisted list so they retry on next reconnect instead of being silently dropped. export function persistPortForwardsWithUnrestored(targetId: string): void { const active = portForwardManager!.listForwards(targetId) - const activeKeys = new Set(active.map((f) => `${f.localPort}:${f.remoteHost}:${f.remotePort}`)) + const activeKeys = new Set(active.map(forwardKey)) const existing = getSshTargetRegistryStore()!.getTarget(targetId)?.portForwards ?? [] - const unrestored = existing.filter( - (pf) => !activeKeys.has(`${pf.localPort}:${pf.remoteHost}:${pf.remotePort}`) - ) + const unrestored = existing.filter((pf) => !activeKeys.has(forwardKey(pf))) const saved: SavedPortForward[] = [ ...active.map((f) => ({ @@ -54,9 +56,15 @@ export async function restorePortForwards( if (!conn) { return } + // Why: a forward bound to the replaced transport still holds its local port but reaches nothing. + await portForwardManager!.closeStaleForwards(targetId, conn) + const live = new Set(portForwardManager!.listForwards(targetId).map(forwardKey)) // Why: keep failed restores in persisted state — a failure may be transient (port temporarily busy), so retry on next reconnect. for (const saved of target.portForwards) { + if (live.has(forwardKey(saved))) { + continue + } // Why: a reconnect mid-loop swaps the connection object; bail on identity change so we don't add forwards to a stale conn (leaking listeners). if (connectionManager!.getConnection(targetId) !== conn) { return diff --git a/src/main/ipc/ssh-port-forward-restore.test.ts b/src/main/ipc/ssh-port-forward-restore.test.ts new file mode 100644 index 000000000000..e4517510dc6f --- /dev/null +++ b/src/main/ipc/ssh-port-forward-restore.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PortForwardEntry, SavedPortForward } from '../../shared/ssh-types' + +const h = vi.hoisted(() => { + const conn = { id: 'conn' } + const saved: { portForwards?: SavedPortForward[] } = {} + const active: PortForwardEntry[] = [] + return { + conn, + saved, + active, + store: { + getTarget: vi.fn(() => ({ id: 't1', ...saved })), + updateTarget: vi.fn((_id: string, update: { portForwards?: SavedPortForward[] }) => { + saved.portForwards = update.portForwards + }) + }, + connectionManager: { getConnection: vi.fn(() => conn) }, + portForwardManager: { + closeStaleForwards: vi.fn(async () => {}), + listForwards: vi.fn(() => [...active]), + addForward: vi.fn( + async ( + connectionId: string, + _conn: unknown, + localPort: number, + remoteHost: string, + remotePort: number, + label?: string + ) => { + const entry = { + id: `pf-${active.length + 1}`, + connectionId, + localPort, + remoteHost, + remotePort, + label + } + active.push(entry) + return entry + } + ) + }, + hostServerStatus: vi.fn<() => { kind: 'managed'; environmentId: string } | undefined>(() => ({ + kind: 'managed', + environmentId: 'env-1' + })), + broadcastPortForwards: vi.fn() + } +}) + +vi.mock('../ssh/ssh-target-registry', () => ({ getSshTargetRegistryStore: () => h.store })) +vi.mock('./ssh-ipc-context', () => ({ + connectionManager: h.connectionManager, + portForwardManager: h.portForwardManager, + getCurrentMainWindow: () => null +})) +vi.mock('./ssh-renderer-broadcast', () => ({ broadcastPortForwards: h.broadcastPortForwards })) +vi.mock('../ssh/ssh-host-server-status', () => ({ getSshHostServerStatus: h.hostServerStatus })) + +import { restorePortForwards } from './ssh-port-forward-persistence' +import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' + +const FORWARD = { localPort: 4100, remoteHost: '127.0.0.1', remotePort: 7860, label: 'app' } + +describe('saved port forward restore', () => { + beforeEach(() => { + vi.clearAllMocks() + h.active.length = 0 + h.saved.portForwards = [{ ...FORWARD }] + }) + + it('closes forwards bound to a replaced transport before re-adding the saved ones', async () => { + await restorePortForwards('t1', () => null) + expect(h.portForwardManager.closeStaleForwards).toHaveBeenCalledWith('t1', h.conn) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.portForwardManager.closeStaleForwards.mock.invocationCallOrder[0]).toBeLessThan( + h.portForwardManager.addForward.mock.invocationCallOrder[0] + ) + }) + + it('leaves a forward that is still live on the current transport alone', async () => { + await restorePortForwards('t1', () => null) + await restorePortForwards('t1', () => null) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.saved.portForwards).toEqual([FORWARD]) + }) + + it('restores a managed host once even when two connects race', async () => { + await Promise.all([restoreManagedHostPortForwards('t1'), restoreManagedHostPortForwards('t1')]) + expect(h.portForwardManager.addForward).toHaveBeenCalledTimes(1) + expect(h.broadcastPortForwards).toHaveBeenCalledWith(expect.any(Function), 't1') + }) + + it('does nothing for a host that is not managed', async () => { + h.hostServerStatus.mockReturnValueOnce(undefined) + await restoreManagedHostPortForwards('t1') + expect(h.portForwardManager.addForward).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/ssh-session-teardown.ts b/src/main/ipc/ssh-session-teardown.ts index bd8dbbe0e9d7..38d9a2520134 100644 --- a/src/main/ipc/ssh-session-teardown.ts +++ b/src/main/ipc/ssh-session-teardown.ts @@ -73,7 +73,10 @@ export async function teardownSshTargetTransport( // Why: start the transport disconnect before session teardown; the async wrapper turns a sync throw into a rejection. const transportDisconnect = (async () => connectionManager?.disconnect(targetId))() const [teardownResult, disconnectResult] = await Promise.allSettled([ - teardownActiveSshSession(targetId, teardown), + activeSessions.has(targetId) + ? teardownActiveSshSession(targetId, teardown) + : // A managed host has no relay session, but its forwards still hold local ports. + (async () => portForwardManager?.removeAllForwards(targetId))(), transportDisconnect ]) if (teardownResult.status === 'rejected') { diff --git a/src/main/ssh/ssh-port-forward-provider.ts b/src/main/ssh/ssh-port-forward-provider.ts index ba17d434dc3c..51c134047498 100644 --- a/src/main/ssh/ssh-port-forward-provider.ts +++ b/src/main/ssh/ssh-port-forward-provider.ts @@ -20,6 +20,8 @@ export type StartedPortForward = { entry: PortForwardEntry close: () => Promise dispose: () => void + /** False once `conn` no longer carries the transport this forward tunnels through. */ + isLive?: (conn: SshConnection) => boolean } export type SshPortForwardProvider = { diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index 147ba2f08d21..f4af3a36e6e6 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -412,6 +412,22 @@ describe('SshPortForwardManager', () => { expect(manager.listForwards('conn-2')).toHaveLength(1) }) + it('closes only the ssh2 forwards bound to a client the connection replaced', async () => { + const conn = createMockConn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock implements the getClient/usesSystemSshTransport surface the ssh2 provider reads. + const sshConn = conn as never + await manager.addForward('conn-1', sshConn, 3000, 'localhost', 8080) + await manager.addForward('conn-2', sshConn, 3002, 'localhost', 8082) + + await manager.closeStaleForwards('conn-1', sshConn) + expect(manager.listForwards('conn-1')).toHaveLength(1) + + conn.getClient.mockReturnValue({ forwardOut: vi.fn() }) + await manager.closeStaleForwards('conn-1', sshConn) + expect(manager.listForwards('conn-1')).toHaveLength(0) + expect(manager.listForwards('conn-2')).toHaveLength(1) + }) + it('dispose removes all forwards', async () => { const conn = createMockConn() await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) diff --git a/src/main/ssh/ssh-port-forward.ts b/src/main/ssh/ssh-port-forward.ts index bc151eaab1e0..4c7b72078253 100644 --- a/src/main/ssh/ssh-port-forward.ts +++ b/src/main/ssh/ssh-port-forward.ts @@ -182,6 +182,15 @@ export class SshPortForwardManager { await Promise.all(toRemove.map((id) => this.removeForwardAsync(id))) } + /** Closes this connection's forwards that tunnel through a transport `conn` replaced. */ + async closeStaleForwards(connectionId: string, conn: SshConnection): Promise { + const stale = [...this.forwards.entries()] + .filter(([, forward]) => forward.entry.connectionId === connectionId) + .filter(([, forward]) => forward.isLive?.(conn) === false) + .map(([id]) => id) + await Promise.all(stale.map((id) => this.removeForwardAsync(id))) + } + dispose(): void { const ids = [...this.forwards.keys()] for (const id of ids) { diff --git a/src/main/ssh/ssh2-port-forward-provider.ts b/src/main/ssh/ssh2-port-forward-provider.ts index 633b69220013..73b4d8a07165 100644 --- a/src/main/ssh/ssh2-port-forward-provider.ts +++ b/src/main/ssh/ssh2-port-forward-provider.ts @@ -86,7 +86,9 @@ export class Ssh2PortForwardProvider implements SshPortForwardProvider { close, dispose: () => { void close() - } + }, + // Why: this forward dials through `client`; a reconnect gives the connection a new one. + isLive: (current) => !closed && current.getClient() === client } } } diff --git a/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx b/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx index 4bab9fd907e7..2284ac53ac48 100644 --- a/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx +++ b/src/renderer/src/components/right-sidebar/activity-bar-buttons.tsx @@ -23,7 +23,7 @@ export type ActivityBarItem = { gitOnly?: boolean /** When true, shown only for folder workspaces. */ folderOnly?: boolean - /** When true, shown only for worktrees that belong to an SSH repo. */ + /** When true, shown only for workspaces on an SSH host. */ sshOnly?: boolean /** Host-owned health indicator; plugin content cannot style this chrome. */ statusIndicator?: CheckStatus diff --git a/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts b/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts index 5082189c5867..1e5b31fb54f4 100644 --- a/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts +++ b/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts @@ -7,6 +7,7 @@ import type { import { getPortsPanelOwnerKey, portsPanelHostForOwnerKey, + portsPanelReachesSshHost, type PortsPanelHost } from './ports-panel-host' @@ -148,3 +149,41 @@ describe('Ports panel host', () => { }) }) }) + +describe('Ports tab visibility', () => { + const managed = { + id: 'env-managed', + orcadDeployment: { sshTargetId: 'box', sshTargetGeneration: 1 } + } as const + const environments = [{ id: 'env-paired' }, managed] + + it.each<[string, PortsPanelHost, boolean]>([ + ['a direct SSH workspace', { kind: 'direct-ssh', connectionId: 'box' }, true], + ['an SSH target behind a paired server', serverSsh('env-paired', 't'), true], + [ + 'a workspace on a managed server reached over SSH', + { kind: 'endpoint', target: { kind: 'environment', environmentId: 'env-managed' } }, + true + ], + [ + 'a workspace on a paired server that is not an SSH host', + { kind: 'endpoint', target: { kind: 'environment', environmentId: 'env-paired' } }, + false + ], + ['a local workspace', { kind: 'endpoint', target: { kind: 'local' } }, false], + ['an unknown owner', { kind: 'unknown' }, false] + ])('%s', (_name, host, visible) => { + expect(portsPanelReachesSshHost(host, environments)).toBe(visible) + }) + + it('follows the workspace owner, not the repo connection', () => { + const state: WorktreeOperationRouteState = { + repos: [{ id: 'repo-1' }], + worktreesByRepo: { 'repo-1': [worktree('runtime:env-managed', 'env-managed')] }, + runtimeEnvironments: [{ id: 'env-managed' }], + runtimeEnvironmentCatalogHydrated: true + } + const host = portsPanelHostForOwnerKey(getPortsPanelOwnerKey(state, WORKTREE_ID)) + expect(portsPanelReachesSshHost(host, environments)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ports-panel-host.ts b/src/renderer/src/components/right-sidebar/ports-panel-host.ts index 0d4a0bacd815..48b4d1561a49 100644 --- a/src/renderer/src/components/right-sidebar/ports-panel-host.ts +++ b/src/renderer/src/components/right-sidebar/ports-panel-host.ts @@ -56,3 +56,27 @@ export function portsPanelHostForOwnerKey(key: HostAuthorityKey | null): PortsPa } return { kind: 'host-scoped', route, executionHostId: authority.at } } + +/** The Ports tab shows for a workspace on an SSH host, dialed directly or through its managed server. */ +export function portsPanelReachesSshHost( + host: PortsPanelHost, + environments: readonly { id: string; orcadDeployment?: unknown }[] +): boolean { + switch (host.kind) { + case 'direct-ssh': + case 'host-scoped': + return true + case 'endpoint': { + const { target } = host + return ( + target.kind === 'environment' && + environments.some( + (environment) => + environment.id === target.environmentId && environment.orcadDeployment !== undefined + ) + ) + } + case 'unknown': + return false + } +} diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts index 01c44ba79c5e..90517dddb0b3 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts +++ b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts @@ -37,12 +37,12 @@ const items: ActivityBarItem[] = [ ] describe('getVisibleRightSidebarActivityItems', () => { - it('shows ports only for SSH repos', () => { + it('shows ports only for workspaces on an SSH host', () => { expect( getVisibleRightSidebarActivityItems(items, { isFolder: false, isFolderWorkspace: false, - isSshRepo: false + hasSshHost: false }).map((item) => item.id) ).toEqual(['explorer', 'source-control', 'plugin:orca-samples.my-plugin/dashboard']) @@ -50,7 +50,7 @@ describe('getVisibleRightSidebarActivityItems', () => { getVisibleRightSidebarActivityItems(items, { isFolder: false, isFolderWorkspace: false, - isSshRepo: true + hasSshHost: true }).map((item) => item.id) ).toEqual(['explorer', 'source-control', 'ports', 'plugin:orca-samples.my-plugin/dashboard']) }) @@ -60,7 +60,7 @@ describe('getVisibleRightSidebarActivityItems', () => { getVisibleRightSidebarActivityItems(items, { isFolder: true, isFolderWorkspace: true, - isSshRepo: true + hasSshHost: true }).map((item) => item.id) ).toEqual([ 'explorer', @@ -74,7 +74,7 @@ describe('getVisibleRightSidebarActivityItems', () => { getVisibleRightSidebarActivityItems(items, { isFolder: true, isFolderWorkspace: false, - isSshRepo: true + hasSshHost: true }).map((item) => item.id) ).toEqual(['explorer', 'ports', 'plugin:orca-samples.my-plugin/dashboard']) }) diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts index f4bc0958959c..2f9fefc70e05 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts +++ b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts @@ -3,17 +3,18 @@ import type { ActivityBarItem } from './activity-bar-buttons' type RightSidebarActivityVisibilityState = { isFolder: boolean isFolderWorkspace: boolean - isSshRepo: boolean + /** The workspace runs on an SSH host, reached directly or through its managed server. */ + hasSshHost: boolean } export function getVisibleRightSidebarActivityItems( items: ActivityBarItem[], - { isFolder, isFolderWorkspace, isSshRepo }: RightSidebarActivityVisibilityState + { isFolder, isFolderWorkspace, hasSshHost }: RightSidebarActivityVisibilityState ): ActivityBarItem[] { return items.filter( (item) => (!item.gitOnly || !isFolder) && (!item.folderOnly || isFolderWorkspace) && - (!item.sshOnly || isSshRepo) + (!item.sshOnly || hasSshHost) ) } diff --git a/src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts b/src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts index 5dd40c53f1cb..bf9ff26770a9 100644 --- a/src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts +++ b/src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts @@ -6,6 +6,11 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getVisibleRightSidebarActivityItems } from './right-sidebar-activity-visibility' import { getPluginPanelActivityItems } from './plugin-panel-activity-items' +import { + getPortsPanelOwnerKey, + portsPanelHostForOwnerKey, + portsPanelReachesSshHost +} from './ports-panel-host' import { collectInstalledPluginTabKeys, usePluginPanels, @@ -44,7 +49,13 @@ export function useRightSidebarActivityItems({ const activeWorkspaceScope = parseWorkspaceKey(activeWorktreeId ?? '') const isFolderWorkspace = activeWorkspaceScope?.type === 'folder' const isFolder = isFolderWorkspace || (activeRepo ? isFolderRepo(activeRepo) : false) - const isSshRepo = Boolean(activeRepo?.connectionId) + // Why the owner: a managed server's workspace names its server, not an SSH connection. + const hasSshHost = useAppStore((s) => + portsPanelReachesSshHost( + portsPanelHostForOwnerKey(getPortsPanelOwnerKey(s, activeWorktreeId)), + s.runtimeEnvironments + ) + ) const pluginSystemEnabled = useAppStore((s) => s.settings?.pluginSystemEnabled === true) const pluginPanels = usePluginPanels() const visiblePluginPanels = useMemo( @@ -130,9 +141,9 @@ export function useRightSidebarActivityItems({ getVisibleRightSidebarActivityItems(activityItems, { isFolder, isFolderWorkspace, - isSshRepo + hasSshHost }), - [activityItems, isFolder, isFolderWorkspace, isSshRepo] + [activityItems, isFolder, isFolderWorkspace, hasSshHost] ) const activeFolderWorkspaceKey = isFolderWorkspace ? (activeWorktreeId ?? null) : null diff --git a/tests/e2e/ssh-orcad-workspace-tools.spec.ts b/tests/e2e/ssh-orcad-workspace-tools.spec.ts index 4edb8dfec116..0c9a9fe7adf8 100644 --- a/tests/e2e/ssh-orcad-workspace-tools.spec.ts +++ b/tests/e2e/ssh-orcad-workspace-tools.spec.ts @@ -61,8 +61,6 @@ test('Quick Open finds a file in a monorepo-sized managed workspace', async (// for (const acrossReconnect of [false, true]) { test(`a managed host reports its listener, and an SSH forward reaches it${acrossReconnect ? ' across a reconnect' : ''}`, async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. {}, testInfo) => { - // Why fixme: saved forwards are re-established only by the relay session's connect callbacks. - test.fixme(acrossReconnect, 'A managed host reconnect never calls restorePortForwards') test.setTimeout(10 * 60_000) const remotePort = 7860 const marker = `ORCA_FORWARD_${Date.now()}` @@ -97,11 +95,18 @@ for (const acrossReconnect of [false, true]) { } } await expect.poll(fetchMarker, { timeout: 30_000 }).toBe(marker) + let forwardId = forward.id if (acrossReconnect) { await reconnect(page, seeded.targetId) await expect.poll(fetchMarker, { timeout: 60_000 }).toBe(marker) + // The restored forward is a new entry for the same saved port. + const restored = await page.evaluate( + (targetId) => window.api.ssh.listPortForwards({ targetId }), + seeded.targetId + ) + forwardId = restored.find((entry) => entry.localPort === localPort)?.id ?? forwardId } - await page.evaluate((id) => window.api.ssh.removePortForward({ id }), forward.id) + await page.evaluate((id) => window.api.ssh.removePortForward({ id }), forwardId) await expect.poll(fetchMarker, { timeout: 30_000 }).not.toBe(marker) }).finally(() => reservation.release()) }) From e0625521ac6c677e85a58e22a7dfd4a90ff9846a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 20:36:10 -0700 Subject: [PATCH 078/110] fix(files): a managed terminal links and opens a host file outside its workspace (#27364) * fix(files): a managed terminal links and opens a host file outside its workspace A paired-server terminal checked out-of-workspace paths on this computer, so a host file such as /tmp/screenshot.png never became a link, and a click was refused. Link detection now asks the host the same question a click asks, with the printing terminal's handle, and a granted file opens read-only through the host's existing terminal-artifact grant. Co-Authored-By: Claude * fix(files): a restored server-granted tab is never this computer's file Co-Authored-By: Claude * test(e2e): keep the external-image journey's /tmp outside every workspace The default folder workspace is /tmp, so the image resolved into it instead of exercising a host file outside all workspaces. Co-Authored-By: Claude --------- Co-authored-by: m4air Co-authored-by: Claude --- config/scripts/orcad-e2e-routing.test.mjs | 10 +- config/scripts/pr-e2e-source-routing.mjs | 2 +- .../components/editor/csv/csv-file-content.ts | 3 +- .../editor/useEditorPanelFileContentLoader.ts | 5 +- .../terminal-file-link-actions.ts | 2 + .../terminal-file-link-hit-testing.ts | 1 + .../terminal-file-link-target.ts | 11 +- .../terminal-file-open-routing.ts | 23 +++- .../terminal-host-file-link-existence.ts | 44 +++++++ .../terminal-host-workspace-file.ts | 45 +++++-- .../terminal-http-link-source-owner.ts | 8 ++ .../terminal-link-client-local-file.test.ts | 50 +++++++- .../terminal-pane/terminal-link-handlers.ts | 11 +- ...rminal-link-host-granted-file-link.test.ts | 121 ++++++++++++++++++ .../terminal-pane-mount-preparation.ts | 6 +- .../src/lib/local-file-access.test.ts | 11 ++ src/renderer/src/lib/local-file-access.ts | 10 +- .../src/runtime/runtime-file-client-types.ts | 2 + .../src/runtime/runtime-file-read-client.ts | 12 +- .../runtime-terminal-artifact-read.test.ts | 66 ++++++++++ .../runtime/runtime-terminal-artifact-read.ts | 43 +++++++ .../slices/editor/actions/open-file-apply.ts | 4 + .../store/slices/editor/types/open-file.ts | 2 + tests/e2e/helpers/orcad-managed-workspace.ts | 6 +- tests/e2e/ssh-orcad-workspace-tools.spec.ts | 5 +- 25 files changed, 471 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-host-granted-file-link.test.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-artifact-read.test.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-artifact-read.ts diff --git a/config/scripts/orcad-e2e-routing.test.mjs b/config/scripts/orcad-e2e-routing.test.mjs index d2ec99491dd3..018eda14a755 100644 --- a/config/scripts/orcad-e2e-routing.test.mjs +++ b/config/scripts/orcad-e2e-routing.test.mjs @@ -143,7 +143,15 @@ it('runs the managed-host journey specs in their own job when routed', () => { const job = jobs['orcad-journeys-docker'] expect(job.needs).toEqual(['build', 'prepare-native-cache']) for (const spec of ORCAD_JOURNEY_E2E_SPECS) { - expectRouted(['tests/e2e/helpers/orcad-managed-workspace.ts'], spec) + expectRouted( + [ + 'tests/e2e/helpers/orcad-managed-workspace.ts', + 'src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts', + 'src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts', + 'src/renderer/src/runtime/runtime-terminal-artifact-read.ts' + ], + spec + ) expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`) expect( job.steps.some((step) => step.run?.includes(spec)), diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 7d7d0813fdb1..c0204a432dc0 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -199,7 +199,7 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) || (isProductSource(file) && - /^src\/(?:main\/ssh\/orcad-managed-|main\/ipc\/ssh-port-forward|renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts$|renderer\/src\/runtime\/web-runtime-session|renderer\/src\/components\/terminal-pane\/remote-runtime-)/.test( + /^src\/(?:main\/ssh\/orcad-managed-|main\/ipc\/ssh-port-forward|renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts$|renderer\/src\/runtime\/(?:web-runtime-session|runtime-terminal-artifact-read\.ts$)|renderer\/src\/components\/terminal-pane\/(?:remote-runtime-|terminal-host-(?:workspace-file|file-link-existence)\.ts$))/.test( file )) }, diff --git a/src/renderer/src/components/editor/csv/csv-file-content.ts b/src/renderer/src/components/editor/csv/csv-file-content.ts index c78b02144143..a5d66664dc73 100644 --- a/src/renderer/src/components/editor/csv/csv-file-content.ts +++ b/src/renderer/src/components/editor/csv/csv-file-content.ts @@ -15,7 +15,8 @@ export async function readEditorCsvFileContent( args: RuntimeFileReadArgs, allowPagedPreview = true ): Promise { - if (allowPagedPreview && /\.(csv|tsv)$/i.test(args.filePath)) { + // Why: a granted host file has no stat route; it reads whole through its grant. + if (allowPagedPreview && !args.terminalArtifactGrantId && /\.(csv|tsv)$/i.test(args.filePath)) { const snapshot = await statRuntimeReadTarget(args) if (!snapshot.isDirectory && snapshot.size >= CSV_PAGED_PREVIEW_BYTES) { return { content: '', isBinary: false, csvPreview: { readArgs: args, snapshot } } diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index a6df92031753..461d7e5a0506 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -185,7 +185,7 @@ export function useEditorPanelFileContentLoader({ await reownRestoredFile(route, runtimeEnvironmentId ?? null) return } - if (runtimeEnvironmentId && !route) { + if (runtimeEnvironmentId && !route && !restoredOpenFile.terminalArtifactGrantId) { throw new Error('External local files are not available for remote workspaces.') } if (!externalSshOwnerId) { @@ -225,7 +225,8 @@ export function useEditorPanelFileContentLoader({ connectionId: readConnectionId, expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, includeLocalLogMetadata: isLiveTailLogTab, - access + access, + terminalArtifactGrantId: restoredOpenFile?.terminalArtifactGrantId }, allowPagedPreview ) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts index a76f4f45763a..4337adb36897 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts @@ -24,6 +24,8 @@ export type TerminalFileLinkActionDeps = { worktreePath: string runtimeEnvironmentId?: string | null wslDistro?: string | null + /** The paired-server terminal that printed the link; lets its host grant a file outside workspaces. */ + terminalHandle?: string | null onOpenFailure?: (failure: FileOpenFailure) => void } diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts index a085acc594be..7715e80c8d86 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts @@ -16,6 +16,7 @@ type FileLinkHitTestDeps = { worktreePath: string runtimeEnvironmentId?: string | null wslDistro?: string | null + terminalHandle?: string | null pathExistsCache?: Map openWithSystemDefault?: boolean } diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-target.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-target.ts index 9505829cf677..9820230fa8f9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-target.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-target.ts @@ -21,6 +21,10 @@ import { writeTerminalPathExistsCache } from './terminal-path-exists-cache' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' +import { + hostFileLinkTargetExists, + isHostPathOutsideWorkspace +} from './terminal-host-file-link-existence' /** Where detected path text resolves and is checked: a terminal pane's or a chat's folder. */ export type FileLinkHost = { @@ -118,7 +122,8 @@ export function mayCheckFileLinkTargetUnprompted( export async function fileLinkTargetExists( target: FileLinkTarget, cache: Map, - pathExists: FileLinkPathExistence + pathExists: FileLinkPathExistence, + terminalHandle?: string | null ): Promise { // Why: exact known workspace roots must stay clickable for SSH or // stale local paths even when filesystem probing says "missing". @@ -129,6 +134,10 @@ export async function fileLinkTargetExists( // Why: no owner means no host to ask; a local stat would answer for the wrong machine. throw new Error('The terminal workspace host could not be determined') } + if (isHostPathOutsideWorkspace(target)) { + // Why: a local stat here would answer for this computer, not the server that printed the path. + return hostFileLinkTargetExists(target, cache, terminalHandle) + } const exists = readTerminalPathExistsCache(cache, target.cacheKey) ?? (await pathExists(target.fileContext, target.absolutePath, target.isRemoteRuntimePath)) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 3596fd9eeea9..ce6508a0cefa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -50,6 +50,8 @@ type TerminalFileOpenDeps = { runtimeEnvironmentId?: string | null wslDistro?: string | null openWithSystemDefault?: boolean + /** The paired-server terminal that printed the link; lets its host grant a file outside workspaces. */ + terminalHandle?: string | null /** Reports a path that could not be verified before opening; skipped once a later open supersedes it. */ onOpenFailure?: (failure: FileOpenFailure) => void } @@ -130,6 +132,7 @@ export function openDetectedFilePath( let statResult let fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) let hostWorkspaceFile: HostWorkspaceFile | null = null + let hostGrantId: string | null = null let clientLocal = false if (!fileContext.sourceHostResolved) { // Why: with no owner the host is unknown — refuse rather than touch this machine's files. @@ -160,8 +163,16 @@ export function openDetectedFilePath( } try { - const linkOwner = await resolveHostWorkspaceFile(fileContext, mappedFilePath) - if (linkOwner?.kind === 'client') { + const linkOwner = await resolveHostWorkspaceFile( + fileContext, + mappedFilePath, + deps.terminalHandle + ) + if (linkOwner?.kind === 'grant') { + hostGrantId = linkOwner.grantId + mappedFilePath = linkOwner.absolutePath + statResult = { isDirectory: false, escapesWorktree: true } + } else if (linkOwner?.kind === 'client') { clientLocal = true fileContext = { ...fileContext, @@ -213,7 +224,8 @@ export function openDetectedFilePath( return } - if (openWithSystemDefault && !canOpenWithSystemDefault) { + // Why: a granted file is readable only through its grant, so it opens in Orca, never as a download. + if (openWithSystemDefault && !canOpenWithSystemDefault && hostGrantId === null) { // Why: the popover names Shift+Cmd/Ctrl "Download & open with default app", and the OS // cannot launch a remote path, so the direct gesture must reach the same download. await downloadAndOpenRemoteTerminalFile(fileContext, mappedFilePath) @@ -222,7 +234,7 @@ export function openDetectedFilePath( // Why: local HTML files render in Orca's browser for ordinary Cmd/Ctrl-click, // and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default. - if (isHtmlFilePath(mappedFilePath)) { + if (isHtmlFilePath(mappedFilePath) && hostGrantId === null) { if (canOpenWithSystemDefault) { openHtmlFileInBrowser(mappedFilePath, worktreeId, clientLocal) return @@ -249,6 +261,7 @@ export function openDetectedFilePath( relativePath = maybeRelative } } else if ( + hostGrantId === null && !isFloatingWorkspaceId(worktreeId) && store.openFiles.some( (openFile) => openFile.filePath === mappedFilePath && openFile.worktreeId !== worktreeId @@ -287,6 +300,8 @@ export function openDetectedFilePath( runtimeEnvironmentId: clientLocal ? null : runtimeEnvironmentId, // Why: a tab's owner must match its workspace's to be editable; this one is this computer's. ...(clientLocal && targetWorktreeId === worktreeId ? { readOnly: true } : {}), + // Why: the grant is the only authority to read a host file outside its workspaces. + ...(hostGrantId !== null ? { readOnly: true, terminalArtifactGrantId: hostGrantId } : {}), // Why: absolute SSH paths outside the worktree otherwise look identical // to client-local external files when the editor reloads or restores. ...(relativePath === filePath && diff --git a/src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts b/src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts new file mode 100644 index 000000000000..489a77a041df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts @@ -0,0 +1,44 @@ +import { hasRemoteRuntimeOwner } from '@/runtime/runtime-file-routing' +import type { FileLinkTarget } from './terminal-file-link-target' +import { HostFileRefusal, resolveHostWorkspaceFile } from './terminal-host-workspace-file' +import { + readTerminalPathExistsCache, + writeTerminalPathExistsCache +} from './terminal-path-exists-cache' + +/** A paired-server path outside the terminal's workspace: only its host can say whether it opens. */ +export function isHostPathOutsideWorkspace(target: FileLinkTarget): boolean { + return !target.isRemoteRuntimePath && hasRemoteRuntimeOwner(target.fileContext) +} + +/** + * Asks the host the same question a click asks, so a link shows exactly when a click can open it. + * Rejects when the host cannot answer, so callers never mistake an outage for a missing file. + */ +export async function hostFileLinkTargetExists( + target: FileLinkTarget, + cache: Map, + terminalHandle: string | null | undefined +): Promise { + // Why: an unprompted stat of a network share can send the user's credentials to its server. + if (/^[\\/]{2}/.test(target.absolutePath)) { + return false + } + const cached = readTerminalPathExistsCache(cache, target.cacheKey) + if (cached !== undefined) { + return cached + } + let exists: boolean + try { + exists = + (await resolveHostWorkspaceFile(target.fileContext, target.absolutePath, terminalHandle)) !== + null + } catch (error) { + if (!(error instanceof HostFileRefusal)) { + throw error + } + exists = false + } + writeTerminalPathExistsCache(cache, target.cacheKey, exists) + return exists +} diff --git a/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts index 9306f2b56b90..4cfba0b4f408 100644 --- a/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts +++ b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts @@ -30,6 +30,18 @@ export type HostWorkspaceFile = { /** A path the host disowned that this computer has; the user's click here is the only authority to read it. */ export type ClientLocalFile = { kind: 'client'; isDirectory: boolean } +/** A host file outside every workspace that the host granted because this terminal printed it. */ +export type HostGrantedFile = { + kind: 'grant' + /** The host's canonical path; the grant is bound to it. */ + absolutePath: string + grantId: string + isDirectory: false +} + +/** The host answered: it has no file there this client may open. Transport failures stay other errors. */ +export class HostFileRefusal extends Error {} + export const CLIENT_LOCAL_FILE_TARGET: RuntimeClientTarget = { kind: 'local' } /** Strips the host's relative suffix; separators are swapped 1:1, so the slice stays byte-exact. */ @@ -73,14 +85,15 @@ async function statClientLocalFile( /** * A clicked paired-server link outside its own workspace: the server, not this client's catalog - * copy, says which of its workspaces holds the path. A path it disowns without a grant falls to - * this computer. Null when the link is not a paired-server path outside the source workspace; - * throws when neither the host nor this computer can open it. + * copy, says which of its workspaces holds the path, or grants it when the terminal printed it. + * A path it disowns without a grant falls to this computer. Null when the link is not a + * paired-server path outside the source workspace; throws when neither can open it. */ export async function resolveHostWorkspaceFile( context: RuntimeFileOperationArgs, - absolutePath: string -): Promise { + absolutePath: string, + terminalHandle?: string | null +): Promise { const environmentId = runtimeTargetEnvironmentId(context.target) if ( !environmentId || @@ -95,26 +108,38 @@ export async function resolveHostWorkspaceFile( { worktree: toRuntimeWorktreeSelector(context.worktreeId), pathText: absolutePath, - crossWorkspace: true + crossWorkspace: true, + // Why: the host grants a file outside its workspaces only to the terminal that printed it. + ...(terminalHandle ? { terminal: terminalHandle } : {}) }, { timeoutMs: 15_000 } ) const relativePath = resolved.relativePath if (relativePath === null || !resolved.absolutePath) { // Why: hosts grant reads only inside their workspaces; servers without crossWorkspace land here too. - const refusal = new Error(`${absolutePath} is outside every workspace on its host`) - if (resolved.openTarget?.kind === 'absolute-file') { + const refusal = new HostFileRefusal(`${absolutePath} is outside every workspace on its host`) + const openTarget = resolved.openTarget + if (openTarget?.kind === 'absolute-file') { // Why: a host grant means the host owns the path; it never becomes a read of this computer. + return { + kind: 'grant', + absolutePath: openTarget.absolutePath, + grantId: openTarget.grantId, + isDirectory: false + } + } + if (resolved.exists) { + // Why: an ungranted host path (a directory, or one no terminal printed) is still the host's. throw refusal } return statClientLocalFile(environmentId, absolutePath, refusal) } if (!resolved.exists) { - throw new Error(`File not found on its host: ${absolutePath}`) + throw new HostFileRefusal(`File not found on its host: ${absolutePath}`) } const worktreePath = workspaceRootOf(resolved.absolutePath, relativePath) if (worktreePath === null) { - throw new Error(`The host answered an unexpected path for ${absolutePath}`) + throw new HostFileRefusal(`The host answered an unexpected path for ${absolutePath}`) } return { kind: 'host', diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts index 31b0479ee510..6f7cc8576d64 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts @@ -44,3 +44,11 @@ export function resolveTerminalHttpLinkSourceOwner( } return { kind: 'local' } } + +/** The pane's terminal handle on its paired server; null for a pane no server runs. */ +export function resolveTerminalRuntimeHandle( + transport: Pick | null | undefined +): string | null { + const ptyId = transport?.getPtyId() ?? null + return (ptyId ? parseRemoteRuntimePtyId(ptyId)?.handle : null) || null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts index b4e708a875f7..e6d516d1491e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts @@ -189,7 +189,7 @@ describe('a link to this computer printed in a paired-server terminal', () => { expect(onOpenFailure).toHaveBeenCalledWith(expect.objectContaining({ verdict: 'unverifiable' })) }) - it('never reads this computer for a path the host granted as its own', async () => { + it('opens a path the host granted read-only from the host, never from this computer', async () => { runtimeEnvironmentCallMock.mockResolvedValueOnce({ id: 'rpc-1', ok: true, @@ -212,10 +212,58 @@ describe('a link to this computer printed in a paired-server terminal', () => { openDetectedFilePath('/tmp/artifact.txt', null, null, { ...serverWorkspace, + terminalHandle: 'term-1', + openWithSystemDefault: true, onOpenFailure }) await flushDoubleRaf() + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'files.resolveTerminalPath', + params: { + worktree: 'id:wt-feat', + pathText: '/tmp/artifact.txt', + crossWorkspace: true, + terminal: 'term-1' + } + }) + ) + expect(statMock).not.toHaveBeenCalled() + expect(openFilePathMock).not.toHaveBeenCalled() + expect(downloadAndOpenRemoteTerminalFile).not.toHaveBeenCalled() + expect(onOpenFailure).not.toHaveBeenCalled() + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/artifact.txt', + relativePath: '/tmp/artifact.txt', + worktreeId: 'wt-feat', + runtimeEnvironmentId: 'env-1', + readOnly: true, + terminalArtifactGrantId: 'g-1' + }), + { forceContentReload: true } + ) + }) + + it('never reads this computer for a host directory outside its workspaces', async () => { + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-feat', + relativePath: null, + absolutePath: '/tmp/out', + exists: true, + isDirectory: true + }, + _meta: { runtimeId: 'remote-runtime' } + }) + const onOpenFailure = vi.fn() + + openDetectedFilePath('/tmp/out', null, null, { ...serverWorkspace, onOpenFailure }) + await flushDoubleRaf() + expect(statMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() expect(onOpenFailure).toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 1b677c8a2fef..6ab7104a692b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -49,6 +49,8 @@ export type LinkHandlerDeps = { terminalHomePath?: string | null wslDistro?: string | null getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null + /** The pane's terminal on its paired server, which may grant files it printed. */ + getRuntimeTerminalHandleForPane?: (paneId: number) => string | null getLinkActionContext?: (paneId: number) => TerminalLinkActionContext | null } @@ -126,7 +128,10 @@ export function createFilePathLinkProvider( if (!target || !range) { return null } - if (!(await fileLinkTargetExists(target, pathExistsCache, pathExists))) { + const terminalHandle = deps.getRuntimeTerminalHandleForPane?.(paneId) ?? null + if ( + !(await fileLinkTargetExists(target, pathExistsCache, pathExists, terminalHandle)) + ) { return null } const { absolutePath: mappedPath, fileContext } = target @@ -147,7 +152,8 @@ export function createFilePathLinkProvider( worktreeId, worktreePath, runtimeEnvironmentId, - wslDistro: deps.wslDistro + wslDistro: deps.wslDistro, + terminalHandle }, deps.getLinkActionContext?.(paneId) ) @@ -247,6 +253,7 @@ export function installFilePathLinkClickFallback( worktreePath: deps.worktreePath, runtimeEnvironmentId, wslDistro: deps.wslDistro, + terminalHandle: deps.getRuntimeTerminalHandleForPane?.(paneId) ?? null, pathExistsCache: deps.pathExistsCache, openWithSystemDefault: Boolean(event.shiftKey) } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-host-granted-file-link.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-host-granted-file-link.test.ts new file mode 100644 index 000000000000..6268313a2704 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-host-granted-file-link.test.ts @@ -0,0 +1,121 @@ +import type { ILink } from '@xterm/xterm' +import { describe, expect, it, vi } from 'vitest' +import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' +import { createProviderSetup, makeBufferLine } from './terminal-link-provider-buffer-fixtures' +import { + flushDoubleRaf, + installTerminalLinkTestEnvironment +} from './terminal-link-handlers-test-harness' + +const doubles = createTerminalLinkTestDoubles() +const { storeState, statMock, openFileMock, runtimeEnvironmentCallMock } = doubles + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => storeState + } +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: vi.fn(), + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(() => null) +})) + +installTerminalLinkTestEnvironment(doubles) + +const IMAGE = '/tmp/orca-preview.png' + +function hostGrants(): void { + runtimeEnvironmentCallMock.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-1', + relativePath: null, + absolutePath: IMAGE, + exists: true, + isDirectory: false, + openTarget: { kind: 'absolute-file', provider: 'local', absolutePath: IMAGE, grantId: 'g-1' } + }, + _meta: { runtimeId: 'remote-runtime' } + }) +} + +function provideLinks(): Promise { + const { provider } = createProviderSetup([makeBufferLine(IMAGE)], new Map(), { + worktreePath: '/srv/repo', + startupCwd: '/srv/repo', + runtimeEnvironmentId: 'env-1', + getRuntimeTerminalHandleForPane: () => 'term-1' + }) + return new Promise((resolve) => { + provider.provideLinks(1, (links) => resolve(links ?? [])) + }) +} + +describe('a paired-server terminal path outside its workspace', () => { + it('links a file the host grants to the terminal that printed it', async () => { + hostGrants() + + const links = await provideLinks() + + expect(links.map((link) => link.text)).toEqual([IMAGE]) + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'files.resolveTerminalPath', + params: { worktree: 'id:wt-1', pathText: IMAGE, crossWorkspace: true, terminal: 'term-1' } + }) + ) + // Why: this computer's copy of the path says nothing about the server's. + expect(window.api.shell.pathExists).not.toHaveBeenCalled() + }) + + it('opens the linked file through its grant', async () => { + hostGrants() + + const links = await provideLinks() + // Why a prototype-less object: node has no MouseEvent; activation reads only these fields. + const click: MouseEvent = Object.assign(Object.create(null), { + metaKey: true, + ctrlKey: true, + button: 0, + preventDefault: vi.fn() + }) + links[0]!.activate(click, IMAGE) + await flushDoubleRaf() + + expect(statMock).not.toHaveBeenCalled() + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: IMAGE, + runtimeEnvironmentId: 'env-1', + readOnly: true, + terminalArtifactGrantId: 'g-1' + }), + { forceContentReload: true } + ) + }) + + it('shows no link for a path the host refuses, even when this computer has it', async () => { + runtimeEnvironmentCallMock.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-1', + relativePath: null, + absolutePath: IMAGE, + exists: true, + isDirectory: true + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + expect(await provideLinks()).toEqual([]) + expect(window.api.shell.pathExists).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts index 22b654073fd7..2433f20bb623 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts @@ -7,7 +7,10 @@ import { canOpenWorkspaceBrowserTabOnSsh } from '@/lib/workspace-browser-tab-open' import { resolvePaneWslDistro } from './terminal-pane-wsl-distro' -import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner' +import { + resolveTerminalHttpLinkSourceOwner, + resolveTerminalRuntimeHandle +} from './terminal-http-link-source-owner' import { getTerminalFileOpenHint, getTerminalUrlOpenHint, @@ -169,6 +172,7 @@ export function prepareTerminalPaneMount( const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null }, + getRuntimeTerminalHandleForPane: (id) => resolveTerminalRuntimeHandle(paneTransports.get(id)), getLinkActionContext } let resizeRaf: number | null = null diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 4d15a2339381..6a3cb560eb82 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -104,6 +104,17 @@ describe('editorTabFileAccess', () => { }, 'user-file' ], + [ + 'a read-only server file outside every workspace, restored without its grant', + { + filePath: '/tmp/out.png', + relativePath: '/tmp/out.png', + worktreeId: localWorktreeId, + runtimeEnvironmentId: 'env-1', + readOnly: true + }, + undefined + ], [ 'a project tab, which stays inside its root', { filePath: '/Users/me/project/a.ts', relativePath: 'a.ts', worktreeId: localWorktreeId }, diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index f6e2741a61e7..8e9823242355 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -47,9 +47,15 @@ type EditorTabAccessFile = Pick< /** A read-only tab named by absolute path is this computer's file: an AI Vault log or a client-local link. */ export function isClientLocalReadOnlyTab( - file: Pick + file: Pick & + Partial> ): boolean { - return file.readOnly === true && (file.liveTail === true || file.relativePath === file.filePath) + return ( + file.readOnly === true && + // Why: a server-granted file (live or restored without its grant) names a server path. + !file.runtimeEnvironmentId?.trim() && + (file.liveTail === true || file.relativePath === file.filePath) + ) } /** diff --git a/src/renderer/src/runtime/runtime-file-client-types.ts b/src/renderer/src/runtime/runtime-file-client-types.ts index e9df1c00d209..b1929218b8d6 100644 --- a/src/renderer/src/runtime/runtime-file-client-types.ts +++ b/src/renderer/src/runtime/runtime-file-client-types.ts @@ -21,6 +21,8 @@ export type RuntimeFileReadArgs = { includeLocalLogMetadata?: boolean /** File access of the local fallback read; remote reads stay root-relative. */ access?: LocalFileAccess + /** Host grant for a runtime file outside every workspace (see OpenFile.terminalArtifactGrantId). */ + terminalArtifactGrantId?: string } export type RuntimeFileOperationArgs = { diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index ce99fd5bda28..37e99b6ec692 100644 --- a/src/renderer/src/runtime/runtime-file-read-client.ts +++ b/src/renderer/src/runtime/runtime-file-read-client.ts @@ -19,6 +19,7 @@ import { import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { isAgentSessionAttachmentStorePath } from '../../../shared/agent-session-attachments' import type { LocalFileAccess } from '../../../shared/local-file-access' +import { readRuntimeTerminalArtifactContent } from './runtime-terminal-artifact-read' const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024 const REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE = @@ -42,7 +43,8 @@ export async function readRuntimeFileContent({ connectionId, expectedExternalSshTargetId, includeLocalLogMetadata, - access + access, + terminalArtifactGrantId }: RuntimeFileReadArgs): Promise { assertExternalSshReadOwnership(target, connectionId, expectedExternalSshTargetId) if (target.kind !== 'environment' || !worktreeId) { @@ -53,6 +55,14 @@ export async function readRuntimeFileContent({ ...localAccess(connectionId, access) }) } + if (terminalArtifactGrantId) { + return readRuntimeTerminalArtifactContent({ + target, + worktreeId, + absolutePath: filePath, + grantId: terminalArtifactGrantId + }) + } if (!canReadRelativeRuntimeFile(relativePath)) { throw new Error('Remote file is outside the owning runtime worktree') } diff --git a/src/renderer/src/runtime/runtime-terminal-artifact-read.test.ts b/src/renderer/src/runtime/runtime-terminal-artifact-read.test.ts new file mode 100644 index 000000000000..e35607c1385b --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-artifact-read.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { readRuntimeFileContent } from './runtime-file-client' +import { + runtimeEnvironmentCall, + installRuntimeFileClientEnvironment +} from './runtime-file-client-test-harness' + +installRuntimeFileClientEnvironment() + +const granted = { + target: { kind: 'environment' as const, environmentId: 'env-1' }, + filePath: '/tmp/out.png', + relativePath: '/tmp/out.png', + worktreeId: 'wt-1', + terminalArtifactGrantId: 'g-1' +} +const grantParams = { worktree: 'id:wt-1', grantId: 'g-1', absolutePath: '/tmp/out.png' } + +describe('a granted host file outside every workspace', () => { + it('reads text through its grant', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-1', + relativePath: '/tmp/out.png', + content: 'log', + truncated: false, + byteLength: 3 + }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect(readRuntimeFileContent(granted)).resolves.toEqual({ + content: 'log', + isBinary: false + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.readTerminalArtifact', params: grantParams }) + ) + }) + + it('previews a binary file through its grant', async () => { + const preview = { content: 'iVBO', isBinary: true, isImage: true, mimeType: 'image/png' } + runtimeEnvironmentCall.mockImplementation((args: { method: string }) => + Promise.resolve( + args.method === 'files.readTerminalArtifact' + ? { + id: 'rpc-1', + ok: false, + error: { code: 'runtime_error', message: 'binary_file' }, + _meta: { runtimeId: 'remote-runtime' } + } + : { id: 'rpc-2', ok: true, result: preview, _meta: { runtimeId: 'remote-runtime' } } + ) + ) + + await expect(readRuntimeFileContent(granted)).resolves.toEqual(preview) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.readTerminalArtifactPreview', params: grantParams }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.read' }) + ) + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-artifact-read.ts b/src/renderer/src/runtime/runtime-terminal-artifact-read.ts new file mode 100644 index 000000000000..bba09589a4df --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-artifact-read.ts @@ -0,0 +1,43 @@ +import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../../shared/runtime-types' +import type { RuntimeClientTarget } from './runtime-client-target' +import type { RuntimeReadableFileContent } from './runtime-file-client-types' +import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client' +import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' + +/** Reads a host file outside every workspace through the grant its host issued for it. */ +export async function readRuntimeTerminalArtifactContent(args: { + target: RuntimeClientTarget & { kind: 'environment' } + worktreeId: string + absolutePath: string + grantId: string +}): Promise { + const params = { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + grantId: args.grantId, + absolutePath: args.absolutePath + } + let result: RuntimeFileReadResult + try { + result = await callRuntimeRpc( + args.target, + 'files.readTerminalArtifact', + params, + { timeoutMs: 15_000 } + ) + } catch (err) { + // Why: the host refuses binary artifacts with this exact typed error; images use the preview. + if (err instanceof RuntimeRpcCallError && err.message === 'binary_file') { + return callRuntimeRpc( + args.target, + 'files.readTerminalArtifactPreview', + params, + { timeoutMs: 15_000 } + ) + } + throw err + } + if (result.truncated) { + throw new Error(`Remote file is too large to open in the editor (${result.byteLength} bytes)`) + } + return { content: result.content, isBinary: false } +} diff --git a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts index f8c2f7b7fe96..80ebbabde550 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts @@ -108,6 +108,8 @@ export function applyOpenFileToState( const updatedPreview = isPreview ? existing.isPreview : false const nextExternalSshTargetId = file.externalSshTargetId ?? existing.externalSshTargetId const refreshExternalSshProvenance = file.externalSshTargetId !== undefined + // Why: a re-click mints a fresh grant; the old one may have expired. + const nextGrantId = file.terminalArtifactGrantId ?? existing.terminalArtifactGrantId const fileContentReloadNonce = shouldRequestExistingFileContentReload( existing, file.mode, @@ -135,6 +137,7 @@ export function applyOpenFileToState( existing.worktreeId !== file.worktreeId || existing.runtimeEnvironmentId !== runtimeEnvironmentId || existing.externalSshTargetId !== nextExternalSshTargetId || + existing.terminalArtifactGrantId !== nextGrantId || refreshExternalSshProvenance || existing.fileContentReloadNonce !== fileContentReloadNonce || existing.readOnly !== nextReadOnly || @@ -152,6 +155,7 @@ export function applyOpenFileToState( language: file.language, runtimeEnvironmentId, externalSshTargetId: nextExternalSshTargetId, + terminalArtifactGrantId: nextGrantId, operationProvenance: refreshExternalSshProvenance ? operationProvenance : f.operationProvenance, diff --git a/src/renderer/src/store/slices/editor/types/open-file.ts b/src/renderer/src/store/slices/editor/types/open-file.ts index fdb924551fd8..507bb53026f1 100644 --- a/src/renderer/src/store/slices/editor/types/open-file.ts +++ b/src/renderer/src/store/slices/editor/types/open-file.ts @@ -98,6 +98,8 @@ export type OpenFile = { runtimeEnvironmentId?: string | null /** SSH target that owns an absolute path outside the worktree. */ externalSshTargetId?: string + /** Host grant for a read-only file outside every workspace on `runtimeEnvironmentId`; never persisted. */ + terminalArtifactGrantId?: string /** Host provenance captured when the tab opened; mutations reject replacement owners. */ operationProvenance?: EditorFileOperationProvenance /** Why: preview tabs mirror a source file's live draft; storing its ID lets the preview follow unsaved edits without becoming editable. */ diff --git a/tests/e2e/helpers/orcad-managed-workspace.ts b/tests/e2e/helpers/orcad-managed-workspace.ts index 70943839d311..b17112155dd5 100644 --- a/tests/e2e/helpers/orcad-managed-workspace.ts +++ b/tests/e2e/helpers/orcad-managed-workspace.ts @@ -55,7 +55,9 @@ export type ManagedWorkspace = { /** Leaves the managed repository active; each launch is recorded on `fixture` for teardown. */ export async function launchManagedWorkspace( - fixture: ManagedHostFixture + fixture: ManagedHostFixture, + /** Overrides the host's folder workspace, e.g. to keep /tmp outside every workspace. */ + options: { folderPath?: string } = {} ): Promise { const { host, session } = fixture // The first launch only creates the profile the saved SSH host is written into. @@ -66,7 +68,7 @@ export async function launchManagedWorkspace( fixture.app = null const seeded = seedRelayEraProfile(session.userDataDir, host.input, { repoPath: host.remoteRepoPath, - folderPath: host.remoteFolderPath + folderPath: options.folderPath ?? host.remoteFolderPath }) const { app, page } = await session.launch() fixture.app = app diff --git a/tests/e2e/ssh-orcad-workspace-tools.spec.ts b/tests/e2e/ssh-orcad-workspace-tools.spec.ts index 0c9a9fe7adf8..8f9c6eddc808 100644 --- a/tests/e2e/ssh-orcad-workspace-tools.spec.ts +++ b/tests/e2e/ssh-orcad-workspace-tools.spec.ts @@ -114,8 +114,6 @@ for (const acrossReconnect of [false, true]) { test('a terminal link opens an image outside the managed workspace', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. {}, testInfo) => { - // Why fixme: in-workspace links work (ssh-orcad-terminal-root-owner); this path gets no link at all. - test.fixme(true, 'A managed terminal does not linkify a path outside its workspace') test.setTimeout(10 * 60_000) const imagePath = '/tmp/orca-ssh-external-preview.png' const imageBase64 = @@ -124,7 +122,8 @@ test('a terminal link opens an image outside the managed workspace', async (// o fixture.host.exec!( `printf '%s' ${shellQuote(imageBase64)} | base64 -d > ${shellQuote(imagePath)}` ) - const { page, seeded } = await launchManagedWorkspace(fixture) + // Why /root: the default folder workspace is /tmp, which would make the image a workspace file. + const { page, seeded } = await launchManagedWorkspace(fixture, { folderPath: '/root' }) // A link is only clickable once the shell has printed it, so wait for one that answers. await expectTerminalAnswers(page, await openTerminalTab(page, seeded.worktreeId)) await openTerminalWorkspaceRootLink(page, testInfo, imagePath, 'open') From 666d990bc015c704c85c78b578cb8aa52ed64ffa Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:36:37 -0400 Subject: [PATCH 079/110] Record a display-only snapshot of each paired server's tabs (inert) (#27359) * feat(layout): record a display-only snapshot of each paired server's tabs Records the session.tabs.subscribeAll frames main already relays to the window into a new remoteLayoutSnapshots profile domain (design 6.3, D3). No new server requests; written only by the recorder; dropped when the server is removed. Nothing reads it yet. * fix(layout): keep only what the snapshot draws and save it on structure changes Drop retirement proofs (sent as deltas) and one-shot navigation from recorded workspaces; save immediately only when tabs or groups change, letting title ticks ride the next save; drop the protocol field. --- src/main/ipc/managed-orcad-actions.ts | 16 +- ...orcad-runtime-maintenance-handlers.test.ts | 10 +- .../remote-layout-snapshot-recorder.test.ts | 194 ++++++++++++++++++ .../ipc/remote-layout-snapshot-recorder.ts | 104 ++++++++++ ...ntime-environment-connectivity-handlers.ts | 4 +- ...untime-environment-removal-cleanup.test.ts | 13 +- .../runtime-environment-removal-cleanup.ts | 15 +- ...untime-environment-removal-storage.test.ts | 12 +- ...ntime-environment-subscription-handlers.ts | 13 +- ...-environment-subscription-identity.test.ts | 3 +- ...ment-subscription-layout-recording.test.ts | 94 +++++++++ ...time-environments-capability-cache.test.ts | 2 + .../ipc/runtime-environments-pairing.test.ts | 5 +- ...environments-subscription-teardown.test.ts | 2 + src/main/ipc/runtime-environments.ts | 7 +- ...remote-layout-snapshot-persistence.test.ts | 148 +++++++++++++ .../remote-layout-snapshot-persistence.ts | 51 +++++ .../state-serialization-secret-handling.ts | 1 + .../loading-store/store-domain-composition.ts | 6 + src/shared/persisted-state-types.ts | 5 + src/shared/remote-layout-snapshot.ts | 18 ++ 21 files changed, 695 insertions(+), 28 deletions(-) create mode 100644 src/main/ipc/remote-layout-snapshot-recorder.test.ts create mode 100644 src/main/ipc/remote-layout-snapshot-recorder.ts create mode 100644 src/main/ipc/runtime-environment-subscription-layout-recording.test.ts create mode 100644 src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts create mode 100644 src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts create mode 100644 src/shared/remote-layout-snapshot.ts diff --git a/src/main/ipc/managed-orcad-actions.ts b/src/main/ipc/managed-orcad-actions.ts index a356605f10c3..f6e2dd8c513d 100644 --- a/src/main/ipc/managed-orcad-actions.ts +++ b/src/main/ipc/managed-orcad-actions.ts @@ -9,8 +9,10 @@ import { updateManagedOrcadEnvironment } from '../ssh/orcad-runtime-lifecycle' import type { ManagedServerActions } from '../runtime/managed-server-actions-registry' -import type { ExecutionHostId } from '../../shared/execution-host' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' +import { + retireRemovedRuntimeEnvironment, + type RemovedServerStore +} from './runtime-environment-removal-cleanup' export type ManagedOrcadActionOptions = { getUserDataPath: () => string @@ -20,8 +22,8 @@ export type ManagedOrcadActionOptions = { clearHostServerNotes: (sshTargetId: string, environmentId: string) => void /** Drops the SSH host's stale managed-server state once its server is unlinked. */ clearHostServerStatus: (sshTargetId: string) => void - /** Drops the unlinked server's workspace session partition. */ - forgetHostSession: (hostId: ExecutionHostId) => void + /** Holds what an unlinked server leaves in the profile. */ + store: RemovedServerStore } export function createManagedOrcadActions( @@ -32,11 +34,7 @@ export function createManagedOrcadActions( isActiveEnvironment: (environmentId: string) => options.getActiveEnvironmentId() === environmentId, retireLocalState: (environmentId: string) => - retireRemovedRuntimeEnvironment( - environmentId, - options.invalidateTransport, - options.forgetHostSession - ) + retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport, options.store) } return { status: (selector) => getManagedOrcadRuntimeStatus(userDataPath(), selector), diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts index 223be8000706..2baa16e22952 100644 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts +++ b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts @@ -28,7 +28,11 @@ const { registerOrcadRuntimeMaintenanceHandlers } = const invalidateTransport = vi.fn() const clearHostServerStatus = vi.fn() const clearHostServerNotes = vi.fn() -const forgetHostSession = vi.fn() +const store = { + removeWorkspaceSessionHost: vi.fn(), + getRemoteLayoutSnapshot: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() +} function handler(channel: string): (_event: unknown, args: unknown) => Promise { const registration = mocks.handle.mock.calls.find(([name]) => name === channel) @@ -47,7 +51,7 @@ describe('managed orcad maintenance IPC', () => { invalidateTransport, clearHostServerStatus, clearHostServerNotes, - forgetHostSession + store }) }) @@ -124,7 +128,7 @@ describe('managed orcad maintenance IPC', () => { expect(policy.isActiveEnvironment('active-environment')).toBe(true) expect(policy.isActiveEnvironment('e-1')).toBe(false) policy.retireLocalState('e-1') - expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, forgetHostSession) + expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, store) // The SSH host stops naming the unlinked server without waiting for a reconnect. expect(clearHostServerStatus).toHaveBeenCalledWith('ssh-1') }) diff --git a/src/main/ipc/remote-layout-snapshot-recorder.test.ts b/src/main/ipc/remote-layout-snapshot-recorder.test.ts new file mode 100644 index 000000000000..6f82cdd6f77c --- /dev/null +++ b/src/main/ipc/remote-layout-snapshot-recorder.test.ts @@ -0,0 +1,194 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { RemoteLayoutSnapshot } from '../../shared/remote-layout-snapshot' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { scanSourceTree, stripComments } from '../../shared/source-scan/source-tree-scan' +import { + forgetRemoteLayoutSnapshot, + recordRemoteLayoutFrame, + type RemoteLayoutSnapshotStore +} from './remote-layout-snapshot-recorder' + +const METHOD = 'session.tabs.subscribeAll' + +function memoryStore() { + const snapshots = new Map() + const saves: ('now' | 'later')[] = [] + const store: RemoteLayoutSnapshotStore = { + getRemoteLayoutSnapshot: (environmentId) => snapshots.get(environmentId), + setRemoteLayoutSnapshot: (environmentId, snapshot, save) => { + saves.push(save) + if (snapshot) { + snapshots.set(environmentId, snapshot) + } else { + snapshots.delete(environmentId) + } + } + } + return { store, snapshots, saves } +} + +function tabs(worktree: string, title = 'shell') { + return { + worktree, + publicationEpoch: 'epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [{ type: 'terminal', id: `${worktree}-tab`, title }] + } +} + +function frame(result: unknown, runtimeId = 'server-1'): RuntimeRpcResponse { + return { id: 'stream', ok: true, result, _meta: { runtimeId } } +} + +function workspacesOf(snapshots: Map, environmentId = 'env') { + return snapshots.get(environmentId)?.workspaces +} + +describe('remote layout snapshot recorder', () => { + it('replaces the whole entry on each census, dropping worktrees it no longer lists', () => { + const { store, snapshots } = memoryStore() + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'snapshots', snapshots: [tabs('a'), tabs('b')] }) + ) + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'snapshots', snapshots: [tabs('b', 'renamed')] }, 'server-2') + ) + + expect(snapshots.get('env')).toMatchObject({ + serverRuntimeId: 'server-2', + workspaces: { b: tabs('b', 'renamed') } + }) + expect(workspacesOf(snapshots)).not.toHaveProperty('a') + }) + + it('replaces one worktree on an update and drops it on a removal', () => { + const { store, snapshots } = memoryStore() + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'snapshots', snapshots: [tabs('a'), tabs('b')] }) + ) + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'updated', ...tabs('a', 'vim') })) + expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim'), b: tabs('b') }) + + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'updated', ...tabs('b'), tabs: [], removed: true }) + ) + expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim') }) + }) + + it('keeps neither retirement proofs nor one-shot navigation', () => { + const { store, snapshots } = memoryStore() + const proof = { parentTabId: 'a-tab', leafId: 'leaf', ptyId: 'pty', terminal: 'term' } + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ type: 'snapshots', snapshots: [{ ...tabs('a'), retiredTerminalSurfaces: [proof] }] }) + ) + expect(workspacesOf(snapshots)).toEqual({ a: tabs('a') }) + + // A capable client is sent only new proofs, so an update's list is a delta, here empty. + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + frame({ + type: 'updated', + ...tabs('a', 'vim'), + retiredTerminalSurfaces: [], + navigationIntent: 'follow' + }) + ) + expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim') }) + }) + + it('saves at once only when tabs or groups change; title ticks ride the next save', () => { + const { store, saves } = memoryStore() + const census = (snapshots: unknown[]) => frame({ type: 'snapshots', snapshots }) + const update = (result: Record) => frame({ type: 'updated', ...result }) + recordRemoteLayoutFrame(store, 'env', METHOD, census([tabs('a')])) + recordRemoteLayoutFrame(store, 'env', METHOD, update(tabs('a', 'thinking.'))) + recordRemoteLayoutFrame(store, 'env', METHOD, update(tabs('a', 'thinking..'))) + const twoTabs = { + ...tabs('a'), + tabs: [...tabs('a').tabs, { type: 'terminal', id: 'second', title: 'shell' }] + } + recordRemoteLayoutFrame(store, 'env', METHOD, update(twoTabs)) + const grouped = { + ...twoTabs, + tabGroups: [{ id: 'g', activeTabId: null, tabOrder: ['second'] }] + } + recordRemoteLayoutFrame(store, 'env', METHOD, update(grouped)) + recordRemoteLayoutFrame( + store, + 'env', + METHOD, + update({ ...grouped, tabGroups: [{ id: 'g', activeTabId: 'second', tabOrder: ['second'] }] }) + ) + recordRemoteLayoutFrame(store, 'env', METHOD, update({ ...tabs('a'), tabs: [], removed: true })) + expect(saves).toEqual(['now', 'later', 'later', 'now', 'now', 'later', 'now']) + }) + + it('keeps each server in its own entry', () => { + const { store, snapshots } = memoryStore() + recordRemoteLayoutFrame( + store, + 'one', + METHOD, + frame({ type: 'snapshots', snapshots: [tabs('a')] }) + ) + recordRemoteLayoutFrame(store, 'two', METHOD, frame({ type: 'snapshots', snapshots: [] })) + expect(workspacesOf(snapshots, 'one')).toEqual({ a: tabs('a') }) + expect(workspacesOf(snapshots, 'two')).toEqual({}) + }) + + it('records nothing from other streams, failures, ends or unreadable frames', () => { + const { store, saves } = memoryStore() + const census = { type: 'snapshots', snapshots: [tabs('a')] } + recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribe', frame(census)) + recordRemoteLayoutFrame(store, 'env', 'terminal.multiplex', frame(census)) + recordRemoteLayoutFrame(store, 'env', METHOD, { + id: 'stream', + ok: false, + error: { code: 'method_not_found', message: 'nope' }, + _meta: { runtimeId: 'server-1' } + }) + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'end' })) + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'snapshots', snapshots: [{}] })) + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'updated', worktree: 'a' })) + expect(saves).toEqual([]) + }) + + it('forgets a removed server', () => { + const { store, snapshots } = memoryStore() + recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'snapshots', snapshots: [] })) + forgetRemoteLayoutSnapshot(store, 'env') + expect(snapshots.has('env')).toBe(false) + }) + + it('is the only code that writes the snapshot', () => { + const src = join(__dirname, '../..') + const writers = scanSourceTree(src) + .filter(({ source }) => stripComments(source).includes('setRemoteLayoutSnapshot(')) + .map(({ relativePath }) => relativePath) + expect(writers.sort()).toEqual([ + 'main/ipc/remote-layout-snapshot-recorder.ts', + 'main/persistence/loading-store/remote-layout-snapshot-persistence.ts' + ]) + }) +}) diff --git a/src/main/ipc/remote-layout-snapshot-recorder.ts b/src/main/ipc/remote-layout-snapshot-recorder.ts new file mode 100644 index 000000000000..de115a9871ed --- /dev/null +++ b/src/main/ipc/remote-layout-snapshot-recorder.ts @@ -0,0 +1,104 @@ +// The one writer of the display-only server layout snapshot (design 6.3, D3). It records the +// `session.tabs.subscribeAll` frames main already relays to the window, so no server receives a +// new request. Nothing reads the snapshot yet. + +import type { + RemoteLayoutSnapshot, + RemoteLayoutSnapshotWorkspace +} from '../../shared/remote-layout-snapshot' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-session-contracts' + +export type RemoteLayoutSnapshotStore = { + getRemoteLayoutSnapshot(environmentId: string): RemoteLayoutSnapshot | undefined + setRemoteLayoutSnapshot( + environmentId: string, + snapshot: RemoteLayoutSnapshot | null, + save: 'now' | 'later' + ): void +} + +type SessionTabsFrame = + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | (RuntimeMobileSessionTabsResult & { type: 'updated'; removed?: true }) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Checks the identity fields only; the rest is the server's published contract. +function isSessionTabsResult(value: unknown): value is RuntimeMobileSessionTabsResult { + return isRecord(value) && typeof value.worktree === 'string' && Array.isArray(value.tabs) +} + +function isSessionTabsFrame(value: unknown): value is SessionTabsFrame { + if (!isRecord(value)) { + return false + } + return value.type === 'snapshots' + ? Array.isArray(value.snapshots) && value.snapshots.every(isSessionTabsResult) + : value.type === 'updated' && isSessionTabsResult(value) +} + +function displayed({ + navigationIntent: _command, + retiredTerminalSurfaces: _proofs, + ...workspace +}: RuntimeMobileSessionTabsResult): RemoteLayoutSnapshotWorkspace { + return workspace +} + +// Which tabs exist and where; titles and status change far more often and can wait for a save. +function structure(workspace: RemoteLayoutSnapshotWorkspace | undefined): string { + return JSON.stringify([ + workspace?.tabs.map((tab) => tab.id), + workspace?.tabGroups?.map((group) => [group.id, group.tabOrder]), + workspace?.tabGroupLayout + ]) +} + +/** Called for every relayed stream frame; only the window's all-worktree tab stream is recorded. */ +export function recordRemoteLayoutFrame( + store: RemoteLayoutSnapshotStore, + environmentId: string, + method: string, + response: RuntimeRpcResponse +): void { + if (method !== 'session.tabs.subscribeAll' || !response.ok) { + return + } + const frame = response.result + if (!isSessionTabsFrame(frame)) { + return + } + const recorded = { receivedAt: Date.now(), serverRuntimeId: response._meta.runtimeId } + if (frame.type === 'snapshots') { + // A census replaces the whole entry: a worktree missing from it is gone. + const workspaces = Object.fromEntries( + frame.snapshots.map((snapshot) => [snapshot.worktree, displayed(snapshot)]) + ) + store.setRemoteLayoutSnapshot(environmentId, { ...recorded, workspaces }, 'now') + return + } + const { type: _type, removed, ...result } = frame + const { [frame.worktree]: previous, ...others } = + store.getRemoteLayoutSnapshot(environmentId)?.workspaces ?? {} + if (removed) { + store.setRemoteLayoutSnapshot(environmentId, { ...recorded, workspaces: others }, 'now') + return + } + const workspace = displayed(result) + store.setRemoteLayoutSnapshot( + environmentId, + { ...recorded, workspaces: { ...others, [frame.worktree]: workspace } }, + structure(previous) === structure(workspace) ? 'later' : 'now' + ) +} + +/** A removed server's snapshot goes with it. */ +export function forgetRemoteLayoutSnapshot( + store: RemoteLayoutSnapshotStore, + environmentId: string +): void { + store.setRemoteLayoutSnapshot(environmentId, null, 'now') +} diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 1b23ccde6487..7a3b88850a24 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -110,9 +110,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ) } const removed = removeEnvironment(getUserDataPath(), args.selector) - void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, (hostId) => - store.removeWorkspaceSessionHost(hostId) - ) + void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, store) closeLegacySelectorTransport(args.selector, removed.id) return { removed: redactRuntimeEnvironment(removed) } } diff --git a/src/main/ipc/runtime-environment-removal-cleanup.test.ts b/src/main/ipc/runtime-environment-removal-cleanup.test.ts index 261a24f652d0..d3f40eeee239 100644 --- a/src/main/ipc/runtime-environment-removal-cleanup.test.ts +++ b/src/main/ipc/runtime-environment-removal-cleanup.test.ts @@ -10,9 +10,14 @@ vi.mock('../browser/browser-route-partition-storage-retirement', () => ({ const { retireRemovedRuntimeEnvironment } = await import('./runtime-environment-removal-cleanup') describe('retiring a removed runtime environment', () => { - it('forgets the server’s workspace session partition under its runtime host id', async () => { - const forgetHostSession = vi.fn() - await retireRemovedRuntimeEnvironment('env 1', vi.fn(), forgetHostSession) - expect(forgetHostSession).toHaveBeenCalledWith('runtime:env%201') + it('forgets the server’s session partition and its layout snapshot', async () => { + const store = { + removeWorkspaceSessionHost: vi.fn(), + getRemoteLayoutSnapshot: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() + } + await retireRemovedRuntimeEnvironment('env 1', vi.fn(), store) + expect(store.removeWorkspaceSessionHost).toHaveBeenCalledWith('runtime:env%201') + expect(store.setRemoteLayoutSnapshot).toHaveBeenCalledWith('env 1', null, 'now') }) }) diff --git a/src/main/ipc/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environment-removal-cleanup.ts index c1961c137d90..2e67142824cd 100644 --- a/src/main/ipc/runtime-environment-removal-cleanup.ts +++ b/src/main/ipc/runtime-environment-removal-cleanup.ts @@ -3,16 +3,27 @@ import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/bro import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect' import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + forgetRemoteLayoutSnapshot, + type RemoteLayoutSnapshotStore +} from './remote-layout-snapshot-recorder' + +/** What a removed server leaves in the profile. */ +export type RemovedServerStore = RemoteLayoutSnapshotStore & { + /** Drops the unlinked server's workspace session partition. */ + removeWorkspaceSessionHost: (hostId: ExecutionHostId) => void +} /** Retires a removed server's client-side state; resolves once its transport is invalidated. */ export function retireRemovedRuntimeEnvironment( environmentId: string, invalidateTransport: (environmentId: string) => Promise | void, - forgetHostSession?: (hostId: ExecutionHostId) => void + store: RemovedServerStore ): Promise { clearRuntimeEnvironmentCapabilityEvidence(environmentId) // Why: listings enumerate session partitions as known hosts, so a kept one names a dead server. - forgetHostSession?.(toRuntimeExecutionHostId(environmentId)) + store.removeWorkspaceSessionHost(toRuntimeExecutionHostId(environmentId)) + forgetRemoteLayoutSnapshot(store, environmentId) clearRuntimeEnvironmentManualDisconnect(environmentId) const retiring = Promise.resolve(invalidateTransport(environmentId)) // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes diff --git a/src/main/ipc/runtime-environment-removal-storage.test.ts b/src/main/ipc/runtime-environment-removal-storage.test.ts index 012df15f47a2..f21d1ac5e29a 100644 --- a/src/main/ipc/runtime-environment-removal-storage.test.ts +++ b/src/main/ipc/runtime-environment-removal-storage.test.ts @@ -48,7 +48,11 @@ describe('runtime environment removal storage clearing', () => { clearStorageMock.mockResolvedValue({ clearedPartitions: ['persist:one'], livePartitions: [] }) registerRuntimeEnvironmentConnectivityHandlers({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: remove reads only settings and the session-host prune. - store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn() } as never, + store: { + getSettings: () => ({}), + removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() + } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => teardown }) @@ -69,7 +73,11 @@ describe('runtime environment removal storage clearing', () => { .mockResolvedValueOnce({ clearedPartitions: ['persist:one'], livePartitions: [] }) registerRuntimeEnvironmentConnectivityHandlers({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: remove reads only settings and the session-host prune. - store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn() } as never, + store: { + getSettings: () => ({}), + removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn() + } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => Promise.resolve() }) diff --git a/src/main/ipc/runtime-environment-subscription-handlers.ts b/src/main/ipc/runtime-environment-subscription-handlers.ts index 0ca81f8bf92d..a579b9c6546d 100644 --- a/src/main/ipc/runtime-environment-subscription-handlers.ts +++ b/src/main/ipc/runtime-environment-subscription-handlers.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { randomUUID } from 'node:crypto' import { resolveEnvironment } from '../../shared/runtime-environment-store' import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' import { getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' @@ -23,8 +24,14 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { getUserDataPath: () => string remoteRuntimeSubscriptions: Map pendingSubscriptions: Map + recordLayoutFrame: ( + environmentId: string, + method: string, + response: RuntimeRpcResponse + ) => void }): void { - const { getUserDataPath, remoteRuntimeSubscriptions, pendingSubscriptions } = args + const { getUserDataPath, remoteRuntimeSubscriptions, pendingSubscriptions, recordLayoutFrame } = + args ipcMain.handle( 'runtimeEnvironments:subscribe', async ( @@ -149,6 +156,10 @@ export function registerRuntimeEnvironmentSubscriptionHandlers(args: { subscriptionId, ...payload }) + // After the send, so recording never delays or changes what the window gets. + if (payload.type === 'response') { + recordLayoutFrame(environment.id, args.method, payload.response) + } } }, onClose: () => { diff --git a/src/main/ipc/runtime-environment-subscription-identity.test.ts b/src/main/ipc/runtime-environment-subscription-identity.test.ts index 8712b5f1cc3c..3f106f8e1f33 100644 --- a/src/main/ipc/runtime-environment-subscription-identity.test.ts +++ b/src/main/ipc/runtime-environment-subscription-identity.test.ts @@ -46,7 +46,8 @@ beforeEach(() => { registerRuntimeEnvironmentSubscriptionHandlers({ getUserDataPath: () => '/profile', remoteRuntimeSubscriptions, - pendingSubscriptions + pendingSubscriptions, + recordLayoutFrame: vi.fn() }) }) afterEach(() => { diff --git a/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts b/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts new file mode 100644 index 000000000000..de5c9255bcef --- /dev/null +++ b/src/main/ipc/runtime-environment-subscription-layout-recording.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' + +const mocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn(), subscribe: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle, on: mocks.on } })) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: () => ({ id: 'env', pairingRevision: 1, createdAt: 1 }) +})) +vi.mock('./runtime-environment-transport-routing', () => ({ + subscribeRuntimeEnvironment: mocks.subscribe +})) +import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' +import { + registerRuntimeEnvironmentSubscriptionHandlers, + type PendingRuntimeSubscription, + type RetainedRemoteRuntimeSubscription +} from './runtime-environment-subscription-handlers' + +type Callbacks = Parameters[5] + +const sender = { + id: 1, + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn(), + removeListener: vi.fn() +} +const recordLayoutFrame = vi.fn() +const census = { + id: 'stream', + ok: true as const, + result: { type: 'snapshots', snapshots: [] }, + _meta: { runtimeId: 'server' } +} + +async function open(method: string): Promise { + let callbacks: Callbacks | undefined + mocks.subscribe.mockImplementationOnce( + async (...parameters: Parameters) => { + callbacks = parameters[5] + return { requestId: 'request', sendBinary: vi.fn(() => true), close: vi.fn() } + } + ) + const subscribe = mocks.handle.mock.calls.find( + ([name]) => name === 'runtimeEnvironments:subscribe' + )![1] + await subscribe({ sender }, { selector: 'env', method, subscriptionId: method }) + return callbacks! +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.subscribe.mockReset() + registerRuntimeEnvironmentSubscriptionHandlers({ + getUserDataPath: () => '/profile', + remoteRuntimeSubscriptions: new Map(), + pendingSubscriptions: new Map(), + recordLayoutFrame + }) +}) + +it('records the window’s own stream after forwarding it unchanged, opening nothing new', async () => { + const callbacks = await open('session.tabs.subscribeAll') + callbacks.onEvent({ type: 'response', response: census }) + + // The window's subscription is the only request the server sees. + expect(mocks.subscribe).toHaveBeenCalledOnce() + expect(mocks.subscribe.mock.calls[0]?.[2]).toBe('session.tabs.subscribeAll') + expect(sender.send).toHaveBeenCalledExactlyOnceWith('runtimeEnvironments:subscriptionEvent', { + subscriptionId: 'session.tabs.subscribeAll', + type: 'response', + response: census + }) + expect(recordLayoutFrame).toHaveBeenCalledExactlyOnceWith( + 'env', + 'session.tabs.subscribeAll', + census + ) + expect(sender.send.mock.invocationCallOrder[0]).toBeLessThan( + recordLayoutFrame.mock.invocationCallOrder[0]! + ) +}) + +it('offers the recorder only frames the window was sent', async () => { + const callbacks = await open('session.tabs.subscribeAll') + callbacks.onEvent({ type: 'binary', bytes: new Uint8Array([1]) }) + callbacks.onEvent({ type: 'error', code: 'lost', message: 'lost' }) + // A replaced transport's late frame reaches neither the window nor the snapshot. + advanceRuntimeEnvironmentTransportGeneration('env') + sender.send.mockClear() + callbacks.onEvent({ type: 'response', response: census }) + expect(sender.send).not.toHaveBeenCalled() + expect(recordLayoutFrame).not.toHaveBeenCalled() +}) diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index 695eb511bcb7..3746780476a2 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -83,6 +83,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } beforeEach(() => { @@ -91,6 +92,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index c435a05ab81c..3f5eb01e8d66 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -101,6 +101,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } beforeEach(() => { @@ -109,6 +110,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -285,10 +287,11 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) expect(activeRuntimeEnvironmentId).toBeNull() expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) - // A removed server's session partition goes too, so listings stop naming it as a host. + // A removed server's session partition and layout snapshot go too. expect(store.removeWorkspaceSessionHost).toHaveBeenCalledWith( `runtime:${encodeURIComponent(added.environment.id)}` ) + expect(store.setRemoteLayoutSnapshot).toHaveBeenCalledWith(added.environment.id, null, 'now') expect(JSON.stringify(removed)).not.toContain('device-token') expect(await list(null, undefined)).toEqual([]) }) diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index e3005c2b6fef..49736d300074 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -91,6 +91,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType + setRemoteLayoutSnapshot: ReturnType } beforeEach(() => { @@ -99,6 +100,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { store = { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), + setRemoteLayoutSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index ab3346a6ddee..20c537486433 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -6,6 +6,7 @@ import { import { app, ipcMain } from 'electron' import { listEnvironments } from '../../shared/runtime-environment-store' import type { Store } from '../persistence' +import { recordRemoteLayoutFrame } from './remote-layout-snapshot-recorder' import { isRuntimeEnvironmentManuallyDisconnected, registerRuntimeEnvironmentConnectivityHandlers, @@ -147,11 +148,13 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { invalidateTransport: invalidateRuntimeEnvironmentTransport, clearHostServerStatus: clearPublishedManagedServer, clearHostServerNotes: clearManagedServerNotes, - forgetHostSession: (hostId) => store.removeWorkspaceSessionHost(hostId) + store }) registerRuntimeEnvironmentSubscriptionHandlers({ getUserDataPath, remoteRuntimeSubscriptions, - pendingSubscriptions + pendingSubscriptions, + recordLayoutFrame: (environmentId, method, response) => + recordRemoteLayoutFrame(store, environmentId, method, response) }) } diff --git a/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts new file mode 100644 index 000000000000..8acf3c96ad05 --- /dev/null +++ b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { setSecretStore } from '../../../shared/secret-store' +import { recordRemoteLayoutFrame } from '../../ipc/remote-layout-snapshot-recorder' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] + +beforeEach(() => { + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plaintext) => Buffer.from(plaintext), + decryptString: (ciphertext) => ciphertext.toString(), + describeProtectionGap: () => null + }) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) +}) + +afterEach(async () => { + for (const store of stores.splice(0)) { + store.freezeWrites() + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function profile() { + const directory = mkdtempSync(join(tmpdir(), 'orca-remote-layout-snapshot-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const profileId = 'remote-layout-snapshot' + new ProfileStateSqliteAuthority(databasePath, profileId).writeSerializedState( + Buffer.from(JSON.stringify(getDefaultPersistedState(directory))) + ) + const open = () => { + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + }) + stores.push(store) + return store + } + const documentRevisions = () => { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return new Map( + opened.db + .prepare('SELECT domain, revision FROM profile_state_documents') + .all() + .map((row) => [String(row.domain), Number(row.revision)]) + ) + } finally { + opened.db.close() + } + } + return { open, documentRevisions } +} + +const workspace = { + worktree: 'repo::/srv/repo', + publicationEpoch: 'epoch', + snapshotVersion: 3, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] +} + +it('survives a restart and saves only its own domain', () => { + const { open, documentRevisions } = profile() + const store = open() + store.flushOrThrow() + const before = documentRevisions() + const fullWrite = vi.spyOn( + ProfileStateSqliteAuthority.prototype, + 'writeCompleteSerializedDomains' + ) + + recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribeAll', { + id: 'stream', + ok: true, + result: { type: 'snapshots', snapshots: [workspace] }, + _meta: { runtimeId: 'server' } + }) + store.flushOrThrow() + + // Not a whole-profile serialization per recorded frame. + expect(fullWrite).not.toHaveBeenCalled() + const after = documentRevisions() + const changed = [...after].filter(([domain, revision]) => before.get(domain) !== revision) + expect(changed.map(([domain]) => domain)).toEqual(['remoteLayoutSnapshots']) + expect(open().getRemoteLayoutSnapshot('env')).toMatchObject({ + serverRuntimeId: 'server', + workspaces: { [workspace.worktree]: workspace } + }) +}) + +it('keeps a title-only change without arming a save, and writes it with the next save', () => { + const { open } = profile() + const store = open() + const record = (result: Record) => + recordRemoteLayoutFrame(store, 'env', 'session.tabs.subscribeAll', { + id: 'stream', + ok: true, + result, + _meta: { runtimeId: 'server' } + }) + record({ type: 'snapshots', snapshots: [workspace] }) + store.flushOrThrow() + + const timers = vi.spyOn(globalThis, 'setTimeout') + const retitled = { ...workspace, snapshotVersion: 4 } + record({ type: 'updated', ...retitled }) + expect(timers).not.toHaveBeenCalled() + timers.mockRestore() + + // Another domain's save writes only the domains marked dirty, which must include this one. + store.updateSettings({ opencodeSessionCookie: 'cookie' }) + store.flushOrThrow() + expect(open().getRemoteLayoutSnapshot('env')?.workspaces[workspace.worktree]).toEqual(retitled) +}) diff --git a/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts new file mode 100644 index 000000000000..31efc87f6665 --- /dev/null +++ b/src/main/persistence/loading-store/remote-layout-snapshot-persistence.ts @@ -0,0 +1,51 @@ +import type { RemoteLayoutSnapshot } from '../../../shared/remote-layout-snapshot' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RemoteLayoutSnapshotRuntime = Pick + +const remoteLayoutSnapshotPersistenceContext = Symbol('RemoteLayoutSnapshotPersistence') +type RemoteLayoutSnapshotPersistenceContext = { + runtime: RemoteLayoutSnapshotRuntime + scheduling: WriteSchedulingOperations +} + +export class RemoteLayoutSnapshotPersistence { + readonly [remoteLayoutSnapshotPersistenceContext]: RemoteLayoutSnapshotPersistenceContext + + constructor(runtime: RemoteLayoutSnapshotRuntime, scheduling: WriteSchedulingOperations) { + this[remoteLayoutSnapshotPersistenceContext] = { runtime, scheduling } + } + + getRemoteLayoutSnapshot(environmentId: string): RemoteLayoutSnapshot | undefined { + return this[remoteLayoutSnapshotPersistenceContext].runtime.state.remoteLayoutSnapshots?.[ + environmentId + ] + } + + /** + * Only the recorder calls this (ratchet in remote-layout-snapshot-recorder.test.ts). `later` + * marks the domain dirty without arming the save timer, so it rides the next save or quit flush. + */ + setRemoteLayoutSnapshot( + environmentId: string, + snapshot: RemoteLayoutSnapshot | null, + save: 'now' | 'later' + ): void { + const { runtime, scheduling } = this[remoteLayoutSnapshotPersistenceContext] + const { [environmentId]: previous, ...others } = runtime.state.remoteLayoutSnapshots ?? {} + if (snapshot === null && previous === undefined) { + return + } + runtime.state.remoteLayoutSnapshots = snapshot + ? { ...others, [environmentId]: snapshot } + : others + if (save === 'later') { + runtime.dirtyProfileStateDomains?.add('remoteLayoutSnapshots') + return + } + // Named so the save writes only this domain and wakes no workspace-session listener. + scheduleSave(scheduling, ['remoteLayoutSnapshots']) + } +} diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index df4101229686..d5c260517b31 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -108,6 +108,7 @@ export class StateSerializationSecretHandlingOperations { } break case 'sshRemotePtyLeases': + case 'remoteLayoutSnapshots': case 'legacyPaneKeyAliasEntries': case 'migrationUnsupportedPtyEntries': stateToSave[domain] = this.runtime.state[domain] diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 2d4fb6e92d87..f6cbe9d7a832 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -16,6 +16,7 @@ import { MetadataLineageOperations } from './metadata-lineage-operations' import { ProjectCollectionOperations } from './project-collection-operations' import { AutomationPersistence } from './automation-persistence' import { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' +import { RemoteLayoutSnapshotPersistence } from './remote-layout-snapshot-persistence' import { SparsePresetPersistence } from './sparse-preset-persistence' import { PtyBindingPersistenceOperations } from './pty-binding-persistence' import { SshProfileOperations } from './ssh-profile-operations' @@ -29,6 +30,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & ProjectCollectionOperations & RepoLifecycleOperations & MobileTabSelectionPersistence & + RemoteLayoutSnapshotPersistence & SparsePresetPersistence & AutomationPersistence & MetadataLineageOperations & @@ -60,6 +62,7 @@ export type StoreDomains = { projects: ProjectCollectionOperations automations: AutomationPersistence mobileTabSelections: MobileTabSelectionPersistence + remoteLayoutSnapshots: RemoteLayoutSnapshotPersistence sparsePresets: SparsePresetPersistence ptyBindings: PtyBindingPersistenceOperations sshProfiles: SshProfileOperations @@ -75,6 +78,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ ProjectCollectionOperations, RepoLifecycleOperations, MobileTabSelectionPersistence, + RemoteLayoutSnapshotPersistence, SparsePresetPersistence, AutomationPersistence, MetadataLineageOperations, @@ -122,6 +126,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata) const automations = new AutomationPersistence(runtime, flushBarriers, preferences) const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) + const remoteLayoutSnapshots = new RemoteLayoutSnapshotPersistence(runtime, scheduling) const sparsePresets = new SparsePresetPersistence(runtime, scheduling) const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) const sshProfiles = new SshProfileOperations(runtime, scheduling, repos) @@ -150,6 +155,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { projects, automations, mobileTabSelections, + remoteLayoutSnapshots, sparsePresets, ptyBindings, sshProfiles, diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index 19966bb2c3f0..936f67c12226 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -17,6 +17,7 @@ import type { IssueInfo, PRInfo } from './github/pull-request-types' import type { OnboardingState } from './onboarding-state-types' import type { PersistedUIState } from './persisted-ui-state-types' import type { ProjectGroup } from './project-group-types' +import type { RemoteLayoutSnapshot } from './remote-layout-snapshot' import type { Project, ProjectHostSetup } from './project-types' import type { Repo } from './repo-types' import type { SparsePreset } from './worktree/create-types' @@ -99,6 +100,10 @@ export type PersistedState = { workspaceSession: WorkspaceSessionState /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:); 'local' stays in workspaceSession so pre-partition builds keep working. */ workspaceSessionsByHostId?: Partial> + /** Display-only snapshot of each paired server's last layout, keyed by environment id. Written + * only by the remote layout snapshot recorder; a separate domain because older builds write + * the `runtime:` partitions. */ + remoteLayoutSnapshots?: Record sshTargets: SshTarget[] /** Highest SSH target registration generation issued; prevents identity reuse after rollback. */ sshTargetGenerationCounter?: number diff --git a/src/shared/remote-layout-snapshot.ts b/src/shared/remote-layout-snapshot.ts new file mode 100644 index 000000000000..9d610113128c --- /dev/null +++ b/src/shared/remote-layout-snapshot.ts @@ -0,0 +1,18 @@ +import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts' + +/** A workspace as last drawn: one-shot commands and retirement proofs (sent as deltas) are not kept. */ +export type RemoteLayoutSnapshotWorkspace = Omit< + RuntimeMobileSessionTabsResult, + 'navigationIntent' | 'retiredTerminalSurfaces' +> + +/** + * The last layout one paired server sent this desktop (design 6.3, D3), kept so a restart while + * the server is offline can still draw its tabs. Display-only: never edited, never sent back. + */ +export type RemoteLayoutSnapshot = { + receivedAt: number + serverRuntimeId: string + /** Keyed by worktree id, each from the server's last `session.tabs` result for it. */ + workspaces: Record +} From 56d25b00bdb72c4313832ae3ec07c14e8d4b873f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:47:45 -0700 Subject: [PATCH 080/110] fix(settings): reenable terminal link Actions after an old opt-out (#26853) An explicit Actions, Open URL, or Leave to terminal choice now also writes the legacy terminalLinkActionPopoverEnabled switch, so an older opt-out no longer snaps the Actions selection back to Leave to terminal. Fixes #25576 Co-authored-by: drakeo338 --- ...gs-update-terminal-link-preference.test.ts | 108 +++++++++++++++++ .../applying-settings/settings-update.ts | 5 + .../e2e/terminal-link-click-ownership.spec.ts | 114 +++++++++++++++++- 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts diff --git a/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts b/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts new file mode 100644 index 000000000000..e3f9e215c0a7 --- /dev/null +++ b/src/main/persistence/applying-settings/settings-update-terminal-link-preference.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + closeTestStores, + createStore, + testState, + writeDataFile +} from '../../persistence-test-harness' +import { getDefaultPersistedState } from '../../../shared/constants' +import { updateSettings, type SettingsMutationOperations } from './settings-update' + +function makeOperations(): SettingsMutationOperations { + const state = getDefaultPersistedState(tmpdir()) + state.settings.terminalLinkActionPopoverEnabled = false + return { + state, + bumpLocalWorktreeScanGeneration: vi.fn(), + removeRetainedBlob: vi.fn(), + scheduleSave: vi.fn(), + notifySettingsChanged: vi.fn() + } +} + +describe('terminal URL click preference writes', () => { + it.each(['actions', 'open', 'none'] as const)( + 'publishes the explicit %s choice with a consistent legacy switch', + (behavior) => { + const operations = makeOperations() + operations.state.settings.terminalLinkActionPopoverEnabled = behavior !== 'actions' + operations.state.settings.terminalLinkClickBehavior = + behavior === 'actions' ? 'none' : 'actions' + updateSettings( + operations, + { + terminalLinkClickBehavior: behavior, + terminalLinkActionPopoverEnabled: behavior !== 'actions' + }, + { notifyListeners: true, originWebContentsId: 7 } + ) + + expect(operations.state.settings.terminalLinkClickBehavior).toBe(behavior) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe( + behavior === 'actions' + ) + expect(operations.notifySettingsChanged).toHaveBeenCalledWith( + expect.objectContaining({ + terminalLinkClickBehavior: behavior, + terminalLinkActionPopoverEnabled: behavior === 'actions' + }), + 7 + ) + } + ) + + it('preserves a legacy-only switch update', () => { + const operations = makeOperations() + updateSettings(operations, { terminalLinkActionPopoverEnabled: true }) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe(true) + updateSettings(operations, { terminalLinkActionPopoverEnabled: false }) + expect(operations.state.settings.terminalLinkActionPopoverEnabled).toBe(false) + }) +}) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) + +describe('terminal URL click preference persistence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-terminal-link-preference-')) + writeDataFile({ version: 1, settings: { terminalLinkActionPopoverEnabled: false } }) + }) + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps an old opt-out after saving unrelated settings and reopening the profile', async () => { + const store = createStore() + store.updateSettings({ terminalFontSize: 15 }) + store.flush() + await closeTestStores() + + const reopened = createStore() + expect(reopened.getSettings()).toMatchObject({ + terminalLinkClickBehavior: 'actions', + terminalLinkActionPopoverEnabled: false, + terminalFontSize: 15 + }) + }) + + it('keeps a newly selected Actions preference after reopening the profile', async () => { + const store = createStore() + store.updateSettings({ terminalLinkClickBehavior: 'actions' }) + store.flush() + await closeTestStores() + + const reopened = createStore() + expect(reopened.getSettings()).toMatchObject({ + terminalLinkClickBehavior: 'actions', + terminalLinkActionPopoverEnabled: true + }) + }) +}) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 706f9105148e..932d4a412049 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -58,6 +58,11 @@ export function updateSettings( options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { const sanitizedUpdates = stripRetiredGlobalSettings(updates) + const linkBehavior = updates.terminalLinkClickBehavior + if (linkBehavior === 'actions' || linkBehavior === 'open' || linkBehavior === 'none') { + // Loaded defaults are ambiguous; synchronize the legacy switch only on an explicit write. + sanitizedUpdates.terminalLinkActionPopoverEnabled = linkBehavior === 'actions' + } if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) } diff --git a/tests/e2e/terminal-link-click-ownership.spec.ts b/tests/e2e/terminal-link-click-ownership.spec.ts index 50b570b0a6d6..7ebad18453d1 100644 --- a/tests/e2e/terminal-link-click-ownership.spec.ts +++ b/tests/e2e/terminal-link-click-ownership.spec.ts @@ -12,6 +12,11 @@ import { } from './helpers/terminal' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + readPanelNavigationObserver, + startPanelNavigationObserver +} from './helpers/plugin-panel-navigation-observer' + const FIXTURE_PATH = path.join( process.cwd(), 'tests/e2e/fixtures/terminal-link-mouse-owner-fixture.cjs' @@ -92,12 +97,117 @@ async function expectChildMouseReports(mouseLogPath: string): Promise { .toBeGreaterThan(0) } -async function expectOrcaOwnedMouseOutcome(mouseLogPath: string): Promise { +async function expectOrcaOwnedMouseOutcome( + mouseLogPath: string, + reportsBeforeClick = 0 +): Promise { await new Promise((resolve) => setTimeout(resolve, 1_000)) - expect(childMouseReportCount(mouseLogPath)).toBe(0) + expect(childMouseReportCount(mouseLogPath)).toBe(reportsBeforeClick) +} + +async function openLegacyDisabledLinkSettings(orcaPage: Page): Promise { + await orcaPage.evaluate(() => { + void window.__store?.getState().updateSettings({ terminalLinkActionPopoverEnabled: false }) + }) + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalLinkActionPopoverEnabled) + ) + .toBe(false) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'browser', repoId: null }) + }) } test.describe('terminal link click ownership', () => { + test('selecting Actions reenables a legacy-disabled popover and survives renderer reload', async ({ + orcaPage + }, testInfo) => { + const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo) + await openLegacyDisabledLinkSettings(orcaPage) + + const choices = orcaPage.getByRole('radiogroup', { name: 'Plain click URL behavior' }) + await expect(choices.getByRole('radio', { name: 'Leave to terminal' })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await choices.getByRole('radio', { name: 'Actions', exact: true }).click() + await expect(choices.getByRole('radio', { name: 'Actions', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + + await orcaPage.reload() + await waitForSessionReady(orcaPage) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'browser', repoId: null }) + }) + await expect(choices.getByRole('radio', { name: 'Actions', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await orcaPage.screenshot({ path: testInfo.outputPath('actions-selected-after.png') }) + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await waitForActiveTerminalManager(orcaPage) + await orcaPage.mouse.click(target.x, target.y) + await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toBeVisible() + await expect(orcaPage.locator('[data-terminal-link-destination]')).toHaveText(LINK) + await expectOrcaOwnedMouseOutcome(mouseLogPath) + await orcaPage.screenshot({ path: testInfo.outputPath('actions-after.png') }) + await sendToTerminal(orcaPage, ptyId, 'q') + }) + + test('explicit Open URL and Leave to terminal choices preserve direct-open modifiers', async ({ + orcaPage, + electronApp + }, testInfo) => { + const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo) + await startPanelNavigationObserver(electronApp, orcaPage.url()) + await openLegacyDisabledLinkSettings(orcaPage) + const choices = orcaPage.getByRole('radiogroup', { name: 'Plain click URL behavior' }) + await choices.getByRole('radio', { name: 'Open URL', exact: true }).click() + await expect(choices.getByRole('radio', { name: 'Open URL', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await orcaPage.mouse.click(target.x, target.y) + await expect + .poll(async () => (await readPanelNavigationObserver(electronApp)).externalUrls) + .toEqual([LINK]) + await expectOrcaOwnedMouseOutcome(mouseLogPath) + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'browser', repoId: null }) + }) + await choices.getByRole('radio', { name: 'Leave to terminal', exact: true }).click() + await expect( + choices.getByRole('radio', { name: 'Leave to terminal', exact: true }) + ).toHaveAttribute('aria-checked', 'true') + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await orcaPage.mouse.click(target.x, target.y) + await expectChildMouseReports(mouseLogPath) + expect((await readPanelNavigationObserver(electronApp)).externalUrls).toEqual([LINK]) + + const reportsBeforeModifiedClick = childMouseReportCount(mouseLogPath) + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' + await orcaPage.keyboard.down(modifier) + await orcaPage.mouse.click(target.x, target.y) + await orcaPage.keyboard.up(modifier) + await expect + .poll(async () => (await readPanelNavigationObserver(electronApp)).externalUrls) + .toEqual([LINK, LINK]) + await expectOrcaOwnedMouseOutcome(mouseLogPath, reportsBeforeModifiedClick) + await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toHaveCount(0) + await sendToTerminal(orcaPage, ptyId, 'q') + }) + test('an Orca-owned plain link click emits no child PTY mouse frames', async ({ orcaPage }, testInfo) => { From a2c2f8b8592fd54d95610f9eb09d0865bc880357 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:55:35 -0700 Subject: [PATCH 081/110] fix(computer-use): clean up completed permission helpers asynchronously (#27347) * fix(computer-use): clean up completed permission helpers asynchronously Stop the helper belonging to each finished status check without waiting for a process scan on the main thread. Keep concurrent probes, setup prompts and agent helpers running. Adapted from #22451; fixes the cleanup gap reported in #22436. Co-authored-by: Rishabh Jain <18290665+jrishabh55@users.noreply.github.com> * test(computer-use): derive cleanup expectations from host paths --------- Co-authored-by: Rishabh Jain <18290665+jrishabh55@users.noreply.github.com> --- ...mission-status-cleanup.integration.test.ts | 84 +++++++++++++++ ...-computer-use-permission-status-cleanup.ts | 15 +++ ...cos-computer-use-permission-status.test.ts | 102 ++++++++++++++++++ .../macos-computer-use-permission-status.ts | 3 + 4 files changed, 204 insertions(+) create mode 100644 src/main/computer/macos-computer-use-permission-status-cleanup.integration.test.ts create mode 100644 src/main/computer/macos-computer-use-permission-status-cleanup.ts diff --git a/src/main/computer/macos-computer-use-permission-status-cleanup.integration.test.ts b/src/main/computer/macos-computer-use-permission-status-cleanup.integration.test.ts new file mode 100644 index 000000000000..4a78022c95b3 --- /dev/null +++ b/src/main/computer/macos-computer-use-permission-status-cleanup.integration.test.ts @@ -0,0 +1,84 @@ +import { once } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' +import { afterEach, describe, expect, it } from 'vitest' +import { stopMacOSPermissionStatusHelper } from './macos-computer-use-permission-status-cleanup' + +describe.skipIf(process.platform !== 'darwin')('real permission status helper cleanup', () => { + const children: ChildProcessHandle[] = [] + const directories: string[] = [] + + afterEach(async () => { + await Promise.all( + children.splice(0).map(async (child) => { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const exited = once(child, 'exit') + child.kill('SIGKILL') + await exited + }) + ) + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) + }) + + async function startFixture(executablePath: string, args: string[]): Promise { + const child = spawnProcess({ + program: process.execPath, + args: [ + '-e', + "setInterval(() => {}, 1000); process.stdout.write('ready')", + '--', + executablePath, + ...args + ], + stdio: 'pipe' + }) + children.push(child) + await once(child.stdout, 'data') + return child + } + + it('removes completed probes without stopping another probe, setup, or agent', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-permission-[cleanup]+-')) + directories.push(directory) + const statusPath = join(directory, 'status.json') + const executablePath = join( + directory, + 'Orca Computer Use.app', + 'Contents', + 'MacOS', + 'orca-computer-use-macos' + ) + const peers = await Promise.all([ + startFixture(executablePath, ['--permission-status-file', `${statusPath}.peer`]), + startFixture(executablePath, ['--permission-status-file', join(directory, 'peer.json')]), + startFixture(executablePath, ['--permission', 'accessibility']), + startFixture(executablePath, ['--permissions']), + startFixture(executablePath, ['--agent', statusPath]), + startFixture(`${executablePath}-peer`, ['--permission-status-file', statusPath]) + ]) + + for (let probe = 0; probe < 3; probe++) { + const completed = await startFixture(executablePath, ['--permission-status-file', statusPath]) + const exited = once(completed, 'exit') + + await stopMacOSPermissionStatusHelper(statusPath) + await exited + + expect(completed.signalCode).toBe('SIGTERM') + expect( + children.filter((child) => child.exitCode === null && child.signalCode === null) + ).toHaveLength(peers.length) + } + for (const peer of peers) { + expect(peer.exitCode).toBeNull() + expect(peer.signalCode).toBeNull() + } + }) +}) diff --git a/src/main/computer/macos-computer-use-permission-status-cleanup.ts b/src/main/computer/macos-computer-use-permission-status-cleanup.ts new file mode 100644 index 000000000000..71c4d1f504ef --- /dev/null +++ b/src/main/computer/macos-computer-use-permission-status-cleanup.ts @@ -0,0 +1,15 @@ +import { runProcess } from '@orca/process-host' +import { escapeRegex } from '../../shared/string-utils' + +export async function stopMacOSPermissionStatusHelper(statusPath: string): Promise { + // The unique status path distinguishes this completed probe from concurrent probes and setup helpers. + await runProcess({ + program: '/usr/bin/pkill', + args: [ + '-f', + `(^|[[:space:]/])orca-computer-use-macos[[:space:]]+--permission-status-file[[:space:]]+${escapeRegex(statusPath)}$` + ], + timeoutMs: 2_000, + stdio: 'ignore' + }).catch(() => undefined) +} diff --git a/src/main/computer/macos-computer-use-permission-status.test.ts b/src/main/computer/macos-computer-use-permission-status.test.ts index cfa511b11ce0..4a5cf2ce3f1d 100644 --- a/src/main/computer/macos-computer-use-permission-status.test.ts +++ b/src/main/computer/macos-computer-use-permission-status.test.ts @@ -2,12 +2,16 @@ import { execFileSync, spawn, spawnSync } from 'node:child_process' import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { escapeRegex } from '../../shared/string-utils' const resolveHelperAppPathMock = vi.hoisted(() => vi.fn()) const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn()) +const runProcessMock = vi.hoisted(() => vi.fn()) const permissionStatusTempDir = '/tmp/orca-computer-use-permissions-test' const permissionStatusPath = join(permissionStatusTempDir, 'status.json') +vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) + vi.mock('child_process', () => ({ execFileSync: vi.fn(), spawn: vi.fn(() => { @@ -51,6 +55,14 @@ describe('getComputerUsePermissionStatus', () => { vi.mocked(readFile).mockReset() vi.mocked(rm).mockReset() vi.mocked(stat).mockReset() + runProcessMock.mockReset() + runProcessMock.mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) resolveHelperAppPathMock.mockReset() resolveHelperExecutablePathMock.mockReset() resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') @@ -93,6 +105,7 @@ describe('getComputerUsePermissionStatus', () => { recursive: true, force: true }) + expectOnlyOwnStatusHelperStopped() }) it('removes permission status helper listeners after close', async () => { @@ -156,6 +169,7 @@ describe('getComputerUsePermissionStatus', () => { expect(settled).toBe(true) await rejection expect(child.kill).toHaveBeenCalled() + expectOnlyOwnStatusHelperStopped() expect(rm).toHaveBeenCalledWith(permissionStatusTempDir, { recursive: true, force: true @@ -187,6 +201,7 @@ describe('getComputerUsePermissionStatus', () => { { stdio: ['ignore', 'pipe', 'pipe'] } ) expect(spawnSync).not.toHaveBeenCalled() + expectOnlyOwnStatusHelperStopped() expect(readFile).toHaveBeenCalledWith(permissionStatusPath, 'utf8') expect(rm).toHaveBeenCalledWith(permissionStatusTempDir, { recursive: true, @@ -208,9 +223,96 @@ describe('getComputerUsePermissionStatus', () => { ] }) expect(execFileSync).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('cleans up when the status file is invalid', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + mockPermissionStatus('{') + + await expect(getComputerUsePermissionStatus()).rejects.toBeInstanceOf(SyntaxError) + + expectOnlyOwnStatusHelperStopped() + }) + + it('cleans up when no status file arrives before the timeout', async () => { + vi.useFakeTimers() + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + vi.mocked(stat).mockRejectedValue(new Error('ENOENT')) + const rejection = expect(getComputerUsePermissionStatus()).rejects.toMatchObject({ + code: 'accessibility_error', + message: 'Timed out checking permissions' + }) + + await vi.advanceTimersByTimeAsync(5000) + await rejection + + expectOnlyOwnStatusHelperStopped() + }) + + it('returns the status while helper cleanup is still pending', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + let finishCleanup = (): void => {} + runProcessMock.mockReturnValue( + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + + await expect(getComputerUsePermissionStatus()).resolves.toMatchObject({ + helperUnavailableReason: null + }) + expectOnlyOwnStatusHelperStopped() + finishCleanup() + }) + + it('preserves the status when helper cleanup cannot start', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + runProcessMock.mockRejectedValue(new Error('spawn ENOENT')) + + await expect(getComputerUsePermissionStatus()).resolves.toMatchObject({ + helperUnavailableReason: null + }) + expectOnlyOwnStatusHelperStopped() + }) + + it('targets each concurrent check separately', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + const peerDirectory = `${permissionStatusTempDir}-peer` + vi.mocked(mkdtemp) + .mockResolvedValueOnce(permissionStatusTempDir) + .mockResolvedValueOnce(peerDirectory) + + await Promise.all([getComputerUsePermissionStatus(), getComputerUsePermissionStatus()]) + + expect(runProcessMock).toHaveBeenCalledTimes(2) + for (const directory of [permissionStatusTempDir, peerDirectory]) { + expect(runProcessMock).toHaveBeenCalledWith({ + program: '/usr/bin/pkill', + args: [ + '-f', + `(^|[[:space:]/])orca-computer-use-macos[[:space:]]+--permission-status-file[[:space:]]+${escapeRegex(join(directory, 'status.json'))}$` + ], + timeoutMs: 2000, + stdio: 'ignore' + }) + } }) }) +function expectOnlyOwnStatusHelperStopped(): void { + expect(runProcessMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledWith({ + program: '/usr/bin/pkill', + args: [ + '-f', + `(^|[[:space:]/])orca-computer-use-macos[[:space:]]+--permission-status-file[[:space:]]+${escapeRegex(permissionStatusPath)}$` + ], + timeoutMs: 2000, + stdio: 'ignore' + }) +} + function mockPermissionStatus(json: string): void { vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) vi.mocked(readFile).mockResolvedValue(json) diff --git a/src/main/computer/macos-computer-use-permission-status.ts b/src/main/computer/macos-computer-use-permission-status.ts index b23c3e9ab96e..2cc5b01bb026 100644 --- a/src/main/computer/macos-computer-use-permission-status.ts +++ b/src/main/computer/macos-computer-use-permission-status.ts @@ -13,6 +13,7 @@ import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths' import { RuntimeClientError } from './runtime-client-error' +import { stopMacOSPermissionStatusHelper } from './macos-computer-use-permission-status-cleanup' const PERMISSION_STATUS_HELPER_LAUNCH_TIMEOUT_MS = 5_000 @@ -95,6 +96,8 @@ async function readPermissionStatusFromHelperApp( } throw new RuntimeClientError('accessibility_error', 'Timed out checking permissions') } finally { + // Do not wait for helper cleanup before returning its permission status. + void stopMacOSPermissionStatusHelper(statusPath) await rm(tempDir, { recursive: true, force: true }) } } From b411bf33fd38942b3afeace1e4f23bef4e60c4f6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:00:37 -0700 Subject: [PATCH 082/110] perf(runtime): reduce terminal ANSI scanning work (#27344) Adapted from outof-place normalization work in #27170. Preserve tail behavior while avoiding repeated CSI scans and newline-density regressions. Co-authored-by: outof-place --- .../runtime/terminal-ansi-normalization.ts | 144 +++++++++++------- .../terminal-vertical-control-scan.test.ts | 25 +++ 2 files changed, 112 insertions(+), 57 deletions(-) diff --git a/src/main/runtime/terminal-ansi-normalization.ts b/src/main/runtime/terminal-ansi-normalization.ts index e1bbe33a79d9..6a5cb9843517 100644 --- a/src/main/runtime/terminal-ansi-normalization.ts +++ b/src/main/runtime/terminal-ansi-normalization.ts @@ -1,5 +1,8 @@ import { MAX_TAIL_PENDING_ANSI_CHARS } from './terminal-tail-limits' -import { classifyTerminalEscapeIntroducer } from '../../shared/terminal-escape-introducer' +import { + classifyTerminalEscapeIntroducer, + type TerminalEscapeIntroducer +} from '../../shared/terminal-escape-introducer' import { ownRetainedString } from '../../shared/own-retained-string' export function parseAnsiControlSequence( @@ -14,44 +17,56 @@ export function parseAnsiControlSequence( | null { // charCodeAt, not value[i]: indexing mints a one-char string on every escape. const introducer = classifyTerminalEscapeIntroducer(value.charCodeAt(escapeIndex + 1)) + const endIndex = findTerminalEscapeSequenceEnd(value, escapeIndex, introducer) + if (endIndex === -1) { + return null + } + if (introducer !== 'csi') { + return { kind: 'other', endIndex } + } + return parseTerminalCsiAtEnd(value, escapeIndex, endIndex) +} + +function parseTerminalCsiAtEnd(value: string, escapeIndex: number, endIndex: number) { + const params = value.slice(escapeIndex + 2, endIndex) + const firstParamMatch = /^(\d+)/.exec(params) + return { + kind: 'csi' as const, + final: value[endIndex] ?? '', + params, + firstParam: firstParamMatch ? Number(firstParamMatch[1]) : null, + endIndex + } +} + +/** The index of the sequence's last code unit, or -1 when the input ends first. */ +function findTerminalEscapeSequenceEnd( + value: string, + escapeIndex: number, + introducer: TerminalEscapeIntroducer +): number { if (introducer === 'csi') { for (let index = escapeIndex + 2; index < value.length; index += 1) { const code = value.charCodeAt(index) - if (code < 0x40 || code > 0x7e) { - continue - } - const params = value.slice(escapeIndex + 2, index) - const firstParamMatch = /^(\d+)/.exec(params) - return { - kind: 'csi', - final: value[index] ?? '', - params, - firstParam: firstParamMatch ? Number(firstParamMatch[1]) : null, - endIndex: index + if (code >= 0x40 && code <= 0x7e) { + return index } } - return null + return -1 } - if (introducer === 'osc') { + if (introducer === 'osc' || introducer === 'string') { for (let index = escapeIndex + 2; index < value.length; index += 1) { - if (value[index] === '\u0007') { - return { kind: 'other', endIndex: index } + const code = value.charCodeAt(index) + if (code === 0x07 && introducer === 'osc') { + return index } - if (value[index] === '\u001b' && value[index + 1] === '\\') { - return { kind: 'other', endIndex: index + 1 } + if (code === 0x1b && value.charCodeAt(index + 1) === 0x5c) { + return index + 1 } } - return null + return -1 } - if (introducer === 'string') { - for (let index = escapeIndex + 2; index < value.length; index += 1) { - if (value[index] === '\u001b' && value[index + 1] === '\\') { - return { kind: 'other', endIndex: index + 1 } - } - } - return null - } - return { kind: 'other', endIndex: escapeIndex + 1 } + return escapeIndex + 1 } export function hasCanonicalNumericCsiParams(params: string): boolean { @@ -61,18 +76,19 @@ export function hasCanonicalNumericCsiParams(params: string): boolean { export function containsTerminalVerticalLineControl(value: string): boolean { // Only ESC can introduce a vertical control; ordinary output needs no code-unit walk. for (let index = value.indexOf('\x1b'); index !== -1; index = value.indexOf('\x1b', index + 1)) { - const parsed = parseAnsiControlSequence(value, index) - if (!parsed) { + const introducer = classifyTerminalEscapeIntroducer(value.charCodeAt(index + 1)) + const endIndex = findTerminalEscapeSequenceEnd(value, index, introducer) + if (endIndex === -1) { return false } - index = parsed.endIndex if ( - parsed.kind === 'csi' && - parsed.final === 'A' && - hasCanonicalNumericCsiParams(parsed.params) + introducer === 'csi' && + value.charCodeAt(endIndex) === 0x41 && + hasCanonicalNumericCsiParams(value.slice(index + 2, endIndex)) ) { return true } + index = endIndex } return false } @@ -88,42 +104,50 @@ export function normalizeTerminalChunk( const combined = `${pendingAnsi}${chunk}` const parts: string[] = [] let textStart = 0 + // Copy spans and skip parameter parsing for controls the preview discards. for (let index = 0; index < combined.length; index += 1) { - const char = combined[index] - if (char === '\x1b') { + const code = combined.charCodeAt(index) + if (code >= 0x20 && code < 0x7f) { + continue + } + if (code === 0x1b) { appendTerminalNormalizedSpan(parts, combined, textStart, index) if (index + 1 >= combined.length) { return { text: parts.join(''), pendingAnsi: combined.slice(index) } } - const parsed = parseAnsiControlSequence(combined, index) - if (!parsed) { + const introducer = classifyTerminalEscapeIntroducer(combined.charCodeAt(index + 1)) + const endIndex = findTerminalEscapeSequenceEnd(combined, index, introducer) + if (endIndex === -1) { return { text: parts.join(''), // Own the tail so it stops pinning the consumed chunk it was sliced from. pendingAnsi: ownRetainedString(trimPendingAnsiControl(combined.slice(index))) } } - if (parsed.kind === 'csi' && isTerminalPreviewLineControl(parsed)) { - // Why: Codex redraws status text with ANSI controls but no CR; keep them so the tail overwrites the prior frame. - parts.push(combined.slice(index, parsed.endIndex + 1)) + if ( + introducer === 'csi' && + isTerminalPreviewLineControlFinal(combined.charCodeAt(endIndex)) + ) { + const parsed = parseTerminalCsiAtEnd(combined, index, endIndex) + if (isTerminalPreviewLineControl(parsed)) { + // Why: Codex redraws status text with ANSI controls but no CR; keep them so the tail overwrites the prior frame. + parts.push(combined.slice(index, endIndex + 1)) + } } - index = parsed.endIndex + index = endIndex textStart = index + 1 continue } - if (char === '\r' && combined[index + 1] === '\n') { + if (code === 0x0d && combined.charCodeAt(index + 1) === 0x0a) { + // CRLF keeps only its LF, which starts the next span. appendTerminalNormalizedSpan(parts, combined, textStart, index) - parts.push('\n') - index += 1 textStart = index + 1 continue } - const code = combined.charCodeAt(index) if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) { - appendTerminalNormalizedSpan(parts, combined, textStart, index) - parts.push(char) - textStart = index + 1 - } else if (!isTerminalPreviewPrintableCodeUnit(code)) { + continue + } + if (!isTerminalPreviewPrintableCodeUnit(code)) { appendTerminalNormalizedSpan(parts, combined, textStart, index) textStart = index + 1 } @@ -173,6 +197,18 @@ function trimPendingAnsiControl(value: string): string { return `${introducer}${value.slice(-suffixBudget)}` } +/** CSI controls that affect retained preview rows. */ +function isTerminalPreviewLineControlFinal(code: number): boolean { + return ( + code === 0x4b || + code === 0x41 || + code === 0x47 || + code === 0x60 || + code === 0x44 || + code === 0x43 + ) +} + function isTerminalPreviewLineControl(parsed: { final: string params: string @@ -185,11 +221,5 @@ function isTerminalPreviewLineControl(parsed: { const mode = parsed.firstParam ?? 0 return mode === 0 || mode === 1 || mode === 2 } - return ( - parsed.final === 'A' || - parsed.final === 'G' || - parsed.final === '`' || - parsed.final === 'D' || - parsed.final === 'C' - ) + return isTerminalPreviewLineControlFinal(parsed.final.charCodeAt(0)) } diff --git a/src/main/runtime/terminal-vertical-control-scan.test.ts b/src/main/runtime/terminal-vertical-control-scan.test.ts index b672e28c921e..9608b7010adc 100644 --- a/src/main/runtime/terminal-vertical-control-scan.test.ts +++ b/src/main/runtime/terminal-vertical-control-scan.test.ts @@ -6,6 +6,31 @@ import { import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' describe('terminal vertical-control scanning', () => { + it('scans retained CSI parameters once while normalizing', () => { + const input = `\x1b[${'1;'.repeat(2048)}A` + const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt') + let normalized: ReturnType + let inspections: number + try { + normalized = normalizeTerminalChunk(input) + inspections = charCodeAt.mock.calls.length + } finally { + charCodeAt.mockRestore() + } + + expect(normalized).toEqual({ text: input, pendingAnsi: '' }) + expect(inspections).toBeLessThanOrEqual(input.length + 8) + }) + + it('preserves printable spans and carried controls across chunk boundaries', () => { + const first = normalizeTerminalChunk('漢\ud800\x1b[31m字\t\r\n\x00a\x1b[2') + expect(first).toEqual({ text: '漢\ud800字\t\na', pendingAnsi: '\x1b[2' }) + expect(normalizeTerminalChunk('K\udfff😀\b\r\x7f\x9fend', first.pendingAnsi)).toEqual({ + text: '\x1b[2K\udfff😀\b\rend', + pendingAnsi: '' + }) + }) + it.each([ ['plain', 'log output '.repeat(8192), false], ['nonvertical CSI', `\x1b[31m${'log output '.repeat(8192)}\x1b[0m`, false], From bc02f3ce6ea57a4b5ed7e907e07a09757c03cc54 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:01:23 -0700 Subject: [PATCH 083/110] perf(main): release startup allocations held by journal sentinel (#27342) Credit to outof-place for identifying the retention and proposing the focused fix in #27335. Independently validated 15.19 MiB of collectible startup allocations. Co-authored-by: outof-place --- .../journal-item-appender.ts | 7 +++--- .../journal-write-queue.test.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 146078d830c9..1faec275ffd7 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -8,7 +8,8 @@ import type { JournalReducerState } from './journal-reducer' import type { JournalAppendResult, JournalItemAppendOptions } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' -const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') +// A module-level Error's lazy stack retains the bundle's startup code and read buffer. +class NothingResolvedError extends Error {} export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } @@ -41,7 +42,7 @@ export class JournalItemAppender { .enqueue((seq, ts) => { const resolved = resolve() if (resolved === null) { - throw NOTHING_RESOLVED + throw new NothingResolvedError('journal_item_resolved_to_nothing') } itemId = agentJournalItemKey(resolved.identity) return journalItemRowBuilder( @@ -54,7 +55,7 @@ export class JournalItemAppender { .then( (row) => appendResult(row, itemId), (error: unknown) => { - if (error === NOTHING_RESOLVED) { + if (error instanceof NothingResolvedError) { return null } throw error diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 47764033180f..69e4dd22f007 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -110,6 +110,31 @@ describe('when a journal write lands', () => { await inner expect(sequenceOf(journal, 2)).toBe(sequenceOf(journal, 1)! + 1) }) + + it('writes no row for a resolve that finds nothing, and keeps the line moving', async () => { + const journal = await idleJournal() + const before = journal.cursor().sequence + + await expect(journal.appendResolvedItem(() => null, OPTIONS)).resolves.toBeNull() + expect(journal.cursor().sequence).toBe(before) + await journal.appendItem(item(1), reply('after'), OPTIONS) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('after')) + }) + + it('preserves other resolver errors even when their message matches the no-row error', async () => { + const journal = await idleJournal() + const before = journal.cursor().sequence + const error = new Error('journal_item_resolved_to_nothing') + + await expect( + journal.appendResolvedItem(() => { + throw error + }, OPTIONS) + ).rejects.toBe(error) + expect(journal.cursor().sequence).toBe(before) + await journal.appendItem(item(1), reply('after'), OPTIONS) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('after')) + }) }) describe('the journal write queue', () => { From 41de89a6265d91bb15ec24fa7d7d3005dc587d08 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:04:49 -0700 Subject: [PATCH 084/110] fix: spell source NUL separators as Unicode escapes (#26864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matheus Breguêz <23284276+matbrgz@users.noreply.github.com> --- cloud/apps/relay/src/idle-regional-rehome-selection.ts | 2 +- src/main/runtime/browser-client-download-transfers.ts | 2 +- src/wsl-guest/dispatcher.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cloud/apps/relay/src/idle-regional-rehome-selection.ts b/cloud/apps/relay/src/idle-regional-rehome-selection.ts index bed205273868..f97570164162 100644 --- a/cloud/apps/relay/src/idle-regional-rehome-selection.ts +++ b/cloud/apps/relay/src/idle-regional-rehome-selection.ts @@ -300,7 +300,7 @@ function inlineRows(columns: ReadonlyArray, rows: num } function hostKey(row: SqlRow): string { - return `${String(row.user_id)}${String(row.relay_host_id)}` + return `${String(row.user_id)}\u0000${String(row.relay_host_id)}` } function idleRehomeCandidate( diff --git a/src/main/runtime/browser-client-download-transfers.ts b/src/main/runtime/browser-client-download-transfers.ts index fce33c3638a9..8341bacfe3f4 100644 --- a/src/main/runtime/browser-client-download-transfers.ts +++ b/src/main/runtime/browser-client-download-transfers.ts @@ -330,5 +330,5 @@ function isDestinationExistsError(error: unknown): boolean { } function sessionKey(browserPageId: string, transferId: string): string { - return `${browserPageId}${transferId}` + return `${browserPageId}\u0000${transferId}` } diff --git a/src/wsl-guest/dispatcher.test.ts b/src/wsl-guest/dispatcher.test.ts index f8ccc11c1534..7312ca402140 100644 --- a/src/wsl-guest/dispatcher.test.ts +++ b/src/wsl-guest/dispatcher.test.ts @@ -802,7 +802,7 @@ describe('RelayDispatcher', () => { } // Multi-byte UTF-8, astral pairs, lone surrogates, controls, quotes, and backslashes. - const alphabet = 'aZ9 "\\\n\r\té߀中𝄞😀𐀀�' + const alphabet = 'aZ9 "\\\n\r\t\u0000é߀中𝄞😀𐀀�' it('matches the pre-optimization sizing loop across randomized inputs', () => { const random = mulberry32(0xc0ffee) From c2d5430489434459d39c1be8e1c80bd5e8389697 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:06:11 -0700 Subject: [PATCH 085/110] [CI] Fix test fixtures and reduce unnecessary test setup (#27330) * [CI] Fix E2E folder and catalog fixtures * [CI] Strengthen folder readiness and reuse composer fixtures * [CI] Repair E2E catalog owners and retain stronger unit checks * [CI] Defer optional unit deletions to avoid extra CI jobs * [CI] Forward Monaco test mocks to the real editor API * [CI] Make agent launch fixtures handle probes and terminal resize * [CI] Avoid unused test initialization and flush version probe output --- .../diff-comment-range-drag.test.ts | 2 + ...ommentDecorator.commentable-lines.test.tsx | 2 + ...fCommentDecorator.model-lifecycle.test.tsx | 2 + ...fCommentDecorator.range-selection.test.tsx | 2 + .../onboarding/OnboardingFlow.test.tsx | 6 ++ .../OnboardingInlineCommandTerminal.test.ts | 8 +- .../settings/EphemeralVmsPane.test.tsx | 6 ++ .../components/settings/GeneralPane.test.ts | 8 +- .../folder-workspace-composer-submit.test.ts | 37 ++++----- .../e2e/ephemeral-vm-provisioned-root.spec.ts | 3 +- .../golden-stub-agent/golden-stub-agent.js | 8 ++ tests/e2e/helpers/fake-codex-launch-probes.ts | 4 + tests/e2e/helpers/golden-source-control.ts | 3 +- .../helpers/nested-runtime-same-id-pairing.ts | 3 +- tests/e2e/helpers/orca-app.ts | 2 +- tests/e2e/helpers/orca-restart.ts | 3 +- .../helpers/setup-split-and-sleep-journeys.ts | 3 +- .../e2e/helpers/ssh-test-target-connection.ts | 3 +- tests/e2e/helpers/terminal-layout-journeys.ts | 3 +- tests/e2e/helpers/worktree-registration.ts | 3 +- tests/e2e/helpers/wsl-golden-stub-agent.ts | 10 ++- ...ackground-terminal-mount-authority.spec.ts | 2 +- tests/e2e/pdf-find-counter.spec.ts | 2 +- .../e2e/pr11346-selected-runtime-add.spec.ts | 6 +- .../project-group-creation-visibility.spec.ts | 4 +- tests/e2e/project-group-manual-sort.spec.ts | 2 +- tests/e2e/repo-registration-async.spec.ts | 5 +- .../e2e/repo-registration-concurrency.spec.ts | 5 +- .../resource-manager-folder-labels.spec.ts | 4 +- .../e2e/runtime-host-status-recovery.spec.ts | 5 +- ...sidebar-folder-keyboard-navigation.spec.ts | 2 +- .../source-control-large-file-count.spec.ts | 2 +- .../e2e/ssh-codex-reconnect-replay-driver.ts | 3 +- tests/e2e/ssh-config-host-import.spec.ts | 2 + tests/e2e/ssh-config-host-picker.spec.ts | 2 + tests/e2e/ssh-host-form-modal.spec.ts | 2 + .../e2e/ssh-route-error-card-preview.spec.ts | 8 +- ...codex-skill-preview-artifact-repro.spec.ts | 3 +- ...terminal-pane-divider-capture-loss.spec.ts | 3 +- ...inal-windows-shell-paste-ownership.spec.ts | 10 ++- .../e2e/windows-project-runtime-smoke.spec.ts | 23 ++++-- tests/e2e/workspace-layout-oracle-ssh.spec.ts | 4 +- .../worktree-lineage-toggle-shortcut.spec.ts | 82 +++++++++++-------- tests/e2e/worktree-scroll-to-current.spec.ts | 5 +- 44 files changed, 191 insertions(+), 116 deletions(-) diff --git a/src/renderer/src/components/diff-comments/diff-comment-range-drag.test.ts b/src/renderer/src/components/diff-comments/diff-comment-range-drag.test.ts index 65b80f90b0c9..2e1ace4df3df 100644 --- a/src/renderer/src/components/diff-comments/diff-comment-range-drag.test.ts +++ b/src/renderer/src/components/diff-comments/diff-comment-range-drag.test.ts @@ -11,6 +11,8 @@ import type { editor as monacoEditor } from 'monaco-editor' import { getGutterPressLine, installDiffCommentRangeDrag } from './diff-comment-range-drag' import type { DiffCommentLineRange } from './diff-comment-line-range' +vi.mock('monaco-editor', () => import('monaco-editor/esm/vs/editor/editor.api.js')) + // Frames are pumped by hand so "one hit-test per frame, whatever the OS delivered" is an // assertion rather than a hope. const frameCallbacks: FrameRequestCallback[] = [] diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx index 14ea929b86ff..a3dbe23daaef 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx @@ -6,6 +6,8 @@ import type * as ReactDomClientModule from 'react-dom/client' import type * as DiffCommentZoneCardModule from './diff-comment-zone-card' import type { NotesSendMenu } from '../editor/NotesSendMenu' +vi.mock('monaco-editor', () => import('monaco-editor/esm/vs/editor/editor.api.js')) + const notesMenuFixture = vi.hoisted(() => ({ NotesSendMenu: vi.fn(() => { throw new Error('Commentable-line lifecycle must not render the agent notes menu') diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx index f7f7971f8696..8139b2409765 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx @@ -3,6 +3,8 @@ import { renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { editor as MonacoEditor } from 'monaco-editor' +vi.mock('monaco-editor', () => import('monaco-editor/esm/vs/editor/editor.api.js')) + const storeFixture = vi.hoisted(() => ({ activeGroupIdByWorktree: {}, clearDeliveredDiffComments: vi.fn() diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx index 1c1e802e650b..fa7b7512a015 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx @@ -5,6 +5,8 @@ import type { Selection } from 'monaco-editor' import type * as DiffCommentZoneCardModule from './diff-comment-zone-card' import type { NotesSendMenu } from '../editor/NotesSendMenu' +vi.mock('monaco-editor', () => import('monaco-editor/esm/vs/editor/editor.api.js')) + const notesMenuFixture = vi.hoisted(() => ({ NotesSendMenu: vi.fn(() => { throw new Error('Range selection must not render the agent notes menu') diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx index a0be9b3a84ff..97dbc731a056 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx @@ -7,6 +7,12 @@ import { getDefaultOnboardingState } from '../../../../shared/onboarding-default import { useAppStore } from '@/store' import OnboardingFlow from './OnboardingFlow' +vi.mock('@/components/terminal-pane/TerminalPane', () => ({ + default: () => { + throw new Error('This suite must not render a terminal pane') + } +})) + function renderOnboardingFlow(props: ComponentProps): string { return renderToStaticMarkup( diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts index 114fd7b2a9a5..e2abc2db64ca 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts @@ -1,9 +1,15 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getNextTerminalReadyRetryAttempt, READY_MAX_ATTEMPTS } from './OnboardingInlineCommandTerminal' +vi.mock('@/components/terminal-pane/TerminalPane', () => ({ + default: () => { + throw new Error('This suite must not render a terminal pane') + } +})) + describe('getNextTerminalReadyRetryAttempt', () => { it('stops scheduling readiness checks after the capped number of attempts', () => { let attempt = 0 diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx index 39400a827d84..543db2dc88e9 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx @@ -5,6 +5,12 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { EphemeralVmsPane } from './EphemeralVmsPane' +vi.mock('@/components/terminal-pane/TerminalPane', () => ({ + default: () => { + throw new Error('This suite must not render a terminal pane') + } +})) + const toastMocks = vi.hoisted(() => ({ error: vi.fn() })) diff --git a/src/renderer/src/components/settings/GeneralPane.test.ts b/src/renderer/src/components/settings/GeneralPane.test.ts index e8fd5ea998bf..05c963feed38 100644 --- a/src/renderer/src/components/settings/GeneralPane.test.ts +++ b/src/renderer/src/components/settings/GeneralPane.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createAutoSaveDelayDraftState, getDesktopPlatformFromUserAgent, @@ -9,6 +9,12 @@ import { } from './GeneralPane' import { matchesSettingsSearch } from './settings-search' +vi.mock('@/components/terminal-pane/TerminalPane', () => ({ + default: () => { + throw new Error('This suite must not render a terminal pane') + } +})) + describe('GeneralPane auto-save delay drafts', () => { it('keeps a committed draft tied to the current persisted source while settings save is pending', () => { const current = createAutoSaveDelayDraftState(1000) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 0964e076f87a..e76933d70e1e 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -29,6 +29,16 @@ import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' +const ISSUE_URL = 'https://github.com/stablyai/orca/issues/42' +const linkedIssue = { + provider: 'github' as const, + type: 'issue' as const, + number: 42, + title: 'Restore linked quick-create', + url: ISSUE_URL, + repoId: 'repo-1' +} + describe('submitFolderWorkspaceCreate', () => { it.each(['347', '002'])('keeps manual folder name %s with its linked source', async (name) => { const linkedWorkItem = { @@ -697,16 +707,6 @@ describe('submitFolderWorkspaceCreate', () => { }) describe('submitFolderWorkspaceCreate native-chat launch draft', () => { - const ISSUE_URL = 'https://github.com/stablyai/orca/issues/42' - const linkedIssue = { - provider: 'github' as const, - type: 'issue' as const, - number: 42, - title: 'Restore linked quick-create', - url: ISSUE_URL, - repoId: 'repo-1' - } - function seededDraftFor(tabId: string): { text: string } | undefined { return useAppStore.getState().nativeChatLaunchDraftByTabId[tabId] } @@ -805,16 +805,6 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => { }) describe('folder-workspace draft: seeded set == chat-opening set', () => { - const ISSUE_URL = 'https://github.com/stablyai/orca/issues/42' - const linkedIssue = { - provider: 'github' as const, - type: 'issue' as const, - number: 42, - title: 'Restore linked quick-create', - url: ISSUE_URL, - repoId: 'repo-1' - } - beforeEach(() => { mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' }) useAppStore.setState({ nativeChatLaunchDraftByTabId: {} }) @@ -849,9 +839,12 @@ describe('folder-workspace draft: seeded set == chat-opening set', () => { }) const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup - const seeded = useAppStore.getState().nativeChatLaunchDraftByTabId['tab-1'] != null + const seededDraft = useAppStore.getState().nativeChatLaunchDraftByTabId['tab-1'] // The draft always reaches the TUI, whichever way it is delivered. expect(`${startup?.command ?? ''}${startup?.draftPrompt ?? ''}`).toContain(ISSUE_URL) - expect(seeded).toBe(expectMirrored) + expect(seededDraft != null).toBe(expectMirrored) + if (expectMirrored) { + expect(seededDraft?.text).toBe(note ? `${note}\n\n${ISSUE_URL}` : ISSUE_URL) + } }) }) diff --git a/tests/e2e/ephemeral-vm-provisioned-root.spec.ts b/tests/e2e/ephemeral-vm-provisioned-root.spec.ts index 3082fea02abe..37db447518d1 100644 --- a/tests/e2e/ephemeral-vm-provisioned-root.spec.ts +++ b/tests/e2e/ephemeral-vm-provisioned-root.spec.ts @@ -127,8 +127,7 @@ async function addRecipeRepo(page: Parameters[0], re throw new Error(result.error) } const store = window.__store! - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) await store.getState().updateSettings({ experimentalEphemeralVms: true }) store.getState().setActiveRepo(result.repo.id) return result.repo.id diff --git a/tests/e2e/fixtures/golden-stub-agent/golden-stub-agent.js b/tests/e2e/fixtures/golden-stub-agent/golden-stub-agent.js index 47810499db1e..1588aa61e725 100644 --- a/tests/e2e/fixtures/golden-stub-agent/golden-stub-agent.js +++ b/tests/e2e/fixtures/golden-stub-agent/golden-stub-agent.js @@ -125,5 +125,13 @@ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { process.on(signal, exitCleanly) } +if (process.stdout.isTTY) { + process.stdout.on('resize', () => { + if (!exiting) { + render() + } + }) +} + process.stdout.write('\x1b[?1049h') render() diff --git a/tests/e2e/helpers/fake-codex-launch-probes.ts b/tests/e2e/helpers/fake-codex-launch-probes.ts index 45371a05ec95..6a60350b5c1f 100644 --- a/tests/e2e/helpers/fake-codex-launch-probes.ts +++ b/tests/e2e/helpers/fake-codex-launch-probes.ts @@ -4,6 +4,10 @@ * `--no-daemon` support probe (`codex --help`) exits without advertising the flag. */ export const FAKE_CODEX_LAUNCH_PROBES_SOURCE = ` +if (process.argv.length === 3 && process.argv[2] === '--version') { + require('node:fs').writeSync(1, 'codex-cli 0.0.0\\n') + process.exit(0) +} if (process.argv.slice(2).includes('app-server')) { process.stderr.write("error: unrecognized subcommand 'app-server'\\n") process.exit(2) diff --git a/tests/e2e/helpers/golden-source-control.ts b/tests/e2e/helpers/golden-source-control.ts index 29428ee12451..3fa52d616a7a 100644 --- a/tests/e2e/helpers/golden-source-control.ts +++ b/tests/e2e/helpers/golden-source-control.ts @@ -162,8 +162,7 @@ export async function activateGoldenWorktree( if (!store) { throw new Error('window.__store is not available') } - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) const repos: { id: string path: string diff --git a/tests/e2e/helpers/nested-runtime-same-id-pairing.ts b/tests/e2e/helpers/nested-runtime-same-id-pairing.ts index 3d344a819429..13899fcd7c68 100644 --- a/tests/e2e/helpers/nested-runtime-same-id-pairing.ts +++ b/tests/e2e/helpers/nested-runtime-same-id-pairing.ts @@ -41,8 +41,7 @@ export async function replaceRuntimePairingInPlace(args: { throw new Error('Same-ID re-paired desktop could not select the HUB runtime') } // Why: same-ID selection is a no-op, so explicitly rehydrate the graph from the replacement transport. - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: selector, throwOnError: true }) await store.getState().fetchAllWorktrees() await store.getState().fetchWorktreeLineage() return window.api.runtimeEnvironments.resolve({ selector }) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index edda9b620893..ce8cd0561624 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -340,7 +340,7 @@ export const test = base.extend({ if (!store) { return false } - await store.getState().fetchRepos({ runtimeEnvironmentId: null }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) const repo = store.getState().repos.find((candidate) => candidate.id === repoId) if (!repo) { return false diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index e04c2fe80141..0b48b5a135fd 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -265,8 +265,7 @@ export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): P } // Why: repos.add emits a concurrent refresh whose generation can // supersede this fetch; poll until either refresh publishes the repo. - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) const repo = store.getState().repos.find((candidate) => candidate.id === repoId) if (!repo) { return false diff --git a/tests/e2e/helpers/setup-split-and-sleep-journeys.ts b/tests/e2e/helpers/setup-split-and-sleep-journeys.ts index 6104501e0060..9e120a44c572 100644 --- a/tests/e2e/helpers/setup-split-and-sleep-journeys.ts +++ b/tests/e2e/helpers/setup-split-and-sleep-journeys.ts @@ -98,8 +98,7 @@ export async function openSetupSplitWorktree(page: Page, userDataDir: string): P .poll(async () => { setupWorktreeId = await page.evaluate(async () => { const store = window.__store! - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) for (const repo of store.getState().repos) { await store.getState().fetchWorktrees(repo.id) } diff --git a/tests/e2e/helpers/ssh-test-target-connection.ts b/tests/e2e/helpers/ssh-test-target-connection.ts index d6fbb74551ac..3c241ffe6a90 100644 --- a/tests/e2e/helpers/ssh-test-target-connection.ts +++ b/tests/e2e/helpers/ssh-test-target-connection.ts @@ -110,8 +110,7 @@ export async function connectSshTestTarget( }) }) } - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) await waitForRepoOwner() const currentState = store.getState().sshConnectionStates.get(createdTarget.id) if ( diff --git a/tests/e2e/helpers/terminal-layout-journeys.ts b/tests/e2e/helpers/terminal-layout-journeys.ts index f3d5a330eb38..27c026770c87 100644 --- a/tests/e2e/helpers/terminal-layout-journeys.ts +++ b/tests/e2e/helpers/terminal-layout-journeys.ts @@ -58,8 +58,7 @@ export async function addFolderWorkspace(page: Page): Promise { worktreeId = await page.evaluate(async (id) => { const store = window.__store! - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) await store.getState().fetchWorktrees(id) const worktree = store.getState().worktreesByRepo[id]?.[0] if (worktree) { diff --git a/tests/e2e/helpers/worktree-registration.ts b/tests/e2e/helpers/worktree-registration.ts index 2e823f739f1a..f4126aad89a9 100644 --- a/tests/e2e/helpers/worktree-registration.ts +++ b/tests/e2e/helpers/worktree-registration.ts @@ -32,8 +32,7 @@ async function resolveE2eWorktreeId( if (!store) { throw new Error('window.__store is not available') } - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) const repo = store.getState().repos.find((entry) => entry.path === repoPath) if (!repo) { throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) diff --git a/tests/e2e/helpers/wsl-golden-stub-agent.ts b/tests/e2e/helpers/wsl-golden-stub-agent.ts index 94ee5727d933..3294d6df3ecb 100644 --- a/tests/e2e/helpers/wsl-golden-stub-agent.ts +++ b/tests/e2e/helpers/wsl-golden-stub-agent.ts @@ -152,8 +152,12 @@ export async function useWslRuntimeForActiveProject(page: Page, distro: string): if (!activeProject) { throw new Error('No active project') } - await state.updateProject(activeProject.id, { - localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } - }) + await state.updateProject( + activeProject.id, + { + localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } + }, + 'local' + ) }, distro) } diff --git a/tests/e2e/live-background-terminal-mount-authority.spec.ts b/tests/e2e/live-background-terminal-mount-authority.spec.ts index 39735b0c1e88..0e4df67740b2 100644 --- a/tests/e2e/live-background-terminal-mount-authority.spec.ts +++ b/tests/e2e/live-background-terminal-mount-authority.spec.ts @@ -544,7 +544,7 @@ test('adopts runtime-owned agent and Setup PTYs on first mount', async ({ orcaPage.evaluate( async ({ repoId, command, windowsShell }) => { const state = window.__store?.getState() - await state?.fetchRepos() + await state?.fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) const repo = window.__store?.getState().repos.find((candidate) => candidate.id === repoId) if (!repo) { return false diff --git a/tests/e2e/pdf-find-counter.spec.ts b/tests/e2e/pdf-find-counter.spec.ts index 56f525a34842..d20358c7a879 100644 --- a/tests/e2e/pdf-find-counter.spec.ts +++ b/tests/e2e/pdf-find-counter.spec.ts @@ -129,7 +129,7 @@ test.describe('PDF in a folder workspace', () => { parentPath: folderPath, createdFrom: 'folder-scan' }) - await state.fetchProjectGroups({ runtimeEnvironmentId: null }) + await state.fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) const folder = await state.createFolderWorkspace( { projectGroupId: group.id, diff --git a/tests/e2e/pr11346-selected-runtime-add.spec.ts b/tests/e2e/pr11346-selected-runtime-add.spec.ts index 7547e90012a7..cc96c481073a 100644 --- a/tests/e2e/pr11346-selected-runtime-add.spec.ts +++ b/tests/e2e/pr11346-selected-runtime-add.spec.ts @@ -563,8 +563,10 @@ async function runSelectedRuntimeAddJourney( if (!store) { throw new Error('Renderer store unavailable') } - await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) - await store.getState().fetchFolderWorkspaces({ runtimeEnvironmentId: null }) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) + await store + .getState() + .fetchFolderWorkspaces({ runtimeEnvironmentId: null, throwOnError: true }) return { folderWorkspaces: store.getState().folderWorkspaces, projectGroups: store.getState().projectGroups diff --git a/tests/e2e/project-group-creation-visibility.spec.ts b/tests/e2e/project-group-creation-visibility.spec.ts index 8f08bb5be698..4dba282e5b41 100644 --- a/tests/e2e/project-group-creation-visibility.spec.ts +++ b/tests/e2e/project-group-creation-visibility.spec.ts @@ -164,7 +164,9 @@ for (const delayCreateResponse of [false, true]) { } await expect(group).toHaveCount(1) await orcaPage.evaluate(() => - window.__store!.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + window + .__store!.getState() + .fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) ) await expect(group).toHaveCount(1) await group.click() diff --git a/tests/e2e/project-group-manual-sort.spec.ts b/tests/e2e/project-group-manual-sort.spec.ts index 05c372314ac0..081cbc045bb1 100644 --- a/tests/e2e/project-group-manual-sort.spec.ts +++ b/tests/e2e/project-group-manual-sort.spec.ts @@ -92,7 +92,7 @@ async function seedProjectHeaderSortScenario( let repos = findSeededRepos() const deadline = Date.now() + 10_000 while (repos.some((repo) => !repo) && Date.now() < deadline) { - await state.fetchRepos({ runtimeEnvironmentId: null }) + await state.fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) repos = findSeededRepos() if (repos.every((repo) => repo)) { break diff --git a/tests/e2e/repo-registration-async.spec.ts b/tests/e2e/repo-registration-async.spec.ts index cb1ad1a61d36..ac4f2ea35ea8 100644 --- a/tests/e2e/repo-registration-async.spec.ts +++ b/tests/e2e/repo-registration-async.spec.ts @@ -55,8 +55,9 @@ test('registration resolves nested and linked paths to one existing project', as if (results.some((result) => 'error' in result)) { throw new Error(JSON.stringify(results)) } - const owner = window.__store!.getState().settings?.activeRuntimeEnvironmentId ?? null - await window.__store!.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await window + .__store!.getState() + .fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) await window.__store!.getState().awaitLocalRepoCatalogSettlement() return { ids: results.map((result) => ('repo' in result ? result.repo.id : null)), diff --git a/tests/e2e/repo-registration-concurrency.spec.ts b/tests/e2e/repo-registration-concurrency.spec.ts index c1e6375441cb..09e3a45e7d34 100644 --- a/tests/e2e/repo-registration-concurrency.spec.ts +++ b/tests/e2e/repo-registration-concurrency.spec.ts @@ -56,8 +56,9 @@ test('concurrent registration keeps one identity, including nested and linked pa if (results.some((result) => 'error' in result)) { throw new Error(JSON.stringify(results)) } - const owner = window.__store!.getState().settings?.activeRuntimeEnvironmentId ?? null - await window.__store!.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await window + .__store!.getState() + .fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) await window.__store!.getState().awaitLocalRepoCatalogSettlement() return { ids: results.map((result) => ('repo' in result ? result.repo.id : null)), diff --git a/tests/e2e/resource-manager-folder-labels.spec.ts b/tests/e2e/resource-manager-folder-labels.spec.ts index 36ef5d4d8549..e04b5b7cad30 100644 --- a/tests/e2e/resource-manager-folder-labels.spec.ts +++ b/tests/e2e/resource-manager-folder-labels.spec.ts @@ -31,7 +31,7 @@ for (const theme of ['dark', 'light'] as const) { parentPath: folder.folderPath, createdFrom: 'folder-scan' }) - await state.fetchProjectGroups({ runtimeEnvironmentId: null }) + await state.fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) if (!group) { throw new Error('Could not create project group') } @@ -96,7 +96,7 @@ test('names a folder terminal recovered from the daemon after restart without an parentPath: folderPath, createdFrom: 'folder-scan' }) - await state.fetchProjectGroups({ runtimeEnvironmentId: null }) + await state.fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) if (!group) { throw new Error('Could not create project group') } diff --git a/tests/e2e/runtime-host-status-recovery.spec.ts b/tests/e2e/runtime-host-status-recovery.spec.ts index d24e5fd84495..8e29e66a7a1e 100644 --- a/tests/e2e/runtime-host-status-recovery.spec.ts +++ b/tests/e2e/runtime-host-status-recovery.spec.ts @@ -120,8 +120,9 @@ for (const topology of ['desktop', 'headless'] as const) { ? headless.client.call('repo.add', { path: testRepoPath }) : page.evaluate(async (path) => { await window.api.repos.add({ path }) - const owner = window.__store?.getState().settings?.activeRuntimeEnvironmentId ?? null - await window.__store?.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await window.__store + ?.getState() + .fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) }, testRepoPath)) proxy = await interruptibleHost(offer) client = await launchPairedElectronClient(offer, testInfo, 'Direct host') diff --git a/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts b/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts index 25c2ebb6fed1..734f86e6ed78 100644 --- a/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts +++ b/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts @@ -23,7 +23,7 @@ test('cycles visible folder workspaces with shortcuts and focused list arrows', parentPath, createdFrom: 'folder-scan' }) - await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) const folder = await store.getState().createFolderWorkspace( { projectGroupId: group.id, diff --git a/tests/e2e/source-control-large-file-count.spec.ts b/tests/e2e/source-control-large-file-count.spec.ts index c8c699b2bd86..4f7b17539d01 100644 --- a/tests/e2e/source-control-large-file-count.spec.ts +++ b/tests/e2e/source-control-large-file-count.spec.ts @@ -137,7 +137,7 @@ async function unregisterLargeFileCountRepos( const store = window.__store const repo = store?.getState().repos.find((entry) => entry.path === pathToRepo) if (repo) { - await store?.getState().removeProject(repo.id) + await store?.getState().removeProject(repo.id, { hostId: 'local' }) } }, repoPath) } diff --git a/tests/e2e/ssh-codex-reconnect-replay-driver.ts b/tests/e2e/ssh-codex-reconnect-replay-driver.ts index 9eb58daed6e9..896944e9876a 100644 --- a/tests/e2e/ssh-codex-reconnect-replay-driver.ts +++ b/tests/e2e/ssh-codex-reconnect-replay-driver.ts @@ -69,8 +69,7 @@ export async function connectDockerRemote( if ('error' in result) { throw new Error(result.error) } - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) await store.getState().fetchWorktrees(result.repo.id) return { targetId: createdTarget.id, repoId: result.repo.id, repoPath: result.repo.path } } finally { diff --git a/tests/e2e/ssh-config-host-import.spec.ts b/tests/e2e/ssh-config-host-import.spec.ts index 4fba64f6adfe..92bef7abf72b 100644 --- a/tests/e2e/ssh-config-host-import.spec.ts +++ b/tests/e2e/ssh-config-host-import.spec.ts @@ -23,6 +23,8 @@ import { type SeededSshConfigHost } from './helpers/ssh-config-host-picker' +test.use({ seedTestRepo: false }) + // Why: afterEach deletes every target carrying this prefix; workers loading the // module in the same millisecond must not collide on a shared Date.now(). const HOST_PREFIX = makeSshConfigHostPrefix() diff --git a/tests/e2e/ssh-config-host-picker.spec.ts b/tests/e2e/ssh-config-host-picker.spec.ts index 467533c58a5f..065b8137c0c1 100644 --- a/tests/e2e/ssh-config-host-picker.spec.ts +++ b/tests/e2e/ssh-config-host-picker.spec.ts @@ -21,6 +21,8 @@ import { type SeededSshConfigHost } from './helpers/ssh-config-host-picker' +test.use({ seedTestRepo: false }) + // Why: afterEach deletes every target carrying this prefix; workers loading the // module in the same millisecond must not collide on a shared Date.now(). const HOST_PREFIX = makeSshConfigHostPrefix() diff --git a/tests/e2e/ssh-host-form-modal.spec.ts b/tests/e2e/ssh-host-form-modal.spec.ts index 6823e386ed34..8ffd09acb04e 100644 --- a/tests/e2e/ssh-host-form-modal.spec.ts +++ b/tests/e2e/ssh-host-form-modal.spec.ts @@ -8,6 +8,8 @@ import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' +test.use({ seedTestRepo: false }) + // Why: afterEach deletes every target carrying this prefix, so two workers loading // the module in the same millisecond must not collide on a shared Date.now(). const HOST_PREFIX = `e2e-ssh-modal-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` diff --git a/tests/e2e/ssh-route-error-card-preview.spec.ts b/tests/e2e/ssh-route-error-card-preview.spec.ts index 2db523bdd5f3..cfba7d2539d8 100644 --- a/tests/e2e/ssh-route-error-card-preview.spec.ts +++ b/tests/e2e/ssh-route-error-card-preview.spec.ts @@ -77,7 +77,13 @@ test('shows the SSH routing error cards and holds for review', async ({ }, targetId) await orcaPage.evaluate(async () => { - await window.__store?.getState().openNewBrowserTabInActiveWorkspace() + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const groupId = worktreeId ? state?.activeGroupIdByWorktree[worktreeId] : null + if (!state || !groupId) { + throw new Error('No active browser group') + } + await state.openNewBrowserTabInActiveWorkspace(groupId) }) await expect(orcaPage.getByText('SSH connection unavailable')).toBeVisible({ diff --git a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts index 2645fdded01e..64fc8baa28d1 100644 --- a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts +++ b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts @@ -119,8 +119,7 @@ async function addRealOrcaRepo(page: Page, repoPath: string): Promise { throw new Error('window.__store unavailable') } const state = store.getState() - const owner = state.settings?.activeRuntimeEnvironmentId ?? null - await state.fetchRepos({ runtimeEnvironmentId: owner }) + await state.fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) const repo = store.getState().repos.find((candidate) => candidate.path === repoPath) if (!repo) { throw new Error(`Real Orca repo did not load: ${repoPath}`) diff --git a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts index 827f7593397e..1b96b775b4d8 100644 --- a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts +++ b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts @@ -37,8 +37,7 @@ async function addTestRepo(page: Page, repoPath: string): Promise { if (!store) { return false } - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) await store.getState().fetchWorktrees(id) const worktree = store .getState() diff --git a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts index 77d7ecc7841e..f944e7686afd 100644 --- a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts +++ b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts @@ -140,9 +140,13 @@ async function configureActiveProjectWslRuntime(page: Page): Promise { } await window.api.repos.add({ path: wslRepoPath }) - const owner = store.getState().settings?.activeRuntimeEnvironmentId ?? null - await store.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) const state = store.getState() const hostRepo = state.repos.find((repo) => repo.path === hostRepoPath) const wslRepo = state.repos.find((repo) => repo.path === wslRepoPath) @@ -120,12 +119,20 @@ test.describe('Windows project runtime smoke', () => { throw new Error('Expected host and WSL smoke projects to be loaded') } - await state.updateProject(hostProject.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) - await state.updateProject(wslProject.id, { - localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } - }) + await state.updateProject( + hostProject.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) + await state.updateProject( + wslProject.id, + { + localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } + }, + 'local' + ) const hostWorktrees = await window.api.worktrees.listDetected({ repoId: hostRepo.id }) const wslWorktrees = await window.api.worktrees.listDetected({ repoId: wslRepo.id }) diff --git a/tests/e2e/workspace-layout-oracle-ssh.spec.ts b/tests/e2e/workspace-layout-oracle-ssh.spec.ts index b5d2db6c3094..08a528fb3645 100644 --- a/tests/e2e/workspace-layout-oracle-ssh.spec.ts +++ b/tests/e2e/workspace-layout-oracle-ssh.spec.ts @@ -136,7 +136,9 @@ async function connectFolderOnlyTarget( parentPath: folderPath, connectionId: added.target.id }) - await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + await store + .getState() + .fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) const workspace = await store.getState().createFolderWorkspace( { projectGroupId: group.id, diff --git a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts index 9d061af85180..6187e43e2fe3 100644 --- a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts @@ -3,6 +3,7 @@ import { mkdirSync } from 'node:fs' import { join } from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { readPaneIdentitySnapshot } from './helpers/terminal' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state' @@ -296,47 +297,30 @@ test.describe('Toggle Child Workspaces shortcut', () => { }) => { const { parentId, childId } = await seedLineageScenario(orcaPage) await setToggleBinding(orcaPage, ['Mod+Alt+H']) - const folderKey = 'folder:child-toggle-folder' - await orcaPage.evaluate(() => { + const folderKey = await orcaPage.evaluate(async () => { const store = window.__store const repo = store?.getState().repos[0] if (!store || !repo) { throw new Error('Missing seeded project') } - store.setState({ - projectGroups: [ - { - id: 'child-toggle-group', - name: 'Folder project', - parentPath: repo.path, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - ], - folderWorkspaces: [ - { - id: 'child-toggle-folder', - projectGroupId: 'child-toggle-group', - name: 'Folder without a child chip', - folderPath: repo.path, - executionHostId: 'local', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } - ] + const group = await window.api.projectGroups.create({ + name: 'Folder project', + parentPath: repo.path, + createdFrom: 'manual' }) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) + const folder = await store.getState().createFolderWorkspace( + { + projectGroupId: group.id, + name: 'Folder without a child chip', + folderPath: repo.path + }, + { runtimeEnvironmentId: null } + ) + if (!folder) { + throw new Error('Missing created folder workspace') + } + return `folder:${folder.id}` }) const folder = sidebarWorktreeRow(orcaPage, folderKey) await expect(folder).toBeVisible() @@ -355,6 +339,34 @@ test.describe('Toggle Child Workspaces shortcut', () => { await orcaPage.keyboard.press(CHORD) await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await expect + .poll(async () => { + if ((await orcaPage.locator('[data-terminal-error-toast]').count()) > 0) { + return 'error' + } + const snapshot = await readPaneIdentitySnapshot(orcaPage) + if (!snapshot) { + return 'pending' + } + const folderOwnsTab = await orcaPage.evaluate( + ({ folderKey, tabId }) => { + const state = window.__store?.getState() + return ( + state?.activeWorktreeId === folderKey && + (state.tabsByWorktree[folderKey] ?? []).some((tab) => tab.id === tabId) + ) + }, + { folderKey, tabId: snapshot.tabId } + ) + return folderOwnsTab && + snapshot.panes.some( + (pane) => pane.ptyId !== null && snapshot.ptyIdsByLeafId[pane.leafId] === pane.ptyId + ) + ? 'ready' + : 'pending' + }) + .not.toBe('pending') + await expect(orcaPage.locator('[data-terminal-error-toast]')).toHaveCount(0) await captureEvidence(orcaPage, 'child-toggle-folder-pass-through.png') }) diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index a65dab40a815..07cc6340897f 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -74,8 +74,9 @@ test.describe('Reveal active workspace button', () => { await expect .poll(() => orcaPage.evaluate(async (id) => { - const owner = window.__store!.getState().settings?.activeRuntimeEnvironmentId ?? null - await window.__store!.getState().fetchRepos({ runtimeEnvironmentId: owner }) + await window + .__store!.getState() + .fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) return window.__store!.getState().repos.some((repo) => repo.id === id) }, filterRepoId) ) From 1e5031e13424e97530e83cbe0ad10a05cb9347cc Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 11 Oct 2026 13:11:29 +0900 Subject: [PATCH 086/110] fix(i18n): correct Korean CI check translations (#27367) * fix(i18n): correct Korean CI check translations * fix(i18n): clarify Korean GitLab pipeline requirement --- config/scripts/locale-ko-key-overrides.json | 21 +++++++++++++++++++++ src/renderer/src/i18n/locales/ko.json | 14 +++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 964de9620bc9..b7ec782b42bd 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -92,9 +92,15 @@ "auto.components.GitHubItemDialog.dc1ca081a8": { "ko": "열림" }, + "auto.components.GitHubItemDialog.e15a8b77ef": { + "ko": "이 검사에는 인라인 세부정보가 없습니다." + }, "auto.components.GitHubItemDialog.e31651a224": { "ko": "실패한 체크 다시 실행" }, + "auto.components.GitHubItemDialog.e45324fbed": { + "ko": "검사 세부정보를 로드하지 못했습니다." + }, "auto.components.GitHubItemDialog.e463ec935f": { "ko": "체크 재실행 요청됨" }, @@ -281,6 +287,9 @@ "auto.components.PullRequestPage.6885c619e7": { "ko": "repos 경로가 없으면 답글을 달 수 없습니다." }, + "auto.components.PullRequestPage.6b1d5ee3e4": { + "ko": "이 검사에는 인라인 세부정보가 없습니다." + }, "auto.components.PullRequestPage.710e47aa06": { "ko": "PR이 다시 열렸습니다" }, @@ -329,6 +338,9 @@ "auto.components.PullRequestPage.ddfd42f460": { "ko": "agent를 시작하기 전에 프롬프트를 검토하세요." }, + "auto.components.PullRequestPage.e04c027d98": { + "ko": "검사 세부정보를 로드하지 못했습니다." + }, "auto.components.PullRequestPage.e1f3641bfd": { "ko": "소스 브랜치" }, @@ -1793,6 +1805,9 @@ "auto.components.right.sidebar.GitHistoryPanel.9289ba0cb9": { "ko": "refs란 무엇인가요?" }, + "auto.components.right.sidebar.GitHubPRStackMap.316039b5db": { + "ko": "검사 결과 대기 중" + }, "auto.components.right.sidebar.PortsPanel.0f1d8cd324": { "ko": "바인딩" }, @@ -1922,6 +1937,9 @@ "auto.components.right.sidebar.gitlab.mr.merge.state.49ac4fec10": { "ko": "검사 실패" }, + "auto.components.right.sidebar.gitlab.mr.merge.state.bf628700f6": { + "ko": "파이프라인이 성공해야 합니다" + }, "auto.components.right.sidebar.index.8bc2bbc3a0": { "ko": "탐색기" }, @@ -5003,6 +5021,9 @@ "auto.web.WebConnect.4a4c017be1": { "ko": "엔드포인트:" }, + "checksPanel.unlinked.description": { + "ko": "이 워크스페이스에서는 PR #{{number}}이(가) 숨겨져 있습니다. 검사와 리뷰 세부정보를 복원하려면 다시 연결하세요." + }, "components.native-chat.approval.allow": { "ko": "허용" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3c15aa5fde10..8bddcb3eda92 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1395,8 +1395,8 @@ "noActivity": "아직 활동이 없습니다." }, "checkActionRequiredHint": "머지가 해제되려면 이 검사에 대해 GitHub에서 수동 작업(예: 워크플로우 실행 승인)이 필요합니다.", - "e15a8b77ef": "이 수표에는 인라인 세부정보가 없습니다.", - "e45324fbed": "수표 세부정보를 로드하지 못했습니다.", + "e15a8b77ef": "이 검사에는 인라인 세부정보가 없습니다.", + "e45324fbed": "검사 세부정보를 로드하지 못했습니다.", "dcb3c546fe": "다시 시도", "85a2b66f54": "GitHub에서 파일 열기", "86d84a17ca": "리뷰 댓글 추가", @@ -1905,8 +1905,8 @@ "closePullRequestFailed": "PR을 닫지 못했습니다.", "reopenPullRequestFailed": "PR을 다시 열지 못했습니다.", "diffLoadTimedOut": "이 diff를 불러오는 데 시간이 초과되었습니다.", - "6b1d5ee3e4": "이 수표에는 인라인 세부정보가 없습니다.", - "e04c027d98": "수표 세부정보를 로드하지 못했습니다.", + "6b1d5ee3e4": "이 검사에는 인라인 세부정보가 없습니다.", + "e04c027d98": "검사 세부정보를 로드하지 못했습니다.", "5df7c41d2a": "다시 시도" }, "QuickOpen": { @@ -13323,7 +13323,7 @@ "c65408a99d": "리뷰어가 이 MR에 대한 변경을 요청했습니다.", "01ab632a21": "해결되지 않은 스레드", "4191fdfcec": "GitLab에서는 병합 전에 해결되지 않은 토론을 해결해야 합니다.", - "bf628700f6": "수표를 통과해야 합니다", + "bf628700f6": "파이프라인이 성공해야 합니다", "37d8637c70": "GitLab에서는 이 MR을 병합하기 전에 파이프라인이 성공해야 합니다.", "049ea91a82": "파이프라인은 여전히 ​​실행 중입니다.", "382c70faeb": "뒤에", @@ -13948,7 +13948,7 @@ "568c647ccd": "초안", "bea9ade223": "충돌", "838aadf512": "확인 실패", - "316039b5db": "보류 중인 수표", + "316039b5db": "검사 결과 대기 중", "4b1e5ee9d3": "변경 요청됨", "9a17b5255c": "리뷰 필요", "d3d97cf3f2": "승인됨", @@ -19213,7 +19213,7 @@ "checksPanel": { "unlinked": { "title": "PR 연결 해제됨", - "description": "이 워크스페이스에서는 PR #{{number}}이(가) 숨겨져 있습니다. 수표를 복원하고 세부정보를 검토하려면 다시 연결하세요.", + "description": "이 워크스페이스에서는 PR #{{number}}이(가) 숨겨져 있습니다. 검사와 리뷰 세부정보를 복원하려면 다시 연결하세요.", "relink": "링크 PR #{{number}}" } }, From 810c262b1e265a81c5c223d035064170a8d4343a Mon Sep 17 00:00:00 2001 From: F8 Team <120022042+f8team@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:11:59 +0700 Subject: [PATCH 087/110] fix(computer-use): release macOS screenshot memory after each request (#27374) The macOS helper serves each sidecar connection on a dedicated thread that lives as long as the connection, and the sidecar keeps one connection open while the helper runs. Encoding a screenshot with NSBitmapImageRep leaves autoreleased objects that keep the captured image alive, and that thread never drained its pool, so every screenshot kept a window-sized buffer in WindowServer until the helper restarted. Drain an autorelease pool around each request. --- .../Sources/OrcaComputerUseMacOS/main.swift | 9 ++-- .../AgentConnectionRequestLoop.swift | 16 ++++++ .../AgentConnectionRequestLoopTests.swift | 50 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentConnectionRequestLoop.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentConnectionRequestLoopTests.swift diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 324dba707da2..4d0428d9691c 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -4008,11 +4008,11 @@ private final class SocketListener: @unchecked Sendable { } let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true let decoder = JSONDecoder() - while let line = readLine(from: fd) { + AgentConnectionRequestLoop.run(readLine: { readLine(from: fd) }) { line in guard let data = line.data(using: .utf8), let request = try? decoder.decode(Request.self, from: data) else { - continue + return true } if !registeredSession && isAuthenticatedAgentSession( expectedToken: token, @@ -4029,7 +4029,7 @@ private final class SocketListener: @unchecked Sendable { ) } catch { fputs("computer-use owner monitor failed: \(error)\n", stderr) - return + return false } sessionLock.lock() let registration = sessionOwnership.registerConnection( @@ -4039,7 +4039,7 @@ private final class SocketListener: @unchecked Sendable { sessionLock.unlock() guard registration != .rejected else { monitor.cancel() - return + return false } registeredSession = true hangupMonitor = monitor @@ -4056,6 +4056,7 @@ private final class SocketListener: @unchecked Sendable { authorizedPeer: authorizedPeer ) writeJSON(response, to: fd) + return true } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentConnectionRequestLoop.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentConnectionRequestLoop.swift new file mode 100644 index 000000000000..b5eeab03a90c --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentConnectionRequestLoop.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum AgentConnectionRequestLoop { + /// Serves request lines until `readLine` returns nil or `handle` returns false. + public static func run(readLine: () -> String?, handle: (String) -> Bool) { + var keepServing = true + while keepServing { + // Why: a connection thread outlives its requests; without a per-request pool, objects + // autoreleased while encoding a screenshot keep its WindowServer buffer until disconnect. + keepServing = autoreleasepool { + guard let line = readLine() else { return false } + return handle(line) + } + } + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentConnectionRequestLoopTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentConnectionRequestLoopTests.swift new file mode 100644 index 000000000000..2218581bc5df --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentConnectionRequestLoopTests.swift @@ -0,0 +1,50 @@ +import Foundation +@testable import OrcaComputerUseMacOSCore +import XCTest + +final class AgentConnectionRequestLoopTests: XCTestCase { + func testReleasesARequestsAutoreleasedObjectsBeforeTheNextRequest() { + var lines = ["screenshot", "next"] + weak var screenshotBuffer: NSObject? + var releasedBeforeNextRequest: Bool? + + AgentConnectionRequestLoop.run(readLine: { lines.isEmpty ? nil : lines.removeFirst() }) { line in + if line == "screenshot" { + let buffer = NSObject() + screenshotBuffer = buffer + // Why: stands in for the objects AppKit PNG encoding leaves in the pool. + _ = Unmanaged.passRetained(buffer).autorelease() + } else { + releasedBeforeNextRequest = screenshotBuffer == nil + } + return true + } + + XCTAssertEqual(releasedBeforeNextRequest, true) + } + + func testServesEveryLineUntilInputEnds() { + var lines = ["a", "b", "c"] + var handled: [String] = [] + + AgentConnectionRequestLoop.run(readLine: { lines.isEmpty ? nil : lines.removeFirst() }) { line in + handled.append(line) + return true + } + + XCTAssertEqual(handled, ["a", "b", "c"]) + } + + func testStopsReadingWhenTheHandlerClosesTheConnection() { + var lines = ["a", "b", "c"] + var handled: [String] = [] + + AgentConnectionRequestLoop.run(readLine: { lines.isEmpty ? nil : lines.removeFirst() }) { line in + handled.append(line) + return line != "b" + } + + XCTAssertEqual(handled, ["a", "b"]) + XCTAssertEqual(lines, ["c"]) + } +} From f49d0bc764380d41179e8f3fe69a2597f6f8b0d7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:19:50 -0700 Subject: [PATCH 088/110] test(main): guard against a shared journal no-row sentinel returning (#27375) Follow-up to #27342: the existing tests also pass on the old module-level Error, so add one that fails if a single error object is reused. --- .../journal-item-appender.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/native-chat/agent-session-journal/journal-item-appender.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.test.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.test.ts new file mode 100644 index 000000000000..895ad880e634 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { JournalItemAppender } from './journal-item-appender' +import { createJournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const OPTIONS = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +describe('a resolved append that finds nothing', () => { + // A shared module-level Error would pin the bundle's startup frames for the whole session. + it('never reuses one thrown object across appends', async () => { + const thrown: unknown[] = [] + const appender = new JournalItemAppender({ + state: () => createJournalReducerState('session-1', 'epoch-1'), + enqueue: (build: (seq: number, ts: number) => JournalRow) => { + try { + return Promise.resolve(build(1, 0)) + } catch (error) { + thrown.push(error) + return Promise.reject(error) + } + } + }) + + await expect(appender.appendResolved(() => null, OPTIONS)).resolves.toBeNull() + await expect(appender.appendResolved(() => null, OPTIONS)).resolves.toBeNull() + expect(new Set(thrown).size).toBe(thrown.length) + }) +}) From dae2cc91108c6a4ae512c7b43d6d496fea844ff1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:21:27 -0700 Subject: [PATCH 089/110] test(runtime): enforce ANSI preview-final pre-filter invariant (#27376) Follow-up to #27344. isTerminalPreviewLineControl now gates on isTerminalPreviewLineControlFinal, so the normalizeTerminalChunk pre-filter cannot drop a final the predicate accepts. Add a test over every CSI final 0x40-0x7e and replace String.prototype.charCodeAt spies with behavior assertions. --- .../runtime/terminal-ansi-normalization.ts | 10 ++- .../terminal-vertical-control-scan.test.ts | 63 +++++++++++-------- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/main/runtime/terminal-ansi-normalization.ts b/src/main/runtime/terminal-ansi-normalization.ts index 6a5cb9843517..253f6c2d8f5b 100644 --- a/src/main/runtime/terminal-ansi-normalization.ts +++ b/src/main/runtime/terminal-ansi-normalization.ts @@ -197,7 +197,7 @@ function trimPendingAnsiControl(value: string): string { return `${introducer}${value.slice(-suffixBudget)}` } -/** CSI controls that affect retained preview rows. */ +/** CSI finals that affect retained preview rows; the sole gate for isTerminalPreviewLineControl. */ function isTerminalPreviewLineControlFinal(code: number): boolean { return ( code === 0x4b || @@ -214,12 +214,16 @@ function isTerminalPreviewLineControl(parsed: { params: string firstParam: number | null }): boolean { - if (!hasCanonicalNumericCsiParams(parsed.params)) { + // Why: normalizeTerminalChunk pre-filters on this predicate, so any final accepted here must pass it first. + if ( + !isTerminalPreviewLineControlFinal(parsed.final.charCodeAt(0)) || + !hasCanonicalNumericCsiParams(parsed.params) + ) { return false } if (parsed.final === 'K') { const mode = parsed.firstParam ?? 0 return mode === 0 || mode === 1 || mode === 2 } - return isTerminalPreviewLineControlFinal(parsed.final.charCodeAt(0)) + return true } diff --git a/src/main/runtime/terminal-vertical-control-scan.test.ts b/src/main/runtime/terminal-vertical-control-scan.test.ts index 9608b7010adc..76d37603991a 100644 --- a/src/main/runtime/terminal-vertical-control-scan.test.ts +++ b/src/main/runtime/terminal-vertical-control-scan.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { containsTerminalVerticalLineControl, normalizeTerminalChunk @@ -6,20 +6,42 @@ import { import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' describe('terminal vertical-control scanning', () => { - it('scans retained CSI parameters once while normalizing', () => { + it('keeps a line control with long retained CSI parameters', () => { const input = `\x1b[${'1;'.repeat(2048)}A` - const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt') - let normalized: ReturnType - let inspections: number - try { - normalized = normalizeTerminalChunk(input) - inspections = charCodeAt.mock.calls.length - } finally { - charCodeAt.mockRestore() - } + expect(normalizeTerminalChunk(input)).toEqual({ + text: input, + pendingAnsi: '' + }) + }) - expect(normalized).toEqual({ text: input, pendingAnsi: '' }) - expect(inspections).toBeLessThanOrEqual(input.length + 8) + it('retains exactly the preview line-control CSI finals', () => { + const retained: string[] = [] + for (let code = 0x40; code <= 0x7e; code += 1) { + const final = String.fromCharCode(code) + for (const params of ['', '2']) { + const control = `\x1b[${params}${final}` + const { text } = normalizeTerminalChunk(`a${control}b`) + if (text === `a${control}b`) { + retained.push(control) + } else { + expect(text).toBe('ab') + } + } + } + expect(retained).toEqual([ + '\x1b[A', + '\x1b[2A', + '\x1b[C', + '\x1b[2C', + '\x1b[D', + '\x1b[2D', + '\x1b[G', + '\x1b[2G', + '\x1b[K', + '\x1b[2K', + '\x1b[`', + '\x1b[2`' + ]) }) it('preserves printable spans and carried controls across chunk boundaries', () => { @@ -35,19 +57,8 @@ describe('terminal vertical-control scanning', () => { ['plain', 'log output '.repeat(8192), false], ['nonvertical CSI', `\x1b[31m${'log output '.repeat(8192)}\x1b[0m`, false], ['vertical CSI', `${'漢字😀 output '.repeat(8192)}\x1b[2A`, true] - ] as const)('bounds code-unit inspections on %s output', (_name, input, expected) => { - const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt') - let actual: boolean - let inspections: number - try { - actual = containsTerminalVerticalLineControl(input) - inspections = charCodeAt.mock.calls.length - } finally { - charCodeAt.mockRestore() - } - - expect(actual).toBe(expected) - expect(inspections).toBeLessThan(16) + ] as const)('detects vertical controls in %s output', (_name, input, expected) => { + expect(containsTerminalVerticalLineControl(input)).toBe(expected) }) it.each([ From 74856c83b34b926e23de20f2bc67a9e38bfda936 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:22:18 -0400 Subject: [PATCH 090/110] fix(dev): keep Node-only Quick Open path accumulator out of the renderer (#27372) The renderer imports quick-open-listing-limits for the result cap, but the same module also held a Buffer-backed accumulator that imports the CommonJS @orca/process-host dist. Vite dev serves that dist as raw ESM, so the named import failed and the workspace workbench and right sidebar crashed. Move the accumulator to its own main-only module. --- src/main/ipc/filesystem-search-file-paths.ts | 2 +- src/shared/quick-open-listing-limits.test.ts | 27 +------- src/shared/quick-open-listing-limits.ts | 69 +------------------ ...k-open-subprocess-path-accumulator.test.ts | 28 ++++++++ .../quick-open-subprocess-path-accumulator.ts | 68 ++++++++++++++++++ 5 files changed, 99 insertions(+), 95 deletions(-) create mode 100644 src/shared/quick-open-subprocess-path-accumulator.test.ts create mode 100644 src/shared/quick-open-subprocess-path-accumulator.ts diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts index ebd39dc24447..867a4fb86869 100644 --- a/src/main/ipc/filesystem-search-file-paths.ts +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -26,7 +26,7 @@ import { import { parseWslPath, toWindowsWslPath } from '../wsl' import { resolveAuthorizedPath } from './filesystem-auth' import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' -import { QuickOpenSubprocessPathAccumulator } from '../../shared/quick-open-listing-limits' +import { QuickOpenSubprocessPathAccumulator } from '../../shared/quick-open-subprocess-path-accumulator' import { bundledRipgrepUnavailableError } from '../ripgrep/bundled-ripgrep-path' import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' diff --git a/src/shared/quick-open-listing-limits.test.ts b/src/shared/quick-open-listing-limits.test.ts index e5e3235e782d..3efd0d7fa646 100644 --- a/src/shared/quick-open-listing-limits.test.ts +++ b/src/shared/quick-open-listing-limits.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { - QuickOpenSubprocessPathAccumulator, resolveQuickOpenResultLimit, QUICK_OPEN_LISTING_MAX_RESULTS } from './quick-open-listing-limits' @@ -14,28 +13,4 @@ describe('Quick Open listing limits', () => { ) expect(resolveQuickOpenResultLimit(0)).toBe(0) }) - - it('bounds one fragmented subprocess path and recovers after overflow', () => { - const onPath = vi.fn(() => true) - const fields = new QuickOpenSubprocessPathAccumulator(0, 3) - - expect(fields.push(Buffer.from('ab'), onPath)).toBe('continue') - expect(fields.push(Buffer.from('cd'), onPath)).toBe('path-too-large') - expect(fields.push(Buffer.from('ok\0'), onPath)).toBe('continue') - expect(onPath).toHaveBeenCalledTimes(1) - expect(onPath).toHaveBeenCalledWith('ok') - }) - - it('stops within a multi-path chunk without visiting later fields', () => { - const visited: string[] = [] - const fields = new QuickOpenSubprocessPathAccumulator(0, 16) - - expect( - fields.push(Buffer.from('one\0two\0three\0'), (path) => { - visited.push(path) - return path !== 'two' - }) - ).toBe('stopped') - expect(visited).toEqual(['one', 'two']) - }) }) diff --git a/src/shared/quick-open-listing-limits.ts b/src/shared/quick-open-listing-limits.ts index acd485c42094..aa50c40454ae 100644 --- a/src/shared/quick-open-listing-limits.ts +++ b/src/shared/quick-open-listing-limits.ts @@ -1,7 +1,5 @@ -import { GrowingByteBuffer } from '@orca/process-host/growing-byte-buffer' - +// Why: the renderer imports this module, so it must stay free of Node-only code. export const QUICK_OPEN_LISTING_MAX_RESULTS = 20_001 -const QUICK_OPEN_LISTING_MAX_PATH_BYTES = 64 * 1024 export function resolveQuickOpenResultLimit(requested?: number): number { if (requested === undefined || requested === Number.POSITIVE_INFINITY) { @@ -12,68 +10,3 @@ export function resolveQuickOpenResultLimit(requested?: number): number { } return Math.min(Math.max(Math.trunc(requested), 0), QUICK_OPEN_LISTING_MAX_RESULTS) } - -export type QuickOpenPathAccumulatorResult = 'continue' | 'stopped' | 'path-too-large' - -export class QuickOpenSubprocessPathAccumulator { - private readonly field = new GrowingByteBuffer() - - constructor( - private readonly delimiter: number, - private readonly maxPathBytes = QUICK_OPEN_LISTING_MAX_PATH_BYTES - ) { - if (!Number.isInteger(delimiter) || delimiter < 0 || delimiter > 0xff) { - throw new RangeError('Quick Open path delimiter must be one byte') - } - if (!Number.isSafeInteger(maxPathBytes) || maxPathBytes < 0) { - throw new RangeError('Quick Open path limit must be a non-negative safe integer') - } - } - - push( - rawChunk: Buffer | string, - onPath: (path: string) => boolean - ): QuickOpenPathAccumulatorResult { - const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk, 'utf8') - let cursor = 0 - while (cursor < chunk.length) { - const delimiter = chunk.indexOf(this.delimiter, cursor) - const end = delimiter === -1 ? chunk.length : delimiter - const segmentBytes = end - cursor - if (this.field.byteLength + segmentBytes > this.maxPathBytes) { - this.clear() - return 'path-too-large' - } - if (delimiter !== -1 && this.field.byteLength === 0) { - if (!onPath(chunk.toString('utf8', cursor, end))) { - return 'stopped' - } - } else if (segmentBytes > 0) { - // Why: copying prevents a short residual path from retaining the whole read buffer. - this.field.append(chunk.subarray(cursor, end)) - if (delimiter !== -1 && !onPath(this.take())) { - return 'stopped' - } - } else if (delimiter !== -1 && !onPath(this.take())) { - return 'stopped' - } - if (delimiter === -1) { - return 'continue' - } - cursor = delimiter + 1 - } - return 'continue' - } - - finish(): string | null { - return this.field.byteLength > 0 ? this.take() : null - } - - clear(): void { - this.field.clear() - } - - private take(): string { - return this.field.takeString() - } -} diff --git a/src/shared/quick-open-subprocess-path-accumulator.test.ts b/src/shared/quick-open-subprocess-path-accumulator.test.ts new file mode 100644 index 000000000000..2ade096de30b --- /dev/null +++ b/src/shared/quick-open-subprocess-path-accumulator.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' +import { QuickOpenSubprocessPathAccumulator } from './quick-open-subprocess-path-accumulator' + +describe('Quick Open subprocess path accumulator', () => { + it('bounds one fragmented subprocess path and recovers after overflow', () => { + const onPath = vi.fn(() => true) + const fields = new QuickOpenSubprocessPathAccumulator(0, 3) + + expect(fields.push(Buffer.from('ab'), onPath)).toBe('continue') + expect(fields.push(Buffer.from('cd'), onPath)).toBe('path-too-large') + expect(fields.push(Buffer.from('ok\0'), onPath)).toBe('continue') + expect(onPath).toHaveBeenCalledTimes(1) + expect(onPath).toHaveBeenCalledWith('ok') + }) + + it('stops within a multi-path chunk without visiting later fields', () => { + const visited: string[] = [] + const fields = new QuickOpenSubprocessPathAccumulator(0, 16) + + expect( + fields.push(Buffer.from('one\0two\0three\0'), (path) => { + visited.push(path) + return path !== 'two' + }) + ).toBe('stopped') + expect(visited).toEqual(['one', 'two']) + }) +}) diff --git a/src/shared/quick-open-subprocess-path-accumulator.ts b/src/shared/quick-open-subprocess-path-accumulator.ts new file mode 100644 index 000000000000..d08ddf1c2634 --- /dev/null +++ b/src/shared/quick-open-subprocess-path-accumulator.ts @@ -0,0 +1,68 @@ +import { GrowingByteBuffer } from '@orca/process-host/growing-byte-buffer' + +const QUICK_OPEN_LISTING_MAX_PATH_BYTES = 64 * 1024 + +export type QuickOpenPathAccumulatorResult = 'continue' | 'stopped' | 'path-too-large' + +export class QuickOpenSubprocessPathAccumulator { + private readonly field = new GrowingByteBuffer() + + constructor( + private readonly delimiter: number, + private readonly maxPathBytes = QUICK_OPEN_LISTING_MAX_PATH_BYTES + ) { + if (!Number.isInteger(delimiter) || delimiter < 0 || delimiter > 0xff) { + throw new RangeError('Quick Open path delimiter must be one byte') + } + if (!Number.isSafeInteger(maxPathBytes) || maxPathBytes < 0) { + throw new RangeError('Quick Open path limit must be a non-negative safe integer') + } + } + + push( + rawChunk: Buffer | string, + onPath: (path: string) => boolean + ): QuickOpenPathAccumulatorResult { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk, 'utf8') + let cursor = 0 + while (cursor < chunk.length) { + const delimiter = chunk.indexOf(this.delimiter, cursor) + const end = delimiter === -1 ? chunk.length : delimiter + const segmentBytes = end - cursor + if (this.field.byteLength + segmentBytes > this.maxPathBytes) { + this.clear() + return 'path-too-large' + } + if (delimiter !== -1 && this.field.byteLength === 0) { + if (!onPath(chunk.toString('utf8', cursor, end))) { + return 'stopped' + } + } else if (segmentBytes > 0) { + // Why: copying prevents a short residual path from retaining the whole read buffer. + this.field.append(chunk.subarray(cursor, end)) + if (delimiter !== -1 && !onPath(this.take())) { + return 'stopped' + } + } else if (delimiter !== -1 && !onPath(this.take())) { + return 'stopped' + } + if (delimiter === -1) { + return 'continue' + } + cursor = delimiter + 1 + } + return 'continue' + } + + finish(): string | null { + return this.field.byteLength > 0 ? this.take() : null + } + + clear(): void { + this.field.clear() + } + + private take(): string { + return this.field.takeString() + } +} From e4f599df05e360ae78d79affcd442c361cc14c6e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:24:34 -0700 Subject: [PATCH 091/110] perf(worktrees): preserve Git scans when saving sidebar order (#27343) * perf(worktrees): preserve Git scans when saving sort order Credit @mmarabel for identifying the repeated scans and the host-only approach in #27271. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> * perf(worktrees): skip Git scan invalidation for order saves on desktop hosts too The desktop window notifier ran the worktree-change invalidators unconditionally, so a desktop (or paired-host) Orca still cleared Git scan caches on every sidebar order save. Carry the invalidateGitScans flag through the notifier; every other caller keeps the default. --------- Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> --- ...-runtime-create-managed-remote-worktree.ts | 2 +- src/main/runtime/orca-runtime-get-status.ts | 18 ++-- src/main/runtime/runtime-notifier-contract.ts | 7 +- .../runtime-sort-order-scan-cache.test.ts | 87 +++++++++++++++++++ src/main/window/runtime-window-lifecycle.ts | 6 +- 5 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 src/main/runtime/runtime-sort-order-scan-cache.test.ts diff --git a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts index 179d796d0e41..e69937888278 100644 --- a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts @@ -179,7 +179,7 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC orderedIds, store: this.store, invalidateResolved: () => this.invalidateResolvedWorktreeCache(), - notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId) + notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId, { invalidateGitScans: false }) }) } diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index d962ae46ce98..7075736b5687 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -267,11 +267,19 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { return { tabId, worktreeId: pty.worktreeId } } - protected notifyWorktreesChanged(repoId: string): void { - // Why here: the listing re-runs a scan this generation overtook, and a headless host has no - // window notifier to bump it, so the runtime's own change event bumps before it is sent. - runWorktreeChangeInvalidators(repoId) - this.notifier?.worktreesChanged(repoId) + protected notifyWorktreesChanged( + repoId: string, + options?: { invalidateGitScans?: boolean } + ): void { + // Why: headless hosts have no window notifier, so invalidate here unless the change is metadata-only. + if (options?.invalidateGitScans !== false) { + runWorktreeChangeInvalidators(repoId) + } + if (options) { + this.notifier?.worktreesChanged(repoId, undefined, options) + } else { + this.notifier?.worktreesChanged(repoId) + } this.emitClientEvent({ type: 'worktreesChanged', repoId }) } diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index d13da3976f1d..795b707a6649 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -31,7 +31,12 @@ export type RuntimeNotifier = { selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } reason?: 'definition' | 'run' | 'usage' }): void - worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void + worktreesChanged( + repoId: string, + renamed?: { oldWorktreeId: string; newWorktreeId: string }, + // Why: false for metadata-only changes (sort order) that leave Git scan results valid. + options?: { invalidateGitScans?: boolean } + ): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void diff --git a/src/main/runtime/runtime-sort-order-scan-cache.test.ts b/src/main/runtime/runtime-sort-order-scan-cache.test.ts new file mode 100644 index 000000000000..d6de711c683b --- /dev/null +++ b/src/main/runtime/runtime-sort-order-scan-cache.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import './orca-runtime-test-lifecycle.spec' +import { OrcaRuntimeService, listWorktrees } from './orca-runtime-test-mocks.spec' +import type { WorktreeMeta } from './orca-runtime-test-mocks.spec' +import { + TEST_REPO_ID, + TEST_REPO_PATH, + makeWorktreeInfo, + makeWorktreeMeta, + store +} from './orca-runtime-test-fixtures.spec' +import { registerRuntimeWindowLifecycle } from '../window/runtime-window-lifecycle' + +vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } })) + +const firstPath = join(TEST_REPO_PATH, 'first') +const secondPath = join(TEST_REPO_PATH, 'second') +const firstId = `${TEST_REPO_ID}::${firstPath}` +const secondId = `${TEST_REPO_ID}::${secondPath}` + +async function createScannedRuntime(): Promise> { + const metaById: Record = { + [firstId]: makeWorktreeMeta({ sortOrder: 200 }), + [secondId]: makeWorktreeMeta({ sortOrder: 100 }) + } + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Partial) => { + metaById[id] = { ...metaById[id], ...meta } + return metaById[id] + } + }) + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockResolvedValue([ + makeWorktreeInfo(firstPath), + makeWorktreeInfo(secondPath) + ]) + await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + return runtime +} + +async function expectOrderSaveSkipsGitScan( + runtime: InstanceType +): Promise { + const events: { type: string; repoId?: string }[] = [] + const unsubscribe = runtime.onClientEvent((event) => events.push(event)) + + expect(runtime.persistManagedWorktreeSortOrder([secondId, firstId])).toEqual({ updated: 2 }) + unsubscribe() + const listed = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + + expect(events).toEqual([{ type: 'worktreesChanged', repoId: TEST_REPO_ID }]) + expect(listWorktrees).toHaveBeenCalledTimes(1) + const sortOrderOf = (id: string) => + listed.worktrees.find((worktree) => worktree.id === id)?.sortOrder ?? Number.NaN + expect(sortOrderOf(secondId)).toBeGreaterThan(sortOrderOf(firstId)) + + runtime.publishWorktreeRemovalChange(TEST_REPO_ID) + await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + expect(listWorktrees).toHaveBeenCalledTimes(2) +} + +describe('runtime sort-order saves', () => { + it('publishes the changed order without rescanning Git on a headless host', async () => { + await expectOrderSaveSkipsGitScan(await createScannedRuntime()) + }) + + it('publishes the changed order without rescanning Git through the desktop window notifier', async () => { + const runtime = await createScannedRuntime() + const send = vi.fn() + const mainWindow = { + id: 1, + isDestroyed: () => false, + on: vi.fn(), + webContents: { isDestroyed: () => false, send, on: vi.fn() } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only reads id, isDestroyed, on and webContents. + registerRuntimeWindowLifecycle(mainWindow as unknown as BrowserWindow, runtime) + + await expectOrderSaveSkipsGitScan(runtime) + expect(send).toHaveBeenCalledWith('worktrees:changed', { repoId: TEST_REPO_ID }) + }) +}) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 95abe7c35158..66e131a2f319 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -37,9 +37,11 @@ export function registerRuntimeWindowLifecycle( }) const send = rendererNotifications.send runtime.setNotifier({ - worktreesChanged: (repoId, renamed) => { + worktreesChanged: (repoId, renamed, options) => { // Why: clear scan caches before the renderer handles this event, so it can't read stale TTL entries after a mutation. - runWorktreeChangeInvalidators(repoId) + if (options?.invalidateGitScans !== false) { + runWorktreeChangeInvalidators(repoId) + } send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }) }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), From d2ba67d1090a0e7707532ebbf4a12e935dbe0185 Mon Sep 17 00:00:00 2001 From: Vladislav <51202067+vampik33@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:25:20 +0300 Subject: [PATCH 092/110] fix(claude): register a short PostCompact hook command (#25211) * fix(claude): register a short PostCompact hook command Claude Code echoes every PostCompact hook's full command into the post-compact transcript, so Orca's ~3KB portable wrapper landed in the model's context after every /compact. On POSIX hosts (local macOS/Linux and SSH remotes) PostCompact now gets only the POSIX branch of the wrapper; every other event, and Windows, keep the full wrapper. Fixes #25209 * fix(claude): guard runtime home and verify hook process identity --------- Co-authored-by: Neil --- .../managed-hook-command-contract.test.ts | 33 ++-- .../agent-hooks/runtime-home-hook-command.ts | 33 +++- .../claude/claude-hook-event-install.test.ts | 2 +- .../compact-status-registration.test.ts | 2 +- src/main/claude/hook-service.ts | 13 +- src/main/claude/hook-settings.ts | 42 ++++- .../claude/post-compact-hook-command.test.ts | 176 ++++++++++++++++++ 7 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 src/main/claude/post-compact-hook-command.test.ts diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 745d5fe92a96..7e22508c10f7 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CLAUDE_HOOK_SETTINGS, OPENCLAUDE_HOOK_SETTINGS, + getManagedEventHook, getManagedLifecycleHook, - getRemoteManagedCommand as getClaudeRemoteCommand + getRemoteManagedCommand as getClaudeRemoteCommand, + getRemoteManagedEventCommand } from '../claude/hook-settings' import { getManagedCommand as getCodexCommand, @@ -65,21 +67,22 @@ function antigravityPosixCommands(path: string): string[] { ) } +function claudeFamilyBuilders(settings: typeof CLAUDE_HOOK_SETTINGS): CommandBuilders { + return { + local: (path) => [ + getManagedLifecycleHook(path, settings).command, + getManagedEventHook('PostCompact', path, settings).command + ], + remote: (path) => [ + getClaudeRemoteCommand(path), + getRemoteManagedEventCommand('PostCompact', path) + ] + } +} + const buildersByAgent = new Map([ - [ - 'claude', - { - local: (path) => [getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS).command], - remote: (path) => [getClaudeRemoteCommand(path)] - } - ], - [ - 'openclaude', - { - local: (path) => [getManagedLifecycleHook(path, OPENCLAUDE_HOOK_SETTINGS).command], - remote: (path) => [getClaudeRemoteCommand(path)] - } - ], + ['claude', claudeFamilyBuilders(CLAUDE_HOOK_SETTINGS)], + ['openclaude', claudeFamilyBuilders(OPENCLAUDE_HOOK_SETTINGS)], [ 'qoder', { diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index 4b59f7b53ed9..4cc61d2988f0 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -12,17 +12,40 @@ import { const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/ const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*' +function assertManagedScriptBaseName(scriptBaseName: string): void { + if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { + throw new Error(`Invalid managed script base name: ${scriptBaseName}`) + } +} + +function buildPosixBranch( + scriptBaseName: string, + missingScriptFallback: string, + options: { requireHome?: boolean } = {} +): string { + const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"` + const homeGuard = options.requireHome ? '[ -n "${HOME-}" ] && ' : '' + const parentCapture = + scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' + ? CLAUDE_HOOK_PARENT_CAPTURE + : '' + return `if ${homeGuard}[ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${parentCapture}/bin/sh ${posixScript}; else ${missingScriptFallback}; fi` +} + +/** The POSIX branch of wrapRuntimeHomeHookCommand alone, for hosts known to be POSIX. */ +export function wrapRuntimeHomePosixHookCommand(scriptBaseName: string): string { + assertManagedScriptBaseName(scriptBaseName) + return buildPosixBranch(scriptBaseName, POSIX_HOOK_STDIN_DRAIN_COMMAND, { requireHome: true }) +} + export function wrapRuntimeHomeHookCommand( scriptBaseName: string, options: { neutralJsonWhenMissing?: boolean } = {} ): string { - if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { - throw new Error(`Invalid managed script base name: ${scriptBaseName}`) - } + assertManagedScriptBaseName(scriptBaseName) // Why: default-form every var — a static hook precheck (Grok) rejects the whole command on a bare // reference it cannot resolve, even in a branch that platform never takes. const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"` - const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"` const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND const neutralJson = options.neutralJsonWhenMissing ? `printf '{}\\n'` : '' // Why two forms: the missing-script fallback owns stdin, so it follows the rule of the host @@ -47,7 +70,7 @@ export function wrapRuntimeHomeHookCommand( const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi` const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi` - const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' ? CLAUDE_HOOK_PARENT_CAPTURE : ''}/bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi` + const posixBranch = buildPosixBranch(scriptBaseName, posixMissingScriptFallback) // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` } diff --git a/src/main/claude/claude-hook-event-install.test.ts b/src/main/claude/claude-hook-event-install.test.ts index 99af9a3fa536..f27a35ad7c14 100644 --- a/src/main/claude/claude-hook-event-install.test.ts +++ b/src/main/claude/claude-hook-event-install.test.ts @@ -14,7 +14,7 @@ const topLevelSettings: Record = fixture.topLevelSet function install(config: HooksConfig, claudeVersion: string | undefined): HooksConfig { return applyManagedHooks( config, - managedHook, + () => managedHook, SCRIPT_FILE_NAME, getClaudeManagedHookPlan(claudeVersion) ) diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index 225a52423c3a..f57ac35fed66 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -109,7 +109,7 @@ describe('Claude compact hook registration', () => { it('writes PostCompact, and no PreCompact, into the settings Claude actually reads', () => { const written = applyManagedHooks( { hooks: {} }, - { type: 'command', command: 'orca-claude-hook' }, + () => ({ type: 'command', command: 'orca-claude-hook' }), 'claude-hook.sh', getClaudeManagedHookPlan(CLAUDE_HOOK_EVENT_FIRST_VERSIONS.PostCompact) ) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 58a62d797e6e..ba32d9eb051a 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -32,11 +32,11 @@ import { getManagedScriptFileName, getConfigPath, getManagedCommand, - getManagedLifecycleHook, + getManagedEventHook, getManagedScriptPath, getPosixManagedScriptFileName, getRemoteConfigPath, - getRemoteManagedCommand, + getRemoteManagedEventCommand, getStatusLineInstallMarkerPath, getStatusLineScriptFileName, getStatusLineScriptPath, @@ -122,10 +122,10 @@ export class ClaudeHookService { } // Why: report partial registration instead of a false installed state. - const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings) const missing: string[] = [] let presentCount = 0 for (const event of this.managedHookPlan(options).install) { + const expectedHook = getManagedEventHook(event.eventName, scriptPath, this.options.settings) const definitions = Array.isArray(config.hooks?.[event.eventName]) ? config.hooks![event.eventName]! : [] @@ -186,11 +186,10 @@ export class ClaudeHookService { } } - const hook = getManagedLifecycleHook(scriptPath, this.options.settings) const plan = this.managedHookPlan(options) let nextConfig = applyManagedHooks( config, - hook, + (eventName) => getManagedEventHook(eventName, scriptPath, this.options.settings), getManagedScriptFileName(this.options.settings), plan ) @@ -275,10 +274,10 @@ export class ClaudeHookService { } // Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home. - const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath)) const nextConfig = applyManagedHooks( config, - hook, + (eventName) => + buildManagedCommandHook(getRemoteManagedEventCommand(eventName, remoteScriptPath)), remoteScriptFileName, this.managedHookPlan(options) ) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 3e15f26c4be8..328e53fffcdc 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -14,7 +14,10 @@ import { type HooksConfig } from '../agent-hooks/installer-utils' import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' -import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' +import { + wrapRuntimeHomeHookCommand, + wrapRuntimeHomePosixHookCommand +} from '../agent-hooks/runtime-home-hook-command' import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' @@ -83,16 +86,34 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S return `${remoteHome.replace(/\/$/, '')}/${settings.configDirName}/settings.json` } +function getScriptBaseName(scriptPath: string): string { + const scriptFileName = basename(scriptPath) + const extension = extname(scriptFileName) + return extension ? scriptFileName.slice(0, -extension.length) : scriptFileName +} + export function getManagedCommand( scriptPath: string, options: { neutralJsonWhenMissing?: boolean } = {} ): string { - const scriptFileName = basename(scriptPath) - const extension = extname(scriptFileName) - return wrapRuntimeHomeHookCommand( - extension ? scriptFileName.slice(0, -extension.length) : scriptFileName, - options - ) + return wrapRuntimeHomeHookCommand(getScriptBaseName(scriptPath), options) +} + +export function getManagedEventHook( + eventName: string, + scriptPath: string, + settings = CLAUDE_HOOK_SETTINGS +): HookCommandConfig { + return process.platform === 'win32' + ? getManagedLifecycleHook(scriptPath, settings) + : buildManagedCommandHook(getRemoteManagedEventCommand(eventName, scriptPath)) +} + +// Claude adds the full PostCompact command to the model's context after compaction. +export function getRemoteManagedEventCommand(eventName: string, scriptPath: string): string { + return eventName === 'PostCompact' + ? wrapRuntimeHomePosixHookCommand(getScriptBaseName(scriptPath)) + : getRemoteManagedCommand(scriptPath) } export function getManagedLifecycleHook( @@ -155,7 +176,7 @@ export function getRemoteManagedCommand(scriptPath: string): string { export function applyManagedHooks( config: HooksConfig, - hook: HookCommandConfig, + hookForEvent: (eventName: string) => HookCommandConfig, scriptFileName: string, plan: ClaudeManagedHookPlan ): HooksConfig { @@ -165,7 +186,10 @@ export function applyManagedHooks( for (const event of plan.install) { const current = nextHooks[event.eventName] const cleaned = Array.isArray(current) ? removeManagedCommands(current, isManagedCommand) : [] - const definition: HookDefinition = { ...event.definition, hooks: [hook] } + const definition: HookDefinition = { + ...event.definition, + hooks: [hookForEvent(event.eventName)] + } nextHooks[event.eventName] = [...cleaned, definition] } diff --git a/src/main/claude/post-compact-hook-command.test.ts b/src/main/claude/post-compact-hook-command.test.ts new file mode 100644 index 000000000000..98d4c0b36388 --- /dev/null +++ b/src/main/claude/post-compact-hook-command.test.ts @@ -0,0 +1,176 @@ +import { runProcess } from '@orca/process-host' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentHookMemorySftp } from '../agent-hooks/agent-hook-memory-sftp.test-fixture' +import { wrapRuntimeHomePosixHookCommand } from '../agent-hooks/runtime-home-hook-command' +import { ClaudeHookService } from './hook-service' +import { + CLAUDE_HOOK_SETTINGS, + getManagedEventHook, + getManagedLifecycleHook, + getRemoteManagedCommand, + getRemoteManagedEventCommand +} from './hook-settings' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/userData' } })) + +const SCRIPT_PATH = '/home/test/.orca/agent-hooks/claude-hook.sh' +const CURRENT_CLAUDE = { claudeVersion: '2.1.261 (Claude Code)' } +// Why: Claude prints the command verbatim after every /compact; the full wrapper is ~3KB. +const MAX_ECHOED_COMMAND_LENGTH = 400 + +afterEach(() => vi.restoreAllMocks()) + +describe('PostCompact managed hook command', () => { + it.each(['darwin', 'linux'] as const)('is the short POSIX form on %s', (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const { command } = getManagedEventHook('PostCompact', SCRIPT_PATH) + + expect(command.length).toBeLessThan(MAX_ECHOED_COMMAND_LENGTH) + expect(command).toContain('if [ -n "${HOME-}" ] && ') + expect(command).toContain('/bin/sh "${HOME-}/.orca/agent-hooks/claude-hook.sh"') + expect(command).toContain('ORCA_HOOK_AGENT_PID="${PPID:-}"') + expect(command).not.toMatch(/powershell|OSTYPE|\/home\/test/i) + }) + + it('leaves every other event on the full wrapper', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + expect(getManagedEventHook('Stop', SCRIPT_PATH)).toEqual( + getManagedLifecycleHook(SCRIPT_PATH, CLAUDE_HOOK_SETTINGS) + ) + }) + + it('keeps the Windows launcher on win32, whose bare script path is already short', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const windowsPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' + expect(getManagedEventHook('PostCompact', windowsPath)).toEqual( + getManagedLifecycleHook(windowsPath, CLAUDE_HOOK_SETTINGS) + ) + }) + + // Why every client platform: a remote host is POSIX even when Orca runs on Windows. + it.each(['darwin', 'linux', 'win32'] as const)( + 'uses the short POSIX form only for remote PostCompact from %s', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + expect(getRemoteManagedEventCommand('PostCompact', SCRIPT_PATH)).toBe( + wrapRuntimeHomePosixHookCommand('claude-hook') + ) + expect(getRemoteManagedEventCommand('Stop', SCRIPT_PATH)).toBe( + getRemoteManagedCommand(SCRIPT_PATH) + ) + } + ) +}) + +describe.skipIf(process.platform === 'win32')('PostCompact command execution', () => { + let home: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-post-compact-')) + }) + + afterEach(() => rmSync(home, { recursive: true, force: true })) + + function run(command: string, env: NodeJS.ProcessEnv) { + return runProcess({ + program: '/bin/sh', + args: ['-c', command], + env: { PATH: '/usr/bin:/bin', ...env }, + input: 'x'.repeat(1_000_000), + timeoutMs: 5_000 + }) + } + + it('runs the HOME script with the agent pid captured', async () => { + const dir = join(home, '.orca', 'agent-hooks') + mkdirSync(dir, { recursive: true }) + const script = join(dir, 'claude-hook.sh') + writeFileSync(script, '#!/bin/sh\ncat >/dev/null\nprintf "%s" "$ORCA_HOOK_AGENT_PID"\nexit 7\n') + chmodSync(script, 0o755) + + const result = await run(getManagedEventHook('PostCompact', SCRIPT_PATH).command, { + HOME: home + }) + + expect(result.code, result.stderr).toBe(7) + expect(result.stdout).toBe(String(process.pid)) + }) + + it.each([ + ['the script is missing', (dir: string) => ({ HOME: dir })], + ['HOME is empty', () => ({ HOME: '' })], + ['HOME is unset', () => ({})] + ])('drains stdin silently when %s', async (_label, env) => { + const result = await run(getManagedEventHook('PostCompact', SCRIPT_PATH).command, env(home)) + + expect(result.code).toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toBe('') + }) + + it.each([{}, { HOME: '' }])('skips script lookup without a runtime HOME: %j', async (env) => { + const lookupProbe = [ + 'probe_test() {', + ' case "$1" in -f|-r|-x) printf "unexpected script lookup: %s\\n" "$2" >&2; return 1 ;;', + ' *) command [ "$@" ;; esac', + '}', + 'alias [=probe_test', + getManagedEventHook('PostCompact', SCRIPT_PATH).command + ].join('\n') + + const result = await run(lookupProbe, env) + + expect(result.code).toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toBe('') + }) +}) + +describe.skipIf(process.platform === 'win32')('ClaudeHookService PostCompact install', () => { + it('writes the short form, reports installed, and replaces an old full-wrapper entry', () => { + const home = mkdtempSync(join(tmpdir(), 'orca-post-compact-install-')) + vi.stubEnv('HOME', home) + try { + const settingsPath = join(home, '.claude', 'settings.json') + mkdirSync(join(home, '.claude'), { recursive: true }) + const fullWrapper = getManagedLifecycleHook(SCRIPT_PATH).command + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + PostCompact: [{ hooks: [{ type: 'command', command: fullWrapper }] }] + } + }) + ) + + const status = new ClaudeHookService().install(CURRENT_CLAUDE) + + expect(status.state).toBe('installed') + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(written.hooks.PostCompact).toEqual([ + { hooks: [getManagedEventHook('PostCompact', SCRIPT_PATH)] } + ]) + expect(written.hooks.Stop[0].hooks[0].command).toBe(fullWrapper) + } finally { + vi.unstubAllEnvs() + rmSync(home, { recursive: true, force: true }) + } + }) +}) + +describe('ClaudeHookService remote PostCompact install', () => { + it('writes the short form for PostCompact and the full wrapper elsewhere', async () => { + const { sftp, fs } = createAgentHookMemorySftp() + await new ClaudeHookService().installRemote(sftp, '/home/dev', CURRENT_CLAUDE) + + const written = JSON.parse(fs.files.get('/home/dev/.claude/settings.json') ?? '{}') + const remoteScript = '/home/dev/.orca/agent-hooks/claude-hook.sh' + expect(written.hooks.PostCompact[0].hooks[0].command).toBe( + getRemoteManagedEventCommand('PostCompact', remoteScript) + ) + expect(written.hooks.Stop[0].hooks[0].command).toBe(getRemoteManagedCommand(remoteScript)) + }) +}) From 0081ea09412c1d05a03f1fcd933abb657816de6d Mon Sep 17 00:00:00 2001 From: "Benjamin Joseph B." Date: Sun, 11 Oct 2026 06:30:27 +0200 Subject: [PATCH 093/110] fix(clipboard): paste a macOS image copied beside its file URL (#27346) * fix(clipboard): paste a macOS image copied beside its file URL When a screenshot tool saves a file and also copies the image, the pasteboard holds public.png and public.file-url, and Chromium reports only text/uri-list from availableFormats(). readClipboardImageSource() then saw no image and Cmd+V dropped the paste, although readImage() returns it. On macOS, also check public.png and public.tiff with clipboard.has() before giving up. Fixes #27345 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HzCzr11hHmaEhiUoGJCrDy * test(clipboard): cover macOS file URL image paste safety --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Neil --- .../window/clipboard-image-source.test.ts | 42 ++++- src/main/window/clipboard-image-source.ts | 7 + .../clipboard-macos-image-paste.test.ts | 169 ++++++++++++++++++ 3 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 src/main/window/clipboard-macos-image-paste.test.ts diff --git a/src/main/window/clipboard-image-source.test.ts b/src/main/window/clipboard-image-source.test.ts index cebd03f19590..4cf003a8048c 100644 --- a/src/main/window/clipboard-image-source.test.ts +++ b/src/main/window/clipboard-image-source.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { readClipboardImageSource } from './clipboard-image-source' -function clipboardReader(filePath = '', itemCount = 1, formats: string[] = ['FileNameW']) { +function clipboardReader( + filePath = '', + itemCount = 1, + formats: string[] = ['FileNameW'], + pasteboardTypes: string[] = [] +) { const shellItems = Buffer.alloc(4 + 4 * (itemCount + 1)) shellItems.writeUInt32LE(itemCount) const buffers: Record = { @@ -10,7 +15,8 @@ function clipboardReader(filePath = '', itemCount = 1, formats: string[] = ['Fil } return { availableFormats: vi.fn(() => formats), - readBuffer: vi.fn((format: string) => buffers[format] ?? Buffer.alloc(0)) + readBuffer: vi.fn((format: string) => buffers[format] ?? Buffer.alloc(0)), + has: vi.fn((format: string) => pasteboardTypes.includes(format)) } } @@ -23,6 +29,7 @@ describe('readClipboardImageSource', () => { kind: 'native', windowsFileFormats: null }) + expect(clipboard.has).not.toHaveBeenCalled() } ) @@ -69,4 +76,35 @@ describe('readClipboardImageSource', () => { expect(source?.kind).toBe('native') expect(source?.windowsFileFormats?.fileNameW).toEqual(clipboard.readBuffer('FileNameW')) }) + + it.each([['public.png'], ['public.tiff']])( + 'recognizes a macOS image copied beside its file URL (%s)', + (pasteboardType) => { + const clipboard = clipboardReader( + '', + 1, + ['text/uri-list'], + [pasteboardType, 'public.file-url'] + ) + expect(readClipboardImageSource(clipboard, 'darwin')).toEqual({ + kind: 'native', + windowsFileFormats: null + }) + expect(clipboard.readBuffer).not.toHaveBeenCalled() + } + ) + + it('ignores a macOS file URL with no image data', () => { + const clipboard = clipboardReader('', 1, ['text/uri-list'], ['public.file-url']) + expect(readClipboardImageSource(clipboard, 'darwin')).toBeNull() + }) + + it.each(['linux', 'win32'] as const)( + 'does not probe macOS pasteboard types on %s', + (platform) => { + const clipboard = clipboardReader('', 1, ['text/uri-list'], ['public.png']) + expect(readClipboardImageSource(clipboard, platform)).toBeNull() + expect(clipboard.has).not.toHaveBeenCalled() + } + ) }) diff --git a/src/main/window/clipboard-image-source.ts b/src/main/window/clipboard-image-source.ts index f5dc72a02477..6bcb4e168eeb 100644 --- a/src/main/window/clipboard-image-source.ts +++ b/src/main/window/clipboard-image-source.ts @@ -7,8 +7,12 @@ import { type ClipboardImageReader = { availableFormats: () => string[] readBuffer: (format: string) => Buffer + has: (format: string) => boolean } +// macOS image data beside a file URL can be omitted from availableFormats(). +const MAC_PASTEBOARD_IMAGE_TYPES = ['public.png', 'public.tiff'] as const + type ClipboardImageSource = | { kind: 'native'; windowsFileFormats: WindowsClipboardFileFormats | null } | { kind: 'windows-file'; windowsFileFormats: WindowsClipboardFileFormats } @@ -29,5 +33,8 @@ export function readClipboardImageSource( if (clipboardFormatsIncludeImage(clipboard.availableFormats())) { return { kind: 'native', windowsFileFormats } } + if (platform === 'darwin' && MAC_PASTEBOARD_IMAGE_TYPES.some((type) => clipboard.has(type))) { + return { kind: 'native', windowsFileFormats: null } + } return windowsFileFormats ? { kind: 'windows-file', windowsFileFormats } : null } diff --git a/src/main/window/clipboard-macos-image-paste.test.ts b/src/main/window/clipboard-macos-image-paste.test.ts new file mode 100644 index 000000000000..87ed0793b617 --- /dev/null +++ b/src/main/window/clipboard-macos-image-paste.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES +} from '../../shared/clipboard-image' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + has: vi.fn(), + readImage: vi.fn(), + readBuffer: vi.fn(), + toPNG: vi.fn(), + writeFile: vi.fn(), + writeFileBase64: vi.fn(), + requireSshFilesystemProvider: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp' }, + clipboard: { + availableFormats: () => ['text/uri-list'], + has: mocks.has, + readImage: mocks.readImage, + readBuffer: mocks.readBuffer + }, + ipcMain: { + removeHandler: (channel: string) => mocks.handlers.delete(channel), + handle: (channel: string, handler: (...args: unknown[]) => unknown) => + mocks.handlers.set(channel, handler) + }, + nativeImage: { createFromBuffer: vi.fn() } +})) +vi.mock('node:fs/promises', async (importOriginal) => { + const fs = await importOriginal() + return { ...fs, default: { ...fs, writeFile: mocks.writeFile } } +}) +vi.mock('../persistence', () => ({ Store: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: mocks.requireSshFilesystemProvider +})) +vi.mock('./clipboard-remote-file-copy', () => ({ + cleanupExpiredRemoteClipboardFiles: vi.fn(), + scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), + writeRemoteFileToClipboard: vi.fn() +})) +vi.mock('./native-chat-paste-files', () => ({ + restoreNativeChatPastes: vi.fn(), + sweepExpiredNativeChatPastes: vi.fn(), + nativeChatPasteFolder: () => '/tmp/native-chat-pastes' +})) +vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) + +import { Store } from '../persistence' +import { + registerClipboardHandlers, + setTrustedClipboardRendererWebContentsId +} from './clipboard-ipc-handlers' + +const event = { + sender: { + id: 17, + getType: () => 'window', + getURL: () => 'file:///orca/index.html', + isDestroyed: () => false + } +} +const PNG = Buffer.from([1, 2, 3]) + +function invoke(channel: string, args?: unknown): unknown { + const handler = mocks.handlers.get(channel) + if (!handler) { + throw new Error(`Missing clipboard handler: ${channel}`) + } + return handler(event, args) +} + +function setImage(size = { width: 32, height: 24 }, empty = false): void { + mocks.readImage.mockReturnValue({ isEmpty: () => empty, getSize: () => size, toPNG: mocks.toPNG }) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.handlers.clear() + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + installFakeAppEnvironment({ getPath: () => '/tmp' }) + mocks.has.mockImplementation((type: string) => type === 'public.png') + mocks.toPNG.mockReturnValue(PNG) + mocks.writeFile.mockResolvedValue(undefined) + mocks.writeFileBase64.mockResolvedValue(undefined) + mocks.requireSshFilesystemProvider.mockReturnValue({ + getTempDir: async () => '/remote/tmp', + writeFileBase64: mocks.writeFileBase64 + }) + setImage() + setTrustedClipboardRendererWebContentsId(17) + registerClipboardHandlers(new Store()) +}) + +afterEach(() => { + vi.restoreAllMocks() + setTrustedClipboardRendererWebContentsId(null) +}) + +describe('macOS clipboard image data beside a file URL', () => { + it.each(['public.png', 'public.tiff'])( + 'saves native %s image data as a local PNG', + async (type) => { + mocks.has.mockImplementation((format: string) => format === type) + expect(invoke('clipboard:hasImage')).toBe(true) + expect(mocks.readImage).not.toHaveBeenCalled() + + const savedPath = await invoke('clipboard:saveImageAsTempFile') + expect(savedPath).toMatch(/orca-paste-.+\.png$/) + expect(mocks.writeFile).toHaveBeenCalledWith(savedPath, PNG) + expect(mocks.readBuffer).not.toHaveBeenCalled() + } + ) + + it('does not decode or save a copied file without native image data', async () => { + mocks.has.mockReturnValue(false) + expect(invoke('clipboard:hasImage')).toBe(false) + await expect(invoke('clipboard:saveImageAsTempFile')).resolves.toBeNull() + expect(mocks.readImage).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) + + it('does not save malformed native image data or read its accompanying file', async () => { + setImage({ width: 0, height: 0 }, true) + await expect(invoke('clipboard:saveImageAsTempFile')).resolves.toBeNull() + expect(mocks.toPNG).not.toHaveBeenCalled() + expect(mocks.readBuffer).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) + + it('checks the renderer before probing native image types', () => { + setTrustedClipboardRendererWebContentsId(42) + expect(() => invoke('clipboard:hasImage')).toThrow('Unauthorized clipboard IPC sender') + expect(mocks.has).not.toHaveBeenCalled() + }) + + it('rejects oversized dimensions before encoding or writing a PNG', async () => { + setImage({ width: CLIPBOARD_IMAGE_MAX_PIXELS + 1, height: 1 }) + await expect(invoke('clipboard:saveImageAsTempFile')).rejects.toThrow( + 'Clipboard image is too large' + ) + expect(mocks.toPNG).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) + + it('rejects oversized PNG bytes before looking up an SSH target', async () => { + mocks.toPNG.mockReturnValue(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) + await expect( + invoke('clipboard:saveImageAsTempFile', { connectionId: 'ssh-1' }) + ).rejects.toThrow('Clipboard image is too large') + expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() + expect(mocks.writeFileBase64).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) + + it('saves native image data on the SSH host instead of the client', async () => { + const savedPath = await invoke('clipboard:saveImageAsTempFile', { connectionId: 'ssh-1' }) + expect(savedPath).toMatch(/^\/remote\/tmp\/orca-paste-.+\.png$/) + expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') + expect(mocks.writeFileBase64).toHaveBeenCalledWith(savedPath, PNG.toString('base64')) + expect(mocks.writeFile).not.toHaveBeenCalled() + }) +}) From bcc50483bb6cc712e8bf7bc8aea7232887f34c81 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 21:39:23 -0700 Subject: [PATCH 094/110] fix(vm): a recipe VM's SSH root runs on a managed Orca server (#27366) * fix(vm): a recipe VM's SSH root runs on a managed Orca server A recipe VM's hidden SSH target was counted as owned by another runtime, so every connect refused to deploy a managed server and fell back to the relay. Its runtime owner only records when the VM is created and destroyed, so it no longer blocks the deploy. The VM's workspace then lives on that server: the server adopts the provisioned root as its main worktree, and removing the project destroys the VM and forgets its server. * fix(vm): release the server of a relay-era VM that converted on connect * fix(vm): resume reconnects an SSH VM's server; removal stays on its own VM * fix(vm): removal and the remove dialog find a VM's server before the catalog loads * fix(vm): a catalog refresh failure never blocks a VM create --------- Co-authored-by: m4air --- ...ral-vm-runtime-environment-release.test.ts | 65 +++++++ ...phemeral-vm-runtime-environment-release.ts | 26 +++ src/main/ephemeral-vm-runtime-ssh.test.ts | 72 ++++++++ src/main/ephemeral-vm-runtime-ssh.ts | 12 +- src/main/ipc/ephemeral-vm-managed-ssh.test.ts | 127 +++++++++++++ src/main/ipc/ephemeral-vm-runtime-handlers.ts | 18 +- src/main/ipc/ephemeral-vm.ts | 6 +- .../ipc/ssh-host-server-on-connect-wiring.ts | 9 +- src/main/provisioned-root-checkout.ts | 173 ++++++++++++++++++ src/main/provisioned-root-ssh-adoption.ts | 137 +++----------- ...-runtime-create-managed-remote-worktree.ts | 27 +++ .../worktree-provisioned-root-method.ts | 21 +++ src/main/runtime/rpc/methods/worktree.ts | 2 + .../runtime-provisioned-root-adoption.test.ts | 94 ++++++++++ .../runtime-provisioned-root-adoption.ts | 77 ++++++++ src/main/ssh/ssh-target-orcad-claims.test.ts | 12 ++ src/main/ssh/ssh-target-orcad-claims.ts | 10 +- src/preload/api/ephemeral-vm-api.ts | 2 + .../sidebar/RemoveFolderDialog.test.tsx | 33 +++- .../components/sidebar/RemoveFolderDialog.tsx | 39 ++-- .../lib/ephemeral-vm-workspace-target.test.ts | 65 +++++++ .../src/lib/ephemeral-vm-workspace-target.ts | 53 ++++-- .../src/lib/ephemeral-vm-worktree-creation.ts | 8 + .../src/lib/recipe-vm-managed-host.test.ts | 23 +++ .../src/lib/recipe-vm-managed-host.ts | 15 ++ .../store/repos/recipe-vm-project-removal.ts | 66 +++++++ src/renderer/src/store/repos/repo-removal.ts | 40 ++-- ...pos-ephemeral-vm-cleanup-retention.test.ts | 147 +++++++++++++++ .../slices/repos-runtime-routing-fixture.ts | 7 +- .../worktrees/create/create-worktree.ts | 29 ++- .../runtime-provisioned-root-adoption.test.ts | 49 +++++ .../runtime-provisioned-root-adoption.ts | 36 ++++ .../create/worktree-create-payload.ts | 1 + src/shared/managed-orcad-ssh-owner.test.ts | 9 +- src/shared/managed-orcad-ssh-owner.ts | 6 + src/shared/protocol-version.ts | 2 + .../rpc-params-catalog.generated.ts | 2 + .../worktree-provisioned-root-params.ts | 37 ++++ .../e2e/ssh-orcad-vm-provisioned-root.spec.ts | 36 ++-- 39 files changed, 1385 insertions(+), 208 deletions(-) create mode 100644 src/main/ephemeral-vm-runtime-environment-release.test.ts create mode 100644 src/main/ephemeral-vm-runtime-environment-release.ts create mode 100644 src/main/ephemeral-vm-runtime-ssh.test.ts create mode 100644 src/main/ipc/ephemeral-vm-managed-ssh.test.ts create mode 100644 src/main/provisioned-root-checkout.ts create mode 100644 src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts create mode 100644 src/main/runtime/runtime-provisioned-root-adoption.test.ts create mode 100644 src/main/runtime/runtime-provisioned-root-adoption.ts create mode 100644 src/renderer/src/lib/recipe-vm-managed-host.test.ts create mode 100644 src/renderer/src/lib/recipe-vm-managed-host.ts create mode 100644 src/renderer/src/store/repos/recipe-vm-project-removal.ts create mode 100644 src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.ts create mode 100644 src/shared/rpc-contract/worktree-provisioned-root-params.ts diff --git a/src/main/ephemeral-vm-runtime-environment-release.test.ts b/src/main/ephemeral-vm-runtime-environment-release.test.ts new file mode 100644 index 000000000000..e563cfc9b1a0 --- /dev/null +++ b/src/main/ephemeral-vm-runtime-environment-release.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + remove: vi.fn(), + removeManaged: vi.fn(), + closeTunnel: vi.fn(async () => {}) +})) + +vi.mock('../shared/runtime-environment-store', () => ({ + listEnvironments: mocks.list, + removeEnvironment: mocks.remove +})) +vi.mock('../shared/runtime-environment-managed-orcad-store', () => ({ + removeManagedOrcadEnvironment: mocks.removeManaged +})) +vi.mock('./ssh/orcad-managed-tunnel', () => ({ closeOrcadManagedTunnel: mocks.closeTunnel })) + +import { releaseEphemeralVmRuntimeEnvironments } from './ephemeral-vm-runtime-environment-release' + +describe('releaseEphemeralVmRuntimeEnvironments', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("closes a destroyed VM's managed server tunnel and forgets the server", async () => { + mocks.list.mockReturnValue([ + { id: 'env-vm', orcadDeployment: { sshTargetId: 'runtime-ssh-vm' } }, + { id: 'env-other', orcadDeployment: { sshTargetId: 'ssh-builder' } } + ]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { + runtimeEnvironmentId: 'env-vm', + sshTargetId: 'runtime-ssh-vm' + }) + expect(mocks.closeTunnel).toHaveBeenCalledTimes(1) + expect(mocks.closeTunnel).toHaveBeenCalledWith('env-vm') + expect(mocks.removeManaged).toHaveBeenCalledTimes(1) + expect(mocks.removeManaged).toHaveBeenCalledWith('/user-data', 'env-vm') + expect(mocks.remove).not.toHaveBeenCalled() + }) + + it('finds the server of a relay-era VM that converted without recording it', async () => { + mocks.list.mockReturnValue([ + { id: 'env-converted', orcadDeployment: { sshTargetId: 'runtime-ssh-vm' } } + ]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { sshTargetId: 'runtime-ssh-vm' }) + expect(mocks.removeManaged).toHaveBeenCalledWith('/user-data', 'env-converted') + }) + + it('removes a paired recipe server the ordinary way', async () => { + mocks.list.mockReturnValue([{ id: 'env-paired' }]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { + runtimeEnvironmentId: 'env-paired' + }) + expect(mocks.remove).toHaveBeenCalledWith('/user-data', 'env-paired') + expect(mocks.closeTunnel).not.toHaveBeenCalled() + }) + + it('ignores a server that is already gone', async () => { + mocks.list.mockReturnValue([]) + await releaseEphemeralVmRuntimeEnvironments('/user-data', { runtimeEnvironmentId: 'env-gone' }) + expect(mocks.remove).not.toHaveBeenCalled() + expect(mocks.removeManaged).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ephemeral-vm-runtime-environment-release.ts b/src/main/ephemeral-vm-runtime-environment-release.ts new file mode 100644 index 000000000000..affe5437fbaa --- /dev/null +++ b/src/main/ephemeral-vm-runtime-environment-release.ts @@ -0,0 +1,26 @@ +import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' +import { removeManagedOrcadEnvironment } from '../shared/runtime-environment-managed-orcad-store' +import { listEnvironments, removeEnvironment } from '../shared/runtime-environment-store' + +/** Forgets the servers a destroyed VM ran; a managed one's tunnel would otherwise keep redialing. */ +export async function releaseEphemeralVmRuntimeEnvironments( + userDataPath: string, + runtime: Pick +): Promise { + // Why the target too: a relay-era VM converted on connect never recorded its server's id. + const environments = listEnvironments(userDataPath).filter( + (entry) => + entry.id === runtime.runtimeEnvironmentId || + (runtime.sshTargetId !== undefined && + entry.orcadDeployment?.sshTargetId === runtime.sshTargetId) + ) + for (const environment of environments) { + if (!environment.orcadDeployment) { + removeEnvironment(userDataPath, environment.id) + continue + } + const { closeOrcadManagedTunnel } = await import('./ssh/orcad-managed-tunnel') + await closeOrcadManagedTunnel(environment.id).catch(() => undefined) + removeManagedOrcadEnvironment(userDataPath, environment.id) + } +} diff --git a/src/main/ephemeral-vm-runtime-ssh.test.ts b/src/main/ephemeral-vm-runtime-ssh.test.ts new file mode 100644 index 000000000000..236a4ba37965 --- /dev/null +++ b/src/main/ephemeral-vm-runtime-ssh.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + connect: vi.fn(), + remove: vi.fn(async () => {}), + gitProvider: vi.fn(), + fsProvider: vi.fn() +})) + +vi.mock('./ipc/ssh', () => ({ + connectRegisteredSshTarget: mocks.connect, + getSshConnectionStore: () => ({ + upsertRuntimeOwnedTarget: (runtimeId: string) => ({ + id: `runtime-ssh-${runtimeId}`, + label: 'VM', + host: 'vm', + port: 22, + username: 'root' + }) + }) +})) +vi.mock('./ipc/ssh-session-teardown', () => ({ + disconnectRegisteredSshTarget: vi.fn(), + removeRegisteredSshTarget: mocks.remove +})) +vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: mocks.gitProvider })) +vi.mock('./providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: mocks.fsProvider +})) + +import { connectRuntimeOwnedSshTarget } from './ephemeral-vm-runtime-ssh' + +const connection = { + type: 'ssh' as const, + projectRoot: '/workspace/repo', + target: { label: 'VM', host: 'vm', port: 22, username: 'root' } +} + +describe('connectRuntimeOwnedSshTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns the managed server a recipe VM's host runs, without waiting on relay providers", async () => { + mocks.connect.mockResolvedValue({ + targetId: 'runtime-ssh-vm-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + managedServer: { kind: 'managed', environmentId: 'env-vm' } + }) + await expect( + connectRuntimeOwnedSshTarget({ runtimeId: 'vm-1', connection }) + ).resolves.toMatchObject({ targetId: 'runtime-ssh-vm-1', environmentId: 'env-vm' }) + expect(mocks.gitProvider).not.toHaveBeenCalled() + expect(mocks.remove).not.toHaveBeenCalled() + }) + + it('waits for the direct providers when the host has no managed server', async () => { + mocks.connect.mockResolvedValue({ + targetId: 'runtime-ssh-vm-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mocks.gitProvider.mockReturnValue({}) + mocks.fsProvider.mockReturnValue({}) + const result = await connectRuntimeOwnedSshTarget({ runtimeId: 'vm-1', connection }) + expect(result.environmentId).toBeUndefined() + expect(mocks.gitProvider).toHaveBeenCalledWith('runtime-ssh-vm-1') + }) +}) diff --git a/src/main/ephemeral-vm-runtime-ssh.ts b/src/main/ephemeral-vm-runtime-ssh.ts index 770a43cbf8f3..a0868544b481 100644 --- a/src/main/ephemeral-vm-runtime-ssh.ts +++ b/src/main/ephemeral-vm-runtime-ssh.ts @@ -14,6 +14,8 @@ const SSH_PROVIDER_READY_INTERVAL_MS = 100 export type RuntimeOwnedSshConnectionResult = { targetId: string target: SshTarget + /** The managed Orca server that serves this host; absent when it is reached directly. */ + environmentId?: string } export async function connectRuntimeOwnedSshTarget(args: { @@ -26,19 +28,25 @@ export async function connectRuntimeOwnedSshTarget(args: { throw new Error('SSH handlers are not registered.') } const target = store.upsertRuntimeOwnedTarget(args.runtimeId, args.connection.target) + let environmentId: string | undefined try { const state = await connectRegisteredSshTarget(target.id) if (state.status !== 'connected') { throw new Error(state.error || `SSH target did not connect: ${state.status}`) } - await waitForRuntimeSshProviders(target.id, args.signal) + if (state.managedServer?.kind === 'managed') { + // Its server answers for the host; no direct providers are registered for it. + environmentId = state.managedServer.environmentId + } else { + await waitForRuntimeSshProviders(target.id, args.signal) + } } catch (error) { // The target is persisted at upsert, so a failed connect/provider-wait would // orphan it; remove it (idempotent) before rethrowing so cleanup is complete. await removeRuntimeOwnedSshTarget(target.id).catch(() => undefined) throw error } - return { targetId: target.id, target } + return { targetId: target.id, target, ...(environmentId ? { environmentId } : {}) } } export async function disconnectRuntimeOwnedSshTarget(targetId: string | undefined): Promise { diff --git a/src/main/ipc/ephemeral-vm-managed-ssh.test.ts b/src/main/ipc/ephemeral-vm-managed-ssh.test.ts new file mode 100644 index 000000000000..8166861ab163 --- /dev/null +++ b/src/main/ipc/ephemeral-vm-managed-ssh.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +type Handler = (event: unknown, args: unknown) => unknown +const { handlers, connect, getPath } = vi.hoisted(() => ({ + handlers: new Map(), + connect: vi.fn(), + getPath: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath }, + ipcMain: { + handle: (channel: string, handler: Handler) => handlers.set(channel, handler), + removeHandler: vi.fn() + } +})) +vi.mock('../ephemeral-vm-runtime-ssh', () => ({ + connectRuntimeOwnedSshTarget: connect, + disconnectRuntimeOwnedSshTarget: vi.fn(async () => {}), + removeRuntimeOwnedSshTarget: vi.fn(async () => {}) +})) +vi.mock('./runtime-environments', () => ({ invalidateRuntimeEnvironmentTransport: vi.fn() })) + +import type { Store } from '../persistence' +import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' +import { registerEphemeralVmHandlers } from './ephemeral-vm' + +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const sshResult = { + schemaVersion: 1, + connection: { + type: 'ssh', + projectRoot: '/workspace/repo', + target: { label: 'Sandbox', host: 'sandbox.example.com', port: 22, username: 'root' } + } +} + +function setupManagedSshRecipe(): { userDataPath: string } { + const userDataPath = tempDir('orca-vm-managed-user-data-') + const repoPath = tempDir('orca-vm-managed-repo-') + getPath.mockReturnValue(userDataPath) + const script = (name: string, body: string): string => { + const scriptPath = join(repoPath, name) + writeFileSync(scriptPath, body) + return JSON.stringify(`"${process.execPath}" "${scriptPath}"`) + } + const printResult = `console.log(${JSON.stringify(JSON.stringify(sshResult))})` + writeFileSync( + join(repoPath, 'orca.yaml'), + [ + 'environmentRecipes:', + ' - id: cloud-sandbox', + ' name: Cloud Sandbox', + ` create: ${script('start.js', printResult)}`, + ` suspend: ${script('suspend.js', '')}`, + ` resume: ${script('resume.js', printResult)}`, + ' destroy: none' + ].join('\n') + ) + connect.mockReset() + connect.mockImplementation(async ({ runtimeId }: { runtimeId: string }) => ({ + targetId: `runtime-ssh-${runtimeId}`, + target: { id: `runtime-ssh-${runtimeId}` }, + environmentId: 'env-managed-vm' + })) + const repo = { id: 'repo-1', path: repoPath, displayName: 'Repo', badgeColor: '#000', addedAt: 0 } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only getRepo, getRepos and getSettings. + const store = { + getRepo: (id: string) => (id === repo.id ? repo : null), + getRepos: () => [repo], + getSettings: () => ({}) + } as unknown as Store + registerEphemeralVmHandlers(store) + return { userDataPath } +} + +async function provision(): Promise { + return handlers.get('ephemeralVm:provision')?.(null, { + repoId: 'repo-1', + recipeId: 'cloud-sandbox' + }) +} + +it("records the managed server that serves a recipe VM's SSH host", async () => { + setupManagedSshRecipe() + expect(await provision()).toMatchObject({ + ok: true, + connectionType: 'ssh', + environmentId: 'env-managed-vm', + runtime: { connectionMode: 'ssh', runtimeEnvironmentId: 'env-managed-vm' } + }) +}) + +it("reconnects a resumed VM's SSH host instead of asking for a pairing code", async () => { + const { userDataPath } = setupManagedSshRecipe() + const provisioned = await provision() + const [runtime] = listEphemeralVmRuntimes(userDataPath) + expect(provisioned).toMatchObject({ ok: true, runtime: { id: runtime.id } }) + const runtimeId = runtime.id + await handlers.get('ephemeralVm:attachWorkspace')?.(null, { runtimeId, workspaceId: 'ws-1' }) + await handlers.get('ephemeralVm:suspendWorkspace')?.(null, { workspaceId: 'ws-1' }) + connect.mockClear() + + const resumed = await handlers.get('ephemeralVm:resumeWorkspace')?.(null, { workspaceId: 'ws-1' }) + + expect(connect).toHaveBeenCalledTimes(1) + expect(resumed).toMatchObject({ + status: 'running', + connectionMode: 'ssh', + runtimeEnvironmentId: 'env-managed-vm' + }) +}) diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index 261c8050df87..95ab7bf6e64b 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -9,10 +9,8 @@ import { getEphemeralVmRecipeResultConnection, getEphemeralVmRecipeResultPairingCode } from '../../shared/ephemeral-vm-recipes' -import { - removeEnvironment, - updateEnvironmentFromPairingCode -} from '../../shared/runtime-environment-store' +import { updateEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { releaseEphemeralVmRuntimeEnvironments } from '../ephemeral-vm-runtime-environment-release' import { cleanupEphemeralVmRuntime, resumeEphemeralVmRuntime, @@ -105,9 +103,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { runtimeId: runtime.id }) } - if (result.ok && runtime.runtimeEnvironmentId) { + if (result.ok) { try { - removeEnvironment(userDataPath, runtime.runtimeEnvironmentId) + await releaseEphemeralVmRuntimeEnvironments(userDataPath, runtime) } catch { // Cleanup of provider resources matters more than hiding a stale local // environment row; users can still remove that manually. @@ -215,7 +213,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { if (!result.ok) { throw new Error(result.error) } - if (!result.skipped && runtime.runtimeEnvironmentId) { + const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult) + // Why: an SSH VM's server is reached through its target, so it has no pairing to refresh. + if (!result.skipped && connection.type !== 'ssh' && runtime.runtimeEnvironmentId) { const pairingCode = getEphemeralVmRecipeResultPairingCode(result.runtime.recipeResult) if (!pairingCode) { throw new Error('Resume result did not include an Orca Server pairing code.') @@ -225,7 +225,6 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { }) invalidateRuntimeEnvironmentTransport(runtime.runtimeEnvironmentId) } - const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult) if (!result.skipped && connection.type === 'ssh') { try { const ssh = await connectRuntimeOwnedSshTarget({ @@ -234,7 +233,8 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { }) return updateEphemeralVmRuntimeStatus(userDataPath, result.runtime.id, { connectionMode: 'ssh', - sshTargetId: ssh.targetId + sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { runtimeEnvironmentId: ssh.environmentId } : {}) }) } catch (error) { updateEphemeralVmRuntimeStatus(userDataPath, result.runtime.id, { diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index bb7ba5c8d66d..5e8ec75b1966 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -53,6 +53,8 @@ export type EphemeralVmProvisionIpcResult = connectionType: 'ssh' runtime: EphemeralVmRuntimeRecord sshTargetId: string + /** The managed Orca server the host runs; its workspace lives there. */ + environmentId?: string expectedRefHead?: string stderr: string warnings: EphemeralVmRecipeResultWarning[] @@ -213,7 +215,8 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin app.getPath('userData'), result.runtime.id, { - sshTargetId: ssh.targetId + sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { runtimeEnvironmentId: ssh.environmentId } : {}) } ) return { @@ -221,6 +224,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin connectionType: 'ssh', runtime, sshTargetId: ssh.targetId, + ...(ssh.environmentId ? { environmentId: ssh.environmentId } : {}), ...(expectedRefHead ? { expectedRefHead } : {}), stderr: redactEphemeralVmRecipeDiagnosticText(result.start.stderr), warnings: getEphemeralVmRecipeResultWarnings(result.start.result) diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.ts index b13a9bc256df..83fd1b2b84b5 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.ts @@ -1,6 +1,9 @@ /** The live collaborators behind each connect's server decision. */ import { getAppEnvironment } from '../../shared/app-environment' -import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { + getManagedOrcadFenceEnvironmentId, + managedOrcadServerName +} from '../../shared/managed-orcad-ssh-owner' import { listEnvironments } from '../../shared/runtime-environment-store' import { findOrcadMigrationSourceCutoverForTarget } from '../ssh/orcad-migration-cutover-journal' import { orcadMigrationRelayPtyLister } from '../ssh/orcad-migration-relay-pty-lister' @@ -89,13 +92,13 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne }), deploy: (target) => createManagedOrcadEnvironment(userDataPath, { - name: target.orcadProvisioning?.name ?? target.label, + name: target.orcadProvisioning?.name ?? managedOrcadServerName(target), sshTargetId: target.id }), convert: (target, hostProof) => convertSshTargetToManagedOrcad(userDataPath, { sshTargetId: target.id, - name: target.label, + name: managedOrcadServerName(target), listRelayPtyIds: orcadMigrationRelayPtyLister(target.id), // The connect's own census already answered; a conversion before any session reuses it. censusHost: (host) => diff --git a/src/main/provisioned-root-checkout.ts b/src/main/provisioned-root-checkout.ts new file mode 100644 index 000000000000..07b7a56e21e4 --- /dev/null +++ b/src/main/provisioned-root-checkout.ts @@ -0,0 +1,173 @@ +/** The host-independent half of adopting a recipe's provisioned root: checkout proof and metadata. */ +import { randomUUID } from 'node:crypto' +import type { Store } from './persistence' +import type { Repo } from '../shared/repo-types' +import type { CreateWorktreeArgs } from '../shared/worktree/create-types' +import type { ExecutionHostId } from '../shared/execution-host' +import type { WorktreeMeta } from '../shared/worktree/meta-types' +import type { AutomationWorkspaceProvenance, GitWorktreeInfo } from '../shared/worktree/types' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup' +import { isTuiAgent } from '../shared/tui-agent-config' +import { + getWorktreeCreationLayout, + resolveWorktreeCreateDisplayNameMeta, + resolveWorktreeCreateDisplayNameRequest +} from './ipc/worktree-logic' + +export type ProvisionedRootRequest = Pick< + CreateWorktreeArgs, + | 'name' + | 'nameWasGenerated' + | 'displayName' + | 'displayNameKind' + | 'baseBranch' + | 'compareBaseRef' + | 'branchNameOverride' + | 'sparseCheckout' + | 'linkedIssue' + | 'linkedPR' + | 'linkedLinearIssue' + | 'linkedLinearIssueWorkspaceId' + | 'linkedLinearIssueOrganizationUrlKey' + | 'linkedGitLabIssue' + | 'linkedGitLabMR' + | 'linkedBitbucketPR' + | 'linkedAzureDevOpsPR' + | 'linkedGiteaPR' + | 'linkedWorkItem' + | 'linkedItems' + | 'linkedTaskSourceContext' + | 'pushTarget' + | 'workspaceStatus' + | 'manualOrder' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' +> & { + expectedRefHead?: string + automationProvenance?: AutomationWorkspaceProvenance +} + +export function provisionedRootPathsEqual(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + +/** The project root must be the repo's only, primary, non-sparse checkout on the requested branch and ref. */ +export function verifyProvisionedRootCheckout(args: { + worktrees: readonly GitWorktreeInfo[] + sparseCheckoutEnabled: boolean + projectRoot: string + request: Pick & { + expectedRefHead?: string + } +}): GitWorktreeInfo { + const { request } = args + const matches = args.worktrees.filter((worktree) => + provisionedRootPathsEqual(worktree.path, args.projectRoot) + ) + if (matches.length !== 1) { + throw new Error('The recipe projectRoot is not a unique Git checkout root.') + } + const gitWorktree = matches[0] + if (!gitWorktree.isMainWorktree) { + throw new Error('The recipe projectRoot must be the repository primary checkout.') + } + if (gitWorktree.isBare) { + throw new Error('Provisioned-root recipes cannot adopt a bare repository.') + } + if (gitWorktree.isSparse || args.sparseCheckoutEnabled) { + throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.') + } + const requestedBranch = request.branchNameOverride ?? request.name + if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) { + throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.") + } + if (request.baseBranch && !request.expectedRefHead) { + throw new Error('The requested provisioned-root ref identity is missing.') + } + if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) { + throw new Error("The recipe projectRoot was not created from Orca's requested ref.") + } + return gitWorktree +} + +export type ProvisionedRootMetaStore = { + getProjectHostSetups?: Store['getProjectHostSetups'] + getSettings(): Parameters[1] +} + +export function buildProvisionedRootMeta( + store: ProvisionedRootMetaStore, + repo: Repo, + args: ProvisionedRootRequest, + options: { + branchName: string + now: number + /** Omitted on the host that runs the checkout, whose own rows carry no host id. */ + hostId?: ExecutionHostId + source: NonNullable + } +): Partial { + const { branchName, now } = options + const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( + args.displayName, + args.displayNameKind, + args.name, + false, + args.nameWasGenerated === true + ) + const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( + displayNameRequest.value, + branchName, + displayNameRequest.kind, + { requestedName: args.name, sanitizedName: args.name } + ) + return { + instanceId: randomUUID(), + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), + ...(options.hostId ? { hostId: options.hostId } : {}), + ephemeralVmCheckoutMode: 'provisioned-root', + displayName: displayNameMeta.displayName ?? args.name, + ...displayNameMeta, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: options.source, + creatorProvenance: { kind: 'host' }, + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, store.getSettings()), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.compareBaseRef || args.baseBranch + ? { baseRef: args.compareBaseRef ?? args.baseBranch } + : {}), + ...(args.pushTarget ? { pushTarget: args.pushTarget } : {}), + ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), + ...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent) + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } + : {}), + ...(args.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), + ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), + ...(args.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } + : {}), + ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), + ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}) + } +} diff --git a/src/main/provisioned-root-ssh-adoption.ts b/src/main/provisioned-root-ssh-adoption.ts index 3f6565382d81..f90aef7df94a 100644 --- a/src/main/provisioned-root-ssh-adoption.ts +++ b/src/main/provisioned-root-ssh-adoption.ts @@ -1,34 +1,29 @@ -import { randomUUID } from 'node:crypto' import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import type { AdoptProvisionedRootArgs, CreateWorktreeResult } from '../shared/worktree/create-types' -import type { WorktreeMeta } from '../shared/worktree/meta-types' import type { AutomationWorkspaceProvenance } from '../shared/worktree/types' import { isRuntimeOwnedSshTargetId, toSshExecutionHostId } from '../shared/execution-host' -import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { getEphemeralVmRecipeResultCheckoutMode, getEphemeralVmRecipeResultProjectRoot } from '../shared/ephemeral-vm-recipes' import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store' import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' -import { getProjectHostSetupWorktreeMeta } from '../shared/project-host-setup-lookup' -import { isTuiAgent } from '../shared/tui-agent-config' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { getSshProviderAuthority, isCurrentSshProviderAuthority } from './ssh/ssh-provider-authority' import { attachEphemeralVmRuntimeToWorkspace } from './ephemeral-vm-runtime-attachment' +import { mergeWorktree } from './ipc/worktree-logic' import { - getWorktreeCreationLayout, - mergeWorktree, - resolveWorktreeCreateDisplayNameMeta, - resolveWorktreeCreateDisplayNameRequest -} from './ipc/worktree-logic' + buildProvisionedRootMeta, + provisionedRootPathsEqual, + verifyProvisionedRootCheckout +} from './provisioned-root-checkout' type AdoptionArgs = AdoptProvisionedRootArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -59,7 +54,10 @@ export async function adoptProvisionedRootSshCheckout(args: { connectionId ) const projectRoot = getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult) - if (!pathsEqual(request.expectedPath, projectRoot) || !pathsEqual(repo.path, projectRoot)) { + if ( + !provisionedRootPathsEqual(request.expectedPath, projectRoot) || + !provisionedRootPathsEqual(repo.path, projectRoot) + ) { throw new Error('The recipe projectRoot does not match the imported Git checkout root.') } @@ -81,30 +79,12 @@ export async function adoptProvisionedRootSshCheckout(args: { } requireOwnedProvisionedRootRuntime(args.userDataPath, request.runtimeId, connectionId) - const matches = worktrees.filter((worktree) => pathsEqual(worktree.path, projectRoot)) - if (matches.length !== 1) { - throw new Error('The recipe projectRoot is not a unique Git checkout root.') - } - const gitWorktree = matches[0] - if (!gitWorktree.isMainWorktree) { - throw new Error('The recipe projectRoot must be the repository primary checkout.') - } - if (gitWorktree.isBare) { - throw new Error('Provisioned-root recipes cannot adopt a bare repository.') - } - if (gitWorktree.isSparse || sparseCheckoutEnabled) { - throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.') - } - const requestedBranch = request.branchNameOverride ?? request.name - if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) { - throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.") - } - if (request.baseBranch && !request.expectedRefHead) { - throw new Error('The requested provisioned-root ref identity is missing.') - } - if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) { - throw new Error("The recipe projectRoot was not created from Orca's requested ref.") - } + const gitWorktree = verifyProvisionedRootCheckout({ + worktrees, + sparseCheckoutEnabled, + projectRoot, + request + }) const worktreeId = `${repo.id}::${gitWorktree.path}` attachEphemeralVmRuntimeToWorkspace({ @@ -115,13 +95,12 @@ export async function adoptProvisionedRootSshCheckout(args: { const now = Date.now() const meta = store.setWorktreeMeta( worktreeId, - buildProvisionedRootMeta( - store, - repo, - request, - gitWorktree.branch.replace(/^refs\/heads\//, ''), - now - ) + buildProvisionedRootMeta(store, repo, request, { + branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''), + now, + hostId: args.request.executionHostId, + source: 'ssh' + }) ) return { worktree: mergeWorktree(repo.id, gitWorktree, meta) } } @@ -157,77 +136,3 @@ function requireOwnedProvisionedRootRuntime( } return runtime } - -function pathsEqual(left: string, right: string): boolean { - return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) -} - -function buildProvisionedRootMeta( - store: Store, - repo: Repo, - args: AdoptionArgs, - branchName: string, - now: number -): Partial { - const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( - args.displayName, - args.displayNameKind, - args.name, - false, - args.nameWasGenerated === true - ) - const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( - displayNameRequest.value, - branchName, - displayNameRequest.kind, - { requestedName: args.name, sanitizedName: args.name } - ) - return { - instanceId: randomUUID(), - ...(store.getProjectHostSetups - ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - : {}), - hostId: args.executionHostId, - ephemeralVmCheckoutMode: 'provisioned-root', - displayName: displayNameMeta.displayName ?? args.name, - ...displayNameMeta, - lastActivityAt: now, - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'ssh', - creatorProvenance: { kind: 'host' }, - orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, store.getSettings()), - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), - ...(args.compareBaseRef || args.baseBranch - ? { baseRef: args.compareBaseRef ?? args.baseBranch } - : {}), - ...(args.pushTarget ? { pushTarget: args.pushTarget } : {}), - ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), - ...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent) - ? { pendingFirstAgentMessageRename: true } - : {}), - ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), - ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), - ...(args.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } - : {}), - ...(args.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), - ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), - ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), - ...(args.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } - : {}), - ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), - ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), - ...(args.linkedItems !== undefined ? { linkedItems: args.linkedItems } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}) - } -} diff --git a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts index e69937888278..29494f2f87f4 100644 --- a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts @@ -16,6 +16,12 @@ import { } from './runtime-managed-worktree-metadata' import { resolveRuntimeGitHubWorktreeBase } from './runtime-github-worktree-base' import { resolveRuntimeGitLabWorktreeBase } from './runtime-gitlab-worktree-base' +import { listWorktreesStrict } from '../git/worktree-listing' +import { + adoptRuntimeProvisionedRoot, + readLocalSparseCheckoutEnabled, + type RuntimeProvisionedRootRequest +} from './runtime-provisioned-root-adoption' export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithCreateManagedWorktree { protected createManagedRemoteWorktree( @@ -171,6 +177,27 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC }) } + adoptManagedProvisionedRoot( + repoSelector: string, + request: RuntimeProvisionedRootRequest + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return adoptRuntimeProvisionedRoot(repoSelector, request, { + store: this.store, + resolveRepo: (selector) => this.resolveRepoSelector(selector), + listWorktrees: (repoPath) => listWorktreesStrict(repoPath), + isSparseCheckoutEnabled: readLocalSparseCheckoutEnabled, + invalidateScan: (repoId) => { + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + }, + notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId), + showWorktree: (selector) => this.showManagedWorktree(selector) + }) + } + persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { if (!this.store) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts b/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts new file mode 100644 index 000000000000..b557ef2e33cd --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-provisioned-root-method.ts @@ -0,0 +1,21 @@ +import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' +import { WorktreeAdoptProvisionedRoot } from '../../../../shared/rpc-contract/worktree-provisioned-root-params' +import { defineMethod } from '../core' + +export const WORKTREE_PROVISIONED_ROOT_METHOD = defineMethod({ + name: 'worktree.adoptProvisionedRoot', + permission: 'workspace', + params: WorktreeAdoptProvisionedRoot, + handler: async (params, { runtime }) => { + const { repo, linkedIssue, linkedPR, linkedGitLabMR, linkedGitLabIssue, ...request } = params + // Why: null clears a link on update; a fresh adoption has nothing to clear. + const result = await runtime.adoptManagedProvisionedRoot(repo, { + ...request, + linkedIssue: linkedIssue ?? undefined, + linkedPR: linkedPR ?? undefined, + linkedGitLabMR: linkedGitLabMR ?? undefined, + linkedGitLabIssue: linkedGitLabIssue ?? undefined + }) + return { ...result, catalogVersion: getLocalWorktreeCatalogVersion(result.worktree.repoId) } + } +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 9d044842673c..bd2b4d7f5c36 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -30,6 +30,7 @@ import { WorktreeTeardownMissingTerminalsParams } from '../../../../shared/rpc-contract/worktree-params' import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods' +import { WORKTREE_PROVISIONED_ROOT_METHOD } from './worktree-provisioned-root-method' import { readsWorktreeRemovalMarker } from '../worktree-removal-marker-projection' export const WORKTREE_METHODS = [ @@ -136,6 +137,7 @@ export const WORKTREE_METHODS = [ } }) }), + WORKTREE_PROVISIONED_ROOT_METHOD, defineMethod({ name: 'worktree.prefetchCreateBase', permission: 'workspace', diff --git a/src/main/runtime/runtime-provisioned-root-adoption.test.ts b/src/main/runtime/runtime-provisioned-root-adoption.test.ts new file mode 100644 index 000000000000..5a6f3261a0e5 --- /dev/null +++ b/src/main/runtime/runtime-provisioned-root-adoption.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { + adoptRuntimeProvisionedRoot, + type RuntimeProvisionedRootPorts +} from './runtime-provisioned-root-adoption' + +const root = '/workspace/repo' +const repo: Repo = { + id: 'repo-vm', + path: root, + displayName: 'repo', + badgeColor: '#000', + addedAt: 1 +} + +function checkout(overrides: Partial = {}): GitWorktreeInfo { + return { + path: root, + head: 'abc123', + branch: 'refs/heads/fix-login', + isBare: false, + isMainWorktree: true, + ...overrides + } +} + +function ports(worktrees: GitWorktreeInfo[], sparse = false) { + const setWorktreeMeta = vi.fn() + const notifyChanged = vi.fn() + const value: RuntimeProvisionedRootPorts = { + store: { + getSettings: () => ({ workspaceDir: '/workspaces', nestWorkspaces: false }), + setWorktreeMeta + }, + resolveRepo: async () => repo, + listWorktrees: async () => worktrees, + isSparseCheckoutEnabled: async () => sparse, + invalidateScan: vi.fn(), + notifyChanged, + showWorktree: async (selector) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adoption only returns the row it was handed; no Worktree field is read. + ({ id: selector.slice('id:'.length), repoId: repo.id, path: root }) as never + } + return { value, setWorktreeMeta, notifyChanged } +} + +describe('adoptRuntimeProvisionedRoot', () => { + it('adopts the primary checkout as the workspace and records it as provisioned-root', async () => { + const { value, setWorktreeMeta, notifyChanged } = ports([checkout()]) + const result = await adoptRuntimeProvisionedRoot( + 'repo-vm', + { name: 'fix-login', baseBranch: 'main', expectedPath: root, expectedRefHead: 'abc123' }, + value + ) + expect(result.worktree.id).toBe(`repo-vm::${root}`) + expect(setWorktreeMeta).toHaveBeenCalledWith( + `repo-vm::${root}`, + expect.objectContaining({ + ephemeralVmCheckoutMode: 'provisioned-root', + displayName: 'fix-login', + orcaCreationSource: 'runtime', + baseRef: 'main' + }) + ) + expect(setWorktreeMeta.mock.calls[0][1]).not.toHaveProperty('hostId') + expect(notifyChanged).toHaveBeenCalledWith('repo-vm') + }) + + it.each<[string, GitWorktreeInfo[], boolean, string]>([ + ['another branch', [checkout({ branch: 'refs/heads/other' })], false, 'requested branch'], + ['another ref', [checkout({ head: 'def456' })], false, 'requested ref'], + ['a linked worktree', [checkout({ isMainWorktree: false })], false, 'primary checkout'], + ['a sparse checkout', [checkout()], true, 'sparse checkout'] + ])('refuses %s and records nothing', async (_label, worktrees, sparse, message) => { + const { value, setWorktreeMeta } = ports(worktrees, sparse) + await expect( + adoptRuntimeProvisionedRoot( + 'repo-vm', + { name: 'fix-login', expectedPath: root, expectedRefHead: 'abc123' }, + value + ) + ).rejects.toThrow(message) + expect(setWorktreeMeta).not.toHaveBeenCalled() + }) + + it('refuses a project whose root is not the recipe root', async () => { + const { value } = ports([checkout()]) + await expect( + adoptRuntimeProvisionedRoot('repo-vm', { name: 'fix-login', expectedPath: '/other' }, value) + ).rejects.toThrow('does not match') + }) +}) diff --git a/src/main/runtime/runtime-provisioned-root-adoption.ts b/src/main/runtime/runtime-provisioned-root-adoption.ts new file mode 100644 index 000000000000..3becab13b6a0 --- /dev/null +++ b/src/main/runtime/runtime-provisioned-root-adoption.ts @@ -0,0 +1,77 @@ +/** + * Adopts a recipe-provisioned project root that this host runs, as the desktop does for a direct + * SSH root: the root becomes the main worktree's workspace, and no linked worktree is created. + */ +import type { Repo } from '../../shared/repo-types' +import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import { getRepoSshConnectionId } from '../../shared/execution-host' +import { isFolderRepo } from '../../shared/repo-kind' +import { + buildProvisionedRootMeta, + provisionedRootPathsEqual, + verifyProvisionedRootCheckout, + type ProvisionedRootMetaStore, + type ProvisionedRootRequest +} from '../provisioned-root-checkout' +import { gitExecFileAsync } from '../git/runner' +import type { RuntimeStore } from './runtime-store-contract' + +export type RuntimeProvisionedRootRequest = ProvisionedRootRequest & { expectedPath: string } + +export type RuntimeProvisionedRootPorts = { + store: ProvisionedRootMetaStore & Pick + resolveRepo: (selector: string) => Promise + listWorktrees: (repoPath: string) => Promise + isSparseCheckoutEnabled: (checkoutPath: string) => Promise + invalidateScan: (repoId: string) => void + notifyChanged: (repoId: string) => void + showWorktree: (selector: string) => Promise +} + +export async function adoptRuntimeProvisionedRoot( + repoSelector: string, + request: RuntimeProvisionedRootRequest, + ports: RuntimeProvisionedRootPorts +): Promise { + if (request.sparseCheckout) { + throw new Error('Provisioned-root recipes do not support sparse checkout.') + } + const repo = await ports.resolveRepo(repoSelector) + if (isFolderRepo(repo) || getRepoSshConnectionId(repo)) { + throw new Error('Provisioned-root adoption requires a Git checkout on this host.') + } + if (!provisionedRootPathsEqual(repo.path, request.expectedPath)) { + throw new Error('The recipe projectRoot does not match the imported Git checkout root.') + } + const [worktrees, sparseCheckoutEnabled] = await Promise.all([ + ports.listWorktrees(repo.path), + ports.isSparseCheckoutEnabled(repo.path) + ]) + const gitWorktree = verifyProvisionedRootCheckout({ + worktrees, + sparseCheckoutEnabled, + projectRoot: request.expectedPath, + request + }) + const worktreeId = `${repo.id}::${gitWorktree.path}` + ports.store.setWorktreeMeta( + worktreeId, + buildProvisionedRootMeta(ports.store, repo, request, { + branchName: gitWorktree.branch.replace(/^refs\/heads\//, ''), + now: Date.now(), + source: 'runtime' + }) + ) + ports.invalidateScan(repo.id) + ports.notifyChanged(repo.id) + return { worktree: await ports.showWorktree(`id:${worktreeId}`) } +} + +export async function readLocalSparseCheckoutEnabled(checkoutPath: string): Promise { + const { stdout } = await gitExecFileAsync( + ['config', '--bool', '--get', '--default=false', 'core.sparseCheckout'], + { cwd: checkoutPath } + ) + return stdout.trim() === 'true' +} diff --git a/src/main/ssh/ssh-target-orcad-claims.test.ts b/src/main/ssh/ssh-target-orcad-claims.test.ts index 319e04da0ee7..6be6d4ee2097 100644 --- a/src/main/ssh/ssh-target-orcad-claims.test.ts +++ b/src/main/ssh/ssh-target-orcad-claims.test.ts @@ -80,6 +80,18 @@ describe('managed orcad SSH target claims', () => { expect(current().orcadProvisioning).toBeUndefined() }) + it("claims a recipe VM's target: its runtime owns the host's lifecycle, not its server", () => { + const { claims } = setup({ + target: { owner: { type: 'on-demand-runtime', runtimeId: 'vm-1' } } + }) + expect(claims.preflight('ssh-1')).toMatchObject({ claimable: true, blockers: [] }) + expect( + getManagedOrcadFenceEnvironmentId( + claims.claim('ssh-1', 'environment-1', { deployName: 'VM', ownerRecorded: false }) + ) + ).toBe('environment-1') + }) + it('claims for SSH access without recording a provisioning intent', () => { const { claims } = setup({ target: { generation: 1 } }) expect( diff --git a/src/main/ssh/ssh-target-orcad-claims.ts b/src/main/ssh/ssh-target-orcad-claims.ts index f2fbe9164483..af73895bbadd 100644 --- a/src/main/ssh/ssh-target-orcad-claims.ts +++ b/src/main/ssh/ssh-target-orcad-claims.ts @@ -5,7 +5,10 @@ * leases: moving a direct SSH host's state into a managed server is the catalog migration. */ import type { Store } from '../persistence' -import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { + getManagedOrcadFenceEnvironmentId, + isEphemeralRuntimeSshOwner +} from '../../shared/managed-orcad-ssh-owner' import type { OrcadMigrationBlocker, OrcadMigrationPreflight @@ -234,11 +237,12 @@ export function collectTargetCatalogBlockers( return blockers } -/** Who holds the target exclusively: an ephemeral runtime or a managed Orca server. */ +/** Who holds the target exclusively: a managed Orca server, or an unmigrated legacy owner. */ function targetHolder( target: SshTarget ): Extract['holder'] | null { - if (target.owner) { + // Why: a recipe VM's runtime owns when the host is created and destroyed, not which server runs it. + if (target.owner && !isEphemeralRuntimeSshOwner(target.owner)) { return { kind: 'runtime', runtimeId: target.owner.runtimeId } } const environmentId = getManagedOrcadFenceEnvironmentId(target) diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index 5b2bfe3bd629..bacfb239f350 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -45,6 +45,8 @@ export type EphemeralVmApi = { connectionType: 'ssh' runtime: EphemeralVmRuntimeRecord sshTargetId: string + /** The managed Orca server the host runs; its workspace lives there. */ + environmentId?: string expectedRefHead?: string stderr: string warnings: EphemeralVmRecipeResultWarning[] diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx index 45c04b08a56f..6e841f672d6f 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx @@ -4,9 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/repo-types' const mocks = vi.hoisted(() => { - const worktreesByRepo: Record = {} + const worktreesByRepo: Record< + string, + { id: string; hostId?: string; ephemeralVmCheckoutMode?: 'provisioned-root' }[] + > = {} const tabsByWorktree: Record = {} const ptyIdsByTabId: Record = {} + const runtimeEnvironments: { id: string; orcadDeployment?: { sshTargetId: string } }[] = [] return { state: { activeModal: 'confirm-remove-folder' as string | null, @@ -16,6 +20,7 @@ const mocks = vi.hoisted(() => { hostId: 'ssh:target-1' } as Record, repos: [] as Repo[], + runtimeEnvironments, sshTargetLabels: new Map(), removedSshTargetLabels: new Map(), worktreesByRepo, @@ -82,6 +87,20 @@ describe('RemoveFolderDialog', () => { mocks.state.worktreesByRepo = {} mocks.state.tabsByWorktree = {} mocks.state.ptyIdsByTabId = {} + mocks.state.runtimeEnvironments = [] + }) + + it("warns about VM recipe cleanup for a project on a recipe VM's managed server", () => { + mocks.state.modalData.hostId = 'runtime:env-vm' + mocks.state.repos = [repo(null, 'runtime:env-vm')] + mocks.state.runtimeEnvironments = [ + { id: 'env-vm', orcadDeployment: { sshTargetId: 'runtime-ssh-runtime-1' } } + ] + + const html = renderToStaticMarkup() + + expect(html).toContain('Its VM recipe determines whether the environment') + expect(html).not.toContain('It is still on your disk') }) it('warns that VM recipe cleanup controls file deletion', () => { @@ -95,6 +114,18 @@ describe('RemoveFolderDialog', () => { expect(html).not.toContain('Its files stay on') }) + it('warns about VM recipe cleanup for an adopted provisioned root before the catalog loads', () => { + mocks.state.modalData.hostId = 'runtime:env-vm' + mocks.state.repos = [repo(null, 'runtime:env-vm')] + mocks.state.worktreesByRepo = { + 'repo-1': [ + { id: 'repo-1::/vm', hostId: 'runtime:env-vm', ephemeralVmCheckoutMode: 'provisioned-root' } + ] + } + + expect(renderToStaticMarkup()).toContain('Its VM recipe determines') + }) + it('keeps the file-preservation promise for ordinary SSH projects', () => { mocks.state.repos = [repo('target-1', 'ssh:target-1')] diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index f7b9776a6e8b..9e6c5d9cf4d0 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -16,6 +16,7 @@ import { normalizeExecutionHostId } from '../../../../shared/execution-host' import { countOpenRepoTerminalTabs } from '@/store/repos/repo-removal' +import { getRecipeVmSshTargetForHost } from '@/lib/recipe-vm-managed-host' // Why: interpolated into the sentence so locales control where the name sits; // U+0000 cannot appear in a real project name, so the split is unambiguous. @@ -54,6 +55,15 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { ) }) + const onRecipeVmServer = useAppStore( + (s) => + getRecipeVmSshTargetForHost(s.runtimeEnvironments, hostId) !== null || + (s.worktreesByRepo[repoId] ?? []).some( + (worktree) => + worktree.hostId === hostId && worktree.ephemeralVmCheckoutMode === 'provisioned-root' + ) + ) + const openTerminalCount = useAppStore((s) => repoId && hostId ? countOpenRepoTerminalTabs(s, repoId, hostId) : 0 ) @@ -74,23 +84,24 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { // Why: fragment concatenation around the styled name cannot be reordered by // SOV locales (#9294). Translate one full sentence with the name as a // sentinel token, then split on it to re-apply the inline emphasis. - const description = isRuntimeOwnedSshTargetId(sshConnectionId) - ? translate( - 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionVmRecipe', - 'This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted.', - { name: NAME_TOKEN } - ) - : sshHostLabel + const description = + onRecipeVmServer || isRuntimeOwnedSshTargetId(sshConnectionId) ? translate( - 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionSsh', - 'This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.', - { name: NAME_TOKEN, host: sshHostLabel } - ) - : translate( - 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionLocal', - 'This only removes {{name}} from Orca. It is still on your disk.', + 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionVmRecipe', + 'This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted.', { name: NAME_TOKEN } ) + : sshHostLabel + ? translate( + 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionSsh', + 'This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.', + { name: NAME_TOKEN, host: sshHostLabel } + ) + : translate( + 'auto.components.sidebar.RemoveFolderDialog.removeDescriptionLocal', + 'This only removes {{name}} from Orca. It is still on your disk.', + { name: NAME_TOKEN } + ) const [descriptionBeforeName, descriptionAfterName] = description.split(NAME_TOKEN) const handleConfirm = useCallback(() => { diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts index cbf0cf511349..1408fcbe2067 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts @@ -174,6 +174,71 @@ describe('prepareEphemeralVmWorkspaceTarget', () => { expect(window.api.ephemeralVm.cleanup).not.toHaveBeenCalled() }) + it("imports a provisioned root on the managed server that runs the VM's SSH host", async () => { + vi.mocked(window.api.ephemeralVm.provision).mockResolvedValue({ + ok: true, + connectionType: 'ssh', + stderr: '', + warnings: [], + sshTargetId: 'runtime-ssh-runtime-1', + environmentId: 'env-vm', + expectedRefHead: 'abc123', + runtime: { + id: 'runtime-1', + repoId: 'repo-1', + recipeId: 'cloud-sandbox', + connectionMode: 'ssh', + sshTargetId: 'runtime-ssh-runtime-1', + runtimeEnvironmentId: 'env-vm', + status: 'running', + cleanupStatus: 'not_started', + createdAt: 1, + updatedAt: 1, + recipeResult: { + schemaVersion: 2, + checkoutMode: 'provisioned-root', + connection: { + type: 'ssh', + projectRoot: '/workspace/repo', + target: { label: 'Sandbox', host: 'sandbox.example.com', port: 22, username: 'root' } + } + } + } + }) + const setupResult = { + project: { id: 'project-1' }, + setup: { id: 'setup-1', hostId: 'local' }, + repo: { id: 'repo-runtime' } + } as ProjectHostSetupResult + const setupExistingFolder = vi.fn( + async () => setupResult + ) + + const result = await prepareEphemeralVmWorkspaceTarget({ + repoId: 'repo-1', + recipeId: 'cloud-sandbox', + projectId: 'project-1', + workspaceName: 'Fix Login Race', + setupExistingFolder + }) + + expect(assertRuntimeEnvironmentCapability).toHaveBeenCalledWith( + 'env-vm', + 'worktree.provisioned-root.v1', + expect.any(String) + ) + expect(setupExistingFolder).toHaveBeenCalledWith( + expect.objectContaining({ hostId: 'runtime:env-vm', path: '/workspace/repo' }) + ) + expect(result).toMatchObject({ + ok: true, + setup: { setup: { hostId: 'runtime:env-vm' } }, + checkoutMode: 'provisioned-root', + environmentId: 'env-vm', + expectedRefHead: 'abc123' + }) + }) + it('rejects and cleans up an Orca-server provisioned root before project import', async () => { vi.mocked(window.api.ephemeralVm.provision).mockResolvedValue({ ok: true, diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts index 67bd053a082e..05c7d813a40f 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts @@ -1,4 +1,8 @@ -import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' import type { ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult @@ -8,7 +12,10 @@ import { getEphemeralVmRecipeResultProjectRoot } from '../../../shared/ephemeral-vm-recipes' import type { EphemeralVmRecipeResultWarning } from '../../../shared/ephemeral-vm-recipe-diagnostics' -import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { translate } from '@/i18n/i18n' import { assertRuntimeEnvironmentCapability } from '@/runtime/runtime-rpc-client' @@ -71,18 +78,22 @@ export async function prepareEphemeralVmWorkspaceTarget( } } - const hostId = - provisioned.connectionType === 'ssh' - ? toSshExecutionHostId(provisioned.sshTargetId) - : toRuntimeExecutionHostId(provisioned.environment.id) + const { hostId, environmentId } = recipeWorkspaceHost(provisioned) - if (provisioned.connectionType === 'orca-server') { + if (environmentId) { try { await assertRuntimeEnvironmentCapability( - provisioned.environment.id, + environmentId, PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, 'The recipe-created Orca server does not support project setup.' ) + if (checkoutMode === 'provisioned-root') { + await assertRuntimeEnvironmentCapability( + environmentId, + WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY, + 'The Orca server on this VM cannot adopt a provisioned root. Update Orca on the VM.' + ) + } } catch (error) { await cleanupProvisionedRuntime(provisioned.runtime.id) return { @@ -92,7 +103,6 @@ export async function prepareEphemeralVmWorkspaceTarget( } } } - let setup: ProjectHostSetupResult | null try { setup = await args.setupExistingFolder({ @@ -144,9 +154,28 @@ export async function prepareEphemeralVmWorkspaceTarget( warnings: provisioned.warnings } satisfies PrepareEphemeralVmWorkspaceTargetResult - return provisioned.connectionType === 'orca-server' - ? { ...success, environmentId: provisioned.environment.id } - : success + return environmentId ? { ...success, environmentId } : success +} + +type ProvisionedVm = Extract< + Awaited>, + { ok: true } +> + +/** A VM whose SSH host runs a managed server keeps its workspace on that server. */ +function recipeWorkspaceHost(provisioned: ProvisionedVm): { + hostId: ExecutionHostId + environmentId?: string +} { + if (provisioned.connectionType === 'orca-server') { + const environmentId = provisioned.environment.id + return { hostId: toRuntimeExecutionHostId(environmentId), environmentId } + } + if (provisioned.environmentId) { + const { environmentId } = provisioned + return { hostId: toRuntimeExecutionHostId(environmentId), environmentId } + } + return { hostId: toSshExecutionHostId(provisioned.sshTargetId) } } async function cleanupProvisionedRuntime(runtimeId: string): Promise { diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts index 77a215d0fa7c..5a910f040f15 100644 --- a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts @@ -71,6 +71,14 @@ export async function prepareRequestForCreate( return null } appendProvisioningWarnings(creationId, preparedTarget.warnings) + if (preparedTarget.environmentId) { + // Why: the VM's first connect may have just deployed this server; host badges and removal read it. + try { + useAppStore.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) + } catch { + // A stale catalog only delays host badges; removal re-reads it. + } + } const preparedRequest: WorktreeCreationRequest = { ...request, repoId: preparedTarget.setup.repo.id, diff --git a/src/renderer/src/lib/recipe-vm-managed-host.test.ts b/src/renderer/src/lib/recipe-vm-managed-host.test.ts new file mode 100644 index 000000000000..8846c9e60cf0 --- /dev/null +++ b/src/renderer/src/lib/recipe-vm-managed-host.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { getRecipeVmSshTargetForHost } from './recipe-vm-managed-host' + +const deployment = (sshTargetId: string) => ({ sshTargetId }) + +describe('getRecipeVmSshTargetForHost', () => { + const environments = [ + { id: 'env-vm', orcadDeployment: deployment('runtime-ssh-vm-1') }, + { id: 'env-host', orcadDeployment: deployment('ssh-builder') }, + { id: 'env-paired' } + ] + + it("names the VM target behind a recipe VM's managed server", () => { + expect(getRecipeVmSshTargetForHost(environments, 'runtime:env-vm')).toBe('runtime-ssh-vm-1') + }) + + it('ignores user SSH hosts, paired servers, and non-runtime hosts', () => { + expect(getRecipeVmSshTargetForHost(environments, 'runtime:env-host')).toBeNull() + expect(getRecipeVmSshTargetForHost(environments, 'runtime:env-paired')).toBeNull() + expect(getRecipeVmSshTargetForHost(environments, 'ssh:runtime-ssh-vm-1')).toBeNull() + expect(getRecipeVmSshTargetForHost(environments, null)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/recipe-vm-managed-host.ts b/src/renderer/src/lib/recipe-vm-managed-host.ts new file mode 100644 index 000000000000..b539cf9ec2b1 --- /dev/null +++ b/src/renderer/src/lib/recipe-vm-managed-host.ts @@ -0,0 +1,15 @@ +import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../shared/execution-host' + +/** The hidden SSH target of the recipe VM whose managed server is `hostId`, or null. */ +export function getRecipeVmSshTargetForHost( + environments: readonly { id: string; orcadDeployment?: { sshTargetId: string } }[], + hostId: string | null | undefined +): string | null { + const host = parseExecutionHostId(hostId) + if (host?.kind !== 'runtime') { + return null + } + const targetId = environments.find((entry) => entry.id === host.environmentId)?.orcadDeployment + ?.sshTargetId + return targetId && isRuntimeOwnedSshTargetId(targetId) ? targetId : null +} diff --git a/src/renderer/src/store/repos/recipe-vm-project-removal.ts b/src/renderer/src/store/repos/recipe-vm-project-removal.ts new file mode 100644 index 000000000000..429d7ecf078f --- /dev/null +++ b/src/renderer/src/store/repos/recipe-vm-project-removal.ts @@ -0,0 +1,66 @@ +import { + isRuntimeOwnedSshTargetId, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { AppState } from '../types' +import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' +import { getRecipeVmSshTargetForHost } from '@/lib/recipe-vm-managed-host' + +/** + * Destroys the recipe VM behind a project before the project is removed, so it never leaks. + * `false` (synchronously) when no VM backs it; otherwise resolves true when the project lived + * on the VM's managed server, which the VM took with it. + */ +export function destroyRecipeVmBeforeProjectRemoval( + state: Pick, + ownerRepo: Pick, + ownerHostId: ExecutionHostId, + workspaceIds: string[] +): Promise | false { + const knownServerTarget = getRecipeVmSshTargetForHost(state.runtimeEnvironments, ownerHostId) + const hasProvisionedRoot = (state.worktreesByRepo[ownerRepo.id] ?? []).some( + (worktree) => + (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === ownerHostId && + worktree.ephemeralVmCheckoutMode === 'provisioned-root' + ) + const directTarget = + ownerRepo.connectionId && isRuntimeOwnedSshTargetId(ownerRepo.connectionId) + ? ownerRepo.connectionId + : null + if (!knownServerTarget && !hasProvisionedRoot && !directTarget) { + return false + } + return destroyRecipeVm(ownerHostId, workspaceIds, knownServerTarget, directTarget) +} + +async function destroyRecipeVm( + ownerHostId: ExecutionHostId, + workspaceIds: string[], + knownServerTarget: string | null, + directTarget: string | null +): Promise { + const serverTarget = + knownServerTarget ?? + // Why: the renderer's catalog may predate the server the VM's first connect deployed. + (ownerHostId.startsWith('runtime:') + ? getRecipeVmSshTargetForHost(await window.api.runtimeEnvironments.list(), ownerHostId) + : null) + const targetId = serverTarget ?? directTarget + if (!targetId) { + return false + } + // Why host-scoped: another VM's server can publish the same raw workspace id. + const cleanup = await cleanupEphemeralVmRuntimesForDeleted({ + hostScopedWorkspaces: workspaceIds.map((workspaceId) => ({ + workspaceId, + executionHostId: ownerHostId + })), + runtimeOwnedSshTargetIds: [targetId] + }) + if (cleanup.retainedSshTargetIds.includes(targetId)) { + throw new Error('The cloud VM could not be destroyed. Retry cleanup before removing it.') + } + return serverTarget !== null +} diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 3695dbbe51a5..bcbc8aaeda2e 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -10,12 +10,8 @@ import { callRuntimeRpc, hasRuntimeRpcErrorCode } from '../../runtime/runtime-rp import { runtimeTargetForOwnerHostId } from '../../runtime/runtime-client-target' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { translate } from '@/i18n/i18n' -import { - getRepoExecutionHostId, - isRuntimeOwnedSshTargetId, - LOCAL_EXECUTION_HOST_ID -} from '../../../../shared/execution-host' -import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { destroyRecipeVmBeforeProjectRemoval } from './recipe-vm-project-removal' import type { RepoSlice } from './repo-state' import { ERROR_TOAST_DURATION } from './repo-state' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' @@ -91,19 +87,14 @@ export function createRepoRemovalActions( return } const ownerHostId = getRepoExecutionHostId(ownerRepo) - const runtimeSshTargetId = ownerRepo.connectionId - // Why: an SSH per-workspace-env's workspace is the repo's main worktree, so removal routes here; tear down its ephemeral runtime first so it doesn't leak. - if (runtimeSshTargetId && isRuntimeOwnedSshTargetId(runtimeSshTargetId)) { - const cleanup = await cleanupEphemeralVmRuntimesForDeleted({ - workspaceIds: getKnownRepoWorktreeIds(get(), projectId, ownerHostId), - runtimeOwnedSshTargetIds: [runtimeSshTargetId] - }) - if (cleanup.retainedSshTargetIds.includes(runtimeSshTargetId)) { - throw new Error( - 'The cloud VM could not be destroyed. Retry cleanup before removing it.' - ) - } - } + // Why only awaited for a VM: concurrent removals of one row must reach the host together. + const vmDestroy = destroyRecipeVmBeforeProjectRemoval( + get(), + ownerRepo, + ownerHostId, + getKnownRepoWorktreeIds(get(), projectId, ownerHostId) + ) + const vmServerDestroyed = vmDestroy !== false && (await vmDestroy) // Why: derive the target from the owner row's host so an SSH host removal never routes repo.rm to the focused runtime. const target = runtimeTargetForOwnerHostId(ownerHostId) if (!target) { @@ -118,10 +109,13 @@ export function createRepoRemovalActions( })) ) try { - // Why: always host-scoped; this catalog may be stale and miss a same-id row on another host (#13071). - await (target.kind === 'local' - ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) - : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) + // Why: destroying the VM took its server, and the catalog holding this row, with it. + if (!vmServerDestroyed) { + // Why: always host-scoped; this catalog may be stale and miss a same-id row on another host (#13071). + await (target.kind === 'local' + ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) + : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) + } } catch (err) { // Why: the owner already dropped this project, so purge the local ghost row instead of aborting (#11994). if (!hasRuntimeRpcErrorCode(err, 'repo_not_found')) { diff --git a/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts b/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts index a6bceb41ef55..8cc9e532f13a 100644 --- a/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts +++ b/src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts @@ -2,11 +2,14 @@ import { expect, it, vi } from 'vitest' import { toast } from 'sonner' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import { ephemeralVmCleanup, ephemeralVmListRuntimes, installReposRuntimeRoutingHarness, reposRemoveForHost, + runtimeEnvironmentCall, + runtimeEnvironmentsList, sshRepo } from './repos-runtime-routing-fixture' import { createTestStore } from './store-test-helpers' @@ -51,3 +54,147 @@ it('retains a runtime-owned SSH project when VM cleanup fails', async () => { expect.objectContaining({ description: expect.stringContaining('Retry cleanup') }) ) }) + +const vmServerRepo: Repo = { + ...sshRepo, + id: 'vm-repo', + connectionId: null, + executionHostId: 'runtime:env-vm' +} +const vmServer: PublicKnownRuntimeEnvironment = { + id: 'env-vm', + name: 'Docker root VM', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [ + { id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://127.0.0.1:41000' } + ], + preferredEndpointId: 'ws', + orcadDeployment: { + sshTargetId: 'runtime-ssh-runtime-1', + sshTargetGeneration: 1, + localPort: 41_000, + remotePort: 6768 + } +} + +it('destroys a recipe VM whose managed server holds the project, without asking the gone server', async () => { + ephemeralVmListRuntimes.mockResolvedValue([ + { id: 'runtime-1', cleanupStatus: 'not_started', sshTargetId: 'runtime-ssh-runtime-1' } + ]) + ephemeralVmCleanup.mockResolvedValue({ status: 'cleaned', cleanupStatus: 'succeeded' }) + const store = createTestStore() + store.setState({ + repos: [vmServerRepo], + runtimeEnvironments: [vmServer] + }) + + await store.getState().removeProject(vmServerRepo.id, { + hostId: 'runtime:env-vm', + errorFeedback: 'toast' + }) + + expect(ephemeralVmCleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'repo.rm' }) + ) + expect(store.getState().repos).toEqual([]) +}) + +it("retains a recipe VM's managed-server project when VM cleanup fails", async () => { + ephemeralVmListRuntimes.mockResolvedValue([ + { id: 'runtime-1', cleanupStatus: 'not_started', sshTargetId: 'runtime-ssh-runtime-1' } + ]) + ephemeralVmCleanup.mockResolvedValue({ + status: 'cleanup_failed', + cleanupStatus: 'failed', + sshTargetId: 'runtime-ssh-runtime-1' + }) + const store = createTestStore() + store.setState({ repos: [vmServerRepo], runtimeEnvironments: [vmServer] }) + + await store.getState().removeProject(vmServerRepo.id, { + hostId: 'runtime:env-vm', + errorFeedback: 'toast' + }) + + expect(store.getState().repos).toEqual([vmServerRepo]) +}) + +it('never destroys another VM whose server publishes the same workspace id', async () => { + const sharedWorkspaceId = `${vmServerRepo.id}::/home/orca/project` + ephemeralVmListRuntimes.mockResolvedValue([ + { + id: 'runtime-1', + cleanupStatus: 'not_started', + sshTargetId: 'runtime-ssh-runtime-1', + runtimeEnvironmentId: 'env-vm', + workspaceId: sharedWorkspaceId + }, + { + id: 'runtime-2', + cleanupStatus: 'not_started', + sshTargetId: 'runtime-ssh-runtime-2', + runtimeEnvironmentId: 'env-vm-2', + workspaceId: sharedWorkspaceId + } + ]) + ephemeralVmCleanup.mockResolvedValue({ status: 'cleaned', cleanupStatus: 'succeeded' }) + const store = createTestStore() + store.setState({ + repos: [vmServerRepo], + runtimeEnvironments: [vmServer], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removal reads only each row's id and hostId. + worktreesByRepo: { + [vmServerRepo.id]: [ + { + id: sharedWorkspaceId, + repoId: vmServerRepo.id, + path: '/home/orca/project', + hostId: 'runtime:env-vm' + } + ] + } as never + }) + + await store.getState().removeProject(vmServerRepo.id, { + hostId: 'runtime:env-vm', + errorFeedback: 'toast' + }) + + expect(ephemeralVmCleanup).toHaveBeenCalledTimes(1) + expect(ephemeralVmCleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' }) +}) + +it("destroys the VM when the renderer's catalog predates the VM's server", async () => { + runtimeEnvironmentsList.mockResolvedValue([vmServer]) + ephemeralVmListRuntimes.mockResolvedValue([ + { id: 'runtime-1', cleanupStatus: 'not_started', sshTargetId: 'runtime-ssh-runtime-1' } + ]) + ephemeralVmCleanup.mockResolvedValue({ status: 'cleaned', cleanupStatus: 'succeeded' }) + const store = createTestStore() + store.setState({ + repos: [vmServerRepo], + runtimeEnvironments: [], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removal reads only id, hostId and ephemeralVmCheckoutMode. + worktreesByRepo: { + [vmServerRepo.id]: [ + { + id: `${vmServerRepo.id}::/home/orca/project`, + hostId: 'runtime:env-vm', + ephemeralVmCheckoutMode: 'provisioned-root' + } + ] + } as never + }) + + await store.getState().removeProject(vmServerRepo.id, { + hostId: 'runtime:env-vm', + errorFeedback: 'toast' + }) + + expect(ephemeralVmCleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' }) + expect(store.getState().repos).toEqual([]) +}) diff --git a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts index 9879516226e9..b61e956b2d0c 100644 --- a/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts +++ b/src/renderer/src/store/slices/repos-runtime-routing-fixture.ts @@ -58,6 +58,7 @@ export const orcaProfileFindProjectProfiles: Mock = vi.fn() export const uiSet: Mock = vi.fn() export const ephemeralVmListRuntimes: Mock = vi.fn() export const ephemeralVmCleanup: Mock = vi.fn() +export const runtimeEnvironmentsList: Mock = vi.fn() // Registers the per-test reset + window stub. Call once inside the suite's module scope. export function installReposRuntimeRoutingHarness(): void { @@ -90,6 +91,7 @@ export function installReposRuntimeRoutingHarness(): void { uiSet.mockResolvedValue(undefined) ephemeralVmListRuntimes.mockReset().mockResolvedValue([]) ephemeralVmCleanup.mockReset() + runtimeEnvironmentsList.mockReset().mockResolvedValue([]) runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) @@ -120,7 +122,10 @@ export function installReposRuntimeRoutingHarness(): void { findProjectProfiles: orcaProfileFindProjectProfiles }, pty: { kill: ptyKill }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + }, ephemeralVm: { listRuntimes: ephemeralVmListRuntimes, cleanup: ephemeralVmCleanup diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index 653f5ea99679..42df8ef91f90 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -1,6 +1,9 @@ import { runtimeTargetForExecutionHostId } from '@/runtime/runtime-client-target' import { repoHostId } from '../listing/worktree-host-ownership' -import { isUnresolvedOwnerHostId } from '../../../../../../shared/execution-host' +import { + isUnresolvedOwnerHostId, + toRuntimeExecutionHostId +} from '../../../../../../shared/execution-host' import { adoptFromEndpoint } from '../../../adopt-from-endpoint' import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' @@ -22,6 +25,7 @@ import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/loca import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast' import { applyCreatedWorktree } from './created-worktree-state-merge' +import { adoptRuntimeProvisionedRoot } from './runtime-provisioned-root-adoption' import { isRuntimeLineageParentMissingError } from '../listing/runtime-worktree-rpc-errors' import { buildLocalWorktreeCreateArgs, @@ -53,10 +57,17 @@ async function runCreateAttempt( parentWorkspace: WorktreeCreateAttempt['parentWorkspace'] ): Promise => provisionedRoot - ? await window.api.worktrees.adoptProvisionedRoot({ - ...buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace }), - ...provisionedRoot - }) + ? target.kind === 'environment' + ? await adoptRuntimeProvisionedRoot( + target, + request, + { ...attempt, parentWorkspace }, + provisionedRoot + ) + : await window.api.worktrees.adoptProvisionedRoot({ + ...buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace }), + ...provisionedRoot + }) : target.kind === 'local' ? // Why local can still reject on the parent: paired web clients route this API to their host. await window.api.worktrees.create( @@ -200,8 +211,12 @@ export function createCreateWorktree( 'Update the remote runtime to link Jira' ) } - if (options?.provisionedRoot && target.kind !== 'local') { - throw new Error('Provisioned-root recipes currently require a direct SSH connection.') + if ( + options?.provisionedRoot && + target.kind === 'environment' && + options.provisionedRoot.executionHostId !== toRuntimeExecutionHostId(target.environmentId) + ) { + throw new Error("The provisioned root's host does not match the workspace host.") } for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { try { diff --git a/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.test.ts b/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.test.ts new file mode 100644 index 000000000000..9fc1fc7d03a1 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../../../runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) + +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { adoptRuntimeProvisionedRoot } from './runtime-provisioned-root-adoption' + +describe('adoptRuntimeProvisionedRoot', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { + api: { ephemeralVm: { attachWorkspace: vi.fn(async () => ({})) } } + }) + }) + + it("adopts the root on the VM's server, then records the workspace on the VM runtime", async () => { + vi.mocked(callRuntimeRpc).mockResolvedValue({ + worktree: { id: 'repo-vm::/workspace/repo', repoId: 'repo-vm' } + }) + const result = await adoptRuntimeProvisionedRoot( + { kind: 'environment', environmentId: 'env-vm' }, + { repoId: 'repo-vm', name: 'fix-login', baseBranch: 'main' }, + { name: 'fix-login' }, + { + runtimeId: 'runtime-1', + executionHostId: 'runtime:env-vm', + expectedPath: '/workspace/repo', + expectedRefHead: 'abc123' + } + ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-vm' }, + 'worktree.adoptProvisionedRoot', + expect.objectContaining({ + repo: 'repo-vm', + name: 'fix-login', + baseBranch: 'main', + expectedPath: '/workspace/repo', + expectedRefHead: 'abc123' + }), + expect.anything() + ) + expect(window.api.ephemeralVm.attachWorkspace).toHaveBeenCalledWith({ + runtimeId: 'runtime-1', + workspaceId: 'repo-vm::/workspace/repo' + }) + expect(result.worktree.id).toBe('repo-vm::/workspace/repo') + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.ts b/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.ts new file mode 100644 index 000000000000..9abf665cde97 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/runtime-provisioned-root-adoption.ts @@ -0,0 +1,36 @@ +import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types' +import type { RuntimeClientTarget } from '../../../../runtime/runtime-client-target' +import { callRuntimeRpc } from '../../../../runtime/runtime-rpc-client' +import { + buildRuntimeWorktreeCreateParams, + type CreateWorktreeCallOptions, + type WorktreeCreateAttempt, + type WorktreeCreateRequest +} from './worktree-create-payload' + +/** A recipe VM whose SSH host runs a managed server: that server adopts the root it checked out. */ +export async function adoptRuntimeProvisionedRoot( + target: Extract, + request: WorktreeCreateRequest, + attempt: WorktreeCreateAttempt, + provisionedRoot: NonNullable +): Promise { + const result = await callRuntimeRpc( + target, + 'worktree.adoptProvisionedRoot', + { + ...buildRuntimeWorktreeCreateParams(request, attempt), + expectedPath: provisionedRoot.expectedPath, + ...(provisionedRoot.expectedRefHead + ? { expectedRefHead: provisionedRoot.expectedRefHead } + : {}) + }, + { timeoutMs: 60_000 } + ) + // Why: removing the workspace later finds the VM to destroy through this desktop record. + await window.api.ephemeralVm.attachWorkspace({ + runtimeId: provisionedRoot.runtimeId, + workspaceId: result.worktree.id + }) + return result +} diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts index 4ea9232927cc..76901272bf1c 100644 --- a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts @@ -22,6 +22,7 @@ export type CreateWorktreeCallOptions = { runtimeId: string executionHostId: ExecutionHostId expectedPath: string + expectedRefHead?: string } } diff --git a/src/shared/managed-orcad-ssh-owner.test.ts b/src/shared/managed-orcad-ssh-owner.test.ts index 2bf77630804c..cf814aa91932 100644 --- a/src/shared/managed-orcad-ssh-owner.test.ts +++ b/src/shared/managed-orcad-ssh-owner.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { getLegacyManagedOrcadOwnerEnvironmentId, getManagedOrcadFenceEnvironmentId, - isEphemeralRuntimeSshOwner + isEphemeralRuntimeSshOwner, + managedOrcadServerName } from './managed-orcad-ssh-owner' describe('managed orcad SSH fence', () => { @@ -25,4 +26,10 @@ describe('managed orcad SSH fence', () => { expect(getLegacyManagedOrcadOwnerEnvironmentId(owner)).toBeNull() expect(isEphemeralRuntimeSshOwner(owner)).toBe(true) }) + + it("names a recipe VM's server after its runtime, so two VMs of one recipe never collide", () => { + const owner = { type: 'on-demand-runtime' as const, runtimeId: 'runtime-0123456789abcdef' } + expect(managedOrcadServerName({ label: 'Docker root', owner })).toBe('Docker root VM 89abcdef') + expect(managedOrcadServerName({ label: 'Builder' })).toBe('Builder') + }) }) diff --git a/src/shared/managed-orcad-ssh-owner.ts b/src/shared/managed-orcad-ssh-owner.ts index d1d9a05a9709..9f220376f353 100644 --- a/src/shared/managed-orcad-ssh-owner.ts +++ b/src/shared/managed-orcad-ssh-owner.ts @@ -27,3 +27,9 @@ export function isEphemeralRuntimeSshOwner(owner: SshTargetOwner | undefined): b owner?.type === 'on-demand-runtime' && getLegacyManagedOrcadOwnerEnvironmentId(owner) === null ) } + +/** Every VM a recipe makes shares its label, so the runtime id keeps their server names unique. */ +export function managedOrcadServerName(target: Pick): string { + const runtimeId = isEphemeralRuntimeSshOwner(target.owner) ? target.owner?.runtimeId : undefined + return runtimeId ? `${target.label} VM ${runtimeId.slice(-8)}` : target.label +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index c87fc2d09887..075e6a26d297 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -83,6 +83,7 @@ export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const +export const WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY = 'worktree.provisioned-root.v1' as const export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY = 'worktree.linked-work-item-context.v1' as const export const WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY = @@ -374,6 +375,7 @@ export const RUNTIME_CAPABILITIES = [ PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, + WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY, WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, ...WORKSPACE_ATTACHMENT_RUNTIME_CAPABILITIES, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 33463e3a7adf..c3aa97088621 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -581,6 +581,7 @@ import { WorktreeSortOrder, WorktreeTeardownMissingTerminalsParams } from './worktree-params' +import { WorktreeAdoptProvisionedRoot } from './worktree-provisioned-root-params' import { SkillBundleInstallRequestSchema } from '../skill-bundle-install-contract' import { SkillDeleteRequestSchema } from '../skill-delete-contract' import { @@ -1266,6 +1267,7 @@ export const RPC_PARAMS_BY_METHOD = { 'workspacePorts.scan': WorkspacePortScanParams, 'workspacePorts.scanHost': WorkspacePortScanHostParams, 'worktree.activate': WorktreeActivate, + 'worktree.adoptProvisionedRoot': WorktreeAdoptProvisionedRoot, 'worktree.create': WorktreeCreate, 'worktree.detectedList': WorktreeDetectedListParams, 'worktree.forceDeleteBranch': WorktreeForceDeleteBranch, diff --git a/src/shared/rpc-contract/worktree-provisioned-root-params.ts b/src/shared/rpc-contract/worktree-provisioned-root-params.ts new file mode 100644 index 000000000000..1fe901dfc10b --- /dev/null +++ b/src/shared/rpc-contract/worktree-provisioned-root-params.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { OptionalString } from './rpc-param-primitives' +import { WorktreeCreate } from './worktree-create-params' + +const create = WorktreeCreate.shape + +/** A recipe's provisioned project root, adopted on its host as the main worktree's workspace. */ +export const WorktreeAdoptProvisionedRoot = z.object({ + repo: create.repo, + nameWasGenerated: create.nameWasGenerated, + displayName: create.displayName, + displayNameKind: create.displayNameKind, + baseBranch: create.baseBranch, + compareBaseRef: create.compareBaseRef, + branchNameOverride: create.branchNameOverride, + sparseCheckout: create.sparseCheckout, + linkedIssue: create.linkedIssue, + linkedPR: create.linkedPR, + linkedLinearIssue: create.linkedLinearIssue, + linkedLinearIssueWorkspaceId: create.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: create.linkedLinearIssueOrganizationUrlKey, + linkedGitLabMR: create.linkedGitLabMR, + linkedGitLabIssue: create.linkedGitLabIssue, + linkedBitbucketPR: create.linkedBitbucketPR, + linkedAzureDevOpsPR: create.linkedAzureDevOpsPR, + linkedGiteaPR: create.linkedGiteaPR, + linkedWorkItem: create.linkedWorkItem, + linkedItems: create.linkedItems, + linkedTaskSourceContext: create.linkedTaskSourceContext, + workspaceStatus: create.workspaceStatus, + manualOrder: create.manualOrder, + pushTarget: create.pushTarget, + createdWithAgent: create.createdWithAgent, + name: z.string().min(1, 'Missing workspace name'), + expectedPath: z.string().min(1, 'Missing provisioned project root'), + expectedRefHead: OptionalString +}) diff --git a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts index bea66842227b..af04ff2453b0 100644 --- a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts +++ b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts @@ -30,7 +30,8 @@ test.use({ test('adopts a recipe-provisioned SSH root without creating a linked worktree', async ({ orcaPage }, testInfo) => { - test.setTimeout(240_000) + // The first connect deploys the VM's managed server before the root is adopted. + test.setTimeout(420_000) let target: DockerSshRelayTarget | null = null const sourceRepo = mkdtempSync(path.join(tmpdir(), 'orca-provisioned-root-source-')) try { @@ -59,27 +60,32 @@ test('adopts a recipe-provisioned SSH root without creating a linked worktree', await expect(dialog).toBeHidden({ timeout: 60_000 }) await expect(orcaPage.getByRole('option', { name: new RegExp(workspaceName) })).toBeVisible({ - timeout: 60_000 + timeout: 240_000 }) - await ensureTerminalVisible(orcaPage) - const adopted = await orcaPage.evaluate( - ({ sourceRepoId, workspaceName }) => { - const state = window.__store!.getState() - return Object.values(state.worktreesByRepo) - .flat() - .find( - (worktree) => worktree.displayName === workspaceName && worktree.repoId !== sourceRepoId - ) - }, - { sourceRepoId, workspaceName } - ) + const findAdopted = () => + orcaPage.evaluate( + ({ sourceRepoId, workspaceName }) => + Object.values(window.__store!.getState().worktreesByRepo) + .flat() + .find( + (worktree) => + worktree.displayName === workspaceName && worktree.repoId !== sourceRepoId + ) ?? null, + { sourceRepoId, workspaceName } + ) + await expect + .poll(findAdopted, { timeout: 60_000, message: 'the provisioned root was never adopted' }) + .not.toBeNull() + const adopted = await findAdopted() + await ensureTerminalVisible(orcaPage) expect(adopted).toMatchObject({ path: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, isMainWorktree: true, ephemeralVmCheckoutMode: 'provisioned-root' }) - expect(adopted?.hostId).toMatch(/^(?:ssh:runtime-ssh-|runtime:)/) + // The VM's managed server holds the workspace; no relay serves it. + expect(adopted?.hostId).toMatch(/^runtime:/) expect( execDockerSshRelayTargetCommand( target, From 22bcda1074a3ae083c3b6811ded4984ee5282310 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 21:39:26 -0700 Subject: [PATCH 095/110] fix(ssh): a restore racing a disconnect never leaves a forward behind (#27368) Co-authored-by: m4air --- .../ipc/ssh-managed-port-forward-restore.ts | 7 ++- src/main/ipc/ssh-port-forward-persistence.ts | 7 ++- src/main/ipc/ssh-port-forward-restore.test.ts | 45 ++++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/ssh-managed-port-forward-restore.ts b/src/main/ipc/ssh-managed-port-forward-restore.ts index 41b868c45cce..126e7916fc96 100644 --- a/src/main/ipc/ssh-managed-port-forward-restore.ts +++ b/src/main/ipc/ssh-managed-port-forward-restore.ts @@ -12,7 +12,12 @@ export function restoreManagedHostPortForwards(targetId: string): Promise // Why chained: overlapping connects would otherwise both bind the same saved local port. const previous = restoring.get(targetId) ?? Promise.resolve() const next = previous - .then(() => restorePortForwards(targetId, getCurrentMainWindow)) + // Why re-checked: a disconnect or relay fallback queued behind the previous restore ends managed. + .then(() => + getSshHostServerStatus(targetId)?.kind === 'managed' + ? restorePortForwards(targetId, getCurrentMainWindow) + : undefined + ) .catch((error: unknown) => { console.warn(`[ssh] Could not restore port forwards for ${targetId}:`, error) }) diff --git a/src/main/ipc/ssh-port-forward-persistence.ts b/src/main/ipc/ssh-port-forward-persistence.ts index 7addd6b7dde3..701858c02eea 100644 --- a/src/main/ipc/ssh-port-forward-persistence.ts +++ b/src/main/ipc/ssh-port-forward-persistence.ts @@ -70,7 +70,7 @@ export async function restorePortForwards( return } try { - await portForwardManager!.addForward( + const entry = await portForwardManager!.addForward( targetId, conn, saved.localPort, @@ -78,6 +78,11 @@ export async function restorePortForwards( saved.remotePort, saved.label ) + // Why: a disconnect during the add already swept this target's forwards; this one came after. + if (connectionManager!.getConnection(targetId) !== conn) { + await portForwardManager!.removeForwardAndWait(entry.id) + return + } } catch (err) { console.warn( `[ssh] Failed to restore forward :${saved.localPort} → ${saved.remoteHost}:${saved.remotePort}: ${err instanceof Error ? err.message : String(err)}` diff --git a/src/main/ipc/ssh-port-forward-restore.test.ts b/src/main/ipc/ssh-port-forward-restore.test.ts index e4517510dc6f..47cf93309766 100644 --- a/src/main/ipc/ssh-port-forward-restore.test.ts +++ b/src/main/ipc/ssh-port-forward-restore.test.ts @@ -15,7 +15,7 @@ const h = vi.hoisted(() => { saved.portForwards = update.portForwards }) }, - connectionManager: { getConnection: vi.fn(() => conn) }, + connectionManager: { getConnection: vi.fn<() => { id: string } | undefined>(() => conn) }, portForwardManager: { closeStaleForwards: vi.fn(async () => {}), listForwards: vi.fn(() => [...active]), @@ -39,7 +39,11 @@ const h = vi.hoisted(() => { active.push(entry) return entry } - ) + ), + removeForwardAndWait: vi.fn(async (id: string) => { + const index = active.findIndex((entry) => entry.id === id) + return index !== -1 ? active.splice(index, 1)[0] : null + }) }, hostServerStatus: vi.fn<() => { kind: 'managed'; environmentId: string } | undefined>(() => ({ kind: 'managed', @@ -97,4 +101,41 @@ describe('saved port forward restore', () => { await restoreManagedHostPortForwards('t1') expect(h.portForwardManager.addForward).not.toHaveBeenCalled() }) + + it('closes a forward whose add finished after the host disconnected', async () => { + h.portForwardManager.addForward.mockImplementationOnce( + async (connectionId, _conn, localPort) => { + const entry = { + id: 'pf-late', + connectionId, + localPort, + remoteHost: '127.0.0.1', + remotePort: 7860, + label: undefined + } + h.active.push(entry) + h.connectionManager.getConnection.mockReturnValue(undefined) + return entry + } + ) + await restorePortForwards('t1', () => null) + expect(h.portForwardManager.removeForwardAndWait).toHaveBeenCalledWith('pf-late') + expect(h.active).toEqual([]) + h.connectionManager.getConnection.mockReturnValue(h.conn) + }) + + it('skips a queued managed restore once the host stopped being managed', async () => { + let release: () => void = () => {} + h.portForwardManager.closeStaleForwards.mockImplementationOnce( + () => new Promise((resolve) => (release = resolve)) + ) + const first = restoreManagedHostPortForwards('t1') + await vi.waitFor(() => expect(h.portForwardManager.closeStaleForwards).toHaveBeenCalled()) + const second = restoreManagedHostPortForwards('t1') + h.hostServerStatus.mockReturnValue(undefined) + release() + await Promise.all([first, second]) + expect(h.portForwardManager.closeStaleForwards).toHaveBeenCalledTimes(1) + h.hostServerStatus.mockReturnValue({ kind: 'managed', environmentId: 'env-1' }) + }) }) From 3aaa6a854a6065f0a4d9c53e864a927fde048122 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:40:35 -0700 Subject: [PATCH 096/110] Require pointer rest before opening activity hover cards (#25931) * Require pointer rest before opening activity hover cards Defer hover card open until pointer stops moving, preventing accidental popover display on rapid mouse movement. Add progress indicator showing rest duration. Keyboard focus still opens the card immediately. * refactor to keep file small --- ...activity-thread-hover-card-intent.test.tsx | 219 ++++++++++++++++++ .../activity-thread-hover-card-intent.ts | 191 +++++++++++++++ .../activity-thread-hover-card-trigger.tsx | 27 +++ .../activity/activity-thread-hover-card.tsx | 15 +- .../activity/activity-thread-row.tsx | 5 + 5 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/activity/activity-thread-hover-card-intent.test.tsx create mode 100644 src/renderer/src/components/activity/activity-thread-hover-card-intent.ts create mode 100644 src/renderer/src/components/activity/activity-thread-hover-card-trigger.tsx diff --git a/src/renderer/src/components/activity/activity-thread-hover-card-intent.test.tsx b/src/renderer/src/components/activity/activity-thread-hover-card-intent.test.tsx new file mode 100644 index 000000000000..5433596fe827 --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-hover-card-intent.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom + +import { act, useState, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { + _resetActivityHoverCardIntentForTest, + ACTIVITY_HOVER_CARD_PROGRESS_DELAY_MS, + ACTIVITY_HOVER_CARD_REST_MS, + ACTIVITY_HOVER_CARD_WARM_REST_MS, + useActivityThreadHoverCardIntent +} from './activity-thread-hover-card-intent' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +// Same wiring as ActivityThreadHoverCard, over the real Radix primitive, without the store-backed card body. +function Row({ id }: { id: string }): ReactElement { + const [open, setOpen] = useState(false) + const intent = useActivityThreadHoverCardIntent({ + open, + onOpenChange: setOpen + }) + return ( + + +
+ {id} +
+
+ card {id} +
+ ) +} + +function List(): ReactElement { + return ( +
+ + +
+ ) +} + +describe('useActivityThreadHoverCardIntent', () => { + let container: HTMLDivElement + let root: Root + + const row = (id: string): HTMLElement => { + const el = container.querySelector(`[data-testid="${id}"]`) + if (!el) { + throw new Error(`missing row ${id}`) + } + return el + } + const isOpen = (id: string): boolean => row(id).getAttribute('data-open') === 'true' + const isResting = (id: string): boolean => row(id).hasAttribute('data-hover-card-resting') + + const pointer = ( + type: string, + target: Element, + x: number, + y: number, + relatedTarget: Element | null = null + ): void => { + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + pointerType: 'mouse', + relatedTarget + }) + ) + }) + } + const enter = (id: string, x: number, y: number, from: Element | null = null): void => { + if (from) { + pointer('pointerout', from, x, y, row(id)) + } + pointer('pointerover', row(id), x, y, from) + pointer('pointermove', row(id), x, y) + } + const advance = (ms: number): void => { + act(() => { + vi.advanceTimersByTime(ms) + }) + } + const scroll = (target: Element): void => { + act(() => { + target.dispatchEvent(new Event('scroll')) + }) + } + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) + _resetActivityHoverCardIntentForTest() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root.render()) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.replaceChildren() + vi.useRealTimers() + }) + + it('opens only after the pointer rests, showing progress partway through', () => { + enter('a', 10, 10) + + advance(ACTIVITY_HOVER_CARD_PROGRESS_DELAY_MS - 1) + expect(isResting('a')).toBe(false) + advance(1) + expect(isResting('a')).toBe(true) + + advance(ACTIVITY_HOVER_CARD_REST_MS - ACTIVITY_HOVER_CARD_PROGRESS_DELAY_MS - 1) + expect(isOpen('a')).toBe(false) + advance(1) + expect(isOpen('a')).toBe(true) + expect(isResting('a')).toBe(false) + }) + + it('restarts the rest while the pointer keeps moving', () => { + enter('a', 10, 10) + advance(400) + pointer('pointermove', row('a'), 11, 10) + advance(400) + expect(isOpen('a')).toBe(false) + expect(isResting('a')).toBe(true) + + advance(ACTIVITY_HOVER_CARD_REST_MS - 400) + expect(isOpen('a')).toBe(true) + }) + + it('ignores hover events from a row sliding under a still pointer', () => { + enter('a', 10, 10) + advance(100) + // Scrolling moves row b under the same coordinates. + enter('b', 10, 10, row('a')) + + advance(ACTIVITY_HOVER_CARD_REST_MS * 2) + expect(isOpen('a')).toBe(false) + expect(isOpen('b')).toBe(false) + expect(isResting('b')).toBe(false) + }) + + it('cancels a pending rest and closes an open card when the list scrolls', () => { + enter('a', 10, 10) + advance(300) + scroll(row('list')) + advance(ACTIVITY_HOVER_CARD_REST_MS) + expect(isOpen('a')).toBe(false) + expect(isResting('a')).toBe(false) + + pointer('pointermove', row('a'), 12, 10) + advance(ACTIVITY_HOVER_CARD_REST_MS) + expect(isOpen('a')).toBe(true) + + scroll(row('list')) + expect(isOpen('a')).toBe(false) + }) + + it('stays open when an unrelated element scrolls', () => { + const unrelated = document.createElement('div') + document.body.appendChild(unrelated) + enter('a', 10, 10) + advance(ACTIVITY_HOVER_CARD_REST_MS) + + scroll(unrelated) + expect(isOpen('a')).toBe(true) + }) + + it('switches rows on a short rest while a card is showing', () => { + enter('a', 10, 10) + advance(ACTIVITY_HOVER_CARD_REST_MS) + expect(isOpen('a')).toBe(true) + + enter('b', 10, 40, row('a')) + advance(ACTIVITY_HOVER_CARD_WARM_REST_MS) + expect(isOpen('b')).toBe(true) + expect(isOpen('a')).toBe(false) + }) + + it('needs the full rest again after a scroll dismissed the card', () => { + enter('a', 10, 10) + advance(ACTIVITY_HOVER_CARD_REST_MS) + scroll(row('list')) + + enter('b', 10, 40, row('a')) + advance(ACTIVITY_HOVER_CARD_WARM_REST_MS) + expect(isOpen('b')).toBe(false) + advance(ACTIVITY_HOVER_CARD_REST_MS - ACTIVITY_HOVER_CARD_WARM_REST_MS) + expect(isOpen('b')).toBe(true) + }) + + it('does not open for a pointer that clicks before resting', () => { + enter('a', 10, 10) + advance(200) + pointer('pointerdown', row('a'), 10, 10) + advance(ACTIVITY_HOVER_CARD_REST_MS) + expect(isOpen('a')).toBe(false) + }) + + it('still opens from keyboard focus', () => { + act(() => row('a').focus()) + advance(200) + expect(isOpen('a')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-hover-card-intent.ts b/src/renderer/src/components/activity/activity-thread-hover-card-intent.ts new file mode 100644 index 000000000000..e409e0a49e2a --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-hover-card-intent.ts @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type React from 'react' + +// The pointer must rest this long on a row before its details card opens. +export const ACTIVITY_HOVER_CARD_REST_MS = 550 +// The rest-progress line waits this long so crossing rows doesn't flash it. +export const ACTIVITY_HOVER_CARD_PROGRESS_DELAY_MS = 150 +// Right after a card was showing, a short rest is enough to switch rows. +export const ACTIVITY_HOVER_CARD_WARM_REST_MS = 120 +const WARM_WINDOW_MS = 400 + +// Module-level so a row that slides under a still pointer compares against what the previous row saw. +let lastPointerPosition: { x: number; y: number } | null = null +let openCardCount = 0 +let lastCardClosedAt = Number.NEGATIVE_INFINITY + +// Why: scrolling slides rows under a still pointer and Chromium fires hover events at the +// unchanged coordinates. Only a pointer that actually moved signals intent to peek. +function recordPointerMove(x: number, y: number): boolean { + const moved = lastPointerPosition?.x !== x || lastPointerPosition?.y !== y + lastPointerPosition = { x, y } + return moved +} + +function isKeyboardFocus(target: EventTarget): boolean { + return target instanceof Element && target.matches(':focus-visible') +} + +export function _resetActivityHoverCardIntentForTest(): void { + lastPointerPosition = null + openCardCount = 0 + lastCardClosedAt = Number.NEGATIVE_INFINITY +} + +type RestTimers = { progress: number | undefined; open: number } + +// Replaces Radix's open-on-enter with open-on-rest; Radix still owns closing on leave/blur. +export function useActivityThreadHoverCardIntent({ + open, + onOpenChange +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [resting, setResting] = useState(false) + const triggerRef = useRef(null) + const restTimersRef = useRef(null) + const detachScrollWatchRef = useRef<(() => void) | null>(null) + const closedByScrollRef = useRef(false) + const latestRef = useRef({ open, onOpenChange }) + useEffect(() => { + latestRef.current = { open, onOpenChange } + }) + + const clearRestTimers = useCallback(() => { + const timers = restTimersRef.current + if (timers) { + window.clearTimeout(timers.progress) + window.clearTimeout(timers.open) + restTimersRef.current = null + } + }, []) + + const cancelRest = useCallback(() => { + clearRestTimers() + setResting(false) + }, [clearRestTimers]) + + // Watch scrolls only while a rest is pending or the card is open, to stay off the hot path. + const syncScrollWatch = useCallback(() => { + const needed = latestRef.current.open || restTimersRef.current !== null + if (!needed) { + detachScrollWatchRef.current?.() + detachScrollWatchRef.current = null + return + } + const trigger = triggerRef.current + if (detachScrollWatchRef.current || !trigger) { + return + } + const handleScroll = (event: Event): void => { + // Scrolls inside the portaled card (or anywhere unrelated) don't move this row. + if (!(event.target instanceof Node) || !event.target.contains(trigger)) { + return + } + clearRestTimers() + setResting(false) + if (latestRef.current.open) { + closedByScrollRef.current = true + latestRef.current.onOpenChange(false) + } + } + document.addEventListener('scroll', handleScroll, { capture: true, passive: true }) + detachScrollWatchRef.current = () => + document.removeEventListener('scroll', handleScroll, { capture: true }) + }, [clearRestTimers]) + + useEffect(() => { + syncScrollWatch() + if (!open) { + return + } + openCardCount += 1 + return () => { + openCardCount -= 1 + // A scroll-dismissed card shouldn't make the next row open on a short rest. + if (closedByScrollRef.current) { + closedByScrollRef.current = false + } else { + lastCardClosedAt = window.performance.now() + } + } + }, [open, syncScrollWatch]) + + useEffect( + () => () => { + clearRestTimers() + detachScrollWatchRef.current?.() + detachScrollWatchRef.current = null + }, + [clearRestTimers] + ) + + const openFromRest = useCallback(() => { + restTimersRef.current = null + setResting(false) + latestRef.current.onOpenChange(true) + }, []) + + const onPointerEnter = useCallback((event: React.PointerEvent) => { + triggerRef.current = event.currentTarget + // Blocks Radix's open-on-enter. While open, let it through so returning from the card cancels its close. + if (!latestRef.current.open) { + event.preventDefault() + } + }, []) + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType === 'touch' || !recordPointerMove(event.clientX, event.clientY)) { + return + } + triggerRef.current = event.currentTarget + if (latestRef.current.open) { + return + } + clearRestTimers() + setResting(false) + const warm = openCardCount > 0 || window.performance.now() - lastCardClosedAt < WARM_WINDOW_MS + restTimersRef.current = warm + ? { + progress: undefined, + open: window.setTimeout(openFromRest, ACTIVITY_HOVER_CARD_WARM_REST_MS) + } + : { + progress: window.setTimeout( + () => setResting(true), + ACTIVITY_HOVER_CARD_PROGRESS_DELAY_MS + ), + open: window.setTimeout(openFromRest, ACTIVITY_HOVER_CARD_REST_MS) + } + syncScrollWatch() + }, + [clearRestTimers, openFromRest, syncScrollWatch] + ) + + // Leaving or clicking the row means the user isn't waiting to peek it. + const onPointerLeave = useCallback(() => { + cancelRest() + syncScrollWatch() + }, [cancelRest, syncScrollWatch]) + + const onFocus = useCallback((event: React.FocusEvent) => { + triggerRef.current = event.currentTarget + // Clicking the row's title focuses it too; only keyboard focus should open the card. + if (!isKeyboardFocus(event.target)) { + event.preventDefault() + } + }, []) + + return { + resting, + triggerHandlers: { + onPointerEnter, + onPointerMove, + onPointerLeave, + onPointerDown: onPointerLeave, + onFocus + } + } +} diff --git a/src/renderer/src/components/activity/activity-thread-hover-card-trigger.tsx b/src/renderer/src/components/activity/activity-thread-hover-card-trigger.tsx new file mode 100644 index 000000000000..7d403f4ac6cd --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-hover-card-trigger.tsx @@ -0,0 +1,27 @@ +import type React from 'react' +import { HoverCardTrigger } from '@/components/ui/hover-card' +import { useActivityThreadHoverCardIntent } from './activity-thread-hover-card-intent' + +type ActivityThreadHoverCardTriggerProps = { + children: React.ReactElement + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ActivityThreadHoverCardTrigger({ + children, + open, + onOpenChange +}: ActivityThreadHoverCardTriggerProps): React.JSX.Element { + const intent = useActivityThreadHoverCardIntent({ open, onOpenChange }) + + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.tsx index 849d8f92e25f..584143e91552 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.tsx @@ -2,7 +2,7 @@ import React, { useCallback } from 'react' import { ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' -import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { HoverCard, HoverCardContent } from '@/components/ui/hover-card' import { LinearIcon } from '@/components/icons/LinearIcon' import { JiraIcon } from '@/components/icons/JiraIcon' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' @@ -28,11 +28,13 @@ import { useWorktreeCardLifecycleEffects } from '../sidebar/use-worktree-card-li import { useWorktreeCardSecondaryDetails } from '../sidebar/use-worktree-card-secondary-details' import { getReviewLabel } from '../sidebar/worktree-review-helpers' import { ActivityThreadHoverCardSummary } from './activity-thread-hover-card-summary' +import { ActivityThreadHoverCardTrigger } from './activity-thread-hover-card-trigger' import type { AgentPaneThread } from './activity-thread-types' export type ActivityThreadHoverCardProps = { thread: AgentPaneThread children: React.ReactElement + // Keyboard focus only; pointer hover opens on rest (see activity-thread-hover-card-intent). openDelay?: number closeDelay?: number onJumpToWorkspace?: (thread: AgentPaneThread) => void @@ -60,7 +62,12 @@ export function ActivityThreadHoverCard({ openDelay={openDelay} closeDelay={closeDelay} > - {children} + + {children} + {open ? (
+ {/* Fills while the pointer rests; the hover card sets the attribute. Duration = rest minus progress delay. */} +
) From 4c4f1ee18c8948cce8cab26d6f9ca29c3b2395a5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:54:03 -0400 Subject: [PATCH 097/110] test(e2e): reusable phone-mirror test setup and baseline specs (#26412) * test(e2e): add a paired phone client helper and phone-mirror baseline specs Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve restart and today's gap: a desktop's phone does not list the desktop's server workspaces. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): compose the phone-mirror topology and check the cold snapshot Slices need one composer for host, desktop and phone with a single teardown, so specs stop copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold start, which is what the phone builds its tab strip from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): keep phone-mirror teardown errors from masking test failures Teardown failures become a test attachment instead of replacing the real failure, and the cold-snapshot poll reports stream errors together with the PTY list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../helpers/headless-paired-runtime-host.ts | 8 +- tests/e2e/helpers/paired-electron-client.ts | 16 ++++ tests/e2e/helpers/paired-mobile-client.ts | 87 ++++++++++++++++++ tests/e2e/helpers/phone-mirror-topology.ts | 89 +++++++++++++++++++ ...red-mobile-desktop-mirror-baseline.spec.ts | 61 +++++++++++++ ...mobile-serve-restart-terminal-tabs.spec.ts | 86 ++++++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/helpers/paired-mobile-client.ts create mode 100644 tests/e2e/helpers/phone-mirror-topology.ts create mode 100644 tests/e2e/paired-mobile-desktop-mirror-baseline.spec.ts create mode 100644 tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index bdf84015ebdd..aa0a1094cf00 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -96,6 +96,11 @@ export async function launchHeadlessPairedRuntimeHost( options: { agentBrowserSocketParent?: string executablePath?: string + /** + * `mobile` makes `offer` a phone pairing (`orca serve --mobile-pairing`). It is the host's only + * offer, so no desktop can pair to such a host: a desktop plus a direct phone is unsupported. + */ + pairingScope?: 'runtime' | 'mobile' /** Bind a stable loopback port so `restartServeProcess` can reclaim it. */ pinnedServePort?: boolean userDataParent?: string @@ -141,7 +146,8 @@ export async function launchHeadlessPairedRuntimeHost( '--serve-port', String(servePort), '--serve-pairing-address', - '127.0.0.1' + '127.0.0.1', + ...(options.pairingScope === 'mobile' ? ['--serve-mobile-pairing'] : []) ], env: isolation.env }) diff --git a/tests/e2e/helpers/paired-electron-client.ts b/tests/e2e/helpers/paired-electron-client.ts index 465974156689..7664b95dd27a 100644 --- a/tests/e2e/helpers/paired-electron-client.ts +++ b/tests/e2e/helpers/paired-electron-client.ts @@ -105,6 +105,22 @@ export async function createRuntimeDesktopPairingOffer( }) } +/** The phone QR a desktop shows in Settings, minted LAN-only so no Relay is involved. */ +export async function createDesktopMobilePairingOffer( + desktopPage: Page +): Promise { + return desktopPage.evaluate(async () => { + const offer = await window.api.mobile.getPairingQR({ + address: '127.0.0.1', + connectionMode: 'local-only' + }) + if (!offer.available) { + throw new Error(`Desktop did not mint a mobile pairing offer: ${JSON.stringify(offer)}`) + } + return { pairingUrl: offer.pairingUrl } + }) +} + export async function launchPairedWebClient( hubApp: ElectronApplication, offer: RuntimeDesktopPairingOffer, diff --git a/tests/e2e/helpers/paired-mobile-client.ts b/tests/e2e/helpers/paired-mobile-client.ts new file mode 100644 index 000000000000..bbed0c524c98 --- /dev/null +++ b/tests/e2e/helpers/paired-mobile-client.ts @@ -0,0 +1,87 @@ +import { MOBILE_RUNTIME_CLIENT_CAPABILITIES } from '../../../mobile/src/transport/mobile-runtime-client-capabilities' +import { decodePairingOffer } from '../../../src/shared/pairing' +import { + sendRemoteRuntimeRequest, + subscribeRemoteRuntimeRequest, + type RemoteRuntimeSubscription, + type RemoteRuntimeSubscriptionCallbacks +} from '../../../src/shared/remote-runtime-client' +import type { RuntimeRpcResponse } from '../../../src/shared/runtime-rpc-envelope' +import type { RuntimeDesktopPairingOffer } from './paired-electron-client' + +const DEFAULT_TIMEOUT_MS = 15_000 + +/** + * A phone paired to one runtime: the mobile-scoped device token and the phone app's own + * capability list, over the app's E2EE transport. Each request opens a fresh socket, so requests + * reach a restarted host; subscriptions do not reconnect across a host restart. + */ +export type PairedMobileClient = { + /** Raw response, for asserting a refusal. */ + request: ( + method: string, + params?: unknown, + timeoutMs?: number + ) => Promise> + /** Result of a call that must succeed; throws with the host's error code otherwise. */ + call: (method: string, params?: unknown, timeoutMs?: number) => Promise + subscribe: ( + method: string, + params: unknown, + callbacks: RemoteRuntimeSubscriptionCallbacks, + timeoutMs?: number + ) => Promise + /** Closes every subscription this client opened. */ + dispose: () => void +} + +export function pairMobileClient(offer: RuntimeDesktopPairingOffer): PairedMobileClient { + const pairing = decodePairingOffer(offer.pairingUrl) + // Why: a runtime-scope token skips the mobile allowlist, so the oracle would not be a phone. + if (pairing.scope !== 'mobile') { + throw new Error(`Expected a mobile-scoped pairing offer, got scope ${String(pairing.scope)}`) + } + const open = new Set() + const request = ( + method: string, + params: unknown = {}, + timeoutMs = DEFAULT_TIMEOUT_MS + ): Promise> => + sendRemoteRuntimeRequest( + pairing, + method, + params, + timeoutMs, + undefined, + undefined, + MOBILE_RUNTIME_CLIENT_CAPABILITIES + ) + return { + request, + call: async (method: string, params?: unknown, timeoutMs?: number): Promise => { + const response = await request(method, params, timeoutMs) + if (!response.ok) { + throw new Error(`${method} failed: ${response.error.code}: ${response.error.message}`) + } + return response.result + }, + subscribe: async (method, params, callbacks, timeoutMs = DEFAULT_TIMEOUT_MS) => { + const subscription = await subscribeRemoteRuntimeRequest( + pairing, + method, + params, + timeoutMs, + callbacks, + { clientCapabilities: MOBILE_RUNTIME_CLIENT_CAPABILITIES } + ) + open.add(subscription) + return subscription + }, + dispose: () => { + for (const subscription of open) { + subscription.close() + } + open.clear() + } + } +} diff --git a/tests/e2e/helpers/phone-mirror-topology.ts b/tests/e2e/helpers/phone-mirror-topology.ts new file mode 100644 index 000000000000..40d2094e9cb6 --- /dev/null +++ b/tests/e2e/helpers/phone-mirror-topology.ts @@ -0,0 +1,89 @@ +import type { TestInfo } from '@stablyai/playwright-test' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './headless-paired-runtime-host' +import { + createDesktopMobilePairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './paired-electron-client' +import { pairMobileClient, type PairedMobileClient } from './paired-mobile-client' + +export type PhoneMirrorTopology = { + /** Headless `orca serve`. */ + host: HeadlessPairedRuntimeHost + /** Desktop paired to `host` as a client; null when the phone pairs with the host directly. */ + desktop: TDesktop + phone: PairedMobileClient + /** + * Tears down phone, desktop, then host; every step runs even if an earlier one throws. Never + * throws, so it cannot replace a test failure: teardown errors become a `teardown-error` attachment. + */ + dispose: () => Promise +} + +type PhoneMirrorTopologyOptions = { + /** Lets `host.restartServeProcess` reclaim the same port. */ + pinnedServePort?: boolean +} + +/** + * The phone-mirror test topology: a serve host, optionally a desktop paired to it, and a phone + * paired with the desktop (the mirror) or directly with the host. + */ +export function launchPhoneMirrorTopology( + options: PhoneMirrorTopologyOptions & { phoneTo: 'desktop' }, + testInfo: TestInfo +): Promise> +export function launchPhoneMirrorTopology( + options: PhoneMirrorTopologyOptions & { phoneTo: 'host' }, + testInfo: TestInfo +): Promise> +export async function launchPhoneMirrorTopology( + options: PhoneMirrorTopologyOptions & { phoneTo: 'desktop' | 'host' }, + testInfo: TestInfo +): Promise> { + const cleanups: (() => void | Promise)[] = [] + const runCleanups = async (): Promise => { + const failures: unknown[] = [] + for (const cleanup of cleanups.splice(0).toReversed()) { + try { + await cleanup() + } catch (error) { + failures.push(error) + } + } + return failures + } + try { + const host = await launchHeadlessPairedRuntimeHost({ + pairingScope: options.phoneTo === 'host' ? 'mobile' : 'runtime', + pinnedServePort: options.pinnedServePort + }) + cleanups.push(host.dispose) + let desktop: PairedElectronClient | null = null + if (options.phoneTo === 'desktop') { + const launched = await launchPairedElectronClient(host.offer, testInfo, 'mirror-desktop') + cleanups.push(launched.dispose) + desktop = launched + } + const phone = pairMobileClient( + desktop ? await createDesktopMobilePairingOffer(desktop.page) : host.offer + ) + cleanups.push(phone.dispose) + const dispose = async (): Promise => { + const failures = await runCleanups() + if (failures.length > 0) { + await testInfo.attach('teardown-error', { body: failures.map(String).join('\n') }) + } + } + return { host, desktop, phone, dispose } + } catch (error) { + const failures = await runCleanups() + if (failures.length > 0) { + throw new AggregateError([error, ...failures], 'Phone mirror startup and cleanup failed') + } + throw error + } +} diff --git a/tests/e2e/paired-mobile-desktop-mirror-baseline.spec.ts b/tests/e2e/paired-mobile-desktop-mirror-baseline.spec.ts new file mode 100644 index 000000000000..8bf436018851 --- /dev/null +++ b/tests/e2e/paired-mobile-desktop-mirror-baseline.spec.ts @@ -0,0 +1,61 @@ +/** + * Phone paired with a desktop that is itself a client of an `orca serve` host. + * + * BASELINE, not the goal: today the phone sees only the desktop's own workspaces, because the + * desktop main process never holds its paired servers' workspaces. The mirror slice (S2) flips the + * second assertion; update it there instead of deleting it. + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology' +import { RuntimeClient } from '../../src/cli/runtime/client' +import type { RuntimeWorktreePsSummary } from '../../src/shared/runtime-worktree-contracts' + +test('baseline until S2: phone paired with a desktop lists its local workspace but not its server workspace', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(120_000) + const serverFolder = testInfo.outputPath('server-folder') + mkdirSync(serverFolder, { recursive: true }) + writeFileSync(path.join(serverFolder, 'README.md'), 'server workspace\n') + + const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology( + { phoneTo: 'desktop' }, + testInfo + ) + try { + await host.client.call('repo.add', { path: serverFolder, kind: 'folder' }) + await new RuntimeClient(desktop.userDataDir, 5_000).call('repo.add', { + path: testRepoPath, + kind: 'git' + }) + // Precondition: the desktop window does show the server's workspace. + await expect + .poll( + () => + desktop.page.evaluate( + (folder) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.path === folder) ?? false, + serverFolder + ), + { timeout: 30_000 } + ) + .toBe(true) + + const { worktrees } = await phone.call<{ worktrees: RuntimeWorktreePsSummary[] }>( + 'worktree.ps', + { limit: 1_000 } + ) + const paths = worktrees.map((row) => row.path) + expect(paths).toContain(testRepoPath) + // BASELINE: the server's workspace is missing from the phone. S2 flips this to an `expect.poll` + // with toContain, since the desktop hydrates the server's rows asynchronously. + expect(paths).not.toContain(serverFolder) + } finally { + await dispose() + } +}) diff --git a/tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts b/tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts new file mode 100644 index 000000000000..caeafffc1577 --- /dev/null +++ b/tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts @@ -0,0 +1,86 @@ +/** + * A phone paired directly with `orca serve` still lists a `terminal.create` terminal after the + * serve process restarts (an update does this), both in its first tab-stream snapshot and in a + * tab list. Regression oracle for #26022: the cold-start hydrate used to keep only serve-minted + * PTYs, so CLI and agent terminals vanished from the phone. + */ +import { expect, test } from './helpers/orca-app' +import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology' +import type { PairedMobileClient } from './helpers/paired-mobile-client' +import type { RuntimeWorktreePsSummary } from '../../src/shared/runtime-worktree-contracts' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts' + +function sortedPtyIds(ids: (string | null | undefined)[]): string[] { + return ids.flatMap((id) => (id ? [id] : [])).sort() +} + +function terminalPtyIds(snapshot: RuntimeMobileSessionTabsResult): string[] { + return sortedPtyIds(snapshot.tabs.map((tab) => (tab.type === 'terminal' ? tab.ptyId : null))) +} + +async function listedPtyIds(phone: PairedMobileClient, worktreeId: string): Promise { + const listed = await phone.request('session.tabs.list', { + worktree: `id:${worktreeId}` + }) + return listed.ok ? terminalPtyIds(listed.result) : [`refused:${listed.error.code}`] +} + +test('phone paired with orca serve keeps a terminal.create terminal across a serve restart', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(120_000) + const { host, phone, dispose } = await launchPhoneMirrorTopology( + { phoneTo: 'host', pinnedServePort: true }, + testInfo + ) + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + const { worktrees } = await phone.call<{ worktrees: RuntimeWorktreePsSummary[] }>( + 'worktree.ps', + { limit: 1_000 } + ) + const worktreeId = worktrees.find((row) => row.path === testRepoPath)?.worktreeId ?? '' + expect(worktreeId, 'the phone lists the serve host workspace').not.toBe('') + + // Why both: the bug kept serve-minted PTYs (the phone's own create) and dropped daemon-minted ones. + const phoneCreated = await phone.call<{ tab: { ptyId?: string | null } }>( + 'session.tabs.createTerminal', + { worktree: `id:${worktreeId}`, activate: false, select: true, navigation: 'caller' } + ) + const cliCreated = await host.client.call<{ terminal: { ptyId?: string | null } }>( + 'terminal.create', + { worktree: `path:${testRepoPath}`, title: 'cli-created' } + ) + const ptyIds = [phoneCreated.tab.ptyId, cliCreated.result.terminal.ptyId] + expect(ptyIds.every(Boolean), 'both creates report their PTY').toBe(true) + const expected = sortedPtyIds(ptyIds) + await expect.poll(() => listedPtyIds(phone, worktreeId)).toEqual(expected) + + await host.restartServeProcess() + + // The phone's session strip is built from this stream's first snapshot after the cold start. + let snapshotPtyIds: string[] | null = null + const streamErrors: string[] = [] + await phone.subscribe<{ type: string } & RuntimeMobileSessionTabsResult>( + 'session.tabs.subscribe', + { worktree: `id:${worktreeId}` }, + { + onResponse: (response) => { + if (!response.ok) { + streamErrors.push(response.error.code) + } else if (response.result.type === 'snapshot' && !snapshotPtyIds) { + snapshotPtyIds = terminalPtyIds(response.result) + } + }, + onError: (error) => streamErrors.push(error.message) + }, + 30_000 + ) + await expect + .poll(() => ({ snapshotPtyIds, streamErrors })) + .toEqual({ snapshotPtyIds: expected, streamErrors: [] }) + expect(await listedPtyIds(phone, worktreeId)).toEqual(expected) + } finally { + await dispose() + } +}) From ff27338299e5db29791a086418d9d3040d8fc956 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 22:10:33 -0700 Subject: [PATCH 098/110] refactor(ssh): connect fails for hosts orcad can't serve; remove the relay client path (#27214) * wip: connect fails for hosts the managed server can't serve; remove relay session, deploy and relay UI * wip: delete relay-only client modules left unreachable, prune their tests and gates * wip: format * wip: exhaustive unserved message * wip: drop relay attestation test for the deleted ownership proof * fix: prune stale baselines; type the test transport stub * fix(ssh): census proves leftover relay leases; name the remedy for older terminals; translate every connect-failure surface * test(ssh): leftover leases defer to the host census * test(e2e): remove relay-path SSH e2e specs and their CI lanes * test(e2e): drop the auto-convert case that built its relay era through a live relay * test(e2e): mark relay-era browser and catalog journeys fixme until they seed a profile * test(e2e): seed relay-era browser and catalog journeys from a saved profile The three journeys built their old-version state through a connect without an orcad template, which now refuses the host. They now start from a seeded relay-era profile, so the first connect converts the host as an upgrading user's would. * chore(ci): drop shard timings for the deleted relay tests * test(e2e): carry a relay-era login cookie across the browser conversion; own every launch * test(ci): drop the deleted SSH layout oracle from its route * test(e2e): open the retained browser after conversion, retrying a pre-connect card * test(e2e): bind the relay-era browser partition before seeding its cookie A relay-era build always recorded the partition binding before a page stored data in it. Seeding the cookie into an unbound partition made the binding store refuse it (browser_route_partition_binding_store_invalid), so the retained tab showed the generic routing card and Retry could never clear it. Co-Authored-By: Claude --------- Co-authored-by: m4air Co-authored-by: Claude --- .github/workflows/e2e.yml | 230 -- .github/workflows/pr.yml | 10 +- .github/workflows/ssh-hostile-hosts.yml | 6 +- .github/workflows/ssh-windows-hosts.yml | 5 +- .../invoke-pinned-relay-cells.ps1 | 4 +- config/max-lines-baseline.txt | 2 - config/performance-audit.md | 3 +- .../process-host-direct-import-baseline.txt | 1 - config/reliability-gates.jsonc | 716 +--- config/scripts/ci-e2e-job-selection.mjs | 50 +- config/scripts/ci-e2e-job-selection.test.mjs | 32 +- config/scripts/ci-shard-timings.json | 116 - ...alization-extraction-change-scope.test.mjs | 2 +- .../orcad-windows-prebuild-workflow.test.mjs | 4 +- config/scripts/pr-code-change-scope.mjs | 1 - config/scripts/pr-e2e-gate-contract.test.mjs | 148 +- .../pr-e2e-layout-oracle-routing.test.mjs | 5 +- .../pr-e2e-native-only-routing.test.mjs | 1 - .../pr-e2e-pane-close-routing.test.mjs | 6 +- config/scripts/pr-e2e-source-routing.mjs | 99 +- config/scripts/pr-ready-check-reuse.test.mjs | 7 +- .../release-cut-token-permissions.test.mjs | 2 - .../run-local-ssh-browser-routing-e2e.mjs | 42 - config/scripts/run-nested-runtime-ssh-e2e.mjs | 32 - ...ient-hosted-browser-drop-reconnect-e2e.mjs | 42 - .../run-ssh-codex-artifacts-repro-e2e.mjs | 45 - .../run-ssh-docker-bulk-open-freeze-e2e.mjs | 43 - config/scripts/run-ssh-docker-e2e.mjs | 99 - config/scripts/run-ssh-docker-perf-e2e.mjs | 39 - .../run-ssh-docker-terminal-parking-e2e.mjs | 49 - .../run-ssh-docker-watcher-isolation-e2e.mjs | 41 - .../run-ssh-staged-upload-reliability.mjs | 7 +- .../scripts/ssh-browser-e2e-routing.test.mjs | 43 +- .../ssh-localhost-e2e-routing.test.mjs | 51 - .../ssh-relay-import-boundary.test.mjs | 3 - .../vitest-sqlite-database-runtime-files.mjs | 2 - .../scripts/vitest-sqlite-runtime-files.mjs | 1 - .../windows-cmd-shim-spawn-boundary.test.mjs | 8 - package.json | 9 - src/main/ai-vault/ssh-session-list.test.ts | 270 -- src/main/ai-vault/ssh-session-list.ts | 248 -- .../ai-vault-host-leg-cache-retention.test.ts | 23 - src/main/ipc/ai-vault-scan-coalescing.test.ts | 178 - src/main/ipc/ai-vault-search.test.ts | 55 +- src/main/ipc/ai-vault-search.ts | 19 +- .../ipc/ai-vault-session-title-routing.ts | 12 - src/main/ipc/ai-vault.test.ts | 422 -- src/main/ipc/ai-vault.ts | 35 +- .../managed-host-status-publication.test.ts | 9 - .../pty-controller-ownership-routing.test.ts | 23 - .../ipc/runtime-environment-managed-tunnel.ts | 8 +- .../ipc/ssh-active-relay-sessions.test.ts | 44 - src/main/ipc/ssh-active-relay-sessions.ts | 29 - src/main/ipc/ssh-advertised-url-refresh.ts | 37 - src/main/ipc/ssh-app-shutdown.test.ts | 279 +- src/main/ipc/ssh-connect-attempt-registry.ts | 5 - ...ssh-connect-decision-cancellation.test.ts} | 97 +- src/main/ipc/ssh-connect-flow.ts | 231 +- src/main/ipc/ssh-connection-handlers.ts | 135 +- .../ipc/ssh-connection-state-callbacks.ts | 111 +- .../ipc/ssh-disconnect-cancellation.test.ts | 382 +- .../ipc/ssh-handler-reregistration.test.ts | 223 +- .../ipc/ssh-host-node-runtime-connect.test.ts | 93 - src/main/ipc/ssh-host-server-connect.ts | 107 +- .../ssh-host-server-decision-failure.test.ts | 6 - .../ssh-host-server-on-connect-wiring.test.ts | 4 +- .../ssh-host-server-relay-decision.test.ts | 51 - src/main/ipc/ssh-host-sleep-reconnect.test.ts | 49 +- src/main/ipc/ssh-host-sleep-reconnect.ts | 58 - src/main/ipc/ssh-ipc-mock-shapes.ts | 4 - src/main/ipc/ssh-ipc-module-mocks.ts | 28 +- src/main/ipc/ssh-ipc-test-harness.ts | 50 +- ...ssh-managed-server-move-conversion.test.ts | 261 -- ...managed-server-move-stop-retention.test.ts | 145 - src/main/ipc/ssh-managed-server-move.test.ts | 143 - src/main/ipc/ssh-managed-server-move.ts | 144 - ...sh-managed-server-unlink-broadcast.test.ts | 17 +- src/main/ipc/ssh-plain-ssh-connect.test.ts | 89 - src/main/ipc/ssh-port-forward-handlers.ts | 6 +- .../ipc/ssh-pty-consumer-identity.test.ts | 245 -- src/main/ipc/ssh-relay-lost-backoff.ts | 32 - src/main/ipc/ssh-relay-reset-resume.test.ts | 584 --- src/main/ipc/ssh-relay-session-callbacks.ts | 197 - src/main/ipc/ssh-renderer-broadcast.ts | 50 +- src/main/ipc/ssh-session-teardown.ts | 119 +- src/main/ipc/ssh-shutdown-drain.ts | 46 +- .../ipc/ssh-state-broadcast-fanout.test.ts | 147 +- src/main/ipc/ssh-target-registry.test.ts | 47 +- ...-terminate-sessions-previous-relay.test.ts | 117 - src/main/ipc/ssh-terminate-sessions.test.ts | 495 --- src/main/ipc/ssh-terminate-sessions.ts | 188 - src/main/ipc/ssh.test.ts | 823 +--- src/main/ipc/ssh.ts | 106 +- .../ssh-lease-async-durability.test.ts | 38 - ...ssh-directory-legacy-compatibility.test.ts | 96 - .../ssh-directory-listing-options.test.ts | 103 - src/main/providers/ssh-directory-listing.ts | 66 - .../ssh-directory-streaming-real.test.ts | 104 - .../providers/ssh-directory-streaming.test.ts | 249 -- src/main/providers/ssh-file-listing.ts | 38 - .../ssh-filesystem-doc-preview.test.ts | 37 - .../providers/ssh-filesystem-doc-preview.ts | 23 - .../providers/ssh-filesystem-download.test.ts | 321 -- src/main/providers/ssh-filesystem-download.ts | 207 - .../providers/ssh-filesystem-file-upload.ts | 35 - .../ssh-filesystem-listing-params.ts | 16 - .../ssh-filesystem-path-existence.ts | 45 - ...h-filesystem-provider-capabilities.test.ts | 143 - .../ssh-filesystem-provider-capabilities.ts | 74 - ...ilesystem-provider-download-folder.test.ts | 180 - .../ssh-filesystem-provider-range.test.ts | 151 - .../ssh-filesystem-provider-stream.test.ts | 412 -- ...-filesystem-provider-watch-waiters.test.ts | 43 - .../ssh-filesystem-provider-watch.ts | 262 -- .../providers/ssh-filesystem-provider.test.ts | 907 ----- src/main/providers/ssh-filesystem-provider.ts | 345 -- .../providers/ssh-filesystem-range-read.ts | 55 - .../ssh-filesystem-terminal-artifact.ts | 54 - ...ssh-filesystem-watch-notifications.test.ts | 172 - .../ssh-filesystem-watch-notifications.ts | 47 - .../ssh-listing-compatibility.test.ts | 98 - .../ssh-markdown-document-listing.ts | 34 - .../ssh-plain-shell-pty-provider.test.ts | 157 - .../providers/ssh-plain-shell-pty-provider.ts | 320 -- .../providers/ssh-sftp-directory-listing.ts | 27 - .../providers/ssh-sftp-filesystem-channel.ts | 55 - .../ssh-sftp-filesystem-provider.test.ts | 333 -- .../providers/ssh-sftp-filesystem-provider.ts | 297 -- ...l-path-existence-batch.integration.test.ts | 69 - .../orca-runtime-files-ssh-rearm.test.ts | 288 -- ...le-commands-ssh-file-watcher-rearm.test.ts | 421 -- .../runtime-file-upload-decode.test.ts | 59 - ...ssh-file-explorer-watch-test-connection.ts | 65 - .../ssh/orcad-migration-terminal-gate.test.ts | 24 + src/main/ssh/orcad-migration-terminal-gate.ts | 5 + src/main/ssh/orcad-runtime-conversion.test.ts | 3 +- src/main/ssh/orcad-windows-host-lane.test.ts | 9 +- ...d-windows-relay-terminal-gate-test-cell.ts | 124 - .../relay-native-dependency-coverage.test.ts | 108 - src/main/ssh/relay-socket-path-limit.test.ts | 141 +- .../ssh/remote-install-gc-termination.test.ts | 8 - .../ssh-bridge-caller-scope.test-fixture.ts | 11 - .../ssh-filesystem-stream-retention.test.ts | 221 - src/main/ssh/ssh-host-cli-deadline.ts | 43 - src/main/ssh/ssh-host-node-runtime-mode.ts | 17 - src/main/ssh/ssh-host-provider-set.test.ts | 89 - src/main/ssh/ssh-host-provider-set.ts | 55 - ...sh-host-relay-terminals-on-connect.test.ts | 14 +- .../ssh/ssh-host-server-move-offer.test.ts | 66 - src/main/ssh/ssh-host-server-move-offer.ts | 41 - src/main/ssh/ssh-host-unserved-status.ts | 36 + src/main/ssh/ssh-hostile-host-cells.ts | 2 +- .../ssh/ssh-hostile-host-managed-orcad.ts | 23 +- src/main/ssh/ssh-hostile-host-test-harness.ts | 274 +- src/main/ssh/ssh-hostile-hosts.docker.test.ts | 64 + .../ssh/ssh-orphan-relay-pty-sweep.test.ts | 325 -- src/main/ssh/ssh-orphan-relay-pty-sweep.ts | 171 - ...h-orphan-sweep-pane-state-verdicts.test.ts | 339 -- .../ssh/ssh-owner-admission-blocked-error.ts | 21 - .../ssh/ssh-pending-pty-kill-replay.test.ts | 410 -- src/main/ssh/ssh-pending-pty-kill-replay.ts | 215 - src/main/ssh/ssh-plain-ssh-session.ts | 115 - src/main/ssh/ssh-port-scanner.test.ts | 295 -- src/main/ssh/ssh-port-scanner.ts | 181 - .../ssh/ssh-pty-consumer-recovery.test.ts | 60 - src/main/ssh/ssh-pty-consumer-recovery.ts | 119 - src/main/ssh/ssh-pty-frame-rejection.ts | 115 - .../ssh-pty-recovery-retention-budget.test.ts | 43 - .../ssh/ssh-pty-recovery-retention-budget.ts | 95 - .../ssh-pty-retired-source-deliveries.test.ts | 35 - .../ssh/ssh-pty-retired-source-deliveries.ts | 36 - .../ssh/ssh-pty-targeted-reattach-queue.ts | 57 - .../ssh/ssh-relay-build-toolchain.test.ts | 197 - src/main/ssh/ssh-relay-build-toolchain.ts | 46 - .../ssh-relay-bundled-ripgrep.docker.test.ts | 197 - .../ssh-relay-cross-version-isolation.test.ts | 225 -- src/main/ssh/ssh-relay-deploy-helpers.test.ts | 25 - ...ssh-relay-deploy-incumbent-verdict.test.ts | 222 - .../ssh/ssh-relay-deploy-pinned-node.test.ts | 907 ----- .../ssh-relay-deploy-staged-upload.test.ts | 687 ---- .../ssh/ssh-relay-deploy-windows-pipe.test.ts | 344 -- src/main/ssh/ssh-relay-deploy.test.ts | 915 ----- src/main/ssh/ssh-relay-deploy.ts | 2641 ------------ src/main/ssh/ssh-relay-gc-retry.test.ts | 351 -- .../ssh/ssh-relay-host-node-addons.test.ts | 117 - src/main/ssh/ssh-relay-host-node-addons.ts | 110 - .../ssh-relay-hostile-hosts.docker.test.ts | 146 - src/main/ssh/ssh-relay-live-connect.test.ts | 93 - ...ssh-relay-native-deps-cache-deploy.test.ts | 319 -- .../ssh-relay-native-deps-cache-install.ts | 196 - ...-native-deps-install-staged-upload.test.ts | 293 -- .../ssh/ssh-relay-native-deps-install.test.ts | 963 ----- ...sh-relay-native-deps-probe-verdict.test.ts | 355 -- src/main/ssh/ssh-relay-node-headers.test.ts | 164 - src/main/ssh/ssh-relay-node-headers.ts | 97 - .../ssh/ssh-relay-node-pty-repair.test.ts | 204 - src/main/ssh/ssh-relay-node-pty-repair.ts | 115 - .../ssh-relay-node-pty-spawn-repair.test.ts | 303 -- ...-relay-offline-node-headers.docker.test.ts | 204 - .../ssh-relay-opencode-pinned-node.test.ts | 97 - .../ssh/ssh-relay-opencode-pinned-node.ts | 66 - ...sh-relay-opencode-runtime-commands.test.ts | 216 - .../ssh-relay-opencode-runtime-commands.ts | 110 - .../ssh-relay-opencode-runtime-retry.test.ts | 90 - .../ssh/ssh-relay-opencode-runtime-retry.ts | 42 - .../ssh/ssh-relay-opencode-runtime.test.ts | 498 --- src/main/ssh/ssh-relay-opencode-runtime.ts | 237 -- .../ssh-relay-orphan-abandon-paths.test.ts | 267 -- .../ssh/ssh-relay-pinned-node-install.test.ts | 366 -- src/main/ssh/ssh-relay-pinned-node-install.ts | 207 - ...h-relay-pty-master-cloexec-install.test.ts | 361 -- .../ssh-relay-repair-lock-termination.test.ts | 137 - src/main/ssh/ssh-relay-repair-lock.ts | 166 - src/main/ssh/ssh-relay-reset.test.ts | 158 - src/main/ssh/ssh-relay-reset.ts | 42 - .../ssh-relay-ripgrep-cache-gc-commands.ts | 99 - .../ssh-relay-ripgrep-cache-gc-real.test.ts | 207 - .../ssh/ssh-relay-ripgrep-cache-gc.test.ts | 254 -- src/main/ssh/ssh-relay-ripgrep-cache-gc.ts | 262 -- .../ssh/ssh-relay-ripgrep-install.test.ts | 404 -- src/main/ssh/ssh-relay-ripgrep-install.ts | 318 -- src/main/ssh/ssh-relay-runtime-step-plan.ts | 121 - ...ay-session-agent-hooks.integration.test.ts | 802 ---- .../ssh-relay-session-data-delivery.test.ts | 856 ---- .../ssh/ssh-relay-session-incarnation.test.ts | 158 - .../ssh-relay-session-managed-hooks.test.ts | 391 -- .../ssh-relay-session-model-migration.test.ts | 266 -- .../ssh-relay-session-orphan-sweep.test.ts | 293 -- ...-relay-session-pending-kill-replay.test.ts | 147 - .../ssh/ssh-relay-session-plain-ssh.test.ts | 220 - ...-relay-session-previous-relay-hold.test.ts | 237 -- ...elay-session-reconnect-incarnation.test.ts | 830 ---- ...-relay-session-recovery-durability.test.ts | 680 ---- .../ssh-relay-session-recovery-races.test.ts | 839 ---- ...sh-relay-session-rejected-delivery.test.ts | 496 --- .../ssh/ssh-relay-session-relay-loss.test.ts | 306 -- .../ssh-relay-session-terminal-error.test.ts | 243 -- .../ssh/ssh-relay-session-test-fixtures.ts | 80 - src/main/ssh/ssh-relay-session.test.ts | 935 ----- src/main/ssh/ssh-relay-session.ts | 3578 ----------------- .../ssh-relay-sftp-namespace-install.test.ts | 777 ---- .../ssh-relay-upload-cancel.docker.test.ts | 461 --- .../ssh-relay-upload-stage-commands.test.ts | 388 -- .../ssh/ssh-relay-upload-stage-commands.ts | 260 -- .../ssh/ssh-relay-upload-stage-contract.ts | 16 - ...ssh-relay-upload-stage-windows-commands.ts | 279 -- ...elay-upload-stage-windows-identity.test.ts | 201 - .../ssh/ssh-relay-versioned-install.test.ts | 79 - .../ssh/ssh-relay-windows-host-lane.test.ts | 127 - src/main/ssh/ssh-remote-cli-args.ts | 127 - src/main/ssh/ssh-remote-cli-argument-error.ts | 14 - .../ssh-remote-cli-bridge-credential.test.ts | 170 - ...e-cli-dispatch-refusal-passthrough.test.ts | 66 - src/main/ssh/ssh-remote-cli-error-response.ts | 10 - src/main/ssh/ssh-remote-cli-format.test.ts | 52 - src/main/ssh/ssh-remote-cli-format.ts | 50 - .../ssh-remote-cli-host-passthrough.test.ts | 496 --- .../ssh/ssh-remote-cli-host-passthrough.ts | 349 -- .../ssh/ssh-remote-cli-in-process-result.ts | 40 - ...ssh-remote-cli-terminal-host-scope.test.ts | 77 - .../ssh-remote-linear-activity-output.test.ts | 44 - src/main/ssh/ssh-remote-linear-cli.test.ts | 877 ---- src/main/ssh/ssh-remote-linear-cli.ts | 48 - .../ssh/ssh-remote-linear-list-issues.test.ts | 82 - src/main/ssh/ssh-remote-linear-list-issues.ts | 90 - src/main/ssh/ssh-remote-linear-output.ts | 301 -- src/main/ssh/ssh-remote-linear-read-cli.ts | 303 -- src/main/ssh/ssh-remote-linear-read-flags.ts | 83 - src/main/ssh/ssh-remote-linear-read-help.ts | 163 - .../ssh-remote-linear-relation-write.test.ts | 70 - .../ssh/ssh-remote-linear-relation-write.ts | 65 - .../ssh/ssh-remote-linear-result-guards.ts | 210 - .../ssh/ssh-remote-linear-save-issue.test.ts | 116 - src/main/ssh/ssh-remote-linear-save-issue.ts | 128 - ...sh-remote-linear-truncation-output.test.ts | 50 - src/main/ssh/ssh-remote-linear-write-cli.ts | 257 -- src/main/ssh/ssh-remote-linear-write-help.ts | 93 - .../ssh/ssh-remote-linear-write-support.ts | 264 -- .../ssh-remote-node-addon-resolution.test.ts | 73 - .../ssh/ssh-remote-node-addon-resolution.ts | 70 - src/main/ssh/ssh-remote-orca-cli.test.ts | 837 ---- src/main/ssh/ssh-remote-orca-cli.ts | 318 -- .../ssh-remote-orchestration-ask-output.ts | 66 - .../ssh-remote-orchestration-check-output.ts | 48 - ...remote-orchestration-compatibility.test.ts | 498 --- .../ssh-remote-orchestration-post-output.ts | 160 - .../ssh/ssh-remote-orchestration-send.test.ts | 72 - src/main/ssh/ssh-remote-orchestration-send.ts | 94 - ...-remote-windows-command-line-limit.test.ts | 24 - .../ssh/ssh-session-command-audit.test.ts | 23 - .../ssh-system-transport.integration.test.ts | 29 - .../headless-serve-ssh-registration.test.ts | 2 - src/preload/api/ssh-api.ts | 11 +- src/preload/api/ssh-bridge.ts | 13 - .../pty-handler-ownership-attestation.test.ts | 310 -- .../settings/SshHostAdvancedFields.tsx | 139 +- .../SshManagedServerMoveDialog.test.tsx | 131 - .../settings/SshManagedServerMoveDialog.tsx | 124 - .../src/components/settings/SshPane.tsx | 68 +- .../settings/SshTargetCard.test.tsx | 45 +- .../src/components/settings/SshTargetCard.tsx | 185 +- .../settings/SshTargetDestructiveActions.tsx | 124 +- .../settings/SshTargetServerStatus.tsx | 54 +- .../ssh-host-server-status-copy.test.ts | 147 +- .../settings/ssh-host-server-status-copy.ts | 80 +- .../settings/ssh-session-termination.ts | 45 - .../settings/ssh-target-draft.test.ts | 51 +- .../components/settings/ssh-target-draft.ts | 44 +- .../settings/ssh-target-remove.test.ts | 112 - .../components/settings/ssh-target-remove.ts | 38 - .../settings/ssh-target-save-payload.test.ts | 94 - .../settings/ssh-target-save-payload.ts | 29 +- .../components/sidebar/HostRemoveDialog.tsx | 4 +- .../sidebar/WorktreeCardSshHostControl.tsx | 15 +- .../sidebar/add-remote-host-ssh-actions.ts | 24 +- .../status-bar/SshTargetStatusRow.tsx | 9 +- .../TerminalSshReconnectOverlay.tsx | 15 +- .../terminal-pane/terminal-pane-host-state.ts | 19 +- .../ssh-managed-server-state-effects.test.ts | 61 +- .../ssh-managed-server-state-effects.ts | 35 - .../src/i18n/en-runtime-required.json | 16 +- src/renderer/src/i18n/locales/en.json | 77 +- src/renderer/src/i18n/locales/es.json | 80 +- src/renderer/src/i18n/locales/fr.json | 78 +- src/renderer/src/i18n/locales/ja.json | 80 +- src/renderer/src/i18n/locales/ko.json | 80 +- src/renderer/src/i18n/locales/zh.json | 80 +- .../src/ssh/ssh-connect-failure-text.ts | 15 + ...-managed-server-move-terminal-tabs.test.ts | 81 - .../ssh-managed-server-move-terminal-tabs.ts | 70 - .../src/ssh/ssh-managed-server-move.ts | 128 - .../slices/direct-ssh-terminal-retry.test.ts | 6 +- .../src/web/preload-api/web-terminal-api.ts | 2 - src/shared/ssh-managed-server-move.ts | 13 - .../ssh-relay-pty-ownership-proof.test.ts | 363 -- src/shared/ssh-relay-pty-ownership-proof.ts | 246 -- ...icial-opencode-hidden-pressure-scenario.ts | 2 +- .../e2e/ephemeral-vm-provisioned-root.spec.ts | 202 - .../global-setup-cli-artifact.unit.test.ts | 2 - tests/e2e/global-setup.ts | 4 +- .../e2e/helpers/browser-pane-mount-census.ts | 153 - tests/e2e/helpers/docker-ssh-relay-faults.ts | 185 - .../e2e/helpers/docker-ssh-relay-processes.ts | 198 - .../docker-ssh-relay-workspace-snapshot.ts | 41 - .../docker-ssh-relay-worktree-activation.ts | 67 - .../nested-runtime-ssh-relay-lifecycle.ts | 83 - tests/e2e/helpers/nested-runtime-ssh-state.ts | 23 - tests/e2e/helpers/orca-app.ts | 5 +- tests/e2e/helpers/orca-restart.ts | 5 +- tests/e2e/helpers/orcad-upgrade-profile.ts | 2 + .../helpers/paired-web-filesystem-route.ts | 138 - .../ssh-port-forward-lifecycle-evidence.ts | 225 -- .../ssh-port-forward-transport-evidence.ts | 145 - .../ssh-reconnect-failure-observation.ts | 120 - .../helpers/ssh-recovery-input-observation.ts | 53 - tests/e2e/helpers/terminal-tab-open.ts | 27 - tests/e2e/local-ssh-browser-routing.spec.ts | 839 ---- .../e2e/nested-runtime-ssh-lifecycle.spec.ts | 761 ---- tests/e2e/nested-runtime-ssh-routing.spec.ts | 804 ---- tests/e2e/pty-input-write-queue-ssh.spec.ts | 186 - tests/e2e/repo-registration-async.spec.ts | 43 - .../e2e/ssh-ai-vault-session-history.spec.ts | 371 -- ...ient-hosted-browser-drop-reconnect.spec.ts | 370 -- .../ssh-codex-display-artifacts-repro.spec.ts | 204 - tests/e2e/ssh-codex-real-remote.ts | 59 - .../e2e/ssh-codex-reconnect-replay-driver.ts | 270 -- tests/e2e/ssh-codex-repro-remote-fixtures.ts | 233 -- tests/e2e/ssh-codex-terminal-observers.ts | 147 - tests/e2e/ssh-cold-activation-restore.spec.ts | 402 -- ...ssh-cold-hydration-gap-tab-seeding.spec.ts | 306 -- .../ssh-docker-bulk-open-freeze-repro.spec.ts | 186 - ...docker-five-pane-input-under-flood.spec.ts | 152 - tests/e2e/ssh-docker-half-open-link.spec.ts | 125 - ...sh-docker-quick-open-large-listing.spec.ts | 134 - .../ssh-docker-reconnect-pane-restore.spec.ts | 179 - tests/e2e/ssh-docker-relay-perf.spec.ts | 415 -- .../ssh-docker-relay-stall-credential.spec.ts | 245 -- .../ssh-docker-resource-accumulation.spec.ts | 238 -- ...ssh-docker-transport-drop-recovery.spec.ts | 493 --- .../e2e/ssh-docker-watcher-isolation.spec.ts | 227 -- .../e2e/ssh-egress-indicator-preview.spec.ts | 76 - .../ssh-emptied-worktree-reactivation.spec.ts | 123 - tests/e2e/ssh-external-image-preview.spec.ts | 197 - tests/e2e/ssh-localhost.spec.ts | 395 -- .../ssh-lost-kill-tab-resurrection.spec.ts | 198 - .../e2e/ssh-native-chat-phone-history.spec.ts | 253 -- tests/e2e/ssh-orcad-auto-convert.spec.ts | 160 +- tests/e2e/ssh-orcad-browser-routing.spec.ts | 125 +- .../ssh-orcad-browser-service-status.spec.ts | 78 +- .../e2e/ssh-pi-compatible-agent-title.spec.ts | 125 - tests/e2e/ssh-port-forward-lifecycle.spec.ts | 370 -- tests/e2e/ssh-reattach-home-partition.spec.ts | 169 - .../e2e/ssh-reconnect-tab-destruction.spec.ts | 143 - .../e2e/ssh-restart-tab-accumulation.spec.ts | 267 -- tests/e2e/ssh-routing-optout-demo.spec.ts | 80 - tests/e2e/ssh-skill-installation.spec.ts | 236 -- ...-stale-resume-execution-host-scope.spec.ts | 306 -- tests/e2e/ssh-startup-exec-readiness.spec.ts | 111 - tests/e2e/ssh-startup-local-shadow.spec.ts | 146 - tests/e2e/ssh-terminal-parking.spec.ts | 151 - tests/e2e/ssh-terminal-stale-grid-probe.ts | 159 - ...minal-window-wake-stale-grid-repro.spec.ts | 195 - tests/e2e/terminal-inline-images-ssh.spec.ts | 120 - .../e2e/terminal-raster-artifact-analysis.ts | 283 -- tests/e2e/terminal-repro-evidence.ts | 19 - tests/e2e/terminal-retention-budget.spec.ts | 142 - tests/e2e/terminal-webgl-reset-capture.ts | 30 - tests/e2e/workspace-layout-oracle-ssh.spec.ts | 328 -- 408 files changed, 832 insertions(+), 72566 deletions(-) delete mode 100644 config/scripts/run-local-ssh-browser-routing-e2e.mjs delete mode 100644 config/scripts/run-nested-runtime-ssh-e2e.mjs delete mode 100644 config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs delete mode 100644 config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs delete mode 100644 config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs delete mode 100644 config/scripts/run-ssh-docker-e2e.mjs delete mode 100644 config/scripts/run-ssh-docker-perf-e2e.mjs delete mode 100644 config/scripts/run-ssh-docker-terminal-parking-e2e.mjs delete mode 100644 config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs delete mode 100644 config/scripts/ssh-localhost-e2e-routing.test.mjs delete mode 100644 src/main/ai-vault/ssh-session-list.test.ts delete mode 100644 src/main/ai-vault/ssh-session-list.ts delete mode 100644 src/main/ipc/ssh-active-relay-sessions.test.ts delete mode 100644 src/main/ipc/ssh-active-relay-sessions.ts delete mode 100644 src/main/ipc/ssh-advertised-url-refresh.ts rename src/main/ipc/{ssh-connect-recheck-cancellation.test.ts => ssh-connect-decision-cancellation.test.ts} (61%) delete mode 100644 src/main/ipc/ssh-host-node-runtime-connect.test.ts delete mode 100644 src/main/ipc/ssh-host-server-relay-decision.test.ts delete mode 100644 src/main/ipc/ssh-managed-server-move-conversion.test.ts delete mode 100644 src/main/ipc/ssh-managed-server-move-stop-retention.test.ts delete mode 100644 src/main/ipc/ssh-managed-server-move.test.ts delete mode 100644 src/main/ipc/ssh-managed-server-move.ts delete mode 100644 src/main/ipc/ssh-plain-ssh-connect.test.ts delete mode 100644 src/main/ipc/ssh-pty-consumer-identity.test.ts delete mode 100644 src/main/ipc/ssh-relay-lost-backoff.ts delete mode 100644 src/main/ipc/ssh-relay-reset-resume.test.ts delete mode 100644 src/main/ipc/ssh-relay-session-callbacks.ts delete mode 100644 src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts delete mode 100644 src/main/ipc/ssh-terminate-sessions.test.ts delete mode 100644 src/main/ipc/ssh-terminate-sessions.ts delete mode 100644 src/main/providers/ssh-directory-legacy-compatibility.test.ts delete mode 100644 src/main/providers/ssh-directory-listing-options.test.ts delete mode 100644 src/main/providers/ssh-directory-listing.ts delete mode 100644 src/main/providers/ssh-directory-streaming-real.test.ts delete mode 100644 src/main/providers/ssh-directory-streaming.test.ts delete mode 100644 src/main/providers/ssh-file-listing.ts delete mode 100644 src/main/providers/ssh-filesystem-doc-preview.test.ts delete mode 100644 src/main/providers/ssh-filesystem-doc-preview.ts delete mode 100644 src/main/providers/ssh-filesystem-download.test.ts delete mode 100644 src/main/providers/ssh-filesystem-download.ts delete mode 100644 src/main/providers/ssh-filesystem-file-upload.ts delete mode 100644 src/main/providers/ssh-filesystem-listing-params.ts delete mode 100644 src/main/providers/ssh-filesystem-path-existence.ts delete mode 100644 src/main/providers/ssh-filesystem-provider-capabilities.test.ts delete mode 100644 src/main/providers/ssh-filesystem-provider-capabilities.ts delete mode 100644 src/main/providers/ssh-filesystem-provider-download-folder.test.ts delete mode 100644 src/main/providers/ssh-filesystem-provider-range.test.ts delete mode 100644 src/main/providers/ssh-filesystem-provider-stream.test.ts delete mode 100644 src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts delete mode 100644 src/main/providers/ssh-filesystem-provider-watch.ts delete mode 100644 src/main/providers/ssh-filesystem-provider.test.ts delete mode 100644 src/main/providers/ssh-filesystem-provider.ts delete mode 100644 src/main/providers/ssh-filesystem-range-read.ts delete mode 100644 src/main/providers/ssh-filesystem-terminal-artifact.ts delete mode 100644 src/main/providers/ssh-filesystem-watch-notifications.test.ts delete mode 100644 src/main/providers/ssh-filesystem-watch-notifications.ts delete mode 100644 src/main/providers/ssh-listing-compatibility.test.ts delete mode 100644 src/main/providers/ssh-markdown-document-listing.ts delete mode 100644 src/main/providers/ssh-plain-shell-pty-provider.test.ts delete mode 100644 src/main/providers/ssh-plain-shell-pty-provider.ts delete mode 100644 src/main/providers/ssh-sftp-directory-listing.ts delete mode 100644 src/main/providers/ssh-sftp-filesystem-channel.ts delete mode 100644 src/main/providers/ssh-sftp-filesystem-provider.test.ts delete mode 100644 src/main/providers/ssh-sftp-filesystem-provider.ts delete mode 100644 src/main/runtime/orca-runtime-files-ssh-rearm.test.ts delete mode 100644 src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts delete mode 100644 src/main/runtime/ssh-file-explorer-watch-test-connection.ts delete mode 100644 src/main/ssh/orcad-windows-relay-terminal-gate-test-cell.ts delete mode 100644 src/main/ssh/relay-native-dependency-coverage.test.ts delete mode 100644 src/main/ssh/ssh-filesystem-stream-retention.test.ts delete mode 100644 src/main/ssh/ssh-host-cli-deadline.ts delete mode 100644 src/main/ssh/ssh-host-node-runtime-mode.ts delete mode 100644 src/main/ssh/ssh-host-provider-set.test.ts delete mode 100644 src/main/ssh/ssh-host-provider-set.ts delete mode 100644 src/main/ssh/ssh-host-server-move-offer.test.ts delete mode 100644 src/main/ssh/ssh-host-server-move-offer.ts create mode 100644 src/main/ssh/ssh-host-unserved-status.ts create mode 100644 src/main/ssh/ssh-hostile-hosts.docker.test.ts delete mode 100644 src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts delete mode 100644 src/main/ssh/ssh-orphan-relay-pty-sweep.ts delete mode 100644 src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts delete mode 100644 src/main/ssh/ssh-owner-admission-blocked-error.ts delete mode 100644 src/main/ssh/ssh-pending-pty-kill-replay.test.ts delete mode 100644 src/main/ssh/ssh-pending-pty-kill-replay.ts delete mode 100644 src/main/ssh/ssh-plain-ssh-session.ts delete mode 100644 src/main/ssh/ssh-port-scanner.test.ts delete mode 100644 src/main/ssh/ssh-port-scanner.ts delete mode 100644 src/main/ssh/ssh-pty-consumer-recovery.test.ts delete mode 100644 src/main/ssh/ssh-pty-consumer-recovery.ts delete mode 100644 src/main/ssh/ssh-pty-frame-rejection.ts delete mode 100644 src/main/ssh/ssh-pty-recovery-retention-budget.test.ts delete mode 100644 src/main/ssh/ssh-pty-recovery-retention-budget.ts delete mode 100644 src/main/ssh/ssh-pty-retired-source-deliveries.test.ts delete mode 100644 src/main/ssh/ssh-pty-retired-source-deliveries.ts delete mode 100644 src/main/ssh/ssh-pty-targeted-reattach-queue.ts delete mode 100644 src/main/ssh/ssh-relay-build-toolchain.test.ts delete mode 100644 src/main/ssh/ssh-relay-build-toolchain.ts delete mode 100644 src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts delete mode 100644 src/main/ssh/ssh-relay-cross-version-isolation.test.ts delete mode 100644 src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts delete mode 100644 src/main/ssh/ssh-relay-deploy-pinned-node.test.ts delete mode 100644 src/main/ssh/ssh-relay-deploy-staged-upload.test.ts delete mode 100644 src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts delete mode 100644 src/main/ssh/ssh-relay-deploy.test.ts delete mode 100644 src/main/ssh/ssh-relay-deploy.ts delete mode 100644 src/main/ssh/ssh-relay-gc-retry.test.ts delete mode 100644 src/main/ssh/ssh-relay-host-node-addons.test.ts delete mode 100644 src/main/ssh/ssh-relay-host-node-addons.ts delete mode 100644 src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts delete mode 100644 src/main/ssh/ssh-relay-live-connect.test.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-cache-install.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-install.test.ts delete mode 100644 src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts delete mode 100644 src/main/ssh/ssh-relay-node-headers.test.ts delete mode 100644 src/main/ssh/ssh-relay-node-headers.ts delete mode 100644 src/main/ssh/ssh-relay-node-pty-repair.test.ts delete mode 100644 src/main/ssh/ssh-relay-node-pty-repair.ts delete mode 100644 src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts delete mode 100644 src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts delete mode 100644 src/main/ssh/ssh-relay-opencode-pinned-node.test.ts delete mode 100644 src/main/ssh/ssh-relay-opencode-pinned-node.ts delete mode 100644 src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts delete mode 100644 src/main/ssh/ssh-relay-opencode-runtime-commands.ts delete mode 100644 src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts delete mode 100644 src/main/ssh/ssh-relay-opencode-runtime-retry.ts delete mode 100644 src/main/ssh/ssh-relay-opencode-runtime.test.ts delete mode 100644 src/main/ssh/ssh-relay-opencode-runtime.ts delete mode 100644 src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts delete mode 100644 src/main/ssh/ssh-relay-pinned-node-install.test.ts delete mode 100644 src/main/ssh/ssh-relay-pinned-node-install.ts delete mode 100644 src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts delete mode 100644 src/main/ssh/ssh-relay-repair-lock-termination.test.ts delete mode 100644 src/main/ssh/ssh-relay-repair-lock.ts delete mode 100644 src/main/ssh/ssh-relay-reset.test.ts delete mode 100644 src/main/ssh/ssh-relay-reset.ts delete mode 100644 src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts delete mode 100644 src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts delete mode 100644 src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts delete mode 100644 src/main/ssh/ssh-relay-ripgrep-cache-gc.ts delete mode 100644 src/main/ssh/ssh-relay-ripgrep-install.test.ts delete mode 100644 src/main/ssh/ssh-relay-ripgrep-install.ts delete mode 100644 src/main/ssh/ssh-relay-runtime-step-plan.ts delete mode 100644 src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-data-delivery.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-incarnation.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-managed-hooks.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-model-migration.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-orphan-sweep.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-plain-ssh.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-recovery-durability.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-recovery-races.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-rejected-delivery.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-relay-loss.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-terminal-error.test.ts delete mode 100644 src/main/ssh/ssh-relay-session-test-fixtures.ts delete mode 100644 src/main/ssh/ssh-relay-session.test.ts delete mode 100644 src/main/ssh/ssh-relay-session.ts delete mode 100644 src/main/ssh/ssh-relay-sftp-namespace-install.test.ts delete mode 100644 src/main/ssh/ssh-relay-upload-cancel.docker.test.ts delete mode 100644 src/main/ssh/ssh-relay-upload-stage-commands.test.ts delete mode 100644 src/main/ssh/ssh-relay-upload-stage-commands.ts delete mode 100644 src/main/ssh/ssh-relay-upload-stage-contract.ts delete mode 100644 src/main/ssh/ssh-relay-upload-stage-windows-commands.ts delete mode 100644 src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts delete mode 100644 src/main/ssh/ssh-relay-windows-host-lane.test.ts delete mode 100644 src/main/ssh/ssh-remote-cli-args.ts delete mode 100644 src/main/ssh/ssh-remote-cli-argument-error.ts delete mode 100644 src/main/ssh/ssh-remote-cli-bridge-credential.test.ts delete mode 100644 src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts delete mode 100644 src/main/ssh/ssh-remote-cli-error-response.ts delete mode 100644 src/main/ssh/ssh-remote-cli-format.test.ts delete mode 100644 src/main/ssh/ssh-remote-cli-format.ts delete mode 100644 src/main/ssh/ssh-remote-cli-host-passthrough.test.ts delete mode 100644 src/main/ssh/ssh-remote-cli-host-passthrough.ts delete mode 100644 src/main/ssh/ssh-remote-cli-in-process-result.ts delete mode 100644 src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts delete mode 100644 src/main/ssh/ssh-remote-linear-activity-output.test.ts delete mode 100644 src/main/ssh/ssh-remote-linear-cli.test.ts delete mode 100644 src/main/ssh/ssh-remote-linear-cli.ts delete mode 100644 src/main/ssh/ssh-remote-linear-list-issues.test.ts delete mode 100644 src/main/ssh/ssh-remote-linear-list-issues.ts delete mode 100644 src/main/ssh/ssh-remote-linear-output.ts delete mode 100644 src/main/ssh/ssh-remote-linear-read-cli.ts delete mode 100644 src/main/ssh/ssh-remote-linear-read-flags.ts delete mode 100644 src/main/ssh/ssh-remote-linear-read-help.ts delete mode 100644 src/main/ssh/ssh-remote-linear-relation-write.test.ts delete mode 100644 src/main/ssh/ssh-remote-linear-relation-write.ts delete mode 100644 src/main/ssh/ssh-remote-linear-result-guards.ts delete mode 100644 src/main/ssh/ssh-remote-linear-save-issue.test.ts delete mode 100644 src/main/ssh/ssh-remote-linear-save-issue.ts delete mode 100644 src/main/ssh/ssh-remote-linear-truncation-output.test.ts delete mode 100644 src/main/ssh/ssh-remote-linear-write-cli.ts delete mode 100644 src/main/ssh/ssh-remote-linear-write-help.ts delete mode 100644 src/main/ssh/ssh-remote-linear-write-support.ts delete mode 100644 src/main/ssh/ssh-remote-node-addon-resolution.test.ts delete mode 100644 src/main/ssh/ssh-remote-node-addon-resolution.ts delete mode 100644 src/main/ssh/ssh-remote-orca-cli.test.ts delete mode 100644 src/main/ssh/ssh-remote-orca-cli.ts delete mode 100644 src/main/ssh/ssh-remote-orchestration-ask-output.ts delete mode 100644 src/main/ssh/ssh-remote-orchestration-check-output.ts delete mode 100644 src/main/ssh/ssh-remote-orchestration-compatibility.test.ts delete mode 100644 src/main/ssh/ssh-remote-orchestration-post-output.ts delete mode 100644 src/main/ssh/ssh-remote-orchestration-send.test.ts delete mode 100644 src/main/ssh/ssh-remote-orchestration-send.ts delete mode 100644 src/relay/pty-handler-ownership-attestation.test.ts delete mode 100644 src/renderer/src/components/settings/SshManagedServerMoveDialog.test.tsx delete mode 100644 src/renderer/src/components/settings/SshManagedServerMoveDialog.tsx delete mode 100644 src/renderer/src/components/settings/ssh-session-termination.ts delete mode 100644 src/renderer/src/components/settings/ssh-target-remove.test.ts delete mode 100644 src/renderer/src/components/settings/ssh-target-remove.ts create mode 100644 src/renderer/src/ssh/ssh-connect-failure-text.ts delete mode 100644 src/renderer/src/ssh/ssh-managed-server-move-terminal-tabs.test.ts delete mode 100644 src/renderer/src/ssh/ssh-managed-server-move-terminal-tabs.ts delete mode 100644 src/renderer/src/ssh/ssh-managed-server-move.ts delete mode 100644 src/shared/ssh-managed-server-move.ts delete mode 100644 src/shared/ssh-relay-pty-ownership-proof.test.ts delete mode 100644 src/shared/ssh-relay-pty-ownership-proof.ts delete mode 100644 tests/e2e/ephemeral-vm-provisioned-root.spec.ts delete mode 100644 tests/e2e/helpers/browser-pane-mount-census.ts delete mode 100644 tests/e2e/helpers/docker-ssh-relay-faults.ts delete mode 100644 tests/e2e/helpers/docker-ssh-relay-processes.ts delete mode 100644 tests/e2e/helpers/docker-ssh-relay-workspace-snapshot.ts delete mode 100644 tests/e2e/helpers/docker-ssh-relay-worktree-activation.ts delete mode 100644 tests/e2e/helpers/nested-runtime-ssh-relay-lifecycle.ts delete mode 100644 tests/e2e/helpers/nested-runtime-ssh-state.ts delete mode 100644 tests/e2e/helpers/paired-web-filesystem-route.ts delete mode 100644 tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts delete mode 100644 tests/e2e/helpers/ssh-port-forward-transport-evidence.ts delete mode 100644 tests/e2e/helpers/ssh-reconnect-failure-observation.ts delete mode 100644 tests/e2e/helpers/ssh-recovery-input-observation.ts delete mode 100644 tests/e2e/helpers/terminal-tab-open.ts delete mode 100644 tests/e2e/local-ssh-browser-routing.spec.ts delete mode 100644 tests/e2e/nested-runtime-ssh-lifecycle.spec.ts delete mode 100644 tests/e2e/nested-runtime-ssh-routing.spec.ts delete mode 100644 tests/e2e/pty-input-write-queue-ssh.spec.ts delete mode 100644 tests/e2e/ssh-ai-vault-session-history.spec.ts delete mode 100644 tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts delete mode 100644 tests/e2e/ssh-codex-display-artifacts-repro.spec.ts delete mode 100644 tests/e2e/ssh-codex-real-remote.ts delete mode 100644 tests/e2e/ssh-codex-reconnect-replay-driver.ts delete mode 100644 tests/e2e/ssh-codex-repro-remote-fixtures.ts delete mode 100644 tests/e2e/ssh-codex-terminal-observers.ts delete mode 100644 tests/e2e/ssh-cold-activation-restore.spec.ts delete mode 100644 tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts delete mode 100644 tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts delete mode 100644 tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts delete mode 100644 tests/e2e/ssh-docker-half-open-link.spec.ts delete mode 100644 tests/e2e/ssh-docker-quick-open-large-listing.spec.ts delete mode 100644 tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts delete mode 100644 tests/e2e/ssh-docker-relay-perf.spec.ts delete mode 100644 tests/e2e/ssh-docker-relay-stall-credential.spec.ts delete mode 100644 tests/e2e/ssh-docker-resource-accumulation.spec.ts delete mode 100644 tests/e2e/ssh-docker-transport-drop-recovery.spec.ts delete mode 100644 tests/e2e/ssh-docker-watcher-isolation.spec.ts delete mode 100644 tests/e2e/ssh-egress-indicator-preview.spec.ts delete mode 100644 tests/e2e/ssh-emptied-worktree-reactivation.spec.ts delete mode 100644 tests/e2e/ssh-external-image-preview.spec.ts delete mode 100644 tests/e2e/ssh-localhost.spec.ts delete mode 100644 tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts delete mode 100644 tests/e2e/ssh-native-chat-phone-history.spec.ts delete mode 100644 tests/e2e/ssh-pi-compatible-agent-title.spec.ts delete mode 100644 tests/e2e/ssh-port-forward-lifecycle.spec.ts delete mode 100644 tests/e2e/ssh-reattach-home-partition.spec.ts delete mode 100644 tests/e2e/ssh-reconnect-tab-destruction.spec.ts delete mode 100644 tests/e2e/ssh-restart-tab-accumulation.spec.ts delete mode 100644 tests/e2e/ssh-routing-optout-demo.spec.ts delete mode 100644 tests/e2e/ssh-skill-installation.spec.ts delete mode 100644 tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts delete mode 100644 tests/e2e/ssh-startup-exec-readiness.spec.ts delete mode 100644 tests/e2e/ssh-startup-local-shadow.spec.ts delete mode 100644 tests/e2e/ssh-terminal-parking.spec.ts delete mode 100644 tests/e2e/ssh-terminal-stale-grid-probe.ts delete mode 100644 tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts delete mode 100644 tests/e2e/terminal-inline-images-ssh.spec.ts delete mode 100644 tests/e2e/terminal-raster-artifact-analysis.ts delete mode 100644 tests/e2e/terminal-repro-evidence.ts delete mode 100644 tests/e2e/terminal-retention-budget.spec.ts delete mode 100644 tests/e2e/terminal-webgl-reset-capture.ts delete mode 100644 tests/e2e/workspace-layout-oracle-ssh.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0f051a32ca8e..6f67dd25ee46 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,10 +27,6 @@ on: required: false type: boolean default: true - ssh_source_changed: - description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane' - required: false - type: string workflow_dispatch: inputs: ref: @@ -285,8 +281,6 @@ jobs: - name: Install native build and headless UI tools # Why ripgrep: Quick Open's bounded host-side search requires rg instead of an # unbounded inventory fallback; the paired fixture exercises that real boundary. - # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this - # lane now receives those specs from pr.yml's SSH source mapping. env: *e2e_tool_packages run: *install_e2e_tools @@ -309,42 +303,7 @@ jobs: else # Dispatching an older ref retains its existing dedicated-owner exclusions. jq -r '.[] | select( - . != "tests/e2e/local-ssh-browser-routing.spec.ts" and - . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and - . != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and - . != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and - . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and - . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and - . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and - . != "tests/e2e/ssh-emptied-worktree-reactivation.spec.ts" and - . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and - . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and - . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and - . != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and - . != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and - . != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and - . != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and - . != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and - . != "tests/e2e/ssh-external-image-preview.spec.ts" and - . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and - . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and - . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and - . != "tests/e2e/ssh-reattach-home-partition.spec.ts" and - . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and - . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and - . != "tests/e2e/ssh-skill-installation.spec.ts" and - . != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and - . != "tests/e2e/ssh-startup-local-shadow.spec.ts" and - . != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and - . != "tests/e2e/terminal-inline-images-ssh.spec.ts" and - . != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and - . != "tests/e2e/ssh-terminal-parking.spec.ts" and - . != "tests/e2e/terminal-retention-budget.spec.ts" and - . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and - . != "tests/e2e/paired-startup-exec-readiness.spec.ts" and - . != "tests/e2e/workspace-layout-oracle-ssh.spec.ts" and . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and - . != "tests/e2e/ssh-localhost.spec.ts" and . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" and . != "tests/e2e/orcad-serve-mode-switch.spec.ts" and . != "tests/e2e/workspace-layout-oracle-headless.spec.ts" and @@ -375,13 +334,6 @@ jobs: exit 0 fi E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay") - # Second clause: a spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself without it, so - # naming only one trigger silently skipped every other Docker-SSH spec in this lane. - # The first clause stays because that spec needs Docker without referencing the variable. - if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' \ - || grep -l 'ORCA_E2E_SSH_DOCKER' "${TEST_FILES[@]}" >/dev/null 2>&1; then - E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1) - fi E2E_PROJECT_ARGS=() if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then E2E_PROJECT_ARGS+=(--project=electron-headful) @@ -398,124 +350,6 @@ jobs: retention-days: 7 if-no-files-found: ignore - ssh-docker-watcher-isolation: - name: ssh docker watcher isolation (${{ matrix.shard }}/4) - needs: [build, prepare-native-cache] - # Each excluded changed spec must trigger its dedicated owner. - if: >- - inputs.test_files == '' || - inputs.ssh_source_changed == 'true' || - contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') || - contains(inputs.test_files, 'tests/e2e/pty-input-write-queue-ssh.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-ai-vault-session-history.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-resource-accumulation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-external-image-preview.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-port-forward-lifecycle.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-restart-tab-accumulation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-skill-installation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts') || - contains(inputs.test_files, 'tests/e2e/terminal-inline-images-ssh.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-docker-watcher-isolation.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-terminal-parking.spec.ts') || - contains(inputs.test_files, 'tests/e2e/terminal-retention-budget.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || - contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts') || - contains(inputs.test_files, 'tests/e2e/workspace-layout-oracle-ssh.spec.ts') - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - timeout-minutes: 60 - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - - name: Install native build and headless UI tools - env: *e2e_tool_packages - run: *install_e2e_tools - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - - name: Download E2E build output - uses: actions/download-artifact@v8 - with: - name: e2e-build-out - path: out/ - - # Why: this is the release-path proof that the deployed Linux relay keeps - # its PTY and explorer live across a real watcher SIGSEGV. - - name: Run Docker SSH watcher isolation E2E - if: matrix.shard == 1 - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation - - # Why: Playwright empties test-results/ when it starts, so each step here used to - # destroy the previous step's traces. Only the last lane's failure was ever - # diagnosable from the artifact; set each lane aside before the next one runs. - - name: Keep watcher-isolation traces - if: always() && matrix.shard == 1 - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/watcher-isolation" - fi - - # Keep every SSH journey after a test failure; stop work on superseded commits. - - name: Run Docker SSH terminal parking + startup readiness E2E - if: '!cancelled() && matrix.shard == 1' - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking - - - name: Keep terminal-parking traces - if: always() && matrix.shard == 1 - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/terminal-parking" - fi - - # Separate VMs isolate destructive SSH fixtures while keeping one worker per shard. - - name: Run remaining Docker SSH E2E - if: '!cancelled()' - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4 - - - name: Keep remaining-ssh-docker traces - if: always() - run: | - if [ -d test-results ]; then - mkdir -p e2e-traces - mv test-results "e2e-traces/remaining-ssh-docker" - fi - - - name: Upload watcher isolation traces - if: failure() || cancelled() - uses: actions/upload-artifact@v7 - with: - name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }} - path: e2e-traces/ - retention-days: 7 - if-no-files-found: ignore - ssh-browser-network-route: name: ssh browser network route if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts') @@ -750,67 +584,3 @@ jobs: path: test-results/ retention-days: 7 if-no-files-found: ignore - - ssh-localhost: - name: localhost SSH terminal and hooks - needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-localhost.spec.ts') - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.ref }} - - name: Install SSH server and headless tools - run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client openssh-server python3 ripgrep xvfb zsh openbox x11-utils - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: electron - - uses: actions/download-artifact@v8 - with: - name: e2e-build-out - path: out/ - - name: Start isolated localhost SSH server - shell: bash - run: | - # Bare shells install Pi extensions only for an existing agent home. - mkdir -p "$HOME/.pi/agent" - fixture="$RUNNER_TEMP/orca-localhost-sshd" - mkdir -p "$fixture" - ssh-keygen -q -t ed25519 -N '' -f "$fixture/host_key" - ssh-keygen -q -t ed25519 -N '' -f "$fixture/client_key" - cat > "$fixture/sshd_config" <> "$GITHUB_ENV" - - name: Run localhost SSH terminal and hook journey - env: - SKIP_BUILD: '1' - ORCA_E2E_SSH_LOCALHOST: '1' - ORCA_FEATURE_REMOTE_AGENT_HOOKS: '1' - ORCA_E2E_FORWARD_APP_LOGS: '1' - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1 - - uses: actions/upload-artifact@v7 - if: failure() || cancelled() - with: - name: localhost-ssh-traces - path: test-results/ - retention-days: 7 - if-no-files-found: ignore diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c98f55d34d09..e1b31592acac 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -53,7 +53,6 @@ jobs: test_files: ${{ steps.e2e_filter.outputs.test_files }} e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }} e2e_needs_build: ${{ steps.e2e_filter.outputs.e2e_needs_build }} - ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }} native_ime_source_changed: ${{ steps.e2e_filter.outputs.native_ime_source_changed }} wsl_source_changed: ${{ steps.e2e_filter.outputs.wsl_source_changed }} steps: @@ -131,12 +130,7 @@ jobs: # authorities, exclusions, and sentinels without evaluating workflow shell. TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT" - # Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a - # spec name surviving in a route's list. Same routes, so the two cannot drift. - SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)" - echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" - echo "SSH source changed: $SSH_SOURCE_CHANGED" - printf '%s\n' "$TEST_FILES_JSON" | E2E_SSH_SOURCE_CHANGED="$SSH_SOURCE_CHANGED" node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT" + printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT" # Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must # trigger on IME source rather than on a spec name in some route's list. NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)" @@ -1277,7 +1271,6 @@ jobs: src/main/ipc/preflight-provider-command-selection.test.ts src/main/ipc/preflight-runnable-local-cli.test.ts src/relay/windows-port-scan.win32.test.ts - src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts src/main/ssh/remote-node-runtime-store-windows.test.ts config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -1352,7 +1345,6 @@ jobs: # workflow is queued. The head SHA is immutable and works for every PR. ref: ${{ github.event.pull_request.head.sha }} test_files: ${{ needs.code_paths.outputs.test_files }} - ssh_source_changed: ${{ needs.code_paths.outputs.ssh_source_changed }} run_changed_e2e: ${{ needs.code_paths.outputs.e2e_run_changed != 'false' }} needs_build: ${{ needs.code_paths.outputs.e2e_needs_build != 'false' }} diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index fc319e9f9762..9ca33af2d4d3 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -26,7 +26,7 @@ on: - 'config/scripts/verify-packaged-orcad-template.cjs' - 'config/patches/node-pty*' - '!src/**/*.test.ts' - - 'src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts' + - 'src/main/ssh/ssh-hostile-hosts.docker.test.ts' - '.github/workflows/ssh-hostile-hosts.yml' - '.github/actions/restore-pnpm-verification/**' workflow_dispatch: @@ -201,7 +201,7 @@ jobs: env: ORCA_RUN_SSH_HOSTILE_HOSTS: '1' ORCA_SSH_HOSTILE_HOST_CELLS: ${{ github.event.inputs.cells || '' }} - run: pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts + run: pnpm test:node src/main/ssh/ssh-hostile-hosts.docker.test.ts # Design D6 on macOS: the runner is the SSH host. A user-level sshd on a loopback port logs in # as the runner user with PATH cut to the toolchain shims and /usr/bin:/bin, so Homebrew's node @@ -240,4 +240,4 @@ jobs: env: ORCA_RUN_SSH_HOSTILE_HOSTS: '1' ORCA_SSH_HOSTILE_HOST_CELLS: ${{ matrix.cell }} - run: pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts + run: pnpm test:node src/main/ssh/ssh-hostile-hosts.docker.test.ts diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 2b6792b94af3..21ca046fc596 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -42,7 +42,6 @@ on: - 'src/shared/windows-breakaway-launch*.ts' - 'src/main/ipc/ssh-host-server-*.ts' - '!src/**/*.test.ts' - - 'src/main/ssh/ssh-relay-windows-host-lane.test.ts' - 'src/main/ssh/orcad-windows-host-lane.test.ts' - 'tests/e2e/ssh-orcad-auto-convert.spec.ts' - 'tests/e2e/helpers/orcad-convert-host.ts' @@ -178,11 +177,11 @@ jobs: $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' New-Item -ItemType Directory -Force -Path $receipts | Out-Null $cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_}) - if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')} + if(-not $cells.Count){$cells=@('orcad-cmd','orcad-powershell')} # One Windows host runs the app-level conversion; it is last because it switches native modules. if(-not $env:CELLS -and '${{ matrix.arch }}' -eq 'x64' -and '${{ matrix.server }}' -eq 'inbox'){$cells+='orcad-convert'} # App cells reach the managed server through an SSH local forward, which provisioning grants to the last accounts only. - $appCellIds=@('orcad-convert','orcad-cli-managed','orcad-cli-convert','orcad-cli-relay-kept') + $appCellIds=@('orcad-convert','orcad-cli-managed','orcad-cli-convert') $cells=@($cells | Where-Object {$appCellIds -notcontains $_})+@($cells | Where-Object {$appCellIds -contains $_}) $forwarding=@($cells | Where-Object {$appCellIds -contains $_}).Count $archive='' diff --git a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 index 9d58935f03a5..23f750ee8eaf 100644 --- a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +++ b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 @@ -117,8 +117,8 @@ try { if($appCells.ContainsKey($cell)){ $code=Invoke-AppCell $account $descriptor (Join-Path $ReceiptRoot "$cell.log") $appCells[$cell][0] $appCells[$cell][1] } else { - # orcad cells deploy managed orcad instead of the relay; same account and descriptor shape. - $lane=if($cell.StartsWith('orcad-')){'src/main/ssh/orcad-windows-host-lane.test.ts'}else{'src/main/ssh/ssh-relay-windows-host-lane.test.ts'} + if(-not $cell.StartsWith('orcad-')){throw "Unknown Windows host cell $cell"} + $lane='src/main/ssh/orcad-windows-host-lane.test.ts' & node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts $lane --reporter=verbose 2>&1 | Tee-Object -FilePath (Join-Path $ReceiptRoot "$cell.log") # Why global: under the workflow's GetNewClosure callback, bare $LASTEXITCODE reads a stale captured copy. $code=$global:LASTEXITCODE diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6d4e17066c8c..33aae8429549 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -5,7 +5,5 @@ inline src/main/ipc/worktree-remote.ts inline src/main/ssh/ssh-channel-multiplexer.ts inline src/main/ssh/ssh-connection.ts -inline src/main/ssh/ssh-relay-deploy.ts -inline src/main/ssh/ssh-relay-session.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts diff --git a/config/performance-audit.md b/config/performance-audit.md index 9c42dd3654b3..31915230babd 100644 --- a/config/performance-audit.md +++ b/config/performance-audit.md @@ -31,8 +31,7 @@ Incremental Claude scans and persisted Claude cache checkpoints are also include macOS and Windows, and on PRs changing this tooling or any listed contract file. It uploads per-OS JSON test results, plus the source inventory once from Linux because that scan is OS-independent. Its schedule starts after merge. Run the existing -`test:e2e:terminal-perf:scale:report` for rendered typing/frame budgets and -`test:e2e:ssh-docker-perf` for real transport behavior. Relay unit tests do not +`test:e2e:terminal-perf:scale:report` for rendered typing/frame budgets. Unit tests do not measure SSH RTT, WSL scheduling or a packaged Electron renderer. To extend coverage, select a production-path regression with an operation-count, diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 7ff6c9f94caf..8818c19ca4b5 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -104,7 +104,6 @@ src/main/runtime/windows-mobile-firewall.ts src/main/skills/skill-update-run.ts src/main/ssh/ssh-connection.ts src/main/ssh/ssh-proxy-command.ts -src/main/ssh/ssh-remote-cli-host-passthrough.ts src/main/ssh/system-ssh-command.ts src/main/ssh/system-ssh-dynamic-forward-process.ts src/main/ssh/system-ssh-file-transfer.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 936a10b4f23a..be9a7c26048b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1692,7 +1692,6 @@ ], "demotionRule": "Keep experimental until soak and platform evidence support promotion; do not relax value or identity assertions to hide failures." }, - { "id": "workspace-session.ssh-host-partition-round-trip", "title": "An SSH workspace round-trips through its own partition without losing tabs, editor files or browser state", @@ -2561,99 +2560,6 @@ ], "demotionRule": "Keep experimental until CI soak is stable; investigate any budget failure without weakening transcript preservation." }, - { - "id": "ssh.localhost-terminal-agent-hooks", - "title": "Localhost SSH terminal and agent hooks reach the owning pane", - "maturity": "experimental", - "protection": "partial", - "owner": "terminal-runtime", - "layer": "electron-ssh-e2e", - "surfaces": ["SSH terminal", "remote agent status", "remote plugin installation"], - "platforms": ["macos", "linux", "windows"], - "providers": ["ssh"], - "coveredPlatforms": ["linux"], - "coveredProviders": ["ssh"], - "coverageNotes": "Ubuntu CI loopback sshd shares the runner filesystem. Fresh per-test repositories isolate retained relay workspace snapshots; existing Pi home supplies the documented bare-shell plugin prerequisite.", - "motivatingLinks": ["https://github.com/stablyai/orca/pull/19097"], - "invariant": "A localhost SSH terminal executes on the SSH host and routes authenticated hook status to its owning pane without treating idle keyboard input as agent interruption.", - "oracle": "Require terminal output markers, exported hook identity, actual OpenCode/Pi plugin files, and matching pane/worktree/connection hook events; Ctrl-C and Escape in an idle shell must not interrupt a hook-owned agent.", - "commands": [ - "gh run view 34045578306 --log", - "gh run view 34045975180 --log", - "gh run view 34046230389 --log", - "ORCA_E2E_SSH_LOCALHOST=1 ORCA_FEATURE_REMOTE_AGENT_HOOKS=1 pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1", - "node_modules/.bin/vitest run --config config/vitest.config.ts config/scripts/ssh-localhost-e2e-routing.test.mjs" - ], - "testFiles": [ - "tests/e2e/ssh-localhost.spec.ts", - "config/scripts/ssh-localhost-e2e-routing.test.mjs" - ], - "assertionRefs": [ - { - "file": "tests/e2e/ssh-localhost.spec.ts", - "assertions": ["routes a terminal and agent-hook status over localhost SSH"] - }, - { - "file": "config/scripts/ssh-localhost-e2e-routing.test.mjs", - "assertions": ["selects the localhost journey for its remote hook authorities"] - } - ], - "evidenceRuns": [ - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "command": "gh run view 34045578306 --log", - "result": "failed", - "summary": "Shared repository:2passed1failed, active pane PTY binding timed out amid old SSH target ownership conflicts.", - "durationSeconds": 150 - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "command": "gh run view 34045975180 --log", - "result": "passed", - "durationSeconds": 114, - "summary": "Fresh per-test repository:3passed,0skips0retries; original assertions retained." - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "command": "gh run view 34046230389 --log", - "result": "passed", - "summary": "Normal selective workflow with isolated repository executed the localhost journey successfully; generic lane filtered it out.", - "durationSeconds": 36.4 - } - ], - "runtimeBudget": { - "p95Seconds": 1200, - "scope": "CI job timeout; measured p95 not established" - }, - "flakeHistory": { - "status": "soaking", - "evidence": "Single baseline passed, shared-path repetitions exposed state leakage; isolated-path3/3 and normal workflow passed. Long-term history missing." - }, - "redGreenEvidence": { - "status": "partial", - "evidence": "Same original scenario failed across shared-path repetitions and passed with unique paths; no application fault-mutation proof." - }, - "performanceBudget": { - "required": false, - "evidence": "Functional terminal and hook routing coverage, not a performance oracle." - }, - "promotionCriteria": [ - "Collect repeated scheduled Linux runs without unexplained failures.", - "Preserve all original terminal, environment, plugin-file, and hook-status assertions." - ], - "knownGaps": [ - "Different client profiles reopening one existing remote workspace can encounter old target-qualified PTY IDs; the fixture isolation does not fix that application behavior.", - "No macOS/Windows, remote network failure, folder-only, packaged, or mixed-version claim.", - "PR E2E is not part of required verify while broader reliability remains unresolved." - ], - "demotionRule": "Keep experimental on unexplained failures; do not mask them with retries, skips, or longer timeouts." - }, { "id": "terminal-output.prestarted-shell-snapshot-adoption", "title": "Prestarted shell adoption paints covered output once", @@ -3178,8 +3084,6 @@ "commands": [ "node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provision-cancel.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts", - "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "SKIP_BUILD=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec playwright test tests/e2e/ephemeral-vm-cleanup-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ @@ -3204,8 +3108,7 @@ "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts", "src/renderer/src/lib/provisioned-root-create-options.test.ts", "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts", - "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts", - "tests/e2e/ephemeral-vm-provisioned-root.spec.ts" + "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts" ], "assertionRefs": [ { @@ -3301,13 +3204,6 @@ "assertions": [ "the rendered runtime row stops a live cleanup process without an automatic deadline and a retry succeeds" ] - }, - { - "file": "tests/e2e/ephemeral-vm-provisioned-root.spec.ts", - "assertions": [ - "default-base create adopts the recipe checkout at the captured source commit", - "workspace removal invokes recipe destroy and removes the provider container" - ] } ], "evidenceRuns": [ @@ -3328,15 +3224,6 @@ "result": "passed", "durationSeconds": 3, "summary": "One hundred twenty-two focused store, recipe-process, lifecycle, cleanup-handler, rendered stop-action, retry-context, source-ref, adoption-integrity, resume-integrity, and provisioning-recovery tests passed." - }, - { - "date": "2026-08-14", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 22, - "summary": "The final-tree full-build run completed the Docker Linux SSH journey: default-base create at the captured source commit, provisioned-root adoption, terminal, workspace removal, destroy, and provider-container cleanup." } ], "runtimeBudget": { @@ -3399,7 +3286,6 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts", "pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1" ], "testFiles": [ @@ -3419,7 +3305,6 @@ "src/main/providers/local-pty-provider-shell-readiness.test.ts", "src/relay/pty-shell-launch.test.ts", "src/relay/pty-handler-startup-command-delivery.test.ts", - "tests/e2e/ssh-startup-exec-readiness.spec.ts", "tests/e2e/paired-startup-exec-readiness.spec.ts" ], "assertionRefs": [ @@ -3471,14 +3356,6 @@ "normal renderer-owned ready markers pass through unchanged and retain no readiness state" ] }, - { - "file": "tests/e2e/ssh-startup-exec-readiness.spec.ts", - "assertions": [ - "a Docker Linux OpenSSH/Bash replacement shell retains one compatibility-fallback input across reconnect without executing it before the blocked replacement can accept it", - "the owning terminal handle and raw PTY identity remain singular and stable", - "painted output, authoritative terminal output, and the first interactive input each appear exactly once" - ] - }, { "file": "tests/e2e/paired-startup-exec-readiness.spec.ts", "assertions": [ @@ -3515,15 +3392,6 @@ "durationSeconds": 6, "summary": "233 tests passed with one local Fish skip, including exact Bash PROMPT_COMMAND byte/status/lifecycle composition across daemon, local, and relay owners." }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "linux", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "result": "passed", - "durationSeconds": 17, - "summary": "The live OpenSSH/Bash relay journey crossed the bounded compatibility fallback while rc-file exec was blocked, proved one queued command echo with no command execution, then passed a disconnect/reconnect with exact-once startup output/input and stable pane plus owner PTY identities." - }, { "date": "2026-08-12", "runner": "local", @@ -3761,23 +3629,13 @@ "invariant": "A non-empty SSH file stream that produces no valid frame for 30 seconds must cancel at its authoritative stream reader and release all local lifecycle state. System suspend is sticky across metadata and subscription races, resume grants every still-live stream one fresh inactivity window, one failing consumer cannot block the others, and the sole Electron bridge survives a vetoed before-quit without retaining a per-stream Electron listener.", "oracle": "Publish suspend before stream metadata resolves, jump wall time by one hour, and require the late-subscribing stream to remain pending until resume grants a fresh 30-second window. Deliver a valid final chunk and end frame, require exact content, then publish another resume and require zero timers, multiplexer listeners, or renewed work; separately require stalled cancellation at 30 seconds, atomic state replay, failure-isolated fanout, and bridge disposal only after the committed will-quit gate.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts --reporter=dot --maxWorkers=2" + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts --reporter=dot --maxWorkers=2" ], "testFiles": [ - "src/main/providers/ssh-filesystem-provider-stream.test.ts", "src/main/system-resume-broadcast.test.ts", "src/main/system-power-lifecycle.test.ts" ], "assertionRefs": [ - { - "file": "src/main/providers/ssh-filesystem-provider-stream.test.ts", - "assertions": [ - "cancels and cleans up a stream that stalls after metadata", - "keeps a long stream alive while chunks continue arriving", - "grants an active stream a fresh inactivity window after system resume", - "keeps metadata received during suspend paused until resume" - ] - }, { "file": "src/main/system-resume-broadcast.test.ts", "assertions": ["publishes suspend and resume to main-process lifecycle consumers"] @@ -3797,9 +3655,9 @@ "runner": "local", "platform": "macos", "result": "passed", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts --reporter=dot --maxWorkers=2", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts --reporter=dot --maxWorkers=2", "durationSeconds": 8.141, - "summary": "The retained behavioral cohort passed 25 tests after removing source-only ordering tests. Historical pre-removal receipts are preserved verbatim with their originally executed commands: [{\"date\":\"2026-08-01\",\"runner\":\"local\",\"platform\":\"macos\",\"command\":\"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts src/main/startup/desktop-startup-ordering.test.ts --reporter=dot\",\"result\":\"passed\",\"durationSeconds\":1.6,\"summary\":\"Four focused files and 33 tests passed, including stalled cancellation, active progress, late metadata replay, failure isolation, committed-quit bridge lifetime, and post-settlement cleanup.\"}] No new native desktop or hosted run is claimed." + "summary": "The retained behavioral cohort passed 25 tests after removing source-only ordering tests. Historical pre-removal receipts are preserved verbatim with their originally executed commands: [{\"date\":\"2026-08-01\",\"runner\":\"local\",\"platform\":\"macos\",\"command\":\"pnpm exec vitest run --config config/vitest.config.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts src/main/startup/desktop-startup-ordering.test.ts --reporter=dot\",\"result\":\"passed\",\"durationSeconds\":1.6,\"summary\":\"Four focused files and 33 tests passed, including stalled cancellation, active progress, late metadata replay, failure isolation, committed-quit bridge lifetime, and post-settlement cleanup.\"}] No new native desktop or hosted run is claimed." } ], "runtimeBudget": { @@ -3856,22 +3714,15 @@ "invariant": "A first-install relay transfer must complete in an attempt-owned fixed staging slot before acquiring the shared version install lock. Reservation, promotion, confirmed cleanup, and stale recovery must reject path replacement, persisted-identity mismatch, POSIX symlinks, and Windows reparse points. A newly acquired install lock atomically records the execution host's boot identity; only a verified identity change or the existing stale-age proof may replace it, while legacy, missing, malformed, and unreadable identity state must retain the conservative stale fallback. Recovery examines only eight fixed slot/claim/delete names and removes at most one stale valid stage per call; eight unclaimable states fail with an explicit manual-recovery message. Split-SFTP hosts must prove the stage identity on the exact transfer session, only payload contents may be promoted under the shared lock, and cancellation must boundedly join SFTP, stream, local file-handle, and transfer settlement.", "oracle": "Pause a real ssh2 SFTP relay.js write after one remotely acknowledged chunk, prove the remote file is partial, abort the live transfer, and require no shared .install-lock, leaked local descriptor, or foreign-process termination. Separately inject two unconfirmed cancellations, require an independent deployment to install, launch, answer relay RPC, and read a real repository HEAD. Keep a fresh install lock on the current POSIX or Windows boot and require takeover to fail; replace its bounded identity with a prior-boot value and race concurrent recoverers, requiring exactly one atomic winner, a current successor identity, and no tombstone residue; omit the marker and require the legacy lock to remain fenced. Replace one retained fixed slot with an old-mtime same-owner directory while preserving the original, add a fixed-slot POSIX symlink, and require installed-path recovery to skip both while reclaiming a valid stale slot behind them. Add 15,197 unrelated relay-shaped names and run the real version GC, requiring bounded stdout and no removal. Unit and wire contracts cover exact POSIX and native PowerShell 0/1/7/8/9+ quota behavior, no-follow identity fencing, payload symlink/reparse rejection, one-item repeated draining, zero lock acquisition before upload settlement, joined transfer/channel teardown including never-settling failures, SFTP redirection, package.json namespace ownership, promotion only after the lock, cross-version isolation, and system-SSH behavior.", "commands": [ - "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts", - "ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST= ORCA_REVIEW_SSH_IMAGE= ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose" + "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts" ], "testFiles": [ "src/main/ssh/sftp-upload.test.ts", "src/main/ssh/ssh-file-transfer-abort.test.ts", - "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts", - "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts", - "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts", "src/main/ssh/ssh-relay-install-namespace.test.ts", - "src/main/ssh/ssh-relay-upload-stage-commands.test.ts", "src/main/ssh/sftp-namespace-resolution.test.ts", "src/main/ssh/ssh-connection-sftp-wire.test.ts", - "src/main/ssh/ssh-remote-commands.test.ts", - "src/main/ssh/ssh-relay-cross-version-isolation.test.ts", - "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts" + "src/main/ssh/ssh-remote-commands.test.ts" ], "assertionRefs": [ { @@ -3885,25 +3736,6 @@ "marks transfer teardown unconfirmed when close wins but the transfer never settles" ] }, - { - "file": "src/main/ssh/ssh-relay-upload-stage-commands.test.ts", - "assertions": [ - "bounds reservation at 0, 1, 7, 8, and 9+ entries on POSIX and native PowerShell", - "rejects same-path replacement, symlink, reparse, and identity substitution before promotion or deletion", - "reclaims at most one valid stale fixed slot and progresses across repeated deployments" - ] - }, - { - "file": "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts", - "assertions": [ - "waits for a deferred SFTP upload before acquiring the install lock", - "drops only its stage when a sibling finishes before the locked re-probe", - "recovers one fixed stale stage before a fresh upload", - "launches before bounded installed-path recovery", - "never enumerates arbitrary stage paths during installation", - "retries immediately after an unconfirmed upload termination instead of waiting on a fresh install lock" - ] - }, { "file": "src/main/ssh/ssh-remote-commands.test.ts", "assertions": [ @@ -3912,25 +3744,6 @@ "lets only one PowerShell caller acquire a legacy-visible lock", "keeps current and legacy fresh locks fenced while one concurrent caller replaces a previous-boot lock" ] - }, - { - "file": "src/main/ssh/ssh-relay-cross-version-isolation.test.ts", - "assertions": ["a v2 deploy never references the v1 install dir or v1 socket path"] - }, - { - "file": "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts", - "assertions": [ - "redirects every first-install artifact transfer while shell commands stay canonical", - "leaves system-SSH connections unmapped and unprobed" - ] - }, - { - "file": "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts", - "assertions": [ - "replaces a fresh previous-boot install lock over live ssh2 while preserving promoted payload and leaving no tombstone", - "aborts a live SFTP upload after remote bytes arrive without creating the shared lock", - "recovers cancellation with bounded safe reclamation and bounded real version GC" - ] } ], "evidenceRuns": [ @@ -3938,19 +3751,10 @@ "date": "2026-07-31", "runner": "local", "platform": "macos", - "command": "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts", + "command": "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts", "result": "passed", "durationSeconds": 57.9, "summary": "Eleven focused files passed 152 tests with 3 platform skips using exact POSIX sh, native ARM macOS PowerShell 7.6.4, real ssh2 split-SFTP wire sessions, fixed-slot identity races, bounded recovery, joined cancellation teardown, and cross-version isolation." - }, - { - "date": "2026-07-31", - "runner": "local", - "platform": "linux", - "command": "ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST= ORCA_REVIEW_SSH_IMAGE= ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose", - "result": "passed", - "durationSeconds": 12.58, - "summary": "A throwaway linux-arm64 Docker sshd acknowledged 65,536 of 837,401 relay.js bytes before live abort with no shared lock and preserved the foreign sleep sentinel. Two injected unconfirmed stages remained pre-lock; retry launched the relay, answered RPC, and read a real Git HEAD. Installed recovery preserved a same-owner identity-mismatched replacement, its original, a POSIX symlink, and its foreign target while draining one valid stale slot behind them. Real version GC retained all 15,197 unrelated names, emitted 25 bytes, and completed in 25 ms." } ], "runtimeBudget": { @@ -4567,7 +4371,6 @@ ], "demotionRule": "Keep experimental or demote if optimization closes an established client, a gate reopens on ambiguous authority, a mutation is replayed, cleanup is lost, or eligible idle hosts starve." }, - { "id": "git-worktree.refresh-event-semantics", "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout", @@ -4600,8 +4403,7 @@ "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", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "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" ], "testFiles": [ "src/main/ipc/worktree-base-directory-event-filter.test.ts", @@ -4622,8 +4424,7 @@ "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", - "tests/e2e/ssh-docker-relay-perf.spec.ts" + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts" ], "assertionRefs": [ { @@ -4771,13 +4572,6 @@ "one coordinator aggregate maps to one typed product event with queue and provider percentiles", "adapter failure is swallowed before it can reach recovery" ] - }, - { - "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", - "assertions": [ - "repo and worktree hydration use the exact direct SSH host and complete provider authority", - "terminal input remains live after disconnect/reconnect and writes a proof file visible inside the Linux target" - ] } ], "evidenceRuns": [ @@ -4808,15 +4602,6 @@ "durationSeconds": 3.45, "summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests." }, - { - "date": "2026-07-27", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 66, - "summary": "Four Electron Docker SSH tests passed: two typing/performance paths, one concurrent file/Git load path, and exact-authority disconnect/reconnect with a container-visible remote proof file." - }, { "date": "2026-08-26", "runner": "local", @@ -7382,9 +7167,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-webcontents-registry.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-session-registry.test.ts src/main/browser/browser-route-persisted-worker-egress.electron.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-tcp-egress.electron.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts", - "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER=1 ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/local-ssh-browser-routing.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3", - "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER=1 ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3" + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts" ], "testFiles": [ "src/main/browser/browser-route-identity.test.ts", @@ -7398,9 +7181,7 @@ "src/main/browser/browser-route-webcontents-registry.test.ts", "src/main/browser/browser-session-registry.test.ts", "src/main/browser/browser-session-startup.test.ts", - "src/main/window/createMainWindow.test.ts", - "tests/e2e/local-ssh-browser-routing.spec.ts", - "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" + "src/main/window/createMainWindow.test.ts" ], "assertionRefs": [ { @@ -7488,20 +7269,6 @@ "a live route partition may attach only the normalized blank document", "an arbitrary URL cannot be the initial route-partition document" ] - }, - { - "file": "tests/e2e/local-ssh-browser-routing.spec.ts", - "assertions": [ - "a remote-only origin renders through direct SSH routing; cookies survive transport recovery", - "unavailable SSH hosts prevent premature webview attachment and offer a working explicit local escape hatch", - "real AllowTcpForwarding refusal is classified and Try anyway preserves the SSH route" - ] - }, - { - "file": "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts", - "assertions": [ - "paired client-hosted browser pages render an SSH-only origin, preserve cookies and retire superseded route pages across a real transport drop" - ] } ], "evidenceRuns": [ @@ -7540,24 +7307,6 @@ "result": "passed", "durationSeconds": 0.5, "summary": "Six files passed 150 opaque identity, durable collision binding, bounded partition/page, proxy-before-allowlist, policy reuse, profile startup, and blank-only attach tests." - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "result": "passed", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER=1 ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/local-ssh-browser-routing.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3", - "durationSeconds": 252, - "summary": "Nine direct SSH cases passed: three repetitions each of routing/reconnect, unavailable-host local escape, and real TCP-forwarding refusal. Run 34040309638, head 259a5f6; unchanged tests and timeouts, zero skips or retries." - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "result": "passed", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_LOCAL_SSH_BROWSER=1 ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER=1 ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3", - "durationSeconds": 138, - "summary": "Three paired client-hosted reconnect cases passed with remote-only origin and cookie-preservation assertions. Run 34040309638, head 259a5f6; unchanged tests and timeouts, zero skips or retries." } ], "runtimeBudget": { @@ -9838,7 +9587,7 @@ "invariant": "PreCompact is never registered and may never move a pane, because it fires before the compact is validated and an aborted compact emits it alone. Only a manual PostCompact may mark done, and it does so as a session boundary so completion-reactive consumers stay out of it; an automatic PostCompact claims nothing and writes no compact-scoped state, since its turn resumes and emits its own Stop. A completion either CLEARS a row or publishes nothing at all: it never creates a row, so a retired pane cannot be resurrected, and it never restates one it cannot clear, because a restated row loses restoredUnconfirmed and restarts the staleness clock. A manual /compact only completes at an idle prompt, so it retires a subagent that exists ONLY as a disk snapshot; live evidence -- a child observed in this runtime, an unclassifiable running background task, a registered session cron -- still holds the pane at working. A completion requires a valid provider prompt UUID, matches the pane owner by connection and provider session -- except on a restored row, which carries the previous connection and may carry no session at all -- and is applied at most once per compact. Ownership is enforced by the client that owns pane identity; a relay forwards and defers, so a cold relay cache cannot swallow the clearing event. A relay predating this contract forwards its own mapping, so ingest drops automatic envelopes, stamps the boundary on manual ones, carries the summarized turn label onto a promptless completion with or without a trigger, and classifies a trigger-stripped replay by payload state while still enforcing ownership. Kimi and other Claude-compatible vendors remain fail-closed.", "oracle": "Replay the measured Claude compact streams through a harness gated on the real CLAUDE_EVENTS, plus local HTTP, relay, SSH, and persisted-restore transports. Require the successful manual stream to end done with sessionBoundary set, and require the aborted stream (PreCompact alone) to leave both an idle and a working pane exactly as it found them. Drive the restart shape end to end over loopback HTTP from a hydrated row: a pane held only by a snapshot child must reach done with an empty roster, while a pane a runtime child still owns must emit NOTHING and keep its row object identical, so receivedAt and restoredUnconfirmed both survive. Deliver the restored-row cases with the hydrated row still cached at completion time and assert the summarizer published nothing, so the restored-row branch is the branch under test. Require rejection of an automatic completion, a completion with absent prompt UUID, a foreign provider session, a duplicate, and a completion for a pane with no cached row; require an automatic completion to leave the consumed-compact map untouched. Require a relay with a COLD cache to forward the completion while the client still refuses to mint a row it never had. Require ingest to drop legacy automatic envelopes, stamp the boundary on legacy manual ones, carry the summarized turn label onto a promptless completion with and without a trigger, and leave a FINISHED pane finished when a trigger-stripped working replay arrives. Require the consumed-compact map to be empty after each pane-scoped teardown route and to follow a pane move. Assert registration membership, emitted event order, and the authoritative lastStatusByPaneKey snapshot.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts --reporter=dot", "Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both" ], "testFiles": [ @@ -9850,8 +9599,7 @@ "src/shared/agent-hook-relay.test.ts", "src/wsl-guest/agent-hook-server.test.ts", "src/main/agent-hooks/server-claude-normalization.test.ts", - "src/main/agent-hooks/server-last-status-write.test.ts", - "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts" + "src/main/agent-hooks/server-last-status-write.test.ts" ], "assertionRefs": [ { @@ -9883,12 +9631,6 @@ { "file": "src/main/agent-hooks/manual-compact-status-cleanup.test.ts", "assertions": ["pane, tab, and server cleanup revoke compact completion authority"] - }, - { - "file": "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts", - "assertions": [ - "SSH stamps the receiving connection and forwards source, prompt UUID, session, and manual trigger" - ] } ], "evidenceRuns": [ @@ -9896,7 +9638,7 @@ "date": "2026-08-18", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/wsl-guest/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts --reporter=dot", "result": "passed", "durationSeconds": 3.28, "summary": "Ten files and 116 tests passed across registration, listener, local HTTP, relay, SSH, persistence, and cleanup. Reverting only the four source files to the merge base first turned 12 of 21 registration-gated tests and 8 of 12 relay/ingest tests red, so the suite observes its own failure rather than passing over unreachable code. Every assertion was additionally mutation-tested: 18 of 18 targeted mutations -- including reaping runtime children as well as snapshot ones, restating a pane live evidence still holds, re-enforcing compact ownership on the relay, and dropping the consumed-compact map from any one teardown route -- are caught." @@ -12799,27 +12541,22 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-pending-projection-admissions.test.ts src/main/ipc/ssh-pty-legacy-projection.test.ts src/main/ipc/ssh-pty-model-admission.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/wsl-guest/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts src/main/ssh/ssh-relay-session-model-migration.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/relay/git-response-stream-ownership.test.ts src/relay/pty-handler-output-streaming.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-recovery-interleavings.test.ts src/relay/relay-pty-source-recovery-completion.test.ts src/relay/relay-pty-source-restore-retry.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/wsl-guest/dispatcher.test.ts src/relay/pty-handler-source-publication.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-legacy-projection.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-generation-guard.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", - "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/relay/ssh-pty-consumer-session-adapter.test.ts src/main/persistence-ssh-targets-and-pane-keys.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/relay-pty-source-recovery-interleavings.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts --reporter=dot", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/pty-consumer-session.test.ts src/main/ipc/ssh-pty-output-intake.test.ts src/main/ipc/ssh-pty-output-generation-guard.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/wsl-guest/dispatcher-client-writer.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", + "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/ssh-pty-consumer-session-adapter.test.ts src/main/persistence-ssh-targets-and-pane-keys.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/relay-pty-source-recovery-interleavings.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts --reporter=dot" // Historical evidence commands; the override is ignored after the always-on transition. - "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/ipc/pty-renderer-inflight-credit.test.ts", @@ -12837,15 +12574,6 @@ "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts", "src/main/providers/ssh-pty-provider-exit-race.test.ts", "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", - "src/main/ssh/ssh-relay-session.test.ts", - "src/main/ssh/ssh-relay-session-data-delivery.test.ts", - "src/main/ssh/ssh-relay-session-model-migration.test.ts", - "src/main/ssh/ssh-relay-session-recovery-races.test.ts", - "src/main/ssh/ssh-relay-session-incarnation.test.ts", - "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts", - "src/main/ssh/ssh-relay-session-terminal-error.test.ts", - "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts", - "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts", "src/wsl-guest/protocol-backpressure.test.ts", "src/relay/git-response-stream-ownership.test.ts", "src/relay/pty-handler-output-streaming.test.ts", @@ -12870,11 +12598,9 @@ "src/main/ssh/ssh-multiplexer-transport-writer.test.ts", "src/main/ssh/ssh-channel-multiplexer.test.ts", "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts", - "src/main/ssh/ssh-relay-deploy.test.ts", "src/main/persistence-ssh-targets-and-pane-keys.test.ts", "src/wsl-guest/dispatcher-client-writer.test.ts", - "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts", - "tests/e2e/ssh-docker-relay-perf.spec.ts" + "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts" ], "assertionRefs": [ { @@ -12969,39 +12695,12 @@ "source-credit restore-required generic reattach fails as expired instead of returning an outputless live PTY" ] }, - { - "file": "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts", - "assertions": [ - "fifty reattaches use at most eight workers and healthy siblings finish before one slow and one failed PTY", - "initial connection and automatic reconnect both offer source credit" - ] - }, - { - "file": "src/main/ssh/ssh-relay-session.test.ts", - "assertions": [ - "each transient PTY failure is retried once without tearing down successful provider registration" - ] - }, { "file": "src/main/persistence-ssh-targets-and-pane-keys.test.ts", "assertions": [ "retired per-target source-credit selections are removed during normalization" ] }, - { - "file": "src/main/ssh/ssh-relay-session-model-migration.test.ts", - "assertions": [ - "overlapping reconnect attaches remain blocked behind the old-generation per-PTY model fence and receive only the post-completion exact checkpoint", - "stale-owner retry retains the old-generation fence through raw completion, then requests checkpoint-unavailable restore" - ] - }, - { - "file": "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts", - "assertions": [ - "fragmented recovery is bounded per PTY and per session in source units, charged bytes, and frames", - "UTF-16 string storage and record overhead are charged before aggregate admission" - ] - }, { "file": "src/relay/pty-source-credit-ledger.test.ts", "assertions": [ @@ -13147,31 +12846,6 @@ "mismatched recovery is typed for one-shot fallback while the stale principal remains a subscriber and the retained principal and lease preserve recovery authority" ] }, - { - "file": "src/main/ssh/ssh-relay-session-data-delivery.test.ts", - "assertions": [ - "the state-machine stale-owner error clears cached state and retries exactly once without resume", - "completion visible before lease-held recovery frames transfers those frames only into private quarantine and projects them once after exact checkpoint-to-recoveryEnd coverage and live handoff", - "invalid recovery cancels only its replacement token without physical PTY or ownership teardown" - ] - }, - { - "file": "src/main/ssh/ssh-relay-session-recovery-races.test.ts", - "assertions": [ - "empty recovery retains recoveryEndSu as the first live-frame continuity anchor", - "validated token cancellation drops queued late frames without physical PTY teardown", - "after recovery quarantine ownership transfer, rejected cancellation publication or proof closes only the exact provider generation and releases its provider, publishers, mux, and activation state once", - "negative, credited-ahead, checkpoint-mismatched, and under-covering recovery cancellation proofs fail closed against the highest privately observed range", - "overlapping recovery never cancels on or mutates the replacement mux, checkpoint, lease, provider state, or ownership" - ] - }, - { - "file": "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts", - "assertions": [ - "10,000 ordered token cancellations for one PTY retain one latest-token retirement record", - "the next activation or PTY exit boundary clears only that PTY's retirement record" - ] - }, { "file": "src/main/ssh/ssh-multiplexer-transport-writer.test.ts", "assertions": [ @@ -13208,14 +12882,6 @@ "assertions": [ "WSL stdin forwards write(false), callback settlement, and drain without claiming a live WSL topology" ] - }, - { - "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", - "assertions": [ - "a stalled renderer ACK produces an exact 256 KiB negotiated source-credit plateau while a second SSH PTY remains responsive", - "fixed-size filesystem frames and Git churn complete without stream corruption while active typing remains within budget", - "the negotiated owner lease reconnects and the existing SSH workspace terminal remains usable" - ] } ], "evidenceRuns": [ @@ -13250,7 +12916,7 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/wsl-guest/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/wsl-guest/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", "result": "passed", "durationSeconds": 2.24, "summary": "Sixteen deterministic SSH relay/session/source/decoder files passed 192 tests, including exit-sealed private recovery, retained stale-transfer cancellation authority, exact private-frame proof watermarks, one retirement record across 10,000 same-PTY token rotations, stale-owner fallback, and scheduler rejection isolation; no live topology was exercised." @@ -13277,7 +12943,7 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-pty-source-restore-retry.test.ts src/main/runtime/rpc/terminal-source-range-ledger.test.ts src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts src/main/ssh/ssh-multiplexer-transport-writer.test.ts src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ipc/ssh-pty-output-exit-deadline.test.ts src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot", "result": "passed", "durationSeconds": 6.89, "summary": "Thirteen current-head deterministic files passed 150 tests across exit-sealed private recovery, stale-transfer cancellation authority, bounded latest-token retirement, restore retirement, fail-closed recovery cancellation, partial ACK and token rotation, reclaimed-span replacement, mux lane fairness, provisional activation, and exit-proof ordering; no live topology was exercised." @@ -13286,7 +12952,7 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session.test.ts --reporter=dot", + "command": "pnpm exec vitest run src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts --reporter=dot", "result": "passed", "durationSeconds": 1.86, "summary": "Seven current-worktree provider/session files passed 94 tests after a red deterministic seam reproduced completion visibility before lease-held recovery data; the candidate transfers held and later frames only into private recovery quarantine, final-commits after fence/admission, retires exited activation state, and preserves fail-closed malformed, gapped, overlapping, missing-body, cancellation-proof, and replacement behavior." @@ -13295,37 +12961,10 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/relay-pty-source-recovery-interleavings.test.ts src/main/ssh/ssh-relay-deploy.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts src/relay/relay-pty-source-recovery-interleavings.test.ts --reporter=dot", "result": "passed", "durationSeconds": 1.63, "summary": "Five deterministic provider, relay, deploy, and session files passed 70 tests after red seams proved that source frames mutated legacy incarnation state and generic restore-required reattach returned an outputless live PTY. The candidate keeps exact source identity side-effect free, fails generic restore-required reattach as expired, offers V1 through reconnect, and proves a failed recovery activation stays private and exact-retryable until successful response settlement; no live topology was exercised." - }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 60, - "summary": "All four deployed Linux relay/direct-SSH cases passed after an exact-current E2E rebuild at code commit 5611bb45b51a/tree 8e85c3afedec: main bundle SHA-256 cbaf4e997d74bbe0ae1179bc20e52b122c60f0b56ee49ca607e12ae4125a4342 and Linux-x64 relay SHA-256 a7438fc47c4da0223ceaafab621086e14b6cdee427f53a3bf2e710fa99bcf2e6. Direct typing was 3.7/109.1 ms median/worst, ACK-stalled typing was 5.1/109.7 ms at exactly 262,144 held source units, fixed-size filesystem/Git churn was 144.9/153.2 ms with 104 bulk reads, and owner-lease reconnect completed in 15.8 seconds." - }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_PTY_SOURCE_CREDIT_V1=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 60, - "summary": "Four post-rebase Docker OpenSSH/deployed-relay tests passed: direct typing median/worst 107.7/113.6 ms, stalled-ACK typing 3.6/107.3 ms at an exact 262,144-source-unit plateau, fixed-size filesystem/Git churn 148.1/161.1 ms with 93 bulk reads, and terminal owner-lease reconnect in 15.7 seconds." - }, - { - "date": "2026-07-29", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 56.7, - "summary": "All four always-on deployed Linux relay/direct-SSH cases passed after rebuilding at current-main merge commit adba3410fe4427ceb7525f3fdce2ec58973263a7/tree e43d87340666ca3a733bdb38007c24f95be3f219: main bundle SHA-256 3c304ffc0618520e42bede9a52f72d4b7bb68cbfe543974f89e82a3c477c44b3 and Linux-x64 relay SHA-256 366cb7ccf2e4b388cc6f81a8f6055ab9fde57d2d91381767005c15908832e776. Direct typing was 5.3/108.9 ms median/worst, ACK-stalled typing was 4.8/107.6 ms at exactly 262,144 held source units, fixed-size filesystem/Git churn was 152.0/173.3 ms with 84 bulk reads, and owner-lease reconnect completed in 14.3 seconds; no paired-runtime topology was exercised." } ], "runtimeBudget": { @@ -13599,8 +13238,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/preload/pty-snapshot-capability-ipc.test.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/main/providers/ssh-pty-provider.test.ts src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/pty-snapshot-capability-main-stall.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/preload/pty-snapshot-capability-ipc.test.ts", @@ -13611,8 +13249,7 @@ "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts", "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts", "tests/e2e/pty-snapshot-capability-main-stall.spec.ts", - "tests/e2e/terminal-cold-activation-deferral.spec.ts", - "tests/e2e/ssh-cold-activation-restore.spec.ts" + "tests/e2e/terminal-cold-activation-deferral.spec.ts" ], "assertionRefs": [ { @@ -13729,9 +13366,7 @@ "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts", @@ -13795,9 +13430,7 @@ "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", - "tests/e2e/terminal-cold-activation-deferral.spec.ts", - "tests/e2e/ssh-cold-activation-restore.spec.ts", - "tests/e2e/ssh-docker-relay-perf.spec.ts" + "tests/e2e/terminal-cold-activation-deferral.spec.ts" ], "assertionRefs": [ { @@ -14169,21 +13802,6 @@ "assertions": [ "abandoning an obsolete reattach drops its data, replay, write-unavailable, and exit handlers without killing the durable PTY" ] - }, - { - "file": "tests/e2e/ssh-cold-activation-restore.spec.ts", - "assertions": [ - "six relay-backed tabs retain the same six PTY ids across renderer reload", - "all six SSH managers mount eagerly and none is parked", - "restored terminal input reaches a proof file on the Linux SSH host" - ] - }, - { - "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", - "assertions": [ - "repo and worktree setup fails closed unless the exact direct SSH host and complete authority are returned", - "a reconnected terminal accepts input and writes a proof file visible inside the Linux SSH target" - ] } ], "evidenceRuns": [ @@ -14241,15 +13859,6 @@ "durationSeconds": 2.54, "summary": "Seven deterministic STA-3107 contracts passed across six-pane multiplex rematerialization and client-owned sidebar-row retention after sleep/wake." }, - { - "date": "2026-07-14", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 128, - "summary": "Six focused files and 475 tests passed; the local cold-activation journey passed; the Docker/Linux SSH journey restored six of six original relay PTYs, mounted six of six managers without parking, and executed remote input through the restored terminal." - }, { "date": "2026-07-17", "runner": "local", @@ -14285,24 +13894,6 @@ "result": "passed", "durationSeconds": 1.48, "summary": "All 90 transport tests passed, including pre-publication admission rejection, handler-complete non-destructive detach for obsolete SSH reattach transports, settled retirement of rejected or destroyed fresh fallbacks, and reported shutdown refusal." - }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 51.4, - "summary": "Four Electron Docker SSH tests passed on the final implementation, including exact-authority repo/worktree hydration, two concurrent immutable file streams under Git churn, live terminal input before and after disconnect/reconnect, and an independent container-visible remote proof file." - }, - { - "date": "2026-07-28", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 12.5, - "summary": "One Electron journey passed on the final implementation after exact-authority hydration; six restored SSH terminal managers remounted after renderer reload and remote input reached the Linux target." } ], "runtimeBudget": { @@ -16184,7 +15775,7 @@ "commands": [ "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot", @@ -16215,7 +15806,6 @@ "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts", "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts", "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts", - "src/main/ssh/ssh-remote-orca-cli.test.ts", "tests/e2e/orchestration-worker-terminal-visibility.spec.ts", "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts", "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts" @@ -16353,13 +15943,6 @@ "file": "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts", "assertions": ["federated worker placement explicitly sets activate=false"] }, - { - "file": "src/main/ssh/ssh-remote-orca-cli.test.ts", - "assertions": [ - "implicit SSH fallback checks retain stable pane identity", - "explicit legacy SSH inspection does not inherit the caller pane key" - ] - }, { "file": "tests/e2e/orchestration-worker-terminal-visibility.spec.ts", "assertions": [ @@ -16503,7 +16086,7 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts", "result": "passed", "durationSeconds": 5.27, "summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity." @@ -17562,8 +17145,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts", "pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts", @@ -17575,8 +17157,7 @@ "src/shared/pty-startup-ingress-live-query-reply.test.ts", "src/shared/pty-startup-reply-echo-shapes.test.ts", "tests/e2e/terminal-osc-color-queries.spec.ts", - "tests/e2e/terminal-typing-latency.spec.ts", - "tests/e2e/pty-input-write-queue-ssh.spec.ts" + "tests/e2e/terminal-typing-latency.spec.ts" ], "assertionRefs": [ { @@ -17647,13 +17228,6 @@ "assertions": [ "real keyboard input reaches and echoes from the active local PTY within the interactive latency ceilings" ] - }, - { - "file": "tests/e2e/pty-input-write-queue-ssh.spec.ts", - "assertions": [ - "a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue", - "an upstream-node-pty Linux relay has no echoState export and keeps OSC/DA1 reply bytes out of the next fish child stdin" - ] } ], "evidenceRuns": [ @@ -17683,24 +17257,6 @@ "result": "passed", "durationSeconds": 10.6, "summary": "Real keyboard events traversed xterm and the input queue to a local interactive PTY with every sampled key echoed inside the latency ceiling." - }, - { - "date": "2026-08-09", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 36.8, - "summary": "A real Linux process behind Docker OpenSSH emitted an OSC 10 query and observed xterm's synthetic RGB reply after it traversed the live renderer queue, Electron IPC, SSH provider, and relay PTY." - }, - { - "date": "2026-08-14", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 36.9, - "summary": "Run with SKIP_BUILD=1 against the existing e2e bundle. Both Docker OpenSSH Electron tests passed: the live OSC query returned its RGB reply, and an upstream-node-pty Linux ARM64 relay without echoState kept OSC/DA1 bytes out of the next fish 4.8.1 child's stdin." } ], "runtimeBudget": { @@ -18865,13 +18421,13 @@ "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-pending-setup-waiters.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-watcher-parent-removal.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "pnpm run build:electron-vite", "node config/scripts/runtime-file-watcher-resource-probe.mjs", "node config/scripts/runtime-file-watcher-fault-harness.mjs", @@ -18918,8 +18474,6 @@ "src/main/runtime/rpc/methods/files.test.ts", "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts", "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts", - "src/main/providers/ssh-filesystem-provider.test.ts", - "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts", "src/main/providers/ssh-pty-provider.test.ts", "src/renderer/src/runtime/runtime-file-client.test.ts", "src/renderer/src/web/web-runtime-client.test.ts", @@ -19240,21 +18794,6 @@ "a failed paired-web unwatch retries after already-resolved or late physical exit and emits stream end exactly once" ] }, - { - "file": "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts", - "assertions": [ - "10,000 cancelled SSH callers detach while one registration-owned setup anchor remains" - ] - }, - { - "file": "src/main/providers/ssh-filesystem-provider.test.ts", - "assertions": [ - "an SSH caller abort rejects promptly without cancelling another same-root subscriber's shared setup", - "the physical relay request is cancelled when the last pending subscriber leaves", - "provider disposal cancels pending setup and sends one remote unwatch", - "equivalent Windows spellings share one provider generation and terminal failure reaches the surviving owner" - ] - }, { "file": "src/main/providers/ssh-pty-provider.test.ts", "assertions": [ @@ -19402,7 +18941,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-pending-assignment.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/shared/promise-settlement-waiters.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/relay-watcher-setup-wait.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", "result": "passed", "durationSeconds": 11.98, "summary": "Thirty-nine watcher, cancellation, desktop/serve, paired-web, WSL, SSH/relay, removal, and PTY physical-exit files passed with 2,008 tests. Capacity, quarantine, runtime-root, relay, and SSH setup layers each prove that 10,000 aborted callers detach while one anchor remains and zero waiters remain after settlement." @@ -19411,7 +18950,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts src/main/ipc/parcel-watcher-child-registry.test.ts src/main/ipc/parcel-watcher-child-launch.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-supervisor-capacity.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/runtime/rpc/methods/files-watch-cancellation.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/shared/physical-exit-tracker.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", "result": "passed", "durationSeconds": 11.59, "summary": "Thirty-four watcher, cancellation, desktop/serve, paired-web, WSL, removal, PTY physical-exit, provider, renderer, and path files passed with 1,999 tests; paired-web cancellation now owns a server token before ready and aborts pending setup without a late native install." @@ -19429,7 +18968,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher.test.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/pty-subprocess.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts --reporter=dot", "result": "passed", "durationSeconds": 10.01, "summary": "Twenty-seven watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,886 tests. The suite includes nested removal fencing, renderer/runtime cwd-only admission, legacy provider rows, rejected kill retries, transient WSL capacity, and synchronous-startup reservation cleanup." @@ -19501,7 +19040,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/local-pty-provider-session-inventory.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", "result": "passed", "durationSeconds": 9.12, "summary": "Twenty-five watcher, WSL, desktop/serve removal, local/daemon/SSH PTY physical-exit, provider ownership, RPC, renderer, relay PTY admission, and cross-platform path files passed with 1,762 tests. The suite covers operations joining in-flight termination, kill failures retaining ownership, bounded timeouts, atomic relay spawn/revive caps, and PTY-removal admission." @@ -19573,7 +19112,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-listener-teardown-and-orphans.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/terminal-host-session-reaping-leak.test.ts src/main/providers/ssh-pty-provider.test.ts src/relay/pty-handler-dispose-lifecycle.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/relay/pty-handler-spawn-admission.test.ts src/relay/pty-handler-attach-replay.test.ts src/relay/pty-handler-revive.test.ts", "result": "passed", "durationSeconds": 9.03, "summary": "Twenty-three watcher, desktop/serve removal, terminal-spawn gate, daemon/SSH physical PTY exit, provider ownership, RPC, renderer, and cross-platform path files passed with 1,630 tests. The suite includes same-turn watcher replacement, authoritative SSH teardown before Git, bounded kill-without-exit failures that retain ownership, and local/SSH deletion fail-closed behavior." @@ -19609,7 +19148,7 @@ "date": "2026-07-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/parcel-watcher-disconnect-termination.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/ipc/worktrees-delete-pty-teardown.test.ts src/main/ipc/worktrees-orphan-directory-cleanup.test.ts src/main/ipc/worktrees-remove-preflight.test.ts src/main/ipc/worktrees-remove-archive-hooks.test.ts src/main/ipc/watcher-removal-gate.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/orca-runtime-files-watch.test.ts src/main/runtime/worktree-teardown.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/files.test.ts src/renderer/src/runtime/runtime-file-client.test.ts src/renderer/src/web/web-runtime-client.test.ts src/shared/cross-platform-path.test.ts", "result": "passed", "durationSeconds": 8.85, "summary": "18 focused supervisor, disconnect termination, pool, child-entry, delivery, desktop/WSL cleanup and IPC removal, removal-gate, runtime-host/commands and physical PTY teardown, RPC, SSH ownership, renderer/paired-web clients, and cross-platform path files passed with 1,193 tests." @@ -19782,19 +19321,15 @@ "invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.", "oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", - "pnpm exec electron-vite build --mode e2e", - "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e" ], "testFiles": [ "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", "src/main/ssh/ssh-port-forward.test.ts", "src/main/ssh/system-ssh-forward-process.test.ts", "src/main/ipc/ssh.test.ts", - "src/main/ssh/ssh-relay-session.test.ts", - "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", - "tests/e2e/ssh-port-forward-lifecycle.spec.ts" + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts" ], "assertionRefs": [ { @@ -19820,17 +19355,9 @@ "persists desired forwards and broadcasts when an active forward closes unexpectedly" ] }, - { - "file": "src/main/ssh/ssh-relay-session.test.ts", - "assertions": ["cleans up port forwards on reconnect"] - }, { "file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", "assertions": ["holds only the first matching request while its snapshot is unresolved"] - }, - { - "file": "tests/e2e/ssh-port-forward-lifecycle.spec.ts", - "assertions": ["keeps a user-forwarded listener live across scan refresh @headful"] } ], "evidenceRuns": [ @@ -19838,28 +19365,10 @@ "date": "2026-07-30", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.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 src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", "result": "passed", "durationSeconds": 6.77, "summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts." - }, - { - "date": "2026-07-30", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "result": "passed", - "durationSeconds": 53.7, - "summary": "Headed Electron passed against a real Docker sshd over ssh2 after forcing the stale startup-snapshot race and exact renderer-continuation barrier, with two remote listeners, scan refresh, in-place relay and full transport reconnect restoration, collision rejection, explicit removal, independent state inventories, HTTP responses, process identity, and no live forward-close warning." - }, - { - "date": "2026-07-30", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "result": "passed", - "durationSeconds": 66, - "summary": "The identical stale-snapshot and headed lifecycle passed through forced system OpenSSH, with a wrapper marker proving the -L forward process executed; in-place relay and full transport reconnects restored both intents, no unexpected forward close was captured before assertions, and intentional removal completed." } ], "runtimeBudget": { @@ -21212,64 +20721,14 @@ "invariant": "Transport loss and frozen-host silence must preserve the remote session; host relay loss may rebind a pane without accumulating reattachable leases. Reconnects must preserve usable terminal content, bounded PTYs/fds/processes, complete large listings, and independently recoverable watcher processes. Electron test shutdown must release inherited pipes after confirmed root exit without closing live-process pipes.", "oracle": "Poll a changed connected SSH authority after injected faults, then require terminal output and appropriate PTY identity. Read remote process/fd state, listFiles replies, and rendered explorer rows. Resolve Playwright cleanup only after the root process exits and its inherited pipes close; live-process pipes remain untouched.", "commands": [ - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.spec.ts tests/e2e/ssh-docker-half-open-link.spec.ts tests/e2e/ssh-docker-quick-open-large-listing.spec.ts tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts tests/e2e/ssh-docker-resource-accumulation.spec.ts tests/e2e/ssh-docker-watcher-isolation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1 --repeat-each=10", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-codex-display-artifacts-repro.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1", - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts" ], "testFiles": [ - "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts", - "tests/e2e/ssh-docker-half-open-link.spec.ts", - "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts", - "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts", - "tests/e2e/ssh-docker-resource-accumulation.spec.ts", - "tests/e2e/ssh-docker-watcher-isolation.spec.ts", "tests/e2e/helpers/electron-process-shutdown.unit.test.ts", - "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts", - "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts", - "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts", - "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" + "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts" ], "assertionRefs": [ - { - "file": "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts", - "assertions": [ - "preserves transport-drop PTY and scrollback, replaces relay-loss binding, and keeps one reattachable lease per pane" - ] - }, - { - "file": "tests/e2e/ssh-docker-half-open-link.spec.ts", - "assertions": [ - "leaves connected after host freeze and renders process-produced output after recovery" - ] - }, - { - "file": "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts", - "assertions": [ - "returns both a bounded client page and a complete legacy-client remote listing" - ] - }, - { - "file": "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts", - "assertions": [ - "restores shell scrollback and full-screen output and opens a usable fresh tab" - ] - }, - { - "file": "tests/e2e/ssh-docker-resource-accumulation.spec.ts", - "assertions": [ - "keeps remote pts devices, relay fds, process counts and inherited master fds bounded" - ] - }, - { - "file": "tests/e2e/ssh-docker-watcher-isolation.spec.ts", - "assertions": [ - "keeps rendered explorer changes and terminal output live after watcher crash and repairs a deleted watcher artifact" - ] - }, { "file": "tests/e2e/helpers/electron-process-shutdown.unit.test.ts", "assertions": [ @@ -21277,24 +20736,6 @@ "retains live-process pipes on shutdown timeout" ] }, - { - "file": "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts", - "assertions": [ - "five flooding SSH panes remain below unchanged 2500ms soft and 5000ms hard freeze budgets during bulk reopen and two double-animation-frame view changes" - ] - }, - { - "file": "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts", - "assertions": [ - "five distinct SSH PTYs acknowledge actual keyboard input after two rendered hide/reopen cycles while all five producers flood" - ] - }, - { - "file": "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts", - "assertions": [ - "normal restore and forced SSH reconnect leave no stale or duplicate status rows; forced reconnect preserves the original PTY and requires nonempty replay from that PTY through an event or reattach reply" - ] - }, { "file": "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts", "assertions": [ @@ -21311,24 +20752,6 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts", "durationSeconds": 0.168, "summary": "All three shutdown regression tests passed; disabling pipe release fails the first two by timeout. Two half-open Electron repetitions separately passed in 1.7m without worker teardown timeout." - }, - { - "date": "2026-09-05", - "runner": "local", - "platform": "macos", - "result": "passed", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.spec.ts tests/e2e/ssh-docker-half-open-link.spec.ts tests/e2e/ssh-docker-quick-open-large-listing.spec.ts tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts tests/e2e/ssh-docker-resource-accumulation.spec.ts tests/e2e/ssh-docker-watcher-isolation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "durationSeconds": 312, - "summary": "Six specs: ten passed, two existing fixme skipped, clean worker shutdown. Baseline same enabled suite: ten passed but worker teardown timed out (7.3m)." - }, - { - "date": "2026-09-06", - "runner": "ci", - "platform": "linux", - "result": "passed", - "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1 --repeat-each=10", - "durationSeconds": 396, - "summary": "Original uninstrumented five-pane workload passed all ten repetitions with zero retries/skips in 6.6m; bulk-open lag 79.3–147.8ms and interaction 127.1–155.9ms, unchanged 2500ms/5000ms budgets. Run 34037669843, head f25eab3fd7d723509ced026633f80b193a139b76, excludes unmerged replay-input application fix #19075." } ], "runtimeBudget": { @@ -21934,7 +21357,6 @@ "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/orchestration src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts src/main/runtime/runtime-legacy-worker-missing-workspace-recovery.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/orchestration/worker-dispatch-settlement.test.ts src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts src/main/runtime/runtime-legacy-worker-missing-workspace-recovery.test.ts src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm exec playwright test tests/e2e/profile-state-terminal-restart-persistence.spec.ts tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 -g 'repairs a settled worker'", "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts" ], "testFiles": [ @@ -21944,8 +21366,7 @@ "src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts", "src/main/runtime/orca-runtime.test.ts", "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts", - "src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts", - "tests/e2e/ssh-cold-activation-restore.spec.ts" + "src/main/runtime/orchestration/worker-dispatch-repair-safety.test.ts" ], "assertionRefs": [ { @@ -21993,25 +21414,9 @@ "an older SQL writer can reintroduce stale bookkeeping after repair and the next open still settles it", "second open and repair perform zero worker mutations with 100,000 historical assignments" ] - }, - { - "file": "tests/e2e/ssh-cold-activation-restore.spec.ts", - "assertions": [ - "historical assignment repair preserves a real SSH shell and its live background child through app restart and reconnect", - "rendered terminal output and host-side process proof retain the same identity, environment and working directory" - ] } ], "evidenceRuns": [ - { - "date": "2026-10-05", - "runner": "local", - "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 -g 'repairs a settled worker'", - "result": "passed", - "durationSeconds": 42.4, - "summary": "Rebuilt hidden Electron with the active-worker partial index and read-only repair preflight. The real Debian ARM64 SSH journey preserves the same live shell and background child, terminal ownership, input and rendered output through app restart and reconnect." - }, { "date": "2026-10-05", "runner": "local", @@ -22065,15 +21470,6 @@ "result": "passed", "durationSeconds": 2.29, "summary": "Three safety and scale tests pass. One local run with 100,000 assignments and 1,000 stale workers: repair 38.83ms, no-op repair 25.07ms, complete subsequent database open 38.52ms. These are observations, not a CI latency bound." - }, - { - "date": "2026-10-05", - "runner": "local", - "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 -g 'repairs a settled worker'", - "result": "passed", - "durationSeconds": 32.1, - "summary": "A rebuilt hidden macOS Electron client against Debian ARM64 Docker SSH repairs historical settled worker bookkeeping while retaining terminal ownership, the same live shell and background child, environment, working directory, input and rendered output through app restart and SSH reconnect. Removing startup repair fails the worker-state assertion; restoring it passes." } ], "runtimeBudget": { diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 7f94ec1eb413..51942d7c7fdd 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -1,45 +1,7 @@ import { pathToFileURL } from 'node:url' -export const DOCKER_SSH_E2E_SPECS = [ - 'tests/e2e/local-ssh-browser-routing.spec.ts', - 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', - 'tests/e2e/pty-input-write-queue-ssh.spec.ts', - 'tests/e2e/ssh-ai-vault-session-history.spec.ts', - 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', - 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts', - 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', - 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', - 'tests/e2e/ssh-docker-half-open-link.spec.ts', - 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts', - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', - 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts', - 'tests/e2e/ssh-docker-resource-accumulation.spec.ts', - 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', - 'tests/e2e/ssh-external-image-preview.spec.ts', - 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', - 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', - 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', - 'tests/e2e/ssh-reattach-home-partition.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', - 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', - 'tests/e2e/ssh-skill-installation.spec.ts', - 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts', - 'tests/e2e/ssh-startup-local-shadow.spec.ts', - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts', - 'tests/e2e/terminal-inline-images-ssh.spec.ts', - 'tests/e2e/ssh-docker-watcher-isolation.spec.ts', - 'tests/e2e/ssh-terminal-parking.spec.ts', - 'tests/e2e/terminal-retention-budget.spec.ts', - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', - 'tests/e2e/paired-startup-exec-readiness.spec.ts', - 'tests/e2e/workspace-layout-oracle-ssh.spec.ts' -] - export const NODE_NETWORK_E2E_SPEC = 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' -export const LOCALHOST_SSH_E2E_SPEC = 'tests/e2e/ssh-localhost.spec.ts' export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.ts' // Needs the packaged orcad slot, which only its own job builds. export const ORCAD_SERVE_MODE_SWITCH_E2E_SPEC = 'tests/e2e/orcad-serve-mode-switch.spec.ts' @@ -78,9 +40,7 @@ export const ORCAD_JOURNEY_E2E_SPECS = [ 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts' ] export const DEDICATED_E2E_SPECS = [ - ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, - LOCALHOST_SSH_E2E_SPEC, NATIVE_IME_E2E_SPEC, ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, LAYOUT_ORACLE_HEADLESS_E2E_SPEC, @@ -103,7 +63,6 @@ export const DEDICATED_E2E_SPECS = [ ...ORCAD_JOURNEY_E2E_SPECS ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) -const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) export function selectGeneralE2eSpecs(specs) { return specs.filter((spec) => !dedicatedSpecs.has(spec)) @@ -117,7 +76,7 @@ function parseSpecs(input) { return specs } -export function classifyE2eJobs(input, sshSourceChanged = 'false') { +export function classifyE2eJobs(input) { const conservative = { e2e_run_changed: true, e2e_needs_build: true } let specs try { @@ -134,11 +93,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { e2e_run_changed: runChanged, e2e_needs_build: runChanged || - sshSourceChanged !== 'false' || specs.some( (spec) => - dockerSpecs.has(spec) || - spec === LOCALHOST_SSH_E2E_SPEC || spec === ORCAD_SERVE_MODE_SWITCH_E2E_SPEC || spec === LAYOUT_ORACLE_HEADLESS_E2E_SPEC || spec === ORCAD_AUTO_CONVERT_E2E_SPEC || @@ -168,9 +124,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) input += chunk } if (process.argv.includes('--job-outputs')) { - for (const [name, value] of Object.entries( - classifyE2eJobs(input, process.env.E2E_SSH_SOURCE_CHANGED ?? 'false') - )) { + for (const [name, value] of Object.entries(classifyE2eJobs(input))) { process.stdout.write(`${name}=${value}\n`) } } else { diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 5c4863712da6..3d293c66c8a2 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -5,8 +5,6 @@ import { runProcess } from '@orca/process-host' import { classifyE2eJobs, DEDICATED_E2E_SPECS, - DOCKER_SSH_E2E_SPECS, - LOCALHOST_SSH_E2E_SPEC, NATIVE_IME_E2E_SPEC, NODE_NETWORK_E2E_SPEC, selectGeneralE2eSpecs @@ -45,7 +43,7 @@ it.each([ const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh) +const classify = (specs) => classifyE2eJobs(JSON.stringify(specs)) it('runs real browser file-drop ownership in the template-building Docker job', () => { const spec = 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts' @@ -81,42 +79,32 @@ it('skips the general consumer only when every requested spec has a dedicated ow }) it('keeps Electron build and native prerequisites for every consumer requiring them', () => { - for (const spec of [...DOCKER_SSH_E2E_SPECS, LOCALHOST_SSH_E2E_SPEC]) { - expect(classify([spec]), spec).toEqual({ - e2e_run_changed: false, - e2e_needs_build: true - }) - } for (const spec of [NODE_NETWORK_E2E_SPEC, NATIVE_IME_E2E_SPEC]) { expect(classify([spec]), spec).toEqual({ e2e_run_changed: false, e2e_needs_build: false }) - expect(classify([spec], 'true').e2e_needs_build).toBe(true) } expect(workflow.jobs['ssh-browser-network-route'].needs).toBeUndefined() - for (const name of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation', 'ssh-localhost']) { + for (const name of ['e2e', 'changed-e2e']) { expect(workflow.jobs[name].needs, name).toEqual(['build', 'prepare-native-cache']) } }) -it('retains allocations when selection or SSH evidence is incomplete', () => { +it('retains allocations when selection evidence is incomplete', () => { for (const input of ['', '[]', 'null', '{}', '[null]', '[""]', 'malformed']) { expect(classifyE2eJobs(input), input).toEqual({ e2e_run_changed: true, e2e_needs_build: true }) } - expect(classify([NODE_NETWORK_E2E_SPEC], '').e2e_needs_build).toBe(true) }) it('preserves the requested specs across source-routed and mixed selections', () => { for (const files of [ - ['src/main/ssh/connection.ts'], ['src/main/browser/ssh-browser-network-execution-route.ts'], - ['src/main/agent-hooks/server.ts'], ['src/shared/terminal-unicode-provider.ts'], - ['tests/e2e/ssh-localhost.spec.ts', 'tests/e2e/future-unclassified.spec.ts'] + [NODE_NETWORK_E2E_SPEC, 'tests/e2e/future-unclassified.spec.ts'] ]) { const specs = selectPrE2eSpecs(files) const general = selectGeneralE2eSpecs(specs) @@ -177,7 +165,6 @@ it('classifies PR consumers in the existing detector and passes conservative all expect(detector.outputs[name]).toBe(`\${{ steps.e2e_filter.outputs.${name} }}`) } const command = detector.steps.find((step) => step.id === 'e2e_filter').run - expect(command).toContain('E2E_SSH_SOURCE_CHANGED="$SSH_SOURCE_CHANGED"') expect(command).toContain('ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT"') expect(prWorkflow.jobs.e2e.with.run_changed_e2e).toBe( "${{ needs.code_paths.outputs.e2e_run_changed != 'false' }}" @@ -187,17 +174,6 @@ it('classifies PR consumers in the existing detector and passes conservative all ) }) -it('runs remaining SSH tests after real failures and stops them when a run is cancelled', () => { - const steps = workflow.jobs['ssh-docker-watcher-isolation'].steps - expect(steps.find((step) => step.name === 'Run remaining Docker SSH E2E').if).toBe('!cancelled()') - expect( - steps.find((step) => step.name === 'Run Docker SSH terminal parking + startup readiness E2E').if - ).toBe('!cancelled() && matrix.shard == 1') - for (const step of steps.filter((step) => step.name?.startsWith('Keep '))) { - expect(step.if).toContain('always()') - } -}) - it.each([ 'src/renderer/src/runtime/web-session-tabs-sync/mirrored-editor-file-identity.ts', 'src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts', diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index ea8616e99dfe..f02525a259b4 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -286,7 +286,6 @@ "config/scripts/sort-comparator-performance-plugin.test.mjs": 283, "config/scripts/space-sharing-copy.test.ts": 96, "config/scripts/ssh-browser-e2e-routing.test.mjs": 176, - "config/scripts/ssh-localhost-e2e-routing.test.mjs": 155, "config/scripts/ssh-relay-import-boundary.test.mjs": 5796, "config/scripts/stable-release-tag-selection-budget.test.mjs": 149, "config/scripts/static-appimage-package-contract.test.mjs": 83, @@ -902,7 +901,6 @@ "src/main/ai-vault/session-transcript-every-agent-capture.test.ts": 680, "src/main/ai-vault/session-transcript-message-content.test.ts": 212, "src/main/ai-vault/session-transcript-record-budget.test.ts": 41, - "src/main/ai-vault/ssh-session-list.test.ts": 303, "src/main/ai-vault/structured-session-ownership.test.ts": 283, "src/main/amp/hook-service.test.ts": 70, "src/main/antigravity/hook-service.test.ts": 210, @@ -2770,26 +2768,18 @@ "src/main/ipc/sparse-preset-attribution.test.ts": 43, "src/main/ipc/speech-worker-exit-lifetime.test.ts": 529, "src/main/ipc/speech.test.ts": 80, - "src/main/ipc/ssh-active-relay-sessions.test.ts": 405, "src/main/ipc/ssh-app-shutdown.test.ts": 4272, "src/main/ipc/ssh-browse.test.ts": 706, "src/main/ipc/ssh-connect-recheck-cancellation.test.ts": 4164, "src/main/ipc/ssh-disconnect-cancellation.test.ts": 5166, "src/main/ipc/ssh-handler-reregistration.test.ts": 4043, - "src/main/ipc/ssh-host-node-runtime-connect.test.ts": 4239, "src/main/ipc/ssh-host-partition-session-export.test.ts": 1644, "src/main/ipc/ssh-host-server-decision-failure.test.ts": 385, "src/main/ipc/ssh-host-server-on-connect-wiring.test.ts": 2374, - "src/main/ipc/ssh-host-server-relay-decision.test.ts": 377, "src/main/ipc/ssh-host-sleep-reconnect.test.ts": 424, - "src/main/ipc/ssh-managed-server-move-conversion.test.ts": 2156, - "src/main/ipc/ssh-managed-server-move-stop-retention.test.ts": 6067, - "src/main/ipc/ssh-managed-server-move.test.ts": 325, "src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts": 487, "src/main/ipc/ssh-passphrase.test.ts": 104, - "src/main/ipc/ssh-plain-ssh-connect.test.ts": 4001, "src/main/ipc/ssh-pty-closed-generation-ranges.test.ts": 71, - "src/main/ipc/ssh-pty-consumer-identity.test.ts": 4353, "src/main/ipc/ssh-pty-legacy-projection.test.ts": 67, "src/main/ipc/ssh-pty-model-admission-generation-scope.test.ts": 69, "src/main/ipc/ssh-pty-model-admission.test.ts": 63, @@ -2803,12 +2793,9 @@ "src/main/ipc/ssh-pty-source-ack-session-contract.test.ts": 88, "src/main/ipc/ssh-pty-source-obligation-coordinator.test.ts": 63, "src/main/ipc/ssh-pty-source-obligation-ledger.test.ts": 189, - "src/main/ipc/ssh-relay-reset-resume.test.ts": 6183, "src/main/ipc/ssh-state-broadcast-fanout.test.ts": 4164, "src/main/ipc/ssh-target-crud-handlers-ownership.test.ts": 560, "src/main/ipc/ssh-target-registry.test.ts": 6430, - "src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts": 4034, - "src/main/ipc/ssh-terminate-sessions.test.ts": 5128, "src/main/ipc/ssh-test-connection-probe-registry.test.ts": 46, "src/main/ipc/ssh-test-probe-presence.test.ts": 63, "src/main/ipc/ssh.test.ts": 4177, @@ -3955,20 +3942,7 @@ "src/main/providers/pty-process-inspection.test.ts": 55, "src/main/providers/pty-process-list-admission.test.ts": 150, "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1987, - "src/main/providers/ssh-directory-legacy-compatibility.test.ts": 225, - "src/main/providers/ssh-directory-listing-options.test.ts": 106, - "src/main/providers/ssh-directory-streaming-real.test.ts": 224, - "src/main/providers/ssh-directory-streaming.test.ts": 130, "src/main/providers/ssh-filesystem-dispatch.test.ts": 52, - "src/main/providers/ssh-filesystem-doc-preview.test.ts": 76, - "src/main/providers/ssh-filesystem-download.test.ts": 193, - "src/main/providers/ssh-filesystem-provider-capabilities.test.ts": 95, - "src/main/providers/ssh-filesystem-provider-download-folder.test.ts": 151, - "src/main/providers/ssh-filesystem-provider-range.test.ts": 130, - "src/main/providers/ssh-filesystem-provider-stream.test.ts": 167, - "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts": 286, - "src/main/providers/ssh-filesystem-provider.test.ts": 258, - "src/main/providers/ssh-filesystem-watch-notifications.test.ts": 61, "src/main/providers/ssh-git-dispatch.test.ts": 54, "src/main/providers/ssh-git-provider-commit-message.test.ts": 122, "src/main/providers/ssh-git-provider-diff.test.ts": 140, @@ -3981,8 +3955,6 @@ "src/main/providers/ssh-git-provider-upstream-lease.test.ts": 110, "src/main/providers/ssh-git-provider-worktree.test.ts": 149, "src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 125, - "src/main/providers/ssh-listing-compatibility.test.ts": 210, - "src/main/providers/ssh-plain-shell-pty-provider.test.ts": 80, "src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 59, "src/main/providers/ssh-pty-legacy-relay-delegation.test.ts": 450, "src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 434, @@ -4004,7 +3976,6 @@ "src/main/providers/ssh-pty-spawn-env.test.ts": 49, "src/main/providers/ssh-pty-write.test.ts": 473, "src/main/providers/ssh-review-draft-context.test.ts": 936, - "src/main/providers/ssh-sftp-filesystem-provider.test.ts": 103, "src/main/providers/ssh-worktree-catalog-authority.test.ts": 1068, "src/main/providers/stable-foreground-process.test.ts": 68, "src/main/providers/startup-line-typed-length.live-shell.test.ts": 165, @@ -4337,7 +4308,6 @@ "src/main/runtime/orca-runtime-files-rename-authority.test.ts": 542, "src/main/runtime/orca-runtime-files-search.test.ts": 622, "src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts": 636, - "src/main/runtime/orca-runtime-files-ssh-rearm.test.ts": 1330, "src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts": 663, "src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts": 635, "src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts": 617, @@ -4994,7 +4964,6 @@ "src/main/runtime/runtime-created-worktree-terminal-target.test.ts": 4338, "src/main/runtime/runtime-extraction-regressions.test.ts": 4478, "src/main/runtime/runtime-failed-local-worktree-removal.test.ts": 723, - "src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts": 1789, "src/main/runtime/runtime-file-listing-producer-budgets.test.ts": 528, "src/main/runtime/runtime-file-path-existence.test.ts": 911, "src/main/runtime/runtime-file-target-execution-host.test.ts": 4300, @@ -5496,7 +5465,6 @@ "src/main/ssh/pinned-runtime-materializer-node.test.ts": 114, "src/main/ssh/pinned-runtime-materializer.test.ts": 3192, "src/main/ssh/relay-bundle-paths.test.ts": 62, - "src/main/ssh/relay-native-dependency-coverage.test.ts": 1185, "src/main/ssh/relay-protocol.test.ts": 120, "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 107, "src/main/ssh/relay-socket-path-limit.test.ts": 559, @@ -5557,7 +5525,6 @@ "src/main/ssh/ssh-exec-stdin-file-transfer.test.ts": 246, "src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts": 58, "src/main/ssh/ssh-file-transfer-abort.test.ts": 105, - "src/main/ssh/ssh-filesystem-stream-retention.test.ts": 252, "src/main/ssh/ssh-g-config-resolution.test.ts": 112, "src/main/ssh/ssh-git-response-pending-retention.test.ts": 238, "src/main/ssh/ssh-git-response-stream-reader.test.ts": 59, @@ -5566,14 +5533,12 @@ "src/main/ssh/ssh-host-key-store.test.ts": 193, "src/main/ssh/ssh-host-key-verifier.test.ts": 92, "src/main/ssh/ssh-host-platform-memo.test.ts": 40, - "src/main/ssh/ssh-host-provider-set.test.ts": 2175, "src/main/ssh/ssh-host-relay-census-for-target.test.ts": 53, "src/main/ssh/ssh-host-relay-endpoint-census.test.ts": 112, "src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts": 576, "src/main/ssh/ssh-host-relay-terminals-once-connected.test.ts": 616, "src/main/ssh/ssh-host-relay-windows-census.test.ts": 209, "src/main/ssh/ssh-host-relay-windows-inventory.test.ts": 179, - "src/main/ssh/ssh-host-server-move-offer.test.ts": 39, "src/main/ssh/ssh-host-server-on-connect-stopped-elsewhere.test.ts": 674, "src/main/ssh/ssh-host-server-on-connect-telemetry.test.ts": 547, "src/main/ssh/ssh-host-server-on-connect.test.ts": 539, @@ -5593,33 +5558,18 @@ "src/main/ssh/ssh-multi-key-authentication.test.ts": 126, "src/main/ssh/ssh-multiplexer-transport-writer.test.ts": 89, "src/main/ssh/ssh-multiplexer-writer-retention.test.ts": 480, - "src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts": 81, - "src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts": 115, "src/main/ssh/ssh-owner-recovery-retry.test.ts": 78, - "src/main/ssh/ssh-pending-pty-kill-replay.test.ts": 97, "src/main/ssh/ssh-port-forward.test.ts": 253, - "src/main/ssh/ssh-port-scanner.test.ts": 69, "src/main/ssh/ssh-posix-command-wrapper.test.ts": 140, "src/main/ssh/ssh-previous-relay-terminals.test.ts": 209, "src/main/ssh/ssh-provider-authority.test.ts": 67, "src/main/ssh/ssh-proxy-command.test.ts": 120, - "src/main/ssh/ssh-pty-consumer-recovery.test.ts": 57, "src/main/ssh/ssh-pty-consumer-session.test.ts": 63, - "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts": 50, - "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts": 47, "src/main/ssh/ssh-quit-abort-upload.test.ts": 256, "src/main/ssh/ssh-reconnect-error-classification.test.ts": 123, "src/main/ssh/ssh-reconnect-ladder.test.ts": 96, "src/main/ssh/ssh-relay-answered-failure-producers.test.ts": 301, - "src/main/ssh/ssh-relay-build-toolchain.test.ts": 133, - "src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts": 613, - "src/main/ssh/ssh-relay-cross-version-isolation.test.ts": 503, "src/main/ssh/ssh-relay-deploy-helpers.test.ts": 571, - "src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts": 669, - "src/main/ssh/ssh-relay-deploy-pinned-node.test.ts": 645, - "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts": 674, - "src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts": 565, - "src/main/ssh/ssh-relay-deploy.test.ts": 1049, "src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 2228, "src/main/ssh/ssh-relay-endpoint-incumbent.test.ts": 115, "src/main/ssh/ssh-relay-endpoint-pty-count.test.ts": 695, @@ -5627,10 +5577,7 @@ "src/main/ssh/ssh-relay-endpoint-takeover.test.ts": 141, "src/main/ssh/ssh-relay-exec-input.test.ts": 91, "src/main/ssh/ssh-relay-gc-claim-termination.test.ts": 261, - "src/main/ssh/ssh-relay-gc-retry.test.ts": 644, "src/main/ssh/ssh-relay-host-answered-failure.test.ts": 100, - "src/main/ssh/ssh-relay-host-node-addons.test.ts": 393, - "src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts": 752, "src/main/ssh/ssh-relay-incumbent-process.test.ts": 5678, "src/main/ssh/ssh-relay-install-lock-exited-owner.test.ts": 324, "src/main/ssh/ssh-relay-install-lock-termination.test.ts": 245, @@ -5638,81 +5585,24 @@ "src/main/ssh/ssh-relay-install-namespace.test.ts": 227, "src/main/ssh/ssh-relay-install-noisy-sftp-wire.test.ts": 292, "src/main/ssh/ssh-relay-install-transfers-exec-fallback.test.ts": 113, - "src/main/ssh/ssh-relay-live-connect.test.ts": 559, - "src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts": 1752, "src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts": 706, "src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts": 421, "src/main/ssh/ssh-relay-native-deps-cache.test.ts": 220, - "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts": 563, - "src/main/ssh/ssh-relay-native-deps-install.test.ts": 5044, - "src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts": 829, - "src/main/ssh/ssh-relay-node-headers.test.ts": 435, - "src/main/ssh/ssh-relay-node-pty-repair.test.ts": 224, - "src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts": 532, - "src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts": 665, - "src/main/ssh/ssh-relay-opencode-pinned-node.test.ts": 379, - "src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts": 586, - "src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts": 59, - "src/main/ssh/ssh-relay-opencode-runtime.test.ts": 585, - "src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts": 3598, - "src/main/ssh/ssh-relay-pinned-node-install.test.ts": 577, "src/main/ssh/ssh-relay-pinned-node.integration.test.ts": 352, "src/main/ssh/ssh-relay-pinned-node.test.ts": 421, - "src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts": 2665, - "src/main/ssh/ssh-relay-repair-lock-termination.test.ts": 248, - "src/main/ssh/ssh-relay-reset.test.ts": 136, - "src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts": 360, - "src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts": 264, - "src/main/ssh/ssh-relay-ripgrep-install.test.ts": 407, "src/main/ssh/ssh-relay-runtime-ladder.test.ts": 346, "src/main/ssh/ssh-relay-runtime-never-worse.test.ts": 422, "src/main/ssh/ssh-relay-runtime-resolution.test.ts": 438, "src/main/ssh/ssh-relay-runtime-self-test.test.ts": 655, "src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts": 165, - "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts": 6042, - "src/main/ssh/ssh-relay-session-data-delivery.test.ts": 4119, - "src/main/ssh/ssh-relay-session-incarnation.test.ts": 3421, - "src/main/ssh/ssh-relay-session-managed-hooks.test.ts": 3951, - "src/main/ssh/ssh-relay-session-model-migration.test.ts": 3953, - "src/main/ssh/ssh-relay-session-orphan-sweep.test.ts": 4431, - "src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts": 4487, - "src/main/ssh/ssh-relay-session-plain-ssh.test.ts": 3774, - "src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts": 3334, - "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts": 3624, - "src/main/ssh/ssh-relay-session-recovery-durability.test.ts": 5835, - "src/main/ssh/ssh-relay-session-recovery-races.test.ts": 5278, - "src/main/ssh/ssh-relay-session-rejected-delivery.test.ts": 5370, - "src/main/ssh/ssh-relay-session-relay-loss.test.ts": 3963, - "src/main/ssh/ssh-relay-session-terminal-error.test.ts": 3680, - "src/main/ssh/ssh-relay-session.test.ts": 5221, - "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts": 651, "src/main/ssh/ssh-relay-superseded-endpoints.test.ts": 146, - "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts": 690, - "src/main/ssh/ssh-relay-upload-stage-commands.test.ts": 23320, - "src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts": 4823, "src/main/ssh/ssh-relay-versioned-install-termination.test.ts": 239, "src/main/ssh/ssh-relay-versioned-install.test.ts": 327, - "src/main/ssh/ssh-relay-windows-host-lane.test.ts": 671, "src/main/ssh/ssh-relay-windows-launch-command.test.ts": 151, - "src/main/ssh/ssh-remote-cli-bridge-credential.test.ts": 4939, - "src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts": 5750, - "src/main/ssh/ssh-remote-cli-format.test.ts": 55, - "src/main/ssh/ssh-remote-cli-host-passthrough.test.ts": 3482, "src/main/ssh/ssh-remote-cli-launcher.test.ts": 145, - "src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts": 5194, "src/main/ssh/ssh-remote-commands.test.ts": 7310, - "src/main/ssh/ssh-remote-linear-activity-output.test.ts": 58, - "src/main/ssh/ssh-remote-linear-cli.test.ts": 4075, - "src/main/ssh/ssh-remote-linear-list-issues.test.ts": 60, - "src/main/ssh/ssh-remote-linear-relation-write.test.ts": 566, - "src/main/ssh/ssh-remote-linear-save-issue.test.ts": 3545, - "src/main/ssh/ssh-remote-linear-truncation-output.test.ts": 48, - "src/main/ssh/ssh-remote-node-addon-resolution.test.ts": 166, "src/main/ssh/ssh-remote-node-resolution.test.ts": 318, "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 173, - "src/main/ssh/ssh-remote-orca-cli.test.ts": 6368, - "src/main/ssh/ssh-remote-orchestration-compatibility.test.ts": 6047, - "src/main/ssh/ssh-remote-orchestration-send.test.ts": 58, "src/main/ssh/ssh-remote-platform-detection.test.ts": 218, "src/main/ssh/ssh-remote-platform.test.ts": 58, "src/main/ssh/ssh-remote-powershell.test.ts": 328, @@ -6231,7 +6121,6 @@ "src/relay/pty-handler-output-drain-differential.test.ts": 728, "src/relay/pty-handler-output-streaming.test.ts": 972, "src/relay/pty-handler-owner-inventory-clone-budget.test.ts": 571, - "src/relay/pty-handler-ownership-attestation.test.ts": 642, "src/relay/pty-handler-pi-owner-env.test.ts": 670, "src/relay/pty-handler-resize-stale-pty.test.ts": 715, "src/relay/pty-handler-retired-pane-surface.test.ts": 649, @@ -8072,7 +7961,6 @@ "src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx": 399, "src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 633, "src/renderer/src/components/settings/SshHostDeltaMoveDialog.test.tsx": 450, - "src/renderer/src/components/settings/SshManagedServerMoveDialog.test.tsx": 1810, "src/renderer/src/components/settings/SshTargetCard.test.tsx": 430, "src/renderer/src/components/settings/SshTargetForm.test.tsx": 1002, "src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx": 484, @@ -8164,7 +8052,6 @@ "src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts": 107, "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 47, "src/renderer/src/components/settings/ssh-target-draft.test.ts": 66, - "src/renderer/src/components/settings/ssh-target-remove.test.ts": 264, "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 114, "src/renderer/src/components/settings/task-source-setup-state.test.ts": 62, "src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx": 565, @@ -10554,7 +10441,6 @@ "src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts": 58, "src/renderer/src/ssh/ssh-connect-verb.test.ts": 57, "src/renderer/src/ssh/ssh-connection-recoverability.test.ts": 52, - "src/renderer/src/ssh/ssh-managed-server-move-terminal-tabs.test.ts": 1540, "src/renderer/src/startup/active-workspace-ssh-targets.test.ts": 70, "src/renderer/src/startup/ssh-startup-reconnect.test.ts": 65, "src/renderer/src/startup/startup-degraded-recovery.test.ts": 408, @@ -11838,7 +11724,6 @@ "src/shared/source-scan/source-tree-walk.test.ts": 71, "src/shared/ssh-pending-pty-kill.test.ts": 55, "src/shared/ssh-pty-id.test.ts": 49, - "src/shared/ssh-relay-pty-ownership-proof.test.ts": 76, "src/shared/ssh-remote-pty-lease-predicates.test.ts": 43, "src/shared/ssh-retained-payload-admission.test.ts": 78, "src/shared/ssh-target-generation.test.ts": 57, @@ -12272,7 +12157,6 @@ "tests/e2e/editor-tab-selection-restore.spec.ts": 28900, "tests/e2e/electron-home-isolation.spec.ts": 12800, "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts": 26800, - "tests/e2e/ephemeral-vm-provisioned-root.spec.ts": 60000, "tests/e2e/feature-wall.spec.ts": 101800, "tests/e2e/file-explorer-watch-refresh.spec.ts": 17700, "tests/e2e/file-open.spec.ts": 60000, diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index f8a19e61a8bb..ab7e68a89c95 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -86,7 +86,7 @@ it('avoids extraction for unrelated CI, documentation, and native changes', () = expect( affectsLocalizationExtraction([ '.github/workflows/e2e.yml', - 'config/scripts/run-ssh-docker-e2e.mjs', + 'config/scripts/run-terminal-scale-perf-e2e.mjs', 'docs/reference/ci-runner-efficiency.md', 'native/windows-registry/src/addon.cc' ]) diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index 696aa26ed05e..6b5fb57c11ec 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -205,9 +205,7 @@ describe('SSH Windows consumers of qualified server slots', () => { expect(template).toBeLessThan(hosts) expect(sshSteps[template].env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS).toBe('${{ matrix.arch }}') expect(sshSteps[template].run).toContain('--require-slots "win32-${{ matrix.arch }}"') - expect(sshSteps[hosts].run).toContain( - "@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')" - ) + expect(sshSteps[hosts].run).toContain("@('orcad-cmd','orcad-powershell')") for (const workflowPaths of [ workflow.on.pull_request.paths, sshWorkflow.on.pull_request.paths diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 632fec09dcdf..89719c5fd5f3 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -414,7 +414,6 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/ipc/preflight-provider-command-selection.test.ts', 'src/main/ipc/preflight-runnable-local-cli.test.ts', 'src/relay/windows-port-scan.win32.test.ts', - 'src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts', 'src/main/ssh/remote-node-runtime-store-windows.test.ts' ] diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 0655446c1e7d..60c6c46248e7 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -1,20 +1,16 @@ import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' -import { linuxInstallPackageList } from './pr-e2e-linux-packages.test-fixture.mjs' import { existsSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse as parseYaml } from 'yaml' import { hasNativeImeSourceChange, - hasSshSourceChange, PR_E2E_SOURCE_ROUTES, - selectPrE2eSpecs, - SSH_SOURCE_ROUTE_IDS + selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const projectDir = resolve(import.meta.dirname, '../..') const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) -const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) const filterStep = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Filter changed E2E specs' @@ -77,152 +73,10 @@ describe('PR E2E gate contract', () => { ).toEqual(['tests/e2e/active-view-restart-restore.spec.ts']) }) - it('maps SSH source edits onto the Docker-backed specs they can break', () => { - // Why: the Docker-SSH specs self-skip without ORCA_E2E_SSH_DOCKER, and the only - // trigger used to be "someone edited a spec" — four pane-restore regressions shipped - // through that hole. Each mapped spec must exist, or the lane runs an empty file list. - const sshSourceAuthorities = [ - 'src/main/ssh/', - 'src/main/providers/ssh-', - 'src/main/ipc/pty', - 'src/relay/', - 'src/wsl-guest/', - 'src/shared/ssh-', - 'src/renderer/src/store/slices/direct-ssh-', - 'src/renderer/src/components/terminal-pane/remote-runtime-' - ] - for (const authority of sshSourceAuthorities) { - expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain( - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' - ) - } - - // Why named files rather than prefixes: these seams are single modules, and a prefix - // here would route their unrelated neighbours. - for (const file of [ - 'src/main/runtime/public-ssh-state.ts', - 'src/renderer/src/startup/ssh-startup-reconnect.ts', - 'src/renderer/src/store/slices/ssh.ts' - ]) { - expect(selectPrE2eSpecs([file]), file).toContain( - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' - ) - } - - const mappedSpecs = [ - 'tests/e2e/pty-input-write-queue-ssh.spec.ts', - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', - 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', - 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' - ] - for (const spec of mappedSpecs) { - expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec) - expect(existsSync(join(projectDir, spec)), spec).toBe(true) - // Why: a spec that stops reading the flag would silently run without Docker. - if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') { - expect(readFileSync(join(projectDir, spec), 'utf8'), spec).toContain('ORCA_E2E_SSH_DOCKER') - } - } - - expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([]) - - // Why: startup readiness is filtered out of changed-e2e, so listing it is only - // meaningful while it still routes the dedicated Docker lane. - expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toContain( - 'tests/e2e/ssh-startup-exec-readiness.spec.ts' - ) - - // Why: this lane can now pay a Docker image build plus serial SSH specs. - expect(e2eWorkflow.jobs['changed-e2e']['timeout-minutes']).toBeGreaterThanOrEqual(45) - const install = e2eWorkflow.jobs['changed-e2e'].steps.find((step) => - step.name.startsWith('Install native build') - ) - expect(linuxInstallPackageList(install, 'changed-e2e')).toMatch(/(^|\s)openssh-client(\s|$)/) - }) - - it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => { - // Why by name: none of these carry "ssh", so the SSH authorities above never reach them - // — a closed-tab tombstone and a dropped default-tabs marker both shipped through it. - for (const file of [ - 'src/renderer/src/hooks/remote-workspace-session-merge.ts', - 'src/main/ipc/remote-workspace-snapshot-normalization.ts', - 'src/renderer/src/lib/worktree-initial-terminal-seeding.ts', - 'src/renderer/src/lib/worktree-default-terminal-tabs.ts', - 'src/shared/remote-workspace-session-projection.ts', - 'src/renderer/src/components/terminal/initial-terminal.ts' - ]) { - const specs = selectPrE2eSpecs([file]) - expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts') - expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts') - } - - expect( - selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts']) - ).toEqual([]) - expect( - selectPrE2eSpecs([ - 'src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts' - ]) - ).toEqual([]) - }) - - it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => { - // The behavioural half of the invariant, and the part that actually matters: an SSH source - // edit is recognised as one, through the same routes that select the specs. - for (const file of [ - 'src/main/ssh/connection.ts', - 'src/relay/pty-handler.ts', - 'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts', - 'src/renderer/src/hooks/remote-workspace-session-merge.ts', - 'src/main/ipc/remote-workspace-snapshot-normalization.ts' - ]) { - expect(hasSshSourceChange([file]), file).toBe(true) - } - for (const file of [ - 'src/main/git/git-status.ts', - 'src/renderer/src/components/tab-bar/BrowserTab.tsx', - 'src/main/ssh/connection.test.ts' - ]) { - expect(hasSshSourceChange([file]), file).toBe(false) - } - - // Why: the signal must stay derived from the routes. A route id that no longer exists would - // silently narrow it to nothing. - for (const id of SSH_SOURCE_ROUTE_IDS) { - expect( - PR_E2E_SOURCE_ROUTES.map((route) => route.id), - id - ).toContain(id) - } - - // Why text and not structure: a job `if:` is only ever available as a string. The strongest - // available assertion is that the source signal is its own disjunct, so the lane no longer - // depends on a spec name surviving in a route's spec list. - const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if - expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||") - - expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string') - expect(prWorkflow.jobs.code_paths.outputs.ssh_source_changed).toBe( - '${{ steps.e2e_filter.outputs.ssh_source_changed }}' - ) - expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe( - '${{ needs.code_paths.outputs.ssh_source_changed }}' - ) - expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source') - expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED') - }) - it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => { expect(rollbackStep.run).toMatch(/diff-base\.mjs "\$BASE_SHA"[\s\S]*"\$DIFF_BASE" HEAD --/) expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts') expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts') - expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([ - 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' - ]) }) it('routes P0 sentinels from their causal sources', () => { diff --git a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs index cd6563d80b0e..f0962edff158 100644 --- a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs +++ b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs @@ -3,7 +3,6 @@ import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { classifyE2eJobs, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs' import { - hasSshSourceChange, PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs, shouldRunReusablePrE2e @@ -52,14 +51,12 @@ describe('workspace layout oracle PR E2E routing', () => { it('sends each oracle spec to the job that can run it', () => { const specs = ORACLE_ROUTE.specs - // Window spec runs in the general shards; headless and SSH run in their dedicated jobs. + // Window spec runs in the general shards; headless runs in its dedicated job. expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts']) expect(classifyE2eJobs(JSON.stringify(specs))).toEqual({ e2e_run_changed: true, e2e_needs_build: true }) - // The SSH oracle reaches the Docker lane by spec name, not by claiming SSH source. - expect(hasSshSourceChange(LAYOUT_AUTHORITIES)).toBe(false) }) it('keeps every routed spec and authority a real file', () => { diff --git a/config/scripts/pr-e2e-native-only-routing.test.mjs b/config/scripts/pr-e2e-native-only-routing.test.mjs index b6c3662cd1d6..2fcd7450b701 100644 --- a/config/scripts/pr-e2e-native-only-routing.test.mjs +++ b/config/scripts/pr-e2e-native-only-routing.test.mjs @@ -17,7 +17,6 @@ describe('native-only PR E2E routing', () => { } expect(shouldRunReusablePrE2e([])).toBe(false) for (const spec of [ - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', 'tests/e2e/paired-startup-exec-readiness.spec.ts', 'tests/e2e/terminal-ime-exact-byte.spec.ts', 'tests/e2e/future.spec.ts' diff --git a/config/scripts/pr-e2e-pane-close-routing.test.mjs b/config/scripts/pr-e2e-pane-close-routing.test.mjs index 889eec7c3615..c66c77fb8f1c 100644 --- a/config/scripts/pr-e2e-pane-close-routing.test.mjs +++ b/config/scripts/pr-e2e-pane-close-routing.test.mjs @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { - hasSshSourceChange, PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs, shouldRunReusablePrE2e @@ -57,10 +56,7 @@ describe('pane close and retirement PR E2E routing', () => { } }) - it('leaves the Docker SSH lane and unrelated changes alone', () => { - // The lane costs a Docker relay per run. Close changes reach SSH only through the shared - // provider, which the non-Docker specs above already cover. - expect(hasSshSourceChange(PENDING_PANE_CLOSE_CHANGE)).toBe(false) + it('leaves unrelated changes alone', () => { // Neighbours in the same directories that this route must not claim. for (const file of [ 'src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx', diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index c0204a432dc0..4d0bdcf94e94 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -203,15 +203,6 @@ export const PR_E2E_SOURCE_ROUTES = [ file )) }, - { - id: 'ssh.localhost-agent-hooks', - specs: ['tests/e2e/ssh-localhost.spec.ts'], - matches: (file) => - isProductSource(file) && - /^src\/(?:relay\/(?:agent-hook|relay-agent-hook-runtime|plugin-overlay)|wsl-guest\/(?:agent-hook|plugin-overlay)|main\/(?:agent-hooks\/|ssh\/ssh-relay-session\.ts$)|shared\/agent-hook)/.test( - file - ) - }, { id: 'browser-network.ssh-docker-route', specs: ['tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts'], @@ -223,17 +214,6 @@ export const PR_E2E_SOURCE_ROUTES = [ file )) }, - { - // Why the host-connection phase: the route gate waits on it, so a phase change can strand the - // SSH-unavailable card without touching a browser file. - id: 'browser.local-ssh-workspace-route', - specs: ['tests/e2e/local-ssh-browser-routing.spec.ts'], - matches: (file) => - isProductSource(file) && - /^src\/(?:main\/browser\/local-ssh-browser-(?:route|partitions)\.ts|renderer\/src\/(?:components\/browser-pane\/(?:use-ssh-workspace-browser-route\.ts|assemble-chrome\/ssh-routed-browser-page-gate\.tsx)|lib\/worktree-host-connection-phase\.ts))$/.test( - file - ) - }, { id: 'terminal.windows-wsl-launch-and-paste', specs: [ @@ -246,55 +226,6 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, - { - id: 'ephemeral-vm-runtime.rollback-readable-sidecar', - specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'], - matches: (file) => - /^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test( - file - ) - }, - { - id: 'ssh-terminal-source', - specs: [ - 'tests/e2e/pty-input-write-queue-ssh.spec.ts', - 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-docker-half-open-link.spec.ts', - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', - 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts', - 'tests/e2e/ssh-docker-resource-accumulation.spec.ts', - 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', - 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' - ], - // Why the store/startup/shared additions: the SSH-named authorities stop at the main - // process and the pane component, but the reconnect ledgers and retained-payload - // admission that decide whether a pane rebinds live in the renderer store. - matches: (file) => - isProductSource(file) && - /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/wsl-guest\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test( - file - ) - }, - { - // Why a sibling route rather than more paths on ssh-terminal-source: these modules carry - // no "ssh" in their names, and only the two restore specs gate them. Folding them in - // would run the whole SSH terminal list for a tab-tombstone edit. - id: 'ssh-workspace-session-restore', - specs: [ - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts' - ], - matches: (file) => - isProductSource(file) && - !file.endsWith('-test-harness.ts') && - /^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test( - file - ) - }, { id: 'terminal-input.ime-and-synthetic-forwarding', specs: [ @@ -457,7 +388,6 @@ export const PR_E2E_SOURCE_ROUTES = [ id: 'workspace-layout.oracle', specs: [ 'tests/e2e/workspace-layout-oracle-headless.spec.ts', - 'tests/e2e/workspace-layout-oracle-ssh.spec.ts', 'tests/e2e/workspace-layout-oracle.spec.ts' ], matches: (file) => @@ -491,23 +421,11 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) { return [...specs].sort((left, right) => left.localeCompare(right)) } -/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */ -export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore'] - -// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to -// trigger only because one route happened to list a startup-readiness spec, so pruning that -// spec would have silently retired the lane. Two lists that must agree is how that drifted. -export function hasSshSourceChange(changedPaths) { - return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some( - (route) => changedPaths.some(route.matches) - ) -} - /** Routes whose authorities a real input method can judge, and so require the native IME lane. */ export const NATIVE_IME_SOURCE_ROUTE_IDS = ['terminal-ime.native-input-method'] -// Why derived from the routes, like hasSshSourceChange: the native lane must trigger on IME -// source, not on the native spec surviving in some route's spec list. +// Why derived from the routes: the native lane must trigger on IME source, not on the native +// spec surviving in some route's spec list. export function hasNativeImeSourceChange(changedPaths) { return PR_E2E_SOURCE_ROUTES.filter((route) => NATIVE_IME_SOURCE_ROUTE_IDS.includes(route.id) @@ -515,12 +433,9 @@ export function hasNativeImeSourceChange(changedPaths) { } export function shouldRunReusablePrE2e(changedPaths) { - // Native IME has its own workflow; SSH still runs inside the reusable workflow. - return ( - hasSshSourceChange(changedPaths) || - selectPrE2eSpecs(changedPaths).some( - (spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts' - ) + // Native IME has its own workflow. + return selectPrE2eSpecs(changedPaths).some( + (spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts' ) } @@ -538,9 +453,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) input += chunk } const changedPaths = input.split(/\r?\n/).filter(Boolean) - if (process.argv.includes('--ssh-source')) { - process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`) - } else if (process.argv.includes('--reusable-workflow')) { + if (process.argv.includes('--reusable-workflow')) { process.stdout.write(`${shouldRunReusablePrE2e(changedPaths)}\n`) } else if (process.argv.includes('--wsl-source')) { process.stdout.write(`${hasWslSourceChange(changedPaths)}\n`) diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index d3a11b7469d0..b37fbb921237 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -145,12 +145,7 @@ describe('ready-for-review required check reuse', () => { ) } expect(detector.outputs.should_run).toBe('${{ steps.filter.outputs.should_run }}') - for (const name of [ - 'test_files', - 'ssh_source_changed', - 'native_ime_source_changed', - 'wsl_source_changed' - ]) { + for (const name of ['test_files', 'native_ime_source_changed', 'wsl_source_changed']) { expect(detector.outputs[name]).toBe(`\${{ steps.e2e_filter.outputs.${name} }}`) } expect(detector.steps.find((step) => step.id === 'e2e_filter').if).toBe( diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 883c82cfd9fb..d58604b63a69 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -17,8 +17,6 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#orcad-serve-mode-switch-windows': { contents: 'read' }, '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' }, - '.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' }, - '.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' }, '.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' }, '.github/workflows/node-server-tests.yml#changes': { contents: 'read' }, '.github/workflows/node-server-tests.yml#desktop_template': { contents: 'read' }, diff --git a/config/scripts/run-local-ssh-browser-routing-e2e.mjs b/config/scripts/run-local-ssh-browser-routing-e2e.mjs deleted file mode 100644 index 4562e21e3b6c..000000000000 --- a/config/scripts/run-local-ssh-browser-routing-e2e.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_LOCAL_SSH_BROWSER: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/local-ssh-browser-routing.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-nested-runtime-ssh-e2e.mjs b/config/scripts/run-nested-runtime-ssh-e2e.mjs deleted file mode 100644 index 577fd40c6bb6..000000000000 --- a/config/scripts/run-nested-runtime-ssh-e2e.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const result = spawnSync( - process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/nested-runtime-ssh-routing.spec.ts', - 'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1' - ], - { - cwd: process.cwd(), - env: { - ...process.env, - ORCA_E2E_NESTED_RUNTIME_SSH: '1', - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_WEB_CLIENT: '1' - }, - stdio: 'inherit' - } -) - -if (result.error) { - throw result.error -} -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs b/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs deleted file mode 100644 index fc1fd5a4e993..000000000000 --- a/config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs b/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs deleted file mode 100644 index a4e3fec42ed5..000000000000 --- a/config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const extraArgs = process.argv.slice(2) -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], { - stdio: 'inherit', - env -}) - -if (runtime.error) { - throw runtime.error -} -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - { - stdio: 'inherit', - env - } -) - -if (result.error) { - throw result.error -} -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs b/config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs deleted file mode 100644 index 294bf7e2c7b2..000000000000 --- a/config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' - -const extraArgs = process.argv.slice(2) -const { command: pnpm, prefixArgs: pnpmPrefix, shell } = resolvePnpmCliInvocation() -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1' -} - -const runtime = spawnSync(pnpm, [...pnpmPrefix, 'run', 'ensure:electron-runtime'], { - stdio: 'inherit', - env, - shell -}) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - ...pnpmPrefix, - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headful', - '--workers=1', - ...extraArgs - ], - { - stdio: 'inherit', - env, - shell - } -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs deleted file mode 100644 index 1a67f56cf10c..000000000000 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ /dev/null @@ -1,99 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_LOCAL_SSH_BROWSER: '1', - ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1', - ORCA_E2E_WEB_CLIENT: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane -// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading -// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion. -// -// Deliberately absent, and therefore still covered by no CI trigger: -// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would -// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in -// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at -// all. Recorded as a real gap, not as coverage living somewhere else. -// The bulk-open frame probe runs headed: headless Linux compositing schedules idle RAFs -// roughly 1s apart, so it cannot measure foreground interaction against the same budget. -// -// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project -// grep-inverts away. -// -// Known gaps in SSH e2e coverage, recorded here because nothing else names them: -// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on -// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment -// has the exact wiring to flip it, and the gate contract asserts the current state. -// - Two specs are gated on env vars no workflow sets, so they run nowhere -// and are not Docker-gated, which puts them outside this file's contract: -// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH) -// The nested-runtime runner remains unused by CI. -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/local-ssh-browser-routing.spec.ts', - 'tests/e2e/repo-registration-async.spec.ts', - 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', - 'tests/e2e/pty-input-write-queue-ssh.spec.ts', - 'tests/e2e/ssh-ai-vault-session-history.spec.ts', - 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', - 'tests/e2e/ssh-cold-activation-restore.spec.ts', - 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', - 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts', - 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts', - 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts', - 'tests/e2e/ssh-docker-half-open-link.spec.ts', - 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts', - 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', - 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts', - 'tests/e2e/ssh-docker-resource-accumulation.spec.ts', - 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', - 'tests/e2e/ssh-external-image-preview.spec.ts', - 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', - 'tests/e2e/ssh-native-chat-phone-history.spec.ts', - 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', - 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', - 'tests/e2e/ssh-reattach-home-partition.spec.ts', - 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', - 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', - 'tests/e2e/ssh-skill-installation.spec.ts', - 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts', - 'tests/e2e/ssh-startup-local-shadow.spec.ts', - 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts', - 'tests/e2e/terminal-inline-images-ssh.spec.ts', - 'tests/e2e/workspace-layout-oracle-ssh.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--project', - 'electron-headful', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-perf-e2e.mjs b/config/scripts/run-ssh-docker-perf-e2e.mjs deleted file mode 100644 index afa880d8cec4..000000000000 --- a/config/scripts/run-ssh-docker-perf-e2e.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const extraArgs = process.argv.slice(2) -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], { - stdio: 'inherit', - env -}) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-docker-relay-perf.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - { - stdio: 'inherit', - env - } -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs deleted file mode 100644 index b894ac15217d..000000000000 --- a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1', - ORCA_E2E_WEB_CLIENT: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -// Why one runner: these specs share the Docker SSH rig; startup-exec also joins -// the paired desktop/headless parity proof under the same isolated CI lane. -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-terminal-parking.spec.ts', - 'tests/e2e/terminal-retention-budget.spec.ts', - 'tests/e2e/ssh-startup-exec-readiness.spec.ts', - 'tests/e2e/paired-startup-exec-readiness.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--project', - 'electron-headful', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs b/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs deleted file mode 100644 index 5fc10e9e491b..000000000000 --- a/config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { spawnSync } from 'node:child_process' - -const rawExtraArgs = process.argv.slice(2) -const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const env = { - ...process.env, - ORCA_E2E_SSH_DOCKER: '1' -} - -// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. -const spawnOptions = { - stdio: 'inherit', - env, - shell: process.platform === 'win32' -} - -const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) - -if (runtime.status !== 0) { - process.exit(runtime.status ?? 1) -} - -const result = spawnSync( - pnpm, - [ - 'exec', - 'playwright', - 'test', - 'tests/e2e/ssh-docker-watcher-isolation.spec.ts', - '--config', - 'tests/playwright.config.ts', - '--project', - 'electron-headless', - '--workers=1', - ...extraArgs - ], - spawnOptions -) - -process.exit(result.status ?? 1) diff --git a/config/scripts/run-ssh-staged-upload-reliability.mjs b/config/scripts/run-ssh-staged-upload-reliability.mjs index fb3d00403505..ca17b774bdbc 100644 --- a/config/scripts/run-ssh-staged-upload-reliability.mjs +++ b/config/scripts/run-ssh-staged-upload-reliability.mjs @@ -3,15 +3,10 @@ import { spawnSync } from 'node:child_process' const defaultFiles = [ 'src/main/ssh/sftp-upload.test.ts', 'src/main/ssh/ssh-file-transfer-abort.test.ts', - 'src/main/ssh/ssh-relay-deploy-staged-upload.test.ts', - 'src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts', - 'src/main/ssh/ssh-relay-sftp-namespace-install.test.ts', 'src/main/ssh/ssh-relay-install-namespace.test.ts', - 'src/main/ssh/ssh-relay-upload-stage-commands.test.ts', 'src/main/ssh/sftp-namespace-resolution.test.ts', 'src/main/ssh/ssh-connection-sftp-wire.test.ts', - 'src/main/ssh/ssh-remote-commands.test.ts', - 'src/main/ssh/ssh-relay-cross-version-isolation.test.ts' + 'src/main/ssh/ssh-remote-commands.test.ts' ] const cliArguments = process.argv.slice(2) diff --git a/config/scripts/ssh-browser-e2e-routing.test.mjs b/config/scripts/ssh-browser-e2e-routing.test.mjs index d7abce7d39b5..e0ffd2f8ad92 100644 --- a/config/scripts/ssh-browser-e2e-routing.test.mjs +++ b/config/scripts/ssh-browser-e2e-routing.test.mjs @@ -3,30 +3,10 @@ import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parse } from 'yaml' import { expect, it } from 'vitest' -import { selectPrE2eSpecs, shouldRunReusablePrE2e } from './pr-e2e-source-routing.mjs' +import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' const root = resolve(import.meta.dirname, '../..') const workflow = parse(readFileSync(join(root, '.github/workflows/e2e.yml'), 'utf8')) -const runner = readFileSync(join(root, 'config/scripts/run-ssh-docker-e2e.mjs'), 'utf8') - -it('routes SSH browser specs to a lane that enables their opt-ins', () => { - const changedRun = workflow.jobs['changed-e2e'].steps.find( - (step) => step.name === 'Run changed E2E specs' - ) - for (const [spec, flag] of [ - ['tests/e2e/local-ssh-browser-routing.spec.ts', 'ORCA_E2E_LOCAL_SSH_BROWSER'], - [ - 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', - 'ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER' - ] - ]) { - expect(runner).toContain(`'${spec}'`) - expect(runner).toContain(`${flag}: '1'`) - expect(workflow.jobs['ssh-docker-watcher-isolation'].if).toContain(spec) - expect(DEDICATED_E2E_SPECS).toContain(spec) - expect(changedRun.run).toContain('node config/scripts/ci-e2e-job-selection.mjs') - } -}) it('executes both Docker network routes in a Node job with their opt-in enabled', () => { const spec = 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' @@ -62,24 +42,3 @@ it('executes both Docker network routes in a Node job with their opt-in enabled' spec ) }) - -it('runs the local SSH browser spec when its route source changes', () => { - const spec = 'tests/e2e/local-ssh-browser-routing.spec.ts' - for (const changed of [ - 'src/main/browser/local-ssh-browser-route.ts', - 'src/main/browser/local-ssh-browser-partitions.ts', - 'src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts', - 'src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.tsx', - 'src/renderer/src/lib/worktree-host-connection-phase.ts' - ]) { - expect(selectPrE2eSpecs([changed])).toContain(spec) - expect(shouldRunReusablePrE2e([changed])).toBe(true) - } - for (const unrelated of [ - 'src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.host-connection.test.tsx', - 'src/main/browser/local-ssh-browser-route.test.ts', - 'src/renderer/src/components/browser-pane/BrowserPane.tsx' - ]) { - expect(selectPrE2eSpecs([unrelated])).not.toContain(spec) - } -}) diff --git a/config/scripts/ssh-localhost-e2e-routing.test.mjs b/config/scripts/ssh-localhost-e2e-routing.test.mjs deleted file mode 100644 index be149f954763..000000000000 --- a/config/scripts/ssh-localhost-e2e-routing.test.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' -import { existsSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { parse } from 'yaml' -import { expect, it } from 'vitest' -import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' - -const workflow = parse( - readFileSync(resolve(import.meta.dirname, '../../.github/workflows/e2e.yml'), 'utf8') -) - -it('gives the localhost SSH journey its same-filesystem server and agent prerequisite', () => { - const spec = 'tests/e2e/ssh-localhost.spec.ts' - const job = workflow.jobs['ssh-localhost'] - expect(job.if).toContain("inputs.test_files == ''") - expect(job.if).toContain(spec) - expect(job['runs-on']).toBe('ubuntu-latest') - expect(job.needs).toEqual(['build', 'prepare-native-cache']) - const setup = job.steps.find((step) => step.name === 'Start isolated localhost SSH server') - expect(setup.run).toContain('ListenAddress 127.0.0.1') - expect(setup.run).toContain('PasswordAuthentication no') - expect(setup.run).toContain('UsePAM yes') - expect(setup.run).toContain('mkdir -p "$HOME/.pi/agent"') - for (const key of ['ORCA_E2E_SSH_PORT', 'ORCA_E2E_SSH_USER', 'ORCA_E2E_SSH_IDENTITY_FILE']) { - expect(setup.run).toContain(key) - } - const run = job.steps.find((step) => step.name === 'Run localhost SSH terminal and hook journey') - expect(run.env.ORCA_E2E_SSH_LOCALHOST).toBe('1') - expect(run.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS).toBe('1') - expect(run.run).toContain(spec) - expect(run.run).toContain('--project=electron-headless') - expect(run.run).not.toContain('--retries') - expect(run['continue-on-error']).toBeUndefined() - expect(DEDICATED_E2E_SPECS).toContain(spec) -}) - -it('selects the localhost journey for its remote hook authorities', () => { - const spec = 'tests/e2e/ssh-localhost.spec.ts' - for (const file of [ - 'src/relay/relay-agent-hook-runtime.ts', - 'src/wsl-guest/agent-hook-server.ts', - 'src/wsl-guest/plugin-overlay.ts', - 'src/main/agent-hooks/server.ts', - 'src/main/ssh/ssh-relay-session.ts', - 'src/shared/agent-hook-relay.ts' - ]) { - expect(existsSync(resolve(import.meta.dirname, '../..', file)), file).toBe(true) - expect(selectPrE2eSpecs([file])).toContain(spec) - } - expect(selectPrE2eSpecs(['src/renderer/src/components/Unrelated.tsx'])).not.toContain(spec) -}) diff --git a/config/scripts/ssh-relay-import-boundary.test.mjs b/config/scripts/ssh-relay-import-boundary.test.mjs index ad5570ce2f32..05eaa62e3025 100644 --- a/config/scripts/ssh-relay-import-boundary.test.mjs +++ b/config/scripts/ssh-relay-import-boundary.test.mjs @@ -26,14 +26,11 @@ const RELAY_TEST_IMPORTERS = [ 'src/main/ipc/worktree-push-target-reconciliation.test.ts', 'src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts', 'src/main/plugins/plugin-host-conformance.test.ts', - 'src/main/providers/ssh-directory-listing-options.test.ts', 'src/main/providers/ssh-review-draft-context.test.ts', - 'src/main/providers/terminal-path-existence-batch.integration.test.ts', 'src/main/runtime/runtime-file-path-existence.test.ts', 'src/main/runtime/runtime-repository-ref-queries.test.ts', 'src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts', 'src/main/shell-wrapper-generated-file-snapshot.test.ts', - 'src/main/ssh/ssh-remote-cli-host-passthrough.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', 'tests/tools/omp-relay-close-lifecycle.test.mjs' diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 2777ad707ef5..8e7a97d517e6 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -194,8 +194,6 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-claude-auth-policy-wiring.test.ts', 'src/main/runtime/structured-codex-child-work-runtime.test.ts', 'src/main/runtime/structured-worker-at-rest.test.ts', - 'src/main/ssh/ssh-remote-orca-cli.test.ts', - 'src/main/ssh/ssh-remote-orchestration-compatibility.test.ts', 'tests/e2e/completed-worker-retirement-resume.unit.test.ts', 'tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts', 'tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts' diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 17d1522c97b1..ae5cc3639858 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -58,7 +58,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts', 'src/main/ipc/ssh-host-partition-session-export.test.ts', 'src/main/ipc/ssh-host-server-on-connect-wiring.test.ts', - 'src/main/ipc/ssh-managed-server-move-conversion.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts', diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index a4a267b45cee..afd6af518d13 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -39,16 +39,8 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ // Benchmarks, repros and e2e drivers — developer-invoked or Linux-only in CI. 'config/scripts/run-ai-vault-typing-bench.mjs', 'config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs', - 'config/scripts/run-local-ssh-browser-routing-e2e.mjs', 'config/scripts/run-multi-client-navigation-e2e.mjs', 'config/scripts/run-multi-workspace-typing-bench.mjs', - 'config/scripts/run-nested-runtime-ssh-e2e.mjs', - 'config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs', - 'config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs', - 'config/scripts/run-ssh-docker-e2e.mjs', - 'config/scripts/run-ssh-docker-perf-e2e.mjs', - 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs', - 'config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs', 'config/scripts/run-ssh-staged-upload-reliability.mjs', 'config/scripts/run-terminal-ibus-hangul-e2e.mjs', 'config/scripts/run-terminal-scale-perf-e2e.mjs', diff --git a/package.json b/package.json index aa063bcc4a94..48d6724b18a8 100644 --- a/package.json +++ b/package.json @@ -140,18 +140,10 @@ "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", - "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", - "test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs", - "test:e2e:ssh-docker": "node config/scripts/run-ssh-docker-e2e.mjs", - "test:e2e:ssh-client-hosted-browser": "node config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs", - "test:e2e:local-ssh-browser": "node config/scripts/run-local-ssh-browser-routing-e2e.mjs", - "test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs", - "test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs", "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tests/tools/win-update-e2e/run.mjs", "win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs", "smoke:windows-pty-native-capability": "node tests/tools/windows-pty-native-capability-smoke/run.mjs", - "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", @@ -177,7 +169,6 @@ "bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs", "test:e2e:remote-bulk-open-freeze": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/remote-session-bulk-open-freeze-repro.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "test:e2e:ssh-docker-bulk-open-freeze": "node config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs", "repro:live-remote-bulk-open-freeze": "node config/scripts/live-remote-bulk-open-freeze-repro.mjs", "repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs", "audit:anti-slop": "node config/scripts/sync-anti-slop-plugin.mjs && oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings", diff --git a/src/main/ai-vault/ssh-session-list.test.ts b/src/main/ai-vault/ssh-session-list.test.ts deleted file mode 100644 index 52454dda966e..000000000000 --- a/src/main/ai-vault/ssh-session-list.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' -import { - createSshDisposalError, - SSH_MUX_REQUEST_TIMEOUT_CODE -} from '../ssh/ssh-channel-multiplexer' - -const requestActiveSshAiVaultSessionList = vi.fn() -const getActiveSshAiVaultHostInfo = vi.fn() -const getSshFilesystemProvider = vi.fn() -const scanRemoteAiVaultSessions = vi.fn() - -vi.mock('../ipc/ssh', () => ({ - requestActiveSshAiVaultSessionList: (...args: unknown[]) => - requestActiveSshAiVaultSessionList(...args), - getActiveSshAiVaultHostInfo: (...args: unknown[]) => getActiveSshAiVaultHostInfo(...args) -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: (...args: unknown[]) => getSshFilesystemProvider(...args), - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH filesystem is unavailable.' -})) - -vi.mock('./remote-session-scanner', () => ({ - scanRemoteAiVaultSessions: (...args: unknown[]) => scanRemoteAiVaultSessions(...args) -})) - -const { scanSshAiVaultSessions } = await import('./ssh-session-list') - -describe('scanSshAiVaultSessions', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.useRealTimers() - getActiveSshAiVaultHostInfo.mockReturnValue({ remoteHome: '/home/dev', hostPlatform: 'linux' }) - getSshFilesystemProvider.mockReturnValue({}) - }) - - it('returns at the deadline when the legacy crawl ignores abort', async () => { - // Relay without the method resolves null, so the leg falls through to the - // desktop crawl — which used to run unbounded even under an all-host budget. - vi.useFakeTimers() - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - let fallbackSignal: AbortSignal | undefined - scanRemoteAiVaultSessions.mockImplementation(({ signal }: { signal?: AbortSignal }) => { - fallbackSignal = signal - return new Promise(() => {}) - }) - - const resultPromise = scanSshAiVaultSessions('dev-box', undefined, { timeoutMs: 20 }) - await vi.advanceTimersByTimeAsync(20) - const result = await Promise.race([resultPromise, Promise.resolve('still-pending' as const)]) - - expect(result).not.toBe('still-pending') - expect(fallbackSignal?.aborted).toBe(true) - if (result === 'still-pending') { - return - } - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([ - expect.objectContaining({ - executionHostId: 'ssh:dev-box', - kind: 'host', - message: 'Agent Session History scan timed out after 20ms on this SSH host.' - }) - ]) - }) - - it('leaves the crawl unbounded when no budget was requested', async () => { - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) - - await scanSshAiVaultSessions('dev-box') - - expect(scanRemoteAiVaultSessions).toHaveBeenCalledWith( - expect.objectContaining({ signal: undefined }) - ) - }) - - it('reports an unexpected crawl failure as a host issue instead of rejecting', async () => { - // Why: `all` scope awaits every host leg together, so a throw here would - // discard the local sessions alongside this host's. - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - scanRemoteAiVaultSessions.mockRejectedValue(new TypeError('provider blew up')) - - const result = await scanSshAiVaultSessions('dev-box') - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([ - expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'provider blew up' }) - ]) - }) - - it('bounds the relay round trip separately from the whole leg', async () => { - // The relay needs seconds to walk a real remote home; only the leg total - // has to stay short enough that one host cannot hold the merge open. - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) - - await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( - 'dev-box', - expect.any(Object), - expect.objectContaining({ timeoutMs: 15_000 }) - ) - }) - - it('keeps a relay scan that outran the old three-second bound', async () => { - // Regression: the shared 3s budget emptied healthy hosts in the all-hosts - // view. A relay answering inside the scan budget must keep its sessions. - requestActiveSshAiVaultSessionList.mockImplementation( - (_targetId: string, _params: unknown, options: { timeoutMs?: number }) => - (options.timeoutMs ?? 0) > 3_000 - ? Promise.resolve({ - sessions: [remoteSession()], - issues: [], - scannedAt: '2026-08-02T00:00:00.000Z' - }) - : Promise.reject(relayTimeoutError()) - ) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() - }) - - it('reports a host issue when the relay timed out on a real scan budget', async () => { - // A relay that had a fair scan window will not answer faster over the far - // slower filesystem crawl, so retrying it only stalls the merge. - requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() - expect(result.issues).toEqual([ - expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' }) - ]) - }) - - it('reports a host issue when the relay link was declared lost on a real scan budget', async () => { - // Declaring a wedged link lost trades SSH_MUX_REQUEST_TIMEOUT for CONNECTION_LOST on this leg. - // Both are unverifiable, so both must surface as a host issue rather than falling through to a - // crawl that would publish an authoritative-looking empty list - // (docs/reference/ssh-execution-boundary.md). - requestActiveSshAiVaultSessionList.mockRejectedValue(createSshDisposalError('connection_lost')) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() - expect(result.issues).toEqual([ - expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' }) - ]) - }) - - it('still falls back when the relay budget was too short for a fair attempt', async () => { - requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) - scanRemoteAiVaultSessions.mockResolvedValue({ - sessions: [remoteSession()], - issues: [], - scannedAt: '2026-08-02T00:00:00.000Z' - }) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 3_000, - relayTimeoutMs: 2_000 - }) - - expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - }) - - it('does not treat an unrelated error mentioning a timeout as a relay timeout', async () => { - requestActiveSshAiVaultSessionList.mockRejectedValue( - new Error('the remote agent timed out after loading its index') - ) - scanRemoteAiVaultSessions.mockResolvedValue({ - sessions: [remoteSession()], - issues: [], - scannedAt: '2026-08-02T00:00:00.000Z' - }) - - const result = await scanSshAiVaultSessions('dev-box', undefined, { - timeoutMs: 20_000, - relayTimeoutMs: 15_000 - }) - - expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - }) - - it('reports the relay error when the fallback crawl recovered nothing', async () => { - // Otherwise a broken relay over an empty crawl reads as a healthy but empty - // host, and the panel shows no reason for the missing sessions. - requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay method exploded')) - scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) - - const result = await scanSshAiVaultSessions('dev-box') - - expect(result.issues).toEqual([ - expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'relay method exploded' }) - ]) - }) - - it('still propagates a caller cancellation', async () => { - const controller = new AbortController() - requestActiveSshAiVaultSessionList.mockResolvedValue(null) - scanRemoteAiVaultSessions.mockImplementation(() => new Promise(() => {})) - - const result = scanSshAiVaultSessions('dev-box', undefined, { - signal: controller.signal, - timeoutMs: 5_000 - }) - await vi.waitFor(() => expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)) - controller.abort() - - await expect(result).rejects.toMatchObject({ name: 'AbortError' }) - }) -}) - -function emptyResult(): AiVaultListResult { - return { sessions: [], issues: [], scannedAt: '2026-08-02T00:00:00.000Z' } -} - -/** Mirrors the multiplexer's typed timeout: the leg branches on the code, not - * on the message text, so a look-alike message must not take that branch. */ -function relayTimeoutError(): Error { - return Object.assign(new Error('Request "aiVault.listSessions" timed out after 15000ms'), { - code: SSH_MUX_REQUEST_TIMEOUT_CODE - }) -} - -// Relay rows are re-validated before they are trusted, so this has to satisfy -// the full session schema rather than a partial stub. -function remoteSession(): AiVaultSession { - return { - id: 'ssh:dev-box:codex:remote-session:/home/dev/remote-session.jsonl', - executionHostId: 'ssh:dev-box', - agent: 'codex', - sessionId: 'remote-session', - title: 'remote-session', - cwd: '/home/dev/repo', - branch: null, - model: null, - filePath: '/home/dev/remote-session.jsonl', - codexHome: null, - createdAt: null, - updatedAt: '2026-08-02T00:00:00.000Z', - modifiedAt: '2026-08-02T00:00:00.000Z', - messageCount: 1, - totalTokens: 0, - previewMessages: [], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: 'codex resume remote-session', - subagent: null - } -} diff --git a/src/main/ai-vault/ssh-session-list.ts b/src/main/ai-vault/ssh-session-list.ts deleted file mode 100644 index bb1bd18c4dac..000000000000 --- a/src/main/ai-vault/ssh-session-list.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - AI_VAULT_SCOPE_PATHS_MAX_COUNT, - type AiVaultListArgs, - type AiVaultListResult, - type AiVaultScanIssue -} from '../../shared/ai-vault-types' -import { toSshExecutionHostId } from '../../shared/execution-host' -import { - getSshFilesystemProvider, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-filesystem-dispatch' -import { getActiveSshAiVaultHostInfo, requestActiveSshAiVaultSessionList } from '../ipc/ssh' -import { isSshRequestOutcomeUnverifiable } from '../ssh/ssh-channel-multiplexer' -import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' -import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { parseAiVaultListResult } from './session-list-result-validation' -import { aiVaultScanIssueResult, restampAiVaultListResult } from './session-list-results' - -/** Bounds one SSH host leg. `relayTimeoutMs` caps the relay round trip alone; - * `timeoutMs` caps the whole leg, including the legacy filesystem crawl. */ -export type SshAiVaultScanBudget = { - signal?: AbortSignal - timeoutMs?: number - relayTimeoutMs?: number -} - -// Why: a relay that was given a real scan budget and still timed out will not -// answer faster over the slower filesystem crawl, so that leg reports a host -// issue. A relay cut short below this never got a fair attempt — those fall -// back rather than emptying a healthy host's sessions (#12178 follow-up). -const MEANINGFUL_RELAY_SCAN_ATTEMPT_MS = 10_000 - -export async function scanSshAiVaultSessions( - targetId: string, - args?: AiVaultListArgs, - options: SshAiVaultScanBudget = {} -): Promise { - const executionHostId = toSshExecutionHostId(targetId) - // Why: in `all` scope every host leg is awaited together, so an unexpected - // throw here (not a caller cancellation) would discard the local results too. - try { - return await scanOneSshHost(targetId, executionHostId, args, options) - } catch (error) { - if (isAbortError(error)) { - throw error - } - return sshScanIssueResult(executionHostId, targetId, errorMessage(error)) - } -} - -async function scanOneSshHost( - targetId: string, - executionHostId: `ssh:${string}`, - args: AiVaultListArgs | undefined, - options: SshAiVaultScanBudget -): Promise { - const startedAt = Date.now() - // Both legs scan the same capped set, so the fallback can't quietly scan more - // paths — or skip the truncation notice — than the relay leg would. - const scopePaths = args?.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT) - const scopePathsTruncated = (args?.scopePaths?.length ?? 0) > AI_VAULT_SCOPE_PATHS_MAX_COUNT - let relayError: unknown - const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs - try { - const params = { - ...(args?.includeAntigravityIdeSessions === true - ? { includeAntigravityIdeSessions: true } - : {}), - limit: args?.limit, - ...(args?.unlimited === true ? { unlimited: true } : {}), - ...(args?.force === true ? { force: true } : {}), - scopePaths, - ...(scopePathsTruncated ? { scopePathsTruncated: true } : {}) - } - const relayResult = - options.signal || relayTimeoutMs !== undefined - ? await requestActiveSshAiVaultSessionList(targetId, params, { - signal: options.signal, - timeoutMs: relayTimeoutMs - }) - : await requestActiveSshAiVaultSessionList(targetId, params) - if (relayResult !== null) { - return restampAiVaultListResult(parseAiVaultListResult(relayResult), executionHostId) - } - } catch (error) { - if (isAbortError(error)) { - throw error - } - if ( - isSshRequestOutcomeUnverifiable(error) && - (relayTimeoutMs === undefined || relayTimeoutMs >= MEANINGFUL_RELAY_SCAN_ATTEMPT_MS) - ) { - return sshScanIssueResult(executionHostId, targetId, errorMessage(error)) - } - relayError = error - } - const hostInfo = getActiveSshAiVaultHostInfo(targetId) - const provider = getSshFilesystemProvider(targetId) - if (!hostInfo || !provider) { - return sshScanIssueResult( - executionHostId, - targetId, - relayError ? errorMessage(relayError) : SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE - ) - } - // Why: `timeoutMs` bounded only the relay round trip, so a host on a relay - // without the list method fell through to the unbounded desktop crawl and one - // stalled SSH file stream could hold every other host's results hostage. - const fallbackResult = await scanRemoteSessionsWithinBudget({ - scan: (signal) => - scanRemoteAiVaultSessions({ - provider, - executionHostId, - includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions, - remoteHome: hostInfo.remoteHome, - hostPlatform: hostInfo.hostPlatform, - ...(args?.includeAntigravityIdeSessions === true - ? { includeAntigravityIdeSessions: true } - : {}), - limit: args?.limit, - unlimited: args?.unlimited, - scopePaths, - signal - }), - signal: options.signal, - remainingMs: remainingScanBudgetMs(options.timeoutMs, startedAt) - }) - if (!fallbackResult) { - return sshScanIssueResult( - executionHostId, - targetId, - `Agent Session History scan timed out after ${options.timeoutMs}ms on this SSH host.` - ) - } - const scopeIssues = scopePathsTruncated - ? [scopeTruncationIssue(executionHostId, hostInfo.remoteHome)] - : [] - // An empty remote home and "the relay method failed and the crawl found - // nothing" look identical, so a fallback that recovered nothing still reports - // the relay error instead of presenting a broken relay as an empty host. - if (!relayError || fallbackResult.sessions.length > 0) { - return { ...fallbackResult, issues: [...fallbackResult.issues, ...scopeIssues] } - } - return { - ...fallbackResult, - issues: [ - ...sshScanIssueResult(executionHostId, targetId, errorMessage(relayError)).issues, - ...fallbackResult.issues, - ...scopeIssues - ] - } -} - -/** Budget left for the legacy crawl after the relay attempt spent part of it. */ -function remainingScanBudgetMs(timeoutMs: number | undefined, startedAt: number): number | null { - if (timeoutMs === undefined) { - return null - } - return Math.max(0, timeoutMs - (Date.now() - startedAt)) -} - -/** Runs the legacy crawl under `remainingMs`; resolves null once the budget is - * spent. Caller cancellation still propagates as an AbortError. */ -async function scanRemoteSessionsWithinBudget(args: { - scan: (signal?: AbortSignal) => Promise - signal?: AbortSignal - remainingMs: number | null -}): Promise { - const remainingMs = args.remainingMs - if (remainingMs === null) { - return args.scan(args.signal) - } - if (args.signal?.aborted) { - throw createAiVaultScanCancelledError() - } - if (remainingMs === 0) { - return null - } - const controller = new AbortController() - return new Promise((resolve, reject) => { - let settled = false - const finish = (settle: () => void): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - args.signal?.removeEventListener('abort', onCallerAbort) - settle() - } - const onCallerAbort = (): void => { - controller.abort() - finish(() => reject(createAiVaultScanCancelledError())) - } - const timer = setTimeout(() => { - controller.abort() - finish(() => resolve(null)) - }, remainingMs) - args.signal?.addEventListener('abort', onCallerAbort, { once: true }) - if (args.signal?.aborted) { - onCallerAbort() - return - } - try { - const scan = args.scan(controller.signal) - // The SSH provider may ignore abort; observe any eventual rejection after - // this caller has already returned its timeout or cancellation result. - void scan.then( - (result) => finish(() => resolve(result)), - (error: unknown) => finish(() => reject(error)) - ) - } catch (error) { - finish(() => reject(error)) - } - }) -} - -// Mirrors the notice the relay leg appends so both legs report the same cap. -function scopeTruncationIssue( - executionHostId: `ssh:${string}`, - remoteHome: string -): AiVaultScanIssue { - return { - executionHostId, - agent: 'codex', - kind: 'scope', - path: remoteHome, - message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` - } -} - -function sshScanIssueResult( - executionHostId: `ssh:${string}`, - targetId: string, - message: string -): AiVaultListResult { - return aiVaultScanIssueResult({ executionHostId, path: targetId, message }) -} - -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError' -} - -function errorMessage(error: unknown): string { - return error instanceof Error - ? error.message - : 'Agent Session History scan failed on the SSH target.' -} diff --git a/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts b/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts index 8f998d440381..81586b7dda7a 100644 --- a/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts +++ b/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts @@ -100,29 +100,6 @@ afterEach(() => { }) describe('host session result cache lifetime', () => { - it('releases both host legs and merged lists at the strict idle TTL with one unref timer', async () => { - const timeouts = vi.spyOn(globalThis, 'setTimeout') - const owner = await cacheOwner(async () => { - for (let scope = 0; scope < 8; scope++) { - await _internals.listAiVaultSessions({ - executionHostScope: 'all', - scopePaths: [`/workspaces/project-${scope}`] - }) - } - }, '|ssh:one') - expect(owner.size).toBe(40) - const pending = vi.getTimerCount() - const timer = timeouts.mock.results[0]?.value - await vi.advanceTimersByTimeAsync(AI_VAULT_CACHE_TTL_MS - 1) - expect(owner.size).toBe(40) - await vi.advanceTimersByTimeAsync(1) - expect(owner.size).toBe(0) - expect(pending).toBe(1) - expect(timer?.hasRef()).toBe(false) - expect(vi.getTimerCount()).toBe(0) - expect(mocks.ssh).toHaveBeenCalledTimes(32) - }) - it('keeps the refreshed deadline and cached scoped depth when the old timer fires', async () => { const scan = vi.fn().mockResolvedValue(result('old')) const owner = await cacheOwner(() => scanHostLegWithCache(args(scan))) diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index 157161f78523..57b6527185bb 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -94,92 +94,6 @@ describe('Agent Session History scan coalescing', () => { await expect(second).resolves.toEqual(EMPTY_RESULT) }) - it('coalesces every all-host leg while isolating caller cancellation', async () => { - let resolveRuntime: ((result: AiVaultListResult) => void) | undefined - mocks.scanRuntimeAiVaultSessions.mockImplementation( - () => - new Promise((resolve) => { - resolveRuntime = resolve - }) - ) - registerRuntimeHost() - const controller = new AbortController() - - const first = _internals.listAiVaultSessions( - { executionHostScope: 'all' }, - { signal: controller.signal } - ) - const firstRejection = expect(first).rejects.toMatchObject({ name: 'AbortError' }) - const second = _internals.listAiVaultSessions({ executionHostScope: 'all' }) - await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) - - expect(mocks.scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1) - controller.abort() - await firstRejection - resolveRuntime?.(EMPTY_RESULT) - await expect(second).resolves.toMatchObject({ sessions: [], issues: [] }) - }) - - it.each(['cancel', 'destroyed', 'render-process-gone', 'did-navigate'])( - 'keeps a shared multi-window scan alive when one window emits %s', - async (eventName) => { - let resolveRelay: ((result: AiVaultListResult) => void) | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - () => - new Promise((resolve) => { - resolveRelay = resolve - }) - ) - registerAiVaultHandlers() - const list = ipcHandler('aiVault:listSessions') - const cancel = ipcHandler('aiVault:cancelListSessions') - const firstEvent = { sender: Object.assign(new EventEmitter(), { id: 1 }) } - const secondEvent = { sender: Object.assign(new EventEmitter(), { id: 2 }) } - const first = list(firstEvent, { - executionHostScope: 'ssh:dev-box', - requestToken: 'scan' - }) as Promise - const second = list(secondEvent, { - executionHostScope: 'ssh:dev-box', - requestToken: 'scan' - }) as Promise - await vi.waitFor(() => expect(resolveRelay).toBeDefined()) - - if (eventName === 'cancel') { - cancel(firstEvent, { requestToken: 'scan' }) - } else { - firstEvent.sender.emit(eventName) - } - - // Electron logs every rejected handler, so a cancelled scan resolves instead. - await expect(first).resolves.toMatchObject({ cancelled: true, sessions: [], issues: [] }) - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) - resolveRelay?.(EMPTY_RESULT) - await expect(second).resolves.toEqual(EMPTY_RESULT) - } - ) - - it('reports a real scan failure as a host issue rather than cancellation', async () => { - mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay socket closed')) - mocks.scanRemoteAiVaultSessions.mockRejectedValue(new Error('relay socket closed')) - registerAiVaultHandlers() - const list = ipcHandler('aiVault:listSessions') - - // SSH host legs convert unexpected throws into scan issues so an `all` - // multi-host list still returns the other hosts' sessions. - const result = await list( - { sender: Object.assign(new EventEmitter(), { id: 1 }) }, - { executionHostScope: 'ssh:dev-box', requestToken: 'scan' } - ) - expect(result).toMatchObject({ - sessions: [], - issues: [expect.objectContaining({ message: 'relay socket closed', kind: 'host' })] - }) - expect(result).not.toHaveProperty('cancelled') - }) - it('reports a failed local scan as a host issue rather than rejecting', async () => { mocks.scanAiVaultSessionsInService.mockRejectedValue(new Error('transcript root is unreadable')) registerAiVaultHandlers() @@ -197,98 +111,6 @@ describe('Agent Session History scan coalescing', () => { }) expect(result).not.toHaveProperty('cancelled') }) - - it('aborts local and SSH all-host legs only after the last renderer leaves', async () => { - const scanSignals: AbortSignal[] = [] - const waitForAbort = (signal: AbortSignal): Promise => { - scanSignals.push(signal) - return new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) - }) - } - mocks.scanAiVaultSessionsInService.mockImplementation((_args, signal: AbortSignal) => - waitForAbort(signal) - ) - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - (_targetId, _params, options: { signal: AbortSignal }) => waitForAbort(options.signal) - ) - let resolveRuntime: ((result: AiVaultListResult) => void) | undefined - mocks.scanRuntimeAiVaultSessions.mockImplementation( - () => - new Promise((resolve) => { - resolveRuntime = resolve - }) - ) - registerRuntimeHost() - const list = ipcHandler('aiVault:listSessions') - const firstEvent = { sender: Object.assign(new EventEmitter(), { id: 1 }) } - const secondEvent = { sender: Object.assign(new EventEmitter(), { id: 2 }) } - const first = list(firstEvent, { executionHostScope: 'all', requestToken: 'scan' }) - const second = list(secondEvent, { executionHostScope: 'all', requestToken: 'scan' }) - await vi.waitFor(() => expect(scanSignals).toHaveLength(2)) - await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) - - firstEvent.sender.emit('did-navigate') - await expect(first).resolves.toMatchObject({ cancelled: true }) - expect(scanSignals.every((signal) => !signal.aborted)).toBe(true) - secondEvent.sender.emit('render-process-gone') - await expect(second).resolves.toMatchObject({ cancelled: true }) - expect(scanSignals.every((signal) => signal.aborted)).toBe(true) - for (const event of [firstEvent, secondEvent]) { - expect(event.sender.eventNames()).toEqual([]) - } - // Runtime RPC cannot be canceled; its late result remains safely observed. - resolveRuntime?.(EMPTY_RESULT) - }) - - it('does not start an abandoned scan after ownership initialization completes', async () => { - let finishOwnership: (() => void) | undefined - registerAiVaultHandlers({ - ensureStructuredSessionOwnership: () => - new Promise((resolve) => { - finishOwnership = resolve - }) - }) - const event = { sender: Object.assign(new EventEmitter(), { id: 1 }) } - const pending = ipcHandler('aiVault:listSessions')(event, { requestToken: 'scan' }) - - event.sender.emit('did-navigate') - finishOwnership?.() - - await expect(pending).resolves.toMatchObject({ cancelled: true }) - expect(mocks.scanAiVaultSessionsInService).not.toHaveBeenCalled() - expect(event.sender.eventNames()).toEqual([]) - }) - - it('re-joins a preempted same-scope caller onto the forced refresh', async () => { - const signals: AbortSignal[] = [] - let resolveForced: ((result: AiVaultListResult) => void) | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - (_targetId, _params, options: { signal: AbortSignal }) => { - signals.push(options.signal) - return new Promise((resolve) => { - if (signals.length === 1) { - options.signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) - } else { - resolveForced = resolve - } - }) - } - ) - const first = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - await vi.waitFor(() => expect(signals).toHaveLength(1)) - - const forced = _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - force: true - }) - await vi.waitFor(() => expect(signals).toHaveLength(2)) - - expect(signals[0]?.aborted).toBe(true) - resolveForced?.(EMPTY_RESULT) - // Another window's Refresh must not surface as this caller's cancellation. - await expect(Promise.all([first, forced])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) - }) }) function registerRuntimeHost(): void { diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index 21c8b3ff6960..b3907603952a 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -85,29 +85,6 @@ describe('desktop IPC and preload search boundary', () => { await expect(handlers.get('aiVault:searchSessions')!(null, { query: 1 })).rejects.toThrow() await expect(handlers.get('aiVault:searchStatus')!(null, 42)).rejects.toThrow() }) - it('routes one SSH target without touching the local index and redacts received paths', async () => { - const local = fakeSearchService() - setSessionSearchService(local) - sshSearch.mockResolvedValue(searchResults()) - const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'ssh:ssh-host') - expect(sshSearch).toHaveBeenCalledWith('ssh-host', 'aiVault.searchSessions', { - query: 'needle', - limit: 20, - supportedAgents: [...AI_VAULT_AGENTS], - supportsQoderHistory: true, - supportsJcodeHistory: true - }) - expect(result).toMatchObject({ - hits: [{ executionHostId: 'ssh:ssh-host', source: { presence: 'present' } }] - }) - expect(JSON.stringify(result)).not.toContain('resumeCommand') - expect(local.search).not.toHaveBeenCalled() - sshSearch.mockRejectedValue(new Error('SSH relay is not ready')) - await expect(aiVaultApi.searchSessions({ query: 'needle' }, 'ssh:ssh-host')).rejects.toThrow( - 'SSH relay is not ready' - ) - expect(local.search).not.toHaveBeenCalled() - }) it('routes one runtime environment over its RPC and stamps the answering host', async () => { const local = fakeSearchService() setSessionSearchService(local) @@ -129,7 +106,7 @@ describe('desktop IPC and preload search boundary', () => { expect(await aiVaultApi.searchStatus('runtime:env-1')).toEqual(unavailableSessionSearchStatus()) expect(runtimeSearch).toHaveBeenLastCalledWith('env-1', 'aiVault.searchStatus', {}) }) - it.each(['ssh:ssh-host', 'runtime:env-1', 'all'] as const)( + it.each(['runtime:env-1'] as const)( 'negotiates each actual remote leg once through preload scope %s', async (scope) => { const local = fakeSearchService() @@ -157,7 +134,7 @@ describe('desktop IPC and preload search boundary', () => { expect(remoteHits).toMatchObject([{ agent: 'jcode', source: { presence: 'present' } }]) expect(JSON.stringify(remoteHits)).not.toContain('/host/transcript') expect(JSON.stringify(remoteHits)).not.toContain('resumeCommand') - expect(local.search).toHaveBeenCalledTimes(scope === 'all' ? 1 : 0) + expect(local.search).not.toHaveBeenCalled() expect(scope === 'runtime:env-1' ? sshSearch : runtimeSearch).not.toHaveBeenCalled() } ) @@ -206,34 +183,6 @@ describe('desktop IPC and preload search boundary', () => { expect(sshSearch).not.toHaveBeenCalled() expect(runtimeSearch).not.toHaveBeenCalled() }) - it('merges every enumerated host under the all scope, and still refuses an all status', async () => { - setSessionSearchService(fakeSearchService()) - sshHostInfos.mockReturnValue([{ targetId: 'box' }]) - sshSearch.mockResolvedValue({ - ...searchResults(), - hits: [{ ...searchHit(), sessionId: 'far' }] - }) - const merged = await aiVaultApi.searchSessions({ query: 'needle' }, 'all') - expect(merged).toMatchObject({ - kind: 'results', - hosts: [ - { executionHostId: 'local', outcome: 'searched' }, - { executionHostId: 'ssh:box', outcome: 'searched' } - ] - }) - expect( - merged.kind === 'results' - ? merged.hits.map((hit) => [hit.executionHostId, hit.sessionId]) - : null - ).toEqual([ - ['local', 'host-session'], - ['ssh:box', 'far'] - ]) - // A merged status would have to reconcile six phases into one; it stays refused. - await expect(handlers.get('aiVault:searchStatus')!(null, 'all')).rejects.toThrow( - 'not available for this execution host' - ) - }) it('turns a paired runtime host on and answers with the status it reported', async () => { const local = fakeSearchService() diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 190e917edb97..98826cc59b74 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -23,17 +23,12 @@ import { ALL_EXECUTION_HOSTS_SCOPE, LOCAL_EXECUTION_HOST_ID, parseRoutableExecutionHostId, - toSshExecutionHostId, type ParsedExecutionHost } from '../../shared/execution-host' import { redactStatusForTransport } from '../../shared/ai-vault-search-transport' -import { requestActiveSshSessionSearch } from './ssh' import { clearSessionSearchInService } from '../ai-vault/session-scanner-service-spawn' import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' -import { - getActiveRuntimeAiVaultHostInfosResult, - getActiveSshAiVaultHostInfosResult -} from './ai-vault' +import { getActiveRuntimeAiVaultHostInfosResult } from './ai-vault' import { AI_VAULT_ALL_HOST_TIMEOUT_MS } from './ai-vault-all-host-timeouts' import { searchWithStructuredOwners } from '../ai-vault/structured-session-ownership' @@ -169,9 +164,6 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] { executionHostId: LOCAL_EXECUTION_HOST_ID, search: searchLocalSessions } - const sshLegs = getActiveSshAiVaultHostInfosResult().hostInfos.map(({ targetId }) => - remoteHostLeg({ kind: 'ssh', id: toSshExecutionHostId(targetId), targetId }) - ) const runtimeLegs = getActiveRuntimeAiVaultHostInfosResult().hostInfos.map((hostInfo) => remoteHostLeg({ kind: 'runtime', @@ -179,7 +171,7 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] { environmentId: hostInfo.environmentId }) ) - return [localLeg, ...sshLegs, ...runtimeLegs] + return [localLeg, ...runtimeLegs] } function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg { @@ -232,13 +224,6 @@ function remoteSearchClient( host: ParsedExecutionHost, call: RuntimeSessionSearchCall | undefined ): ReturnType | null { - if (host.kind === 'ssh') { - const { targetId } = host - return createSessionSearchClient( - (method, params) => requestActiveSshSessionSearch(targetId, method, params), - 'relay' - ) - } if (host.kind === 'runtime' && call) { const { environmentId } = host return createSessionSearchClient( diff --git a/src/main/ipc/ai-vault-session-title-routing.ts b/src/main/ipc/ai-vault-session-title-routing.ts index cf3ce4cfeef6..5a9d953e0644 100644 --- a/src/main/ipc/ai-vault-session-title-routing.ts +++ b/src/main/ipc/ai-vault-session-title-routing.ts @@ -8,8 +8,6 @@ import { requestedExecutionHostScope } from '../../shared/execution-host' import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' -import { parseAiVaultSessionTitlesResult } from '../ai-vault/session-title-result-validation' -import { requestActiveSshAiVaultSessionTitles } from './ssh' export type RuntimeAiVaultSessionTitleResolver = ( environmentId: string, @@ -25,16 +23,6 @@ export async function resolveAiVaultSessionTitlesByHost( return resolveLocalAiVaultSessionTitles(args.requests) } const parsed = parseRoutableExecutionHostId(executionHostScope) - if (parsed?.kind === 'ssh') { - try { - const result = await requestActiveSshAiVaultSessionTitles(parsed.targetId, { - requests: args.requests - }) - return result === null ? { titles: [] } : parseAiVaultSessionTitlesResult(result) - } catch { - return { titles: [] } - } - } if (parsed?.kind === 'runtime' && resolveRuntime) { return resolveRuntime(parsed.environmentId, args).catch(() => ({ titles: [] })) } diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index ec5269be4203..4e4f20407890 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from 'node:events' import { homedir } from 'node:os' import { join, sep } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,7 +6,6 @@ import type { IFilesystemProvider } from '../providers/types' import type * as CachedSessionListModule from '../ai-vault/cached-session-list' import type * as SessionParseCacheModule from '../ai-vault/session-scanner-parse-cache' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' const mocks = vi.hoisted(() => ({ scanLocalSessions: vi.fn(), @@ -129,275 +127,6 @@ describe('listAiVaultSessions host routing', () => { expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() }) - it('routes SSH scope to only that SSH target', async () => { - await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths: ['/home/ada/repo'] - }) - - expect(mocks.scanLocalSessions).not.toHaveBeenCalled() - expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box') - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( - expect.objectContaining({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - scopePaths: ['/home/ada/repo'] - }) - ) - }) - - it('uses one target-side relay scan when the SSH relay supports it', async () => { - mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( - result([session('local', 'remote-session')]) - ) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths: ['/home/ada/repo'] - }) - - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( - 'dev-box', - { - limit: undefined, - scopePaths: ['/home/ada/repo'] - }, - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ) - expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() - expect(scanned.sessions[0]).toMatchObject({ - executionHostId: 'ssh:dev-box', - id: expect.stringContaining('ssh:dev-box:') - }) - }) - - it('marks oversized project scopes when sending their bounded relay form', async () => { - const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) - mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(result([])) - - await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths - }) - - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( - 'dev-box', - { - limit: undefined, - scopePaths: scopePaths.slice(0, 64), - scopePathsTruncated: true - }, - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ) - }) - - it('caps and reports oversized project scopes on the SSH filesystem fallback', async () => { - const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths - }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( - expect.objectContaining({ scopePaths: scopePaths.slice(0, 64) }) - ) - expect(scanned.issues).toContainEqual( - expect.objectContaining({ - kind: 'scope', - message: expect.stringContaining('first 64 project paths') - }) - ) - }) - - it('coalesces concurrent cancellable requests into one scan', async () => { - let resolveRelay: (() => void) | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - () => - new Promise((resolve) => { - resolveRelay = () => resolve(result([])) - }) - ) - const args = { executionHostScope: 'ssh:dev-box' as const } - - const first = _internals.listAiVaultSessions(args, { signal: new AbortController().signal }) - const second = _internals.listAiVaultSessions(args, { signal: new AbortController().signal }) - await vi.waitFor(() => expect(resolveRelay).toBeDefined()) - - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) - resolveRelay?.() - await expect(Promise.all([first, second])).resolves.toHaveLength(2) - }) - - it('does not start a second remote crawl after the relay scan budget expires', async () => { - mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box' - }) - - expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() - expect(scanned.sessions).toEqual([]) - expect(scanned.issues[0]?.message).toContain('timed out') - }) - - it('does not cache a host-level relay failure', async () => { - mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) - - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(2) - }) - - it('falls back to the filesystem crawl after a non-timeout relay failure', async () => { - mocks.requestActiveSshAiVaultSessionList.mockRejectedValue( - new Error('Invalid aiVault.listSessions response') - ) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box' - }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - }) - - it('falls back when a nonempty relay sessions array contains no valid rows', async () => { - mocks.requestActiveSshAiVaultSessionList.mockResolvedValue({ - sessions: [{ id: 42 }], - issues: [], - scannedAt: '2026-07-27T00:00:00.000Z' - }) - - const scanned = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) - }) - - it('uses the relay scan without requiring the fallback filesystem provider', async () => { - mocks.getSshFilesystemProvider.mockReturnValue(undefined) - mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( - result([session('local', 'remote-session')]) - ) - - const scanned = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box' - }) - - expect(scanned.sessions).toHaveLength(1) - expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() - }) - - it('merges local plus connected SSH targets for all hosts', async () => { - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( - 'dev-box', - expect.any(Object), - expect.objectContaining({ timeoutMs: 15_000 }) - ) - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) - }) - - it('merges paired runtime servers for all hosts', async () => { - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => [ - { - environmentId: 'remote-server', - executionHostId: 'runtime:remote-server' - } - ], - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith( - 'remote-server', - { - executionHostScope: 'runtime:remote-server' - }, - expect.objectContaining({ timeoutMs: expect.any(Number) }) - ) - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual([ - 'runtime:remote-server', - 'ssh:dev-box', - 'local' - ]) - }) - - it('keeps local and SSH results when runtime host discovery fails', async () => { - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => { - throw new Error('runtime store is invalid') - }, - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled() - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) - expect(result.issues).toEqual([ - expect.objectContaining({ - agent: 'codex', - path: 'runtime environments', - message: 'runtime store is invalid' - }) - ]) - }) - - it('keeps SSH results when the local scan itself throws', async () => { - // Why: `all` awaits every leg together, so an unguarded local throw (parse - // cache load, WSL home resolution) would discard every host's sessions. - mocks.scanLocalSessions.mockRejectedValue(new Error('session parse cache is corrupt')) - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => [], - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box']) - expect(result.issues).toEqual([ - expect.objectContaining({ - executionHostId: 'local', - kind: 'host', - path: 'this computer', - message: 'session parse cache is corrupt' - }) - ]) - }) - - it('keeps local results when SSH host discovery fails', async () => { - mocks.getActiveSshAiVaultHostInfos.mockImplementation(() => { - throw new Error('relay session map is unavailable') - }) - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => [], - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) - expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local']) - expect(result.issues).toEqual([ - expect.objectContaining({ - agent: 'codex', - path: 'SSH hosts', - message: 'relay session map is unavailable' - }) - ]) - }) - it('keeps direct runtime host scans on the normal runtime timeout', async () => { registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => [], @@ -418,86 +147,6 @@ describe('listAiVaultSessions host routing', () => { {} ) }) - - it('returns a scan issue for a disconnected SSH target', async () => { - mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null) - mocks.getSshFilesystemProvider.mockReturnValue(undefined) - - const result = await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:disconnected' - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toMatchObject([ - { - executionHostId: 'ssh:disconnected', - agent: 'codex', - path: 'disconnected' - } - ]) - }) - - it('keeps host scope in the cache key', async () => { - await _internals.listAiVaultSessions({ executionHostScope: 'local' }) - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - }) - - it('caches completed SSH scans by host and workspace scope', async () => { - await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths: ['/home/ada/repo-a', '/home/ada/repo-b'] - }) - await _internals.listAiVaultSessions({ - executionHostScope: 'ssh:dev-box', - scopePaths: ['/home/ada/repo-b', '/home/ada/repo-a'] - }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - }) - - it('serves lower SSH depths from a larger completed scan', async () => { - const base = { executionHostScope: 'ssh:dev-box' as const, scopePaths: ['/home/ada/repo'] } - await _internals.listAiVaultSessions({ ...base, limit: 1000 }) - await _internals.listAiVaultSessions({ ...base, limit: 250 }) - await _internals.listAiVaultSessions({ ...base, limit: 500 }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - }) - - it('threads renderer cancellation into the SSH relay request', async () => { - let relaySignal: AbortSignal | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - (_targetId, _params, options: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - relaySignal = options.signal - options.signal?.addEventListener( - 'abort', - () => { - const error = new Error('cancelled') - error.name = 'AbortError' - reject(error) - }, - { once: true } - ) - }) - ) - registerAiVaultHandlers() - const event = { sender: Object.assign(new EventEmitter(), { id: 7 }) } - const pending = getIpcHandler('aiVault:listSessions')(event, { - executionHostScope: 'ssh:dev-box', - requestToken: 'scan-1' - }) - await vi.waitFor(() => expect(relaySignal).toBeDefined()) - - await getIpcHandler('aiVault:cancelListSessions')(event, { requestToken: 'scan-1' }) - - expect(relaySignal?.aborted).toBe(true) - // Superseded scans resolve because Electron logs every rejected handler. - await expect(pending).resolves.toMatchObject({ cancelled: true, sessions: [] }) - }) }) describe('resolveAiVaultSessionTitles host routing', () => { @@ -519,22 +168,6 @@ describe('resolveAiVaultSessionTitles host routing', () => { expect(mocks.scanLocalSessions).not.toHaveBeenCalled() }) - it('routes SSH identities to the transcript-owning relay', async () => { - mocks.requestActiveSshAiVaultSessionTitles.mockResolvedValue(titles) - - await expect( - _internals.resolveAiVaultSessionTitles({ - executionHostScope: 'ssh:dev-box', - requests - }) - ).resolves.toEqual(titles) - - expect(mocks.requestActiveSshAiVaultSessionTitles).toHaveBeenCalledWith('dev-box', { - requests - }) - expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() - }) - it('routes runtime identities to the paired runtime host', async () => { const resolveRuntimeAiVaultSessionTitles = vi.fn().mockResolvedValue(titles) registerAiVaultHandlers({ resolveRuntimeAiVaultSessionTitles }) @@ -643,14 +276,6 @@ function getPrepareSessionResumeHandler(): ( return registration[1] } -function getIpcHandler(channel: string): (...args: unknown[]) => unknown { - const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel) - if (!registration) { - throw new Error(`${channel} was not registered`) - } - return registration[1] -} - describe('listAiVaultSubagentSessions gating', () => { const claudeRoot = join(homedir(), '.claude', 'projects') @@ -860,45 +485,6 @@ describe('deleteAiVaultSession', () => { expect(mocks.ipcHandle).toHaveBeenCalledWith('aiVault:deleteSession', expect.any(Function)) }) - - // Observes the real multi-host invalidation seam (not mocked) end-to-end: - // deleting must clear this module's own cache, not only the two shared caches. - it('clears the multi-host scan-result cache after a real delete', async () => { - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - // Second list is a cache hit, so only one scan so far. - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - - mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) - await _internals.deleteAiVaultSession(args) - - // Cache was cleared, so the next list re-scans instead of serving the stale - // list that still contained the deleted session. - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) - }) - - // Generation guard: a scan already in flight when the delete lands must not - // write its pre-delete result back into the multi-host cache. - it('does not let an in-flight multi-host scan repopulate the cache after a delete', async () => { - let resolveScan: (value: AiVaultListResult) => void = () => {} - mocks.scanRemoteAiVaultSessions.mockReturnValueOnce( - new Promise((resolve) => { - resolveScan = resolve - }) - ) - const inFlight = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) - await _internals.deleteAiVaultSession(args) - - resolveScan(result([session('ssh:dev-box', 'stale-session')])) - await inFlight - - // The late scan must not have cached its stale result: the next list re-scans. - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) - }) }) function hostInfo(targetId: string) { @@ -910,14 +496,6 @@ function hostInfo(targetId: string) { } } -/** Mirrors the multiplexer's typed timeout: callers branch on the code, not on - * the message text. */ -function relayTimeoutError(): Error { - return Object.assign(new Error('Request "aiVault.listSessions" timed out after 130000ms'), { - code: SSH_MUX_REQUEST_TIMEOUT_CODE - }) -} - function result(sessions: AiVaultSession[]): AiVaultListResult { return { sessions, issues: [], scannedAt: new Date().toISOString() } } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 06f6caebdb2d..355b5a612703 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -12,7 +12,6 @@ import { cancelledAiVaultListResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' -import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list' import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator' import type { AiVaultDeleteSessionArgs } from '../../shared/ai-vault-session-deletion' import { describeAiVaultScanError } from '../../shared/ai-vault-scan-error-message' @@ -32,10 +31,8 @@ import { parseRoutableExecutionHostId, requestedExecutionHostScope, toRuntimeExecutionHostId, - toSshExecutionHostId, type ExecutionHostScope } from '../../shared/execution-host' -import { getActiveSshAiVaultHostInfos } from './ssh' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' import { discoverAiVaultHosts, type AiVaultHostDiscoveryResult } from './ai-vault-host-discovery' import { @@ -133,27 +130,9 @@ async function scanAiVaultSessionsByHostScope( } if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() - const sshHosts = getActiveSshAiVaultHostInfosResult() - const runtimeResults = [ - ...(runtimeHosts.issue ? [runtimeHosts.issue] : []), - ...(sshHosts.issue ? [sshHosts.issue] : []) - ] + const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] const scannedResults = await Promise.all([ scanLocalAiVaultSessionsAsIssue(args, signal), - ...sshHosts.hostInfos.map((hostInfo) => - scanHostLegWithCache({ - cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`, - depth, - scopePaths, - force: args?.force === true, - scan: () => - scanSshAiVaultSessions(hostInfo.targetId, args, { - signal, - timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.sshScan, - relayTimeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.sshScanRelay - }) - }) - ), ...runtimeHosts.hostInfos.map((hostInfo) => scanHostLegWithCache({ cacheKey: `${cacheKey}|${hostInfo.executionHostId}`, @@ -178,9 +157,6 @@ async function scanAiVaultSessionsByHostScope( } const parsed = parseRoutableExecutionHostId(executionHostScope) - if (parsed?.kind === 'ssh') { - return scanSshAiVaultSessions(parsed.targetId, args, { signal }) - } if (parsed?.kind === 'runtime') { return scanRuntimeAiVaultSessions({ hostInfo: { @@ -207,15 +183,6 @@ export function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryRe }) } -export function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ - targetId: string -}> { - return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, { - path: 'SSH hosts', - fallbackMessage: 'SSH hosts are unavailable.' - }) -} - // Why: the SSH legs already degrade to an issue row so one bad host can't take // the shared Promise.all down; the local leg can throw too (parse-cache load, // WSL home resolution, scanner service supervision) and would otherwise discard diff --git a/src/main/ipc/managed-host-status-publication.test.ts b/src/main/ipc/managed-host-status-publication.test.ts index 0f529e539e7d..6807215984b7 100644 --- a/src/main/ipc/managed-host-status-publication.test.ts +++ b/src/main/ipc/managed-host-status-publication.test.ts @@ -16,7 +16,6 @@ vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ const { clearManagedServerNotes, publishHostServerStatus } = await import('./runtime-environment-managed-tunnel') -const { getPublicSshState, relayStateOverrides } = await import('./ssh-renderer-broadcast') const { clearSshHostServerStatus, getSshHostServerStatus, setSshHostServerStatus } = await import('../ssh/ssh-host-server-status') @@ -33,7 +32,6 @@ function connection(status: 'connected' | 'disconnected') { describe('publishing a verified managed host status', () => { beforeEach(() => { vi.clearAllMocks() - relayStateOverrides.clear() clearSshHostServerStatus('ssh-1') clearSshHostServerStatus('ssh-2') }) @@ -53,13 +51,6 @@ describe('publishing a verified managed host status', () => { ) }) - it('refreshes a retained override so later reads also forget the deferral', () => { - relayStateOverrides.set('ssh-1', connection('connected')) - publishHostServerStatus('ssh-1', ready) - expect(relayStateOverrides.get('ssh-1')?.managedServer).toEqual(ready) - expect(getPublicSshState('ssh-1')?.managedServer).toEqual(ready) - }) - it('preserves disconnected contact instead of inventing a connection', () => { mocks.getState.mockReturnValue(connection('disconnected')) publishHostServerStatus('ssh-1', ready) diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index 5c95b3e27de9..b5ed72243975 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -3,7 +3,6 @@ import { toSshExecutionHostId } from '../../shared/execution-host' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { LocalPtyProvider } from '../providers/local-pty-provider' -import { SshPlainShellPtyProvider } from '../providers/ssh-plain-shell-pty-provider' import { registerPtyHandlers, registerSshPtyProvider, @@ -209,28 +208,6 @@ describe('registerPtyHandlers', () => { } }) - it('answers unknown for an earlier relay PTY once the target is in plain SSH mode', async () => { - // Why: rung D never saw that PTY; it may still run on the earlier relay. - const connectionId = 'ssh-plain-1' - const ptyId = `ssh:${connectionId}@@prior-relay-pty` - setLocalPtyProvider(new LocalPtyProvider()) - const openShell = vi.fn(async () => { - throw new Error('no shell in this test') - }) - const mode = { reason: 'no_runtime' as const, message: 'plain' } - registerSshPtyProvider( - connectionId, - new SshPlainShellPtyProvider(connectionId, openShell, mode, true, 1) - ) - const controller = registerAgentClaimController() - try { - await expect(controller.probePtyLiveness(ptyId)).resolves.toBeNull() - await expect(controller.attach(ptyId)).resolves.toBe(false) - } finally { - unregisterSshPtyProvider(connectionId) - } - }) - it('answers unknown for remote-scoped ids without consulting local providers', async () => { // Why: a locally routed provider would answer confidently — and wrongly — // for a PTY that lives on a remote Orca host. diff --git a/src/main/ipc/runtime-environment-managed-tunnel.ts b/src/main/ipc/runtime-environment-managed-tunnel.ts index 2f6b4ccb2136..9a4f96549359 100644 --- a/src/main/ipc/runtime-environment-managed-tunnel.ts +++ b/src/main/ipc/runtime-environment-managed-tunnel.ts @@ -19,7 +19,7 @@ import { import { getSshHostServerStatus, setSshHostServerStatus } from '../ssh/ssh-host-server-status' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' -import { broadcastSshState, relayStateOverrides } from './ssh-renderer-broadcast' +import { broadcastSshState } from './ssh-renderer-broadcast' export async function resolveManagedRuntimeEnvironment( userDataPath: string, @@ -100,12 +100,8 @@ export function publishResolvedChangedHostStatus(target: SshTarget, environmentI export function publishHostServerStatus(targetId: string, status: SshManagedServerStatus): void { setSshHostServerStatus(targetId, status) - const override = relayStateOverrides.get(targetId) - if (override) { - relayStateOverrides.set(targetId, { ...override, managedServer: status }) - } // Only a host with a connection state has a status line to refresh. - const state = relayStateOverrides.get(targetId) ?? connectionManager?.getState(targetId) + const state = connectionManager?.getState(targetId) if (state) { broadcastSshState(getCurrentMainWindow, targetId, { ...state, managedServer: status }) } diff --git a/src/main/ipc/ssh-active-relay-sessions.test.ts b/src/main/ipc/ssh-active-relay-sessions.test.ts deleted file mode 100644 index e7784131188b..000000000000 --- a/src/main/ipc/ssh-active-relay-sessions.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { activeSessions, getActiveSshHostHomeDirectory } from './ssh-active-relay-sessions' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' -import { - resolveWorktreeRemovalHome, - resolveWorktreeRemovalRoute -} from '../worktree-removal-execution-host-route' - -const TARGET = 'target-home' - -function sessionReporting(remoteHome: string | null): never { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getRemoteHomeDirectory is the only member the home lookup calls on a session. - return { getRemoteHomeDirectory: () => remoteHome } as never -} - -afterEach(() => { - activeSessions.delete(TARGET) - unregisterSshGitProvider(TARGET) -}) - -describe('getActiveSshHostHomeDirectory', () => { - it('reports the home the session read on its host', () => { - activeSessions.set(TARGET, sessionReporting('/srv/homes/alice')) - - expect(getActiveSshHostHomeDirectory(TARGET)).toBe('/srv/homes/alice') - }) - - it('reports an absent session as unknown', () => { - expect(getActiveSshHostHomeDirectory(TARGET)).toBeNull() - }) - - it('is wired into worktree removal, so an SSH delete asks the host', () => { - // Pins the module-scope registration: without it the route silently answers - // `homePath: null` forever and the host home stops protecting anything. - activeSessions.set(TARGET, sessionReporting('/srv/homes/alice')) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the provider is registered only so the SSH route resolves; the home answer comes from the session, not from git. - registerSshGitProvider(TARGET, {} as never) - - expect(resolveWorktreeRemovalHome(resolveWorktreeRemovalRoute(`ssh:${TARGET}`))).toEqual({ - kind: 'executionHost', - homePath: '/srv/homes/alice' - }) - }) -}) diff --git a/src/main/ipc/ssh-active-relay-sessions.ts b/src/main/ipc/ssh-active-relay-sessions.ts deleted file mode 100644 index 3c12496f7d4f..000000000000 --- a/src/main/ipc/ssh-active-relay-sessions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { - setDirectSshAuthorityResolver, - setSshActiveMultiplexerResolver -} from '../ssh/ssh-target-registry' -import { setWorktreeRemovalSshHostHomeResolver } from '../worktree-removal-execution-host-route' - -// One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). -export const activeSessions = new Map() - -// Why at module scope: this resolver is pure state lookup with no handler lifecycle, so -// installing it on import keeps it correct even before registerSshHandlers runs. -setSshActiveMultiplexerResolver( - (connectionId) => activeSessions.get(connectionId)?.getMux() ?? undefined -) - -/** - * The `$HOME` the SSH host reported, or `null` while no session has resolved it. - * - * `null` means unknown, never "same as this client's home" — callers must keep - * their path-shape guards rather than fall back to `os.homedir()`. - */ -export function getActiveSshHostHomeDirectory(targetId: string): string | null { - return activeSessions.get(targetId)?.getRemoteHomeDirectory() ?? null -} - -setWorktreeRemovalSshHostHomeResolver(getActiveSshHostHomeDirectory) - -setDirectSshAuthorityResolver((targetId) => activeSessions.has(targetId)) diff --git a/src/main/ipc/ssh-advertised-url-refresh.ts b/src/main/ipc/ssh-advertised-url-refresh.ts deleted file mode 100644 index 927b7017ba31..000000000000 --- a/src/main/ipc/ssh-advertised-url-refresh.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow } from 'electron' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { getConnectionIdsForWorktree } from '../ports/ssh-advertised-url-enrichment' -import { activeSessions } from './ssh-active-relay-sessions' -import { persistedStore } from './ssh-ipc-context' -import { broadcastDetectedPorts, broadcastPortForwards } from './ssh-renderer-broadcast' - -let advertisedUrlWatcherUnsubscribe: (() => void) | null = null - -export function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null): void { - advertisedUrlWatcherUnsubscribe?.() - // Why: SSH port scans only emit on raw host/port/PID changes, but a terminal can print the advertised URL later, so the watcher must also refresh the renderer. - advertisedUrlWatcherUnsubscribe = advertisedUrlWatcher.onDidChange(({ worktreeId }) => { - if (!persistedStore) { - return - } - for (const targetId of getConnectionIdsForWorktree(persistedStore, worktreeId)) { - const session = activeSessions.get(targetId) - if (!session) { - continue - } - const scanner = session.getPortScanner() - if (scanner) { - // Why: watcher changes can arrive before the next SSH scan refreshes listener PIDs, so don't validate PIDs against cached scanner rows. - broadcastDetectedPorts(getMainWindow, targetId, scanner.getDetectedPorts(targetId), { - validatePid: false - }) - } - broadcastPortForwards(getMainWindow, targetId) - } - }) -} - -export function unregisterAdvertisedUrlRefresh(): void { - advertisedUrlWatcherUnsubscribe?.() - advertisedUrlWatcherUnsubscribe = null -} diff --git a/src/main/ipc/ssh-app-shutdown.test.ts b/src/main/ipc/ssh-app-shutdown.test.ts index 5842f37fb08d..ea96fc51eb21 100644 --- a/src/main/ipc/ssh-app-shutdown.test.ts +++ b/src/main/ipc/ssh-app-shutdown.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -23,227 +21,19 @@ vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import { getActiveMultiplexer } from './ssh' -import { - beginSshShutdown, - SSH_SHUTDOWN_BUDGET_MS, - type SshShutdownResult -} from './ssh-shutdown-drain' +import { beginSshShutdown } from './ssh-shutdown-drain' import type { SshTarget } from '../../shared/ssh-types' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { mockSshStore, mockConnectionManager, mockMux, mockPortForwardManager } = mocks +const { mockSshStore, mockConnectionManager } = mocks describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) - const { handlers, mockStore, mockWindow } = harness + const { handlers } = harness beforeEach(harness.reset) - it('detaches active SSH sessions during app shutdown without terminating recovery', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockMux.dispose.mockClear() - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - - await beginSshShutdown() - - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(mockConnectionManager.disconnectAll).toHaveBeenCalled() - }) - - it('detaches every lease in memory before a slow forward removal can cross the final flush', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - let releaseForwards!: () => void - vi.mocked(mockPortForwardManager.removeAllForwards).mockImplementationOnce( - () => - new Promise((resolve) => { - releaseForwards = resolve - }) - ) - vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() - quitTeardownStartGate.tryStart({ preventDefault() {} }) - - const shutdown = beginSshShutdown() - - // Why asserted with no await: the committed quit path calls store.flushAsync() on the very next - // line, so anything not already in memory at this instant can never reach the final snapshot. - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-1', 'detached') - // Why 'detached' and never 'terminated': the app is letting go of the lease, not proving the - // remote shell died. Those PTYs keep running for the next attach. - expect(mockStore.markSshRemotePtyLeasesForShutdown).not.toHaveBeenCalledWith( - 'ssh-1', - 'terminated' - ) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - - releaseForwards() - await shutdown - }) - - it('repeats no state transition when shutdown is begun twice', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() - quitTeardownStartGate.tryStart({ preventDefault() {} }) - - const first = beginSshShutdown() - const second = beginSshShutdown() - - expect(second).toBe(first) - await Promise.all([first, second]) - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledExactlyOnceWith( - 'ssh-1', - 'detached' - ) - }) - - it('detaches the remaining sessions and still returns when one session throws mid-transition', async () => { - const targets: Record = { - 'ssh-1': { id: 'ssh-1', label: 'A', host: 'a.example.com', port: 22, username: 'deploy' }, - 'ssh-2': { id: 'ssh-2', label: 'B', host: 'b.example.com', port: 22, username: 'deploy' } - } - mockSshStore.getTarget.mockImplementation((id: string) => targets[id] ?? null) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockImplementation((targetId: string) => ({ - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0 - })) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-2' }) - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - vi.mocked(mockStore.markSshRemotePtyLeasesForShutdown).mockClear() - // Why webContents.send: quit destroys the renderer, and that is what makes broadcastEmptyLists - // throw out of the pre-pass for whichever session reaches it first. - mockWindow.webContents.send.mockImplementation((_channel: string, payload: unknown) => { - if ((payload as { targetId?: string } | undefined)?.targetId === 'ssh-1') { - throw new Error('Object has been destroyed') - } - }) - quitTeardownStartGate.tryStart({ preventDefault() {} }) - - // Why not-throw rather than a resolved promise: the caller is a non-async will-quit listener, so - // a synchronous throw escapes it and skips killAllPty, the watchers and store.flushAsync() — the - // very flush that persists the detached leases this pre-pass just staged. - let shutdown!: Promise - expect(() => { - shutdown = beginSshShutdown() - }).not.toThrow() - // Why asserted before the await: the real flush starts on the next synchronous line. - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-2', 'detached') - - const result = await shutdown - expect( - result.errors.some( - (error) => error instanceof Error && error.message === 'Object has been destroyed' - ) - ).toBe(true) - }) - - it('reports the target and phase left unfinished when the shutdown budget expires', async () => { - vi.useFakeTimers() - try { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockConnectionManager.disconnectAll.mockClear().mockResolvedValue(undefined) - // Why never resolved, on every call: a forward whose child never reports exit is exactly the - // case the shared deadline exists for, and it must not get a fresh budget per phase. - vi.mocked(mockPortForwardManager.removeAllForwards).mockImplementation( - () => new Promise(() => {}) - ) - quitTeardownStartGate.tryStart({ preventDefault() {} }) - - let settled = false - const shutdown = beginSshShutdown().then((value) => { - settled = true - return value - }) - // Why the whole budget and not a millisecond more: the entire drain/join/drain sequence has to - // fit inside one deadline, so advancing exactly that far must be enough to settle it. Asserting - // the flag rather than awaiting keeps a per-phase budget a failure instead of a hang. - await vi.advanceTimersByTimeAsync(SSH_SHUTDOWN_BUDGET_MS) - expect(settled).toBe(true) - const result = await shutdown - - expect(result.unfinished).toContainEqual({ targetId: 'ssh-1', phase: 'drain' }) - // Why no final-drain entry: the budget was already spent, so no later phase was awaited at all. - expect(result.unfinished.some((entry) => entry.phase === 'final-drain')).toBe(false) - // Why the lease still stands: the drain timing out says nothing about the remote PTYs, and the - // pre-pass already recorded the only thing that was ever provable. - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledWith('ssh-1', 'detached') - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - } finally { - vi.mocked(mockPortForwardManager.removeAllForwards).mockReset().mockResolvedValue(undefined) - vi.useRealTimers() - } - }) - it('returns as soon as a fast shutdown drains rather than waiting out the budget', async () => { vi.useFakeTimers() try { @@ -259,69 +49,6 @@ describe('SSH IPC handlers', () => { } }) - it('refuses the replacement session a paused connect would publish after shutdown began', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockConnectionManager.disconnectAll.mockResolvedValue(undefined) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - // Why here: the old session's lease flush is the one window where doConnect resumes straight into - // publishing its replacement session without re-checking authority. - let releaseDetach = (): void => {} - const detachFlush = new Promise((resolve) => { - releaseDetach = resolve - }) - let signalEnteredDetach = (): void => {} - const enteredDetach = new Promise((resolve) => { - signalEnteredDetach = resolve - }) - mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce(() => { - signalEnteredDetach() - return detachFlush - }) - - const replacement = handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - // Why await the flush entry and not ticks: shutdown must not snapshot what to drain until the - // replacement connect is parked in the lease flush. - await enteredDetach - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') - - mockConnectionManager.disconnectAll.mockClear() - // Why latch the gate here: the committed quit path owns it, so the drain alone must not fence. - quitTeardownStartGate.tryStart({ preventDefault() {} }) - const shutdown = beginSshShutdown() - releaseDetach() - - await expect(replacement).rejects.toThrow('closed for app shutdown') - await shutdown - - // Why one detach: the fence sits at the publication point, so the resumed connect never registers a - // replacement session — only the old session it had already torn down was detached. - const detaches = mockStore.markSshRemotePtyLeasesAsync.mock.calls.filter( - (call) => call[1] === 'detached' - ) - expect(detaches).toHaveLength(1) - // Why twice: once for the drain's snapshot, once after joining the connect that was still in flight. - expect(mockConnectionManager.disconnectAll).toHaveBeenCalledTimes(2) - expect(getActiveMultiplexer('ssh-1')).toBeUndefined() - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'closed for app shutdown' - ) - }) - it('joins an in-flight test-connection probe before the final shutdown disconnect', async () => { const target: SshTarget = { id: 'ssh-probe', diff --git a/src/main/ipc/ssh-connect-attempt-registry.ts b/src/main/ipc/ssh-connect-attempt-registry.ts index 5aba526d6832..c1986886b1e9 100644 --- a/src/main/ipc/ssh-connect-attempt-registry.ts +++ b/src/main/ipc/ssh-connect-attempt-registry.ts @@ -16,7 +16,6 @@ export type ConnectAttempt = { } export const connectInFlight = new Map() -export const pendingTransportReconnects = new Set() // Why the quit gate rather than a local latch: "the committed quit has begun" already has an owner, // and a private copy could be set by something that is not actually quitting — leaving SSH connects @@ -29,7 +28,6 @@ export function assertSshConnectsNotFenced(): void { export function invalidateConnectAttempt(targetId: string): void { rotateSshProviderAuthority(targetId) - pendingTransportReconnects.delete(targetId) connectInFlight.delete(targetId) credentialRequestedForTarget.delete(targetId) } @@ -38,9 +36,6 @@ export function isCurrentConnectAttempt(targetId: string, authority: DirectSshAu return authority.targetId === targetId && isCurrentSshProviderAuthority(authority) } -// Why: publish reset's teardown/force-stop/disconnect lifecycle so new connects and duplicate resets can't race it. -export const resetRelayInFlight = new Map>() - // Why: ssh:testConnection connects then disconnects; suppressing broadcasts during the test avoids worktree cards flashing connected → disconnected. export const testingTargets = new Set() export const testConnectionProbes = new Set>() diff --git a/src/main/ipc/ssh-connect-recheck-cancellation.test.ts b/src/main/ipc/ssh-connect-decision-cancellation.test.ts similarity index 61% rename from src/main/ipc/ssh-connect-recheck-cancellation.test.ts rename to src/main/ipc/ssh-connect-decision-cancellation.test.ts index e55a00cdf89e..322821026025 100644 --- a/src/main/ipc/ssh-connect-recheck-cancellation.test.ts +++ b/src/main/ipc/ssh-connect-decision-cancellation.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -26,48 +24,17 @@ vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) import type { SshTarget } from '../../shared/ssh-types' import type { SshConnection } from '../ssh/ssh-connection' import { recordSshConnectionOpened } from '../ssh/ssh-connection-attribution' -import { - decideHostServer, - publishHostServerDecisionFailure, - refineRelayTerminalDecision -} from './ssh-host-server-connect' +import { decideHostServer, publishHostServerDecisionFailure } from './ssh-host-server-connect' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks +const { mockSshStore, mockConnectionManager } = mocks -describe('a connect cancelled during the relay-terminal re-check', () => { +describe('a connect cancelled during its server decision', () => { const harness = createSshIpcHarness(mocks) - const { handlers, createRelayLaunchResult } = harness + const { handlers } = harness beforeEach(harness.reset) - it('never reports connected for a connect cancelled during the relay-terminal re-check', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = { id: 'rechecking-transport' } - let releaseRecheck = (): void => {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockDeployAndLaunchRelay.mockResolvedValueOnce(createRelayLaunchResult()) - vi.mocked(refineRelayTerminalDecision).mockImplementationOnce( - () => new Promise((resolve) => (releaseRecheck = resolve)) - ) - const connect = Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })) - await vi.waitFor(() => expect(refineRelayTerminalDecision).toHaveBeenCalled()) - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - releaseRecheck() - - await expect(connect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', conn) - }) - it('publishes the error when a managed host fails to set up, rather than staying connecting', async () => { const target: SshTarget = { id: 'ssh-1', @@ -83,30 +50,6 @@ describe('a connect cancelled during the relay-terminal re-check', () => { expect(publishHostServerDecisionFailure).toHaveBeenCalledWith('ssh-1', failure) }) - it('publishes the setup failure when a failed setup kept the host fenced', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy', - orcadFence: { environmentId: 'env-1' } - } - mockSshStore.getTarget.mockReturnValue(target) - vi.mocked(decideHostServer).mockResolvedValueOnce({ - route: 'relay', - reason: 'failed', - detail: 'The destination could not stage the migration.' - }) - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'The destination could not stage the migration.' - ) - expect(publishHostServerDecisionFailure).toHaveBeenCalledWith( - 'ssh-1', - expect.objectContaining({ message: 'The destination could not stage the migration.' }) - ) - }) - it('closes a transport the cancelled decision opened after the user disconnected', async () => { const target: SshTarget = { id: 'ssh-1', @@ -126,7 +69,7 @@ describe('a connect cancelled during the relay-terminal re-check', () => { // The census dials after the teardown, opening a transport no one else holds. recordSshConnectionOpened(asTransport(opened)) mockConnectionManager.getConnection.mockReturnValue(opened) - return null + return { route: 'relay', reason: 'failed' } }) await expect( Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })) @@ -146,8 +89,6 @@ describe('a connect cancelled during the relay-terminal re-check', () => { mockSshStore.getTarget.mockReturnValue(target) mockConnectionManager.getConnection.mockReturnValue(undefined) mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockConnectionManager.connect.mockResolvedValue(opened) - mockDeployAndLaunchRelay.mockResolvedValue(createRelayLaunchResult()) let resumeStale = (): void => {} let staleDecided = false vi.mocked(decideHostServer).mockImplementationOnce(async () => { @@ -156,12 +97,12 @@ describe('a connect cancelled during the relay-terminal re-check', () => { recordSshConnectionOpened(asTransport(opened)) mockConnectionManager.getConnection.mockReturnValue(opened) await new Promise((resolve) => (resumeStale = resolve)) - return null + return { route: 'relay', reason: 'orcad_unavailable', detail: 'unsupported_host' } }) const stale = Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })) await vi.waitFor(() => expect(staleDecided).toBe(true)) await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - // The replacement reuses the pooled transport and completes, leaving connectInFlight. + // The managed replacement adopts the pooled transport and completes. await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) mockConnectionManager.disconnectConnection.mockClear() resumeStale() @@ -193,30 +134,6 @@ describe('a connect cancelled during the relay-terminal re-check', () => { ).rejects.toBe(failure) expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', opened) }) - - it('closes the transport a fenced failed setup dialed before reporting it', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy', - orcadFence: { environmentId: 'env-1' } - } - const opened = { id: 'conversion-transport' } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(undefined) - vi.mocked(decideHostServer).mockImplementationOnce(async () => { - await Promise.resolve() - recordSshConnectionOpened(asTransport(opened)) - mockConnectionManager.getConnection.mockReturnValue(opened) - return { route: 'relay', reason: 'failed', detail: 'Staging failed.' } - }) - await expect( - Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })) - ).rejects.toThrow('Staging failed.') - expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', opened) - }) }) /** The stand-in transports these tests hand the mocked pool. */ diff --git a/src/main/ipc/ssh-connect-flow.ts b/src/main/ipc/ssh-connect-flow.ts index dc11e5121565..5c3b82a1e36b 100644 --- a/src/main/ipc/ssh-connect-flow.ts +++ b/src/main/ipc/ssh-connect-flow.ts @@ -1,61 +1,28 @@ import { appendFileSync } from 'node:fs' -import type { SshConnection } from '../ssh/ssh-connection' -import { SshRelaySession } from '../ssh/ssh-relay-session' -import type { SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types' +import type { SshConnectionState } from '../../shared/ssh-types' import { createCancelledConnectAttemptError } from '../ssh/ssh-connect-attempt-cancellation' -import { isAuthError } from '../ssh/ssh-connection-utils' import { getSshProviderAuthority, isCurrentSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { allowsDirectSshRelay } from '../ssh/ssh-connection-store' import { adoptSshConnection, runAttributedToSshOwner } from '../ssh/ssh-connection-attribution' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { decideHostServer, recheckWhenManagedFenceClears, publishHostServerDecisionFailure, - refineRelayTerminalDecision, publishManagedServerConnect, - recordRelayDecision + publishUnservedHostServer } from './ssh-host-server-connect' -import { activeSessions } from './ssh-active-relay-sessions' import { assertSshConnectsNotFenced, connectInFlight, - credentialRequestedForTarget, - isCurrentConnectAttempt, - pendingTransportReconnects, - resetRelayInFlight + isCurrentConnectAttempt } from './ssh-connect-attempt-registry' -import { - handleSshConnectionStateChange, - relayGracePeriodForTarget -} from './ssh-connection-state-callbacks' -import { - connectionManager, - currentRuntime, - getCurrentMainWindow, - persistedStore, - portForwardManager -} from './ssh-ipc-context' -import { clearRelayLostBackoff, relayLostBackoff } from './ssh-relay-lost-backoff' -import { configureRelaySessionCallbacks } from './ssh-relay-session-callbacks' -import { - broadcastDetectedPortsFromCurrentWindow, - broadcastSshState, - clearRelayStateOverride, - getPublicSshState, - relayStateOverrides -} from './ssh-renderer-broadcast' -import { - abandonCancelledConnectAttempt, - abandonDecisionTransport, - abandonFailedSshSession -} from './ssh-session-teardown' +import { connectionManager } from './ssh-ipc-context' +import { abandonDecisionTransport } from './ssh-session-teardown' import { awaitTargetLifecycle } from './ssh-target-lifecycle-queue' -import { errorMessage } from '../../shared/error-message' export async function connectTarget(targetId: string): Promise { const e2eProbePath = process.env.ORCA_E2E_FORBID_LOCAL_SSH_CONNECT_PROBE @@ -66,10 +33,6 @@ export async function connectTarget(targetId: string): Promise ({ ok: true }) as const, - (error: unknown) => ({ ok: false, error }) as const - ) - : null - let conn - // Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error). - if (existingSession) { - // Why: await port teardown before disposing, else the new session's restorePortForwards can hit EADDRINUSE on not-yet-released ports. - await portForwardManager!.removeAllForwards(targetId) - if (!isCurrentConnectAttempt(targetId, authority)) { - throw createCancelledConnectAttemptError() - } - try { - await existingSession.detachAndPersist() - } finally { - // Why finally: detachAndPersist runs its in-memory half synchronously, so the session is - // dead even when the lease write rejects — keeping it in activeSessions would strand every - // later connect on the same dead session. Why still after the await, not before it: the - // write has settled by now, so it can no longer clobber the replacement's 'attached' write. - if (activeSessions.get(targetId) === existingSession) { - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) - } - } - } - - if (pendingTransportDisconnect) { - const disconnectResult = await pendingTransportDisconnect - if (!disconnectResult.ok) { - throw disconnectResult.error - } + if (replacePendingTransport) { + await connectionManager!.disconnect(targetId) if (!isCurrentConnectAttempt(targetId, authority)) { throw createCancelledConnectAttemptError() } } - // Why before the decision: a transport the relay connect below reuses was not this attempt's. - const priorConnection = connectionManager!.getConnection(targetId) // A transport the decision's census, deploy or conversion opens is attributed to this attempt, // so a cancelled attempt closes exactly that one and nothing a newer owner took over. const owner = Symbol(targetId) @@ -205,7 +112,7 @@ async function doConnect( throw createCancelledConnectAttemptError() } adoptCurrentTransport(targetId, owner) - if (server?.route === 'managed') { + if (server.route === 'managed') { if (server.fenceHeld) { recheckWhenManagedFenceClears(target, server.environmentId) } @@ -216,125 +123,9 @@ async function doConnect( server.serving ) } - if (server) { - recordRelayDecision(target, server) - } - // Re-read: a conversion attempt may have fenced the host since the lookup above. - const relayTarget = getSshTargetRegistryStore()!.getTarget(targetId) ?? target - if (!allowsDirectSshRelay(relayTarget)) { - // A setup that failed but kept its fence: the relay decision's detail is the real cause. - const blocked = new Error( - server?.detail ?? - 'This SSH host serves a managed Orca server; it is reached through that server.' - ) - await abandonDecisionTransport(targetId, owner, authority) - publishHostServerDecisionFailure(targetId, blocked) - throw blocked - } - - // Why here and not only at entry: this is the publication point, and it is the last statement - // before the transport opens. Checking it in the same synchronous block as activeSessions.set - // means a connect either registers before the shutdown drain snapshots, or registers never and - // owns nothing to clean up. - assertSshConnectsNotFenced() - // Why: create the session early so onStateChange sees it in 'deploying' and skips reconnect logic. - const session = new SshRelaySession( - targetId, - getCurrentMainWindow, - persistedStore!, - portForwardManager!, - currentRuntime, - broadcastDetectedPortsFromCurrentWindow - ) - configureRelaySessionCallbacks(session) - activeSessions.set(targetId, session) - const ownsSession = (): boolean => - isCurrentConnectAttempt(targetId, authority) && activeSessions.get(targetId) === session - - const mintedConnection = (): SshConnection | null => - conn && conn !== priorConnection ? conn : null - - try { - conn = await connectionManager!.connect(target) - adoptSshConnection(conn, owner) - if (!ownsSession()) { - throw createCancelledConnectAttemptError() - } - } catch (err) { - // Why: connect()'s internal state may not have reached the renderer; broadcast explicitly so the UI leaves 'connecting'. - const errObj = err instanceof Error ? err : new Error(String(err)) - const status: SshConnectionStatus = isAuthError(errObj) ? 'auth-failed' : 'error' - if (!ownsSession()) { - await abandonCancelledConnectAttempt(targetId, session, mintedConnection()) - throw createCancelledConnectAttemptError() - } - // Why: clear this failed connect's flag so a later non-prompting connect isn't deferred. - credentialRequestedForTarget.delete(targetId) - await abandonFailedSshSession(targetId, session) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) - broadcastSshState(getCurrentMainWindow, targetId, { - targetId, - status, - error: errObj.message, - reconnectAttempt: 0 - }) - throw err - } - - try { - handleSshConnectionStateChange(targetId, { - targetId, - status: 'deploying-relay', - error: null, - reconnectAttempt: 0 - }) - - await session.establish(conn, relayGracePeriodForTarget(target)) - if (!ownsSession()) { - throw createCancelledConnectAttemptError() - } - await refineRelayTerminalDecision(target, server, ownsSession) - // The re-check can wait seconds on the relay; a connect cancelled meanwhile must not report. - if (!ownsSession()) { - throw createCancelledConnectAttemptError() - } - - // Why: we manually pushed `deploying-relay`, so send `connected` straight to the renderer — routing through onStateChange would trigger reconnect logic. - clearRelayStateOverride(targetId) - broadcastSshState(getCurrentMainWindow, targetId, { - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - supportsFolderDownload: conn.usesSystemSshTransport?.() !== true - }) - } catch (err) { - if (!ownsSession()) { - await abandonCancelledConnectAttempt(targetId, session, mintedConnection()) - throw createCancelledConnectAttemptError() - } - await abandonFailedSshSession(targetId, session) - clearRelayLostBackoff(targetId) - try { - await connectionManager!.disconnect(targetId) - } catch (disconnectError) { - // Why: the establish failure is the actionable error; a teardown throw must not replace it. - console.warn( - `[ssh] Failed to disconnect transport after failed establish for ${targetId}: ${errorMessage(disconnectError)}` - ) - } - throw err - } - - // Why: persist whether this connect needed a credential so startup can partition targets into eager vs deferred without re-probing keys. - const requiredPassphrase = credentialRequestedForTarget.has(targetId) - credentialRequestedForTarget.delete(targetId) - getSshTargetRegistryStore()!.updateTarget(targetId, { - lastRequiredPassphrase: requiredPassphrase - }) - - return getPublicSshState(targetId)! + // This version has no relay to fall back to: a host its managed server doesn't serve fails here. + await abandonDecisionTransport(targetId, owner, authority) + throw publishUnservedHostServer(target, server) } function adoptCurrentTransport(targetId: string, owner: symbol): void { diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 3542b21f7e82..dce8bf94c761 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -1,94 +1,16 @@ import { ipcMain } from 'electron' -import { isLiveSshPtyLease, type SshTarget } from '../../shared/ssh-types' -import { toAppSshPtyId } from '../providers/ssh-pty-id' -import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { setSshTargetRegistryHandlers, getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { clearProviderPtyState, deletePtyOwnership, getPtyIdsForConnection } from './pty' -import { activeSessions } from './ssh-active-relay-sessions' import { assertSshConnectsNotFenced, connectInFlight, - credentialRequestedForTarget, - resetRelayInFlight, runSshTestConnectionProbe } from './ssh-connect-attempt-registry' import { connectTarget } from './ssh-connect-flow' -import { connectionManager, persistedStore } from './ssh-ipc-context' +import { connectionManager } from './ssh-ipc-context' import { getPublicSshState } from './ssh-renderer-broadcast' -import { disconnectRegisteredSshTarget, teardownActiveSshSession } from './ssh-session-teardown' -import { terminateSshTargetSessions } from './ssh-terminate-sessions' -import { assertNotManagedServerHost } from './ssh-target-crud-handlers' -import { moveSshHostToManagedServer } from './ssh-managed-server-move' -import { runTargetLifecycle } from './ssh-target-lifecycle-queue' +import { disconnectRegisteredSshTarget } from './ssh-session-teardown' import { errorMessage } from '../../shared/error-message' -async function doResetRelay(targetId: string, target: SshTarget): Promise { - const inFlightConnect = connectInFlight.get(targetId) - if (inFlightConnect) { - try { - // Why: resetting activeSessions mid-deploy would dispose the session doConnect will use. - await inFlightConnect.promise - } catch { - // The reset can still recover a stale remote relay after a failed connect. - } - } - - rotateSshProviderAuthority(targetId) - const session = activeSessions.get(targetId) - if (session) { - // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. - await teardownActiveSshSession(targetId, (capturedSession) => - capturedSession.detachAndPersist() - ) - } - - const existingConn = connectionManager!.getConnection(targetId) - let conn = existingConn - if (!conn) { - // Why re-check: admission fenced this reset before it parked on the in-flight connect, so shutdown - // may have started (and drained) while we waited — opening a transport now would outlive the drain. - assertSshConnectsNotFenced() - conn = await connectionManager!.connect(target) - } - let relayStopAcknowledged = false - try { - await forceStopRelayForTarget(conn, targetId) - relayStopAcknowledged = true - } finally { - const ptyIds = new Set(getPtyIdsForConnection(targetId)) - for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { - // Deliberately the raw state, not `sshRemotePtyLeaseAllowsReattach`: this asks which routes - // the force-stop just invalidated, not which leases may be reattached. An already-`expired` - // lease has no route left for reset to retire — re-marking it `expired` is a no-op write, and - // any local handle this connection really holds arrives through `getPtyIdsForConnection` - // above, whatever the lease says. Reset also may not upgrade it to `terminated`: killing the - // relay daemon makes its PTYs permanently unreachable, which is not evidence they exited - // (docs/reference/ssh-execution-boundary.md). Nothing here can adopt a stranger either — the - // replacement relay namespaces every id under a fresh mint epoch, so an old orphan lease can - // only fail its next reattach. - if (isLiveSshPtyLease(lease)) { - ptyIds.add(lease.ptyId) - // Why: only a host-acknowledged force-stop may retire a lease. When it threw we never - // observed those shells, so expiring them would record a verdict we do not hold; mirrors - // ssh:terminateSessions, and the next connect re-attaches (or expires) them on evidence. - if (relayStopAcknowledged) { - persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') - } - } - } - // Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM. - for (const ptyId of ptyIds) { - const appPtyId = toAppSshPtyId(targetId, ptyId) - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - } - // Why: reset's connect() may trip onCredentialRequest; clear so a later non-prompting doConnect doesn't persist lastRequiredPassphrase=true. - credentialRequestedForTarget.delete(targetId) - await connectionManager!.disconnect(targetId) - } -} - export function registerSshConnectionHandlers(): void { setSshTargetRegistryHandlers({ connect: connectTarget, @@ -103,46 +25,6 @@ export function registerSshConnectionHandlers(): void { await disconnectRegisteredSshTarget(args.targetId) }) - ipcMain.handle( - 'ssh:terminateSessions', - (_event, args: { targetId: string; forRemoval?: boolean }) => { - // Why here: only main sees a fence that landed after the renderer loaded its targets. - if (args.forRemoval) { - assertNotManagedServerHost(args.targetId) - } - return terminateSshTargetSessions(args.targetId) - } - ) - - ipcMain.handle('ssh:moveToManagedServer', (_event, args: { targetId: string }) => - moveSshHostToManagedServer(args.targetId) - ) - - ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => { - const existingReset = resetRelayInFlight.get(args.targetId) - if (existingReset) { - return existingReset - } - - const target = getSshTargetRegistryStore()!.getTarget(args.targetId) - if (!target) { - throw new Error(`SSH target "${args.targetId}" not found`) - } - // Why: reset opens its own transport, so it must be fenced by shutdown the same way connect is. - assertSshConnectsNotFenced() - - let resetPromise: Promise - resetPromise = runTargetLifecycle(args.targetId, () => - doResetRelay(args.targetId, target) - ).finally(() => { - if (resetRelayInFlight.get(args.targetId) === resetPromise) { - resetRelayInFlight.delete(args.targetId) - } - }) - resetRelayInFlight.set(args.targetId, resetPromise) - return resetPromise - }) - ipcMain.handle('ssh:getState', (_event, args: { targetId: string }) => { return getPublicSshState(args.targetId) }) @@ -163,18 +45,7 @@ export function registerSshConnectionHandlers(): void { throw new Error(`SSH target "${args.targetId}" not found`) } - // Why: with a live/reconnecting session, testConnection's disconnect() would tear down the relay stack (PTYs, watchers), so skip. - const existingSession = activeSessions.get(args.targetId) - const sessionState = existingSession?.getState() - if ( - sessionState === 'ready' || - sessionState === 'deploying' || - sessionState === 'reconnecting' - ) { - return { success: true, state: connectionManager!.getState(args.targetId) } - } - - // Why: testConnection's disconnect() would tear down an in-flight connect's relay deployment; await it instead. + // Why: testConnection's disconnect() would tear down an in-flight connect's transport; await it instead. const inFlight = connectInFlight.get(args.targetId) if (inFlight) { try { diff --git a/src/main/ipc/ssh-connection-state-callbacks.ts b/src/main/ipc/ssh-connection-state-callbacks.ts index 3d190c9f7306..63c4f808d0dc 100644 --- a/src/main/ipc/ssh-connection-state-callbacks.ts +++ b/src/main/ipc/ssh-connection-state-callbacks.ts @@ -1,127 +1,38 @@ import type { SshConnectionCallbacks } from '../ssh/ssh-connection' -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' +import type { SshConnectionState } from '../../shared/ssh-types' import { isSshHostCensusInFlight } from '../ssh/ssh-connection-attribution' -import { activeSessions } from './ssh-active-relay-sessions' import { connectInFlight, credentialRequestedForTarget, - pendingTransportReconnects, testingTargets } from './ssh-connect-attempt-registry' -import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' +import { getCurrentMainWindow } from './ssh-ipc-context' import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' import { requestCredential } from './ssh-passphrase' -import { clearRelayLostBackoff } from './ssh-relay-lost-backoff' -import { - broadcastSshState, - clearRelayStateOverride, - publishRelayOverride, - relayStateOverrides -} from './ssh-renderer-broadcast' - -export function relayGracePeriodForTarget( - target: SshTarget | null | undefined -): number | undefined { - return target?.relayGracePeriodSeconds -} +import { broadcastSshState } from './ssh-renderer-broadcast' -// Why extracted from the callbacks object: an explicit connect pushes its own 'deploying-relay' -// through this exact path, so both callers share one implementation. export function handleSshConnectionStateChange(targetId: string, state: SshConnectionState): void { if (testingTargets.has(targetId)) { return } - - // Why: an SSH reconnect must re-deploy the relay and rebuild providers; the guard below fires only for real reconnects, not an explicit connect's 'deploying'. - const session = activeSessions.get(targetId) - const sessionState = session?.getState() - const transportReconnectStarted = - state.status === 'reconnecting' && - (sessionState === 'ready' || sessionState === 'reconnecting') && - !pendingTransportReconnects.has(targetId) - if (transportReconnectStarted) { - rotateSshProviderAuthority(targetId) - pendingTransportReconnects.add(targetId) - } else if ( - state.status === 'disconnected' || - state.status === 'auth-failed' || - state.status === 'reconnection-failed' || - state.status === 'error' - ) { - pendingTransportReconnects.delete(targetId) - } - const completedTransportReconnect = - state.status === 'connected' && pendingTransportReconnects.delete(targetId) - const shouldReconnectRelay = - session !== undefined && - completedTransportReconnect && - state.reconnectAttempt === 0 && - (sessionState === 'ready' || sessionState === 'reconnecting') - const relayReconnectAlreadyInFlight = - !completedTransportReconnect && + if ( state.status === 'connected' && - sessionState === 'reconnecting' && - relayStateOverrides.has(targetId) - - if (shouldReconnectRelay) { - // Why: this branch redeploys the relay itself over a fresh transport, so any pending relay-lost retry is stale — dropping it also gives the new transport generation a full attempt budget. - clearRelayLostBackoff(targetId) - // Why: SSH connects before the relay providers rebuild; keep renderer actions gated until SshRelaySession reaches ready again. - publishRelayOverride( - getCurrentMainWindow, - targetId, - 'reconnecting', - 'Relay channel reconnecting...', - state.reconnectAttempt - ) - } else if (relayReconnectAlreadyInFlight) { - // Why: duplicate connected notifications belong to the same socket generation and must not expose providers before relay recovery finishes. - return - } else if ( - state.status === 'connected' && - // No session yet: the server decision's census, deploy or conversion opened the transport. - sessionState !== 'ready' && - !completedTransportReconnect && (connectInFlight.has(targetId) || isSshHostCensusInFlight(targetId)) ) { - // Why: the raw SSH transport reaches 'connected' before the relay session establishes during an - // explicit connect. Forwarding it makes the renderer treat the host as fully up — it remounts - // SSH panes (-> window.api.ssh.connect) and fires connected-gated data reads before any provider - // exists. On a permanent relay-deploy failure that premature 'connected' drives an unbounded - // reconnect loop. Hold it at 'deploying-relay'; the in-flight doConnect broadcasts the - // authoritative 'connected' directly (bypassing this callback) after establish() succeeds, or a - // terminal state on failure. The connectInFlight gate keeps this scoped to a live connect, so a - // stray raw 'connected' with no follow-up (e.g. a transport blip on a session left 'idle' by a - // relay version mismatch) is never wedged at 'deploying-relay'. - clearRelayStateOverride(targetId) + // Why: the raw transport reaches 'connected' while the connect is still deciding the host's + // server; forwarding it would let the renderer treat the host as up before anything serves it. broadcastSshState(getCurrentMainWindow, targetId, { targetId, - // Before any session the connect is still deciding the host's server. - status: session ? 'deploying-relay' : 'connecting', + status: 'connecting', error: state.error, reconnectAttempt: state.reconnectAttempt }) - } else { - clearRelayStateOverride(targetId) - broadcastSshState(getCurrentMainWindow, targetId, state) - } - - if (!session) { - // An explicit connect restores through its own publish; this covers a transport that recovered. - if (state.status === 'connected' && !connectInFlight.has(targetId)) { - void restoreManagedHostPortForwards(targetId) - } return } - // Why: allow reconnect from both 'ready' and 'reconnecting'; without the latter, a failed relay deploy would permanently brick the session. - if (shouldReconnectRelay) { - const target = getSshTargetRegistryStore()?.getTarget(targetId) - const conn = connectionManager?.getConnection(targetId) - if (conn) { - void session.reconnect(conn, relayGracePeriodForTarget(target)) - } + broadcastSshState(getCurrentMainWindow, targetId, state) + // An explicit connect restores through its own publish; this covers a transport that recovered. + if (state.status === 'connected') { + void restoreManagedHostPortForwards(targetId) } } diff --git a/src/main/ipc/ssh-disconnect-cancellation.test.ts b/src/main/ipc/ssh-disconnect-cancellation.test.ts index 5448580c7ed0..5abefd2cce99 100644 --- a/src/main/ipc/ssh-disconnect-cancellation.test.ts +++ b/src/main/ipc/ssh-disconnect-cancellation.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -23,21 +21,14 @@ vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import { getActiveMultiplexer } from './ssh' import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { - mockSshStore, - mockConnectionManager, - mockDeployAndLaunchRelay, - mockMux, - mockPortForwardManager -} = mocks +const { mockSshStore, mockConnectionManager } = mocks describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) - const { handlers, mockStore, createRelayLaunchResult } = harness + const { handlers } = harness beforeEach(harness.reset) @@ -72,373 +63,4 @@ describe('SSH IPC handlers', () => { await expect(connect).rejects.toThrow('SSH connection attempt was cancelled') expect(mockConnectionManager.connect).not.toHaveBeenCalled() }) - - it('invalidates a pending connect when disconnect wins and allows a fresh connect', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const staleConn = {} - const freshConn = {} - let resolveStaleConnect!: (connection: unknown) => void - let resolveForwardRemoval!: () => void - let transportConnectPending = false - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect - .mockReturnValueOnce( - new Promise((resolve) => { - transportConnectPending = true - resolveStaleConnect = resolve - }) - ) - .mockImplementationOnce(async () => { - if (transportConnectPending) { - throw new Error('Connection to Server is already in progress') - } - return freshConn - }) - mockConnectionManager.disconnect.mockImplementationOnce(async () => { - transportConnectPending = false - }) - mockPortForwardManager.removeAllForwards.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveForwardRemoval = resolve - }) - ) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - const staleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - - const disconnect = handlers.get('ssh:disconnect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) - const freshConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - resolveStaleConnect(staleConn) - await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - resolveForwardRemoval() - await disconnect - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) - - await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - }) - - it('closes the transport a cancelled connect opened after the disconnect finished', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const lateConn = { id: 'late-transport' } - let resolveStaleConnect!: (connection: unknown) => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockConnectionManager.connect.mockReturnValueOnce( - new Promise((resolve) => { - resolveStaleConnect = resolve - }) - ) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - - const staleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - // Why await the whole disconnect: the leak only exists once its teardown has already run, so - // nothing else is left to close the transport this attempt opens afterwards. - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - - resolveStaleConnect(lateConn) - - await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', lateConn) - expect(getActiveMultiplexer('ssh-1')).toBeUndefined() - }) - - it('closes the transport when establish resumes after the connect was invalidated', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = { id: 'establishing-transport' } - let releaseRelayLaunch = (): void => {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockDeployAndLaunchRelay.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRelayLaunch = () => resolve(createRelayLaunchResult()) - }) - ) - - const connect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1)) - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - - releaseRelayLaunch() - - await expect(connect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', conn) - expect(getActiveMultiplexer('ssh-1')).toBeUndefined() - }) - - it('leaves a reused transport to its replacement when the connect is cancelled', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - // Why: connect() hands back the already-open transport, so this attempt never owned it. - const sharedConn = { id: 'shared-transport' } - let resolveStaleConnect!: (connection: unknown) => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(sharedConn) - mockConnectionManager.connect.mockReturnValueOnce( - new Promise((resolve) => { - resolveStaleConnect = resolve - }) - ) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - - const staleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - - resolveStaleConnect(sharedConn) - - await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') - expect(mockConnectionManager.disconnectConnection).not.toHaveBeenCalled() - }) - - it('keeps reconnect behind transport disconnect when forward teardown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveTransportDisconnect!: () => void - let transportDisconnectPending = false - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValueOnce({}).mockImplementationOnce(async () => { - if (transportDisconnectPending) { - throw new Error('Connection to Server is already in progress') - } - return {} - }) - mockConnectionManager.disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - transportDisconnectPending = true - resolveTransportDisconnect = () => { - transportDisconnectPending = false - resolve() - } - }) - ) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( - new Error('forward teardown failed') - ) - - const disconnect = handlers.get('ssh:disconnect')!(null, { - targetId: 'ssh-1' - }) as Promise - const disconnectSettled = vi.fn() - void disconnect.then(disconnectSettled, disconnectSettled) - await vi.waitFor(() => - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - ) - const reconnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - const reconnectResult = reconnect.then( - (state) => ({ ok: true as const, state }), - (error: unknown) => ({ ok: false as const, error }) - ) - await Promise.resolve() - - expect(disconnectSettled).not.toHaveBeenCalled() - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - resolveTransportDisconnect() - - await expect(disconnect).rejects.toThrow('forward teardown failed') - await expect(reconnectResult).resolves.toMatchObject({ - ok: true, - state: { targetId: 'ssh-1', status: 'connected' } - }) - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2) - expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') - }) - - it('retires a removed target session after forward teardown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveTransportDisconnect!: () => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveTransportDisconnect = resolve - }) - ) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( - new Error('forward teardown failed') - ) - - const removal = handlers.get('ssh:removeTarget')!(null, { - id: 'ssh-1' - }) as Promise - await vi.waitFor(() => - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - ) - await Promise.resolve() - - expect(mockSshStore.removeTarget).not.toHaveBeenCalled() - resolveTransportDisconnect() - await removal - - expect(mockMux.dispose).toHaveBeenCalledWith('shutdown') - expect(mockStore.removeSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1') - expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') - }) - - it('replaces a stale shared connect after authority rotates without disconnect', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveStaleConnect!: (connection: unknown) => void - let resolveForwardRemoval!: () => void - let resolveTransportDisconnect!: () => void - let transportConnectPending = false - mockSshStore.getTarget.mockReturnValue(target) - mockSshStore.addTarget.mockReturnValue(target) - mockConnectionManager.connect - .mockReturnValueOnce( - new Promise((resolve) => { - transportConnectPending = true - resolveStaleConnect = resolve - }) - ) - .mockImplementationOnce(async () => { - if (transportConnectPending) { - throw new Error('Connection to Server is already in progress') - } - return {} - }) - mockConnectionManager.disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveTransportDisconnect = () => { - transportConnectPending = false - resolve() - } - }) - ) - mockPortForwardManager.removeAllForwards.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveForwardRemoval = resolve - }) - ) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - const staleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - const sharedStaleConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - - mockSshStore.lastRepoReadoptions = [ - { oldTargetId: 'ssh-1', newTargetId: 'ssh-new', repoIds: ['repo-1'] } - ] - await handlers.get('ssh:addTarget')!(null, { target }) - const freshConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - ) - await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) - const sharedFreshConnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - resolveForwardRemoval() - resolveTransportDisconnect() - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) - - resolveStaleConnect({}) - - await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') - await expect(sharedStaleConnect).rejects.toThrow('SSH connection attempt was cancelled') - await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) - await expect(sharedFreshConnect).resolves.toMatchObject({ - targetId: 'ssh-1', - status: 'connected' - }) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/main/ipc/ssh-handler-reregistration.test.ts b/src/main/ipc/ssh-handler-reregistration.test.ts index 52c5ca366b20..ebcf35836cc1 100644 --- a/src/main/ipc/ssh-handler-reregistration.test.ts +++ b/src/main/ipc/ssh-handler-reregistration.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -23,20 +21,14 @@ vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import { getSshConnectionManager, registerSshHandlers } from './ssh' +import { registerSshHandlers } from './ssh' import type { SshTarget } from '../../shared/ssh-types' -import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' import { createSshIpcHarness } from './ssh-ipc-test-harness' const { mockSshStore, mockConnectionManager, - mockDeployAndLaunchRelay, - mockAcceptSshPtyOutputData, - mockAcceptSshPtyOutputExit, - mockPtyProvider, mockPortForwardManager, - mockPortScannerCallbacks, mockNextConnectionManagers, mockNextPortForwardManagers } = mocks @@ -44,7 +36,6 @@ const { describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) const { - ipcTestSource, handlers, mockStore, mockWindow, @@ -55,114 +46,6 @@ describe('SSH IPC handlers', () => { beforeEach(harness.reset) - it('preserves active port forwards and live connections across handler re-registration', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - const forward = { - id: 'pf-1', - connectionId: 'ssh-1', - localPort: 4100, - remoteHost: '127.0.0.1', - remotePort: 3000, - label: 'app' - } - const updatedForward = { ...forward, remotePort: 3001 } - const newForward = { ...forward, id: 'pf-2', localPort: 4101 } - const connectedState = { - targetId: 'ssh-1', - status: 'connected' as const, - error: null, - reconnectAttempt: 0 - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue(connectedState) - mockPortForwardManager.addForward - .mockResolvedValueOnce(forward) - .mockResolvedValueOnce(newForward) - mockPortForwardManager.updateForward.mockResolvedValue(updatedForward) - mockPortForwardManager.removeForwardAndWait.mockResolvedValue(updatedForward) - mockPortForwardManager.listForwards.mockReturnValue([forward]) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:addPortForward')!(null, { - targetId: 'ssh-1', - localPort: 4100, - remoteHost: '127.0.0.1', - remotePort: 3000, - label: 'app' - }) - const replacementConnectionManager = createConnectionManagerMock() - const replacementPortForwardManager = createPortForwardManagerMock() - mockNextConnectionManagers.push(replacementConnectionManager) - mockNextPortForwardManagers.push(replacementPortForwardManager) - - registerSshHandlers(mockStore as never, () => createMockWindow() as never) - - expect(getSshConnectionManager()).toBe(mockConnectionManager) - expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ - forward - ]) - mockDeployAndLaunchRelay.mockClear() - mockPortForwardManager.removeAllForwards.mockClear() - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ - ...connectedState, - providerEpoch: expect.any(String), - connectionGeneration: 1 - }) - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() - expect(await handlers.get('ssh:listPortForwards')!(null, { targetId: 'ssh-1' })).toEqual([ - forward - ]) - - await handlers.get('ssh:updatePortForward')!(null, { - id: 'pf-1', - targetId: 'ssh-1', - localPort: 4100, - remoteHost: '127.0.0.1', - remotePort: 3001, - label: 'app' - }) - expect(mockPortForwardManager.updateForward).toHaveBeenCalledWith( - 'pf-1', - conn, - 4100, - '127.0.0.1', - 3001, - 'app' - ) - - expect(await handlers.get('ssh:removePortForward')!(null, { id: 'pf-1' })).toEqual( - updatedForward - ) - await handlers.get('ssh:addPortForward')!(null, { - targetId: 'ssh-1', - localPort: 4101, - remoteHost: '127.0.0.1', - remotePort: 3000, - label: 'app' - }) - expect(mockPortForwardManager.addForward).toHaveBeenLastCalledWith( - 'ssh-1', - conn, - 4101, - '127.0.0.1', - 3000, - 'app' - ) - expect(replacementConnectionManager.getConnection).not.toHaveBeenCalled() - expect(replacementPortForwardManager.listForwards).not.toHaveBeenCalled() - }) - it('persists desired forwards and broadcasts when an active forward closes unexpectedly', () => { const target: SshTarget = { id: 'ssh-1', @@ -247,110 +130,6 @@ describe('SSH IPC handlers', () => { expect(replacementConnectionManager.disconnect).not.toHaveBeenCalled() }) - it('refreshes live session callbacks to the newest window and output authorities', async () => { - const firstWindow = createMockWindow() - const secondWindow = createMockWindow() - const firstRuntime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn() - } - const secondRuntime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn() - } - registerSshHandlers(mockStore as never, () => firstWindow as never, firstRuntime as never) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as SshPtyDataCallback | undefined - const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as - | ((payload: { - id: string - code: number - providerGeneration: number - ptyIncarnation: string - }) => void) - | undefined - const onDetectedPorts = mockPortScannerCallbacks.get('ssh-1') as - | ((targetId: string, ports: unknown[], platform: string) => void) - | undefined - firstWindow.webContents.send.mockClear() - secondWindow.webContents.send.mockClear() - - registerSshHandlers(mockStore as never, () => secondWindow as never, secondRuntime as never) - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: unknown) => void - } - - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'error', - error: 'network down', - reconnectAttempt: 0 - }) - onData?.({ - id: 'remote-pty', - data: 'hello', - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty', - source: ipcTestSource - }) - onExit?.({ - id: 'remote-pty', - code: 9, - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty' - }) - onDetectedPorts?.( - 'ssh-1', - [{ host: '127.0.0.1', port: 3000, pid: 12, processName: 'node' }], - 'linux-x64' - ) - - expect(firstWindow.webContents.send).not.toHaveBeenCalled() - expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - targetId: 'ssh-1', - status: 'error', - error: 'network down', - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 1 - } - }) - expect(mockAcceptSshPtyOutputData).toHaveBeenCalledWith( - expect.objectContaining({ id: 'remote-pty', data: 'hello' }) - ) - expect(mockAcceptSshPtyOutputExit).toHaveBeenCalledWith( - expect.objectContaining({ id: 'remote-pty', code: 9 }) - ) - expect(secondWindow.webContents.send).toHaveBeenCalledWith('ssh:detected-ports-changed', { - targetId: 'ssh-1', - ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })]) - }) - expect(secondRuntime.onPtyData).not.toHaveBeenCalled() - expect(secondRuntime.onPtyExit).not.toHaveBeenCalled() - expect(firstRuntime.onPtyData).not.toHaveBeenCalled() - expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() - }) - it('re-registers without replacing managers when no targets are connected', () => { const replacementConnectionManager = createConnectionManagerMock() const replacementPortForwardManager = createPortForwardManagerMock() diff --git a/src/main/ipc/ssh-host-node-runtime-connect.test.ts b/src/main/ipc/ssh-host-node-runtime-connect.test.ts deleted file mode 100644 index b2c7941eb66d..000000000000 --- a/src/main/ipc/ssh-host-node-runtime-connect.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import { recordSshRelayRuntimeStep } from '../ssh/ssh-host-node-runtime-mode' -import { setSshHostServerStatus, clearSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { getPublicSshState } from './ssh-renderer-broadcast' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks - -const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy', - remoteRuntime: 'legacy' -} - -function lastConnectedState(send: ReturnType): SshConnectionState | undefined { - return ( - send.mock.calls - .filter(([channel]) => channel === 'ssh:state-changed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh:state-changed always carries { targetId, state }. - .map(([, payload]) => (payload as { state: SshConnectionState }).state) - .findLast((state) => state.status === 'connected') - ) -} - -describe('ssh:connect on the opt-in Host Node runtime', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockWindow } = harness - - beforeEach(async () => { - await harness.reset() - recordSshRelayRuntimeStep('ssh-1', false) - clearSshHostServerStatus('ssh-1') - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - }) - - it('publishes the connection as running the unsupported Host Node runtime', async () => { - mockDeployAndLaunchRelay.mockImplementationOnce(async () => { - recordSshRelayRuntimeStep('ssh-1', true) - return harness.createRelayLaunchResult() - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - expect(lastConnectedState(mockWindow.webContents.send)?.hostNodeRuntime).toBe(true) - }) - - it('stays quiet on a ladder connect and once the host moved to its managed server', async () => { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - expect(lastConnectedState(mockWindow.webContents.send)).not.toHaveProperty('hostNodeRuntime') - - recordSshRelayRuntimeStep('ssh-1', true) - setSshHostServerStatus('ssh-1', { - kind: 'managed', - environmentId: 'env-1' - }) - expect(getPublicSshState('ssh-1')).not.toHaveProperty('hostNodeRuntime') - }) -}) diff --git a/src/main/ipc/ssh-host-server-connect.ts b/src/main/ipc/ssh-host-server-connect.ts index e15c60e4bd98..91d24b34cb7a 100644 --- a/src/main/ipc/ssh-host-server-connect.ts +++ b/src/main/ipc/ssh-host-server-connect.ts @@ -7,47 +7,27 @@ import type { SshTarget } from '../../shared/ssh-types' import type { HostServerOnConnectResult } from '../ssh/ssh-host-server-on-connect' -import { relayServerStatus, shouldToastManagedServerMove } from '../ssh/ssh-host-server-move-offer' +import { unservedHostServerMessage, unservedServerStatus } from '../ssh/ssh-host-unserved-status' import { clearSshHostServerStatus, getSshHostServerStatus, setSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { trackSshHostServerMove } from '../ssh/ssh-host-server-telemetry' -import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { allowsDirectSshRelay } from '../ssh/ssh-connection-store' import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context' import { restoreManagedHostPortForwards } from './ssh-managed-port-forward-restore' -import { - broadcastSshState, - clearRelayStateOverride, - getPublicSshState -} from './ssh-renderer-broadcast' +import { broadcastSshState, getPublicSshState } from './ssh-renderer-broadcast' import { isAuthError } from '../ssh/ssh-connection-utils' -/** Resolves null when the decision couldn't run on a host that may still use the relay. */ -export async function decideHostServer( - target: SshTarget -): Promise { - try { - // Why lazy: the managed-server graph (deploy, migration, tunnel) loads only when a host connects. - const [{ resolveHostServerOnConnect }, { hostServerOnConnectDeps }] = await Promise.all([ - import('../ssh/ssh-host-server-on-connect'), - import('./ssh-host-server-on-connect-wiring') - ]) - return await resolveHostServerOnConnect( - target, - hostServerOnConnectDeps(getAppEnvironment().getPath('userData')) - ) - } catch (error) { - // A host with no relay fallback surfaces the real failure (auth, unreachable), not a generic one. - if (!allowsDirectSshRelay(getSshTargetRegistryStore()?.getTarget(target.id) ?? target)) { - throw error - } - console.warn('[ssh] Could not decide the managed Orca server for this host:', error) - return null - } +export async function decideHostServer(target: SshTarget): Promise { + // Why lazy: the managed-server graph (deploy, migration, tunnel) loads only when a host connects. + const [{ resolveHostServerOnConnect }, { hostServerOnConnectDeps }] = await Promise.all([ + import('../ssh/ssh-host-server-on-connect'), + import('./ssh-host-server-on-connect-wiring') + ]) + return await resolveHostServerOnConnect( + target, + hostServerOnConnectDeps(getAppEnvironment().getPath('userData')) + ) } /** Rechecks a host another desktop's update held during this connect, until the fence clears. */ @@ -80,7 +60,6 @@ export function recheckWhenManagedFenceClears(target: SshTarget, environmentId: export function publishHostServerDecisionFailure(targetId: string, error: unknown): void { const failure = error instanceof Error ? error : new Error(String(error)) clearSshHostServerStatus(targetId) - clearRelayStateOverride(targetId) broadcastSshState(getCurrentMainWindow, targetId, { targetId, status: isAuthError(failure) ? 'auth-failed' : 'error', @@ -114,51 +93,23 @@ export function publishManagedServerConnect( return getPublicSshState(targetId) ?? state } -/** Records why the host keeps the relay; the first live-terminals stop this version offers a move. */ -export function recordRelayDecision( +/** + * A host the managed server doesn't serve: publish why and fail the connect. There is no fallback; + * the renderer translates the status, and this message is only its untranslated twin. + */ +export function publishUnservedHostServer( target: SshTarget, decision: Extract -): void { - const appVersion = getAppEnvironment().getVersion() - const offerMove = shouldToastManagedServerMove(target, decision, appVersion) - if (offerMove) { - getSshTargetRegistryStore()!.updateTarget(target.id, { - managedServerMoveOffered: { appVersion } - }) - trackSshHostServerMove('offered', knownSshHostPlatform(target.id)) - } - setSshHostServerStatus(target.id, relayServerStatus(decision, offerMove)) -} - -/** After the relay session is up, a terminal the first decision could not ask about may prove live. */ -export async function refineRelayTerminalDecision( - target: SshTarget, - decision: HostServerOnConnectResult | null, - isCurrent: () => boolean -): Promise { - try { - const [ - { relayTerminalsOnceConnected }, - { orcadMigrationRelayPtyLister }, - { censusHostRelayTerminalsFor } - ] = await Promise.all([ - import('../ssh/ssh-host-relay-terminals-once-connected'), - import('../ssh/orcad-migration-relay-pty-lister'), - import('../ssh/ssh-host-relay-census-for-target') - ]) - const refined = await relayTerminalsOnceConnected({ - store: getSshTargetRegistryStore()!.getOrcadMigrationSource(), - targetId: target.id, - decision, - listRelayPtyIds: orcadMigrationRelayPtyLister(target.id), - censusHost: censusHostRelayTerminalsFor(target), - isCurrent - }) - if (refined && isCurrent()) { - recordRelayDecision(target, refined) - } - } catch (error) { - // The first decision's status stands; it already keeps the host on the relay. - console.warn('[ssh] Could not re-check relay terminals after connecting:', error) - } +): Error { + const managedServer = unservedServerStatus(decision) + setSshHostServerStatus(target.id, managedServer) + const failure = new Error(unservedHostServerMessage(decision)) + broadcastSshState(getCurrentMainWindow, target.id, { + targetId: target.id, + status: 'error', + error: failure.message, + reconnectAttempt: 0, + managedServer + }) + return failure } diff --git a/src/main/ipc/ssh-host-server-decision-failure.test.ts b/src/main/ipc/ssh-host-server-decision-failure.test.ts index ff74ae9a41ea..9656ecdbb66b 100644 --- a/src/main/ipc/ssh-host-server-decision-failure.test.ts +++ b/src/main/ipc/ssh-host-server-decision-failure.test.ts @@ -37,12 +37,6 @@ const fenced: SshTarget = { beforeEach(() => vi.clearAllMocks()) describe('a managed-server decision that fails', () => { - it('keeps the relay path on a host that may still use it', async () => { - mocks.getTarget.mockReturnValue(target) - mocks.resolve.mockRejectedValue(new Error('All configured authentication methods failed')) - await expect(decideHostServer(target)).resolves.toBeNull() - }) - it('surfaces the real error on a host only its managed server can reach', async () => { mocks.getTarget.mockReturnValue(fenced) const auth = new Error('All configured authentication methods failed') diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts index 4b15671de249..a026c676ed36 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts @@ -104,7 +104,7 @@ describe('connect-time server decision against the real profile', () => { expect(deps.recordedUnavailable(target())).toBeNull() }) - it('keeps the relay while a saved relay terminal is unproven, without converting', async () => { + it('refuses while a saved relay terminal is unproven, without converting', async () => { store.addRepo({ id: 'repo-1', path: '/srv/app', @@ -117,7 +117,7 @@ describe('connect-time server decision against the real profile', () => { store.upsertSshRemotePtyLease({ targetId: TARGET.id, ptyId: 'pty-1', state: 'expired' }) await expect( resolveHostServerOnConnect(target(), hostServerOnConnectDeps(userDataPath)) - ).resolves.toEqual({ route: 'relay', reason: 'relay_terminals_unverifiable', terminals: 1 }) + ).resolves.toEqual({ route: 'relay', reason: 'relay_terminals_unverifiable', terminals: 0 }) expect(mocks.convert).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/ssh-host-server-relay-decision.test.ts b/src/main/ipc/ssh-host-server-relay-decision.test.ts deleted file mode 100644 index b1677d6d51ea..000000000000 --- a/src/main/ipc/ssh-host-server-relay-decision.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshTarget } from '../../shared/ssh-types' - -const mocks = vi.hoisted(() => ({ - updateTarget: vi.fn(), - setStatus: vi.fn() -})) -vi.mock('../../shared/app-environment', () => ({ - getAppEnvironment: () => ({ getVersion: () => '1.5.0' }) -})) -vi.mock('../ssh/ssh-target-registry', () => ({ - getSshTargetRegistryStore: () => ({ updateTarget: mocks.updateTarget }) -})) -vi.mock('../ssh/ssh-host-server-status', () => ({ setSshHostServerStatus: mocks.setStatus })) -vi.mock('./ssh-ipc-context', () => ({ connectionManager: null, getCurrentMainWindow: () => null })) -vi.mock('./ssh-renderer-broadcast', () => ({ - broadcastSshState: vi.fn(), - getPublicSshState: vi.fn() -})) - -const { recordRelayDecision } = await import('./ssh-host-server-connect') - -const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } -const live = { route: 'relay' as const, reason: 'relay_terminals_live' as const, terminals: 2 } - -beforeEach(() => vi.clearAllMocks()) - -describe('recording why a connect kept the relay', () => { - it('records the first offer this version and marks the status for the toast', () => { - recordRelayDecision(target, live) - expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { - managedServerMoveOffered: { appVersion: '1.5.0' } - }) - expect(mocks.setStatus).toHaveBeenCalledWith('ssh-1', { - kind: 'relay', - reason: 'relay_terminals_live', - terminals: 2, - offerMove: true - }) - }) - - it('keeps the status line offer but skips the toast once this version offered it', () => { - recordRelayDecision({ ...target, managedServerMoveOffered: { appVersion: '1.5.0' } }, live) - expect(mocks.updateTarget).not.toHaveBeenCalled() - expect(mocks.setStatus).toHaveBeenCalledWith('ssh-1', { - kind: 'relay', - reason: 'relay_terminals_live', - terminals: 2 - }) - }) -}) diff --git a/src/main/ipc/ssh-host-sleep-reconnect.test.ts b/src/main/ipc/ssh-host-sleep-reconnect.test.ts index e0f9721e6e03..0c3285a9c582 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.test.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.test.ts @@ -1,67 +1,28 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -const manager = vi.hoisted(() => { - const connection = {} - return { getConnection: vi.fn(() => connection), reconnect: vi.fn(async () => {}) } -}) const recoverManagedTunnels = vi.hoisted(() => vi.fn(async () => {})) vi.mock('electron', async () => { const { EventEmitter } = await import('node:events') return { powerMonitor: new EventEmitter() } }) -vi.mock('./ssh-ipc-context', () => ({ connectionManager: manager })) vi.mock('../ssh/orcad-managed-tunnel', () => ({ recoverOrcadManagedTunnelsAfterHostResume: recoverManagedTunnels })) import { powerMonitor } from 'electron' -import { activeSessions } from './ssh-active-relay-sessions' import { registerPowerMonitorReconnect, unregisterPowerMonitorReconnect } from './ssh-host-sleep-reconnect' -function plainSession(probe: () => Promise) { - return { - prepareForHostSleep: vi.fn(), - getMux: () => null, - getPlainSshSession: () => ({ probeTransport: vi.fn(probe) }) - } -} - -async function resumeAndSettle(): Promise { - powerMonitor.emit('resume') - await vi.waitFor(() => expect(manager.getConnection).toHaveBeenCalled()) - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('host sleep reconnect in plain SSH mode', () => { +describe('host sleep reconnect', () => { afterEach(() => { unregisterPowerMonitorReconnect() - activeSessions.clear() vi.clearAllMocks() }) - it('keeps plain shells when the SSH transport still answers after resume', async () => { - const session = plainSession(async () => true) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) - registerPowerMonitorReconnect() - await resumeAndSettle() - expect(manager.reconnect).not.toHaveBeenCalled() - }) - - it('reconnects when the plain SSH transport stops answering', async () => { - const session = plainSession(async () => false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) - registerPowerMonitorReconnect() - powerMonitor.emit('resume') - await vi.waitFor(() => expect(manager.reconnect).toHaveBeenCalledWith('target-1')) - }) - - it('recovers managed tunnels with the same probe policy, even with no relay sessions', async () => { + it('recovers managed tunnels with the same probe policy', async () => { registerPowerMonitorReconnect(() => '/user-data') powerMonitor.emit('resume') await vi.waitFor(() => @@ -73,11 +34,9 @@ describe('host sleep reconnect in plain SSH mode', () => { }) it('leaves managed tunnels alone when the caller supplies no profile path', async () => { - const session = plainSession(async () => true) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) registerPowerMonitorReconnect() - await resumeAndSettle() + powerMonitor.emit('resume') + await new Promise((resolve) => setImmediate(resolve)) expect(recoverManagedTunnels).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/ssh-host-sleep-reconnect.ts b/src/main/ipc/ssh-host-sleep-reconnect.ts index 6bae0902a631..ff252a54505c 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.ts @@ -1,8 +1,5 @@ import { powerMonitor } from 'electron' import { recoverOrcadManagedTunnelsAfterHostResume } from '../ssh/orcad-managed-tunnel' -import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { activeSessions } from './ssh-active-relay-sessions' -import { connectionManager } from './ssh-ipc-context' import { errorMessage } from '../../shared/error-message' let powerMonitorUnsubscribe: (() => void) | null = null @@ -11,62 +8,9 @@ let powerMonitorUnsubscribe: (() => void) | null = null const RESUME_PROBE_TIMEOUT_MS = 5_000 const RESUME_PROBE_ATTEMPTS = 2 -async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise { - const plainSsh = session.getPlainSshSession() - if (plainSsh) { - // Why: plain SSH has no mux to probe, and a needless reconnect would end every open shell. - for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) { - if (await plainSsh.probeTransport(RESUME_PROBE_TIMEOUT_MS)) { - return true - } - } - return false - } - const mux = session.getMux() - if (!mux || mux.isDisposed()) { - return false - } - for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) { - if (await mux.probeLiveness(RESUME_PROBE_TIMEOUT_MS)) { - return true - } - } - return false -} - export function registerPowerMonitorReconnect(getUserDataPath?: () => string): void { powerMonitorUnsubscribe?.() - const onSuspend = (): void => { - for (const session of activeSessions.values()) { - session.prepareForHostSleep() - } - } const onResume = (): void => { - for (const [targetId, session] of activeSessions) { - const manager = connectionManager - const conn = manager?.getConnection(targetId) - if (!conn) { - continue - } - void (async () => { - // Why: unconditional reconnect on wake tore down live sessions and flashed the overlay (#7773); only reconnect if the relay link actually died during sleep. - if (await isRelayLinkAliveAfterResume(session)) { - return - } - // Why: the probe can take ~10s; bail if the session/connection was replaced or torn down meanwhile, else we'd resurrect it. - if (activeSessions.get(targetId) !== session || manager?.getConnection(targetId) !== conn) { - return - } - try { - await manager?.reconnect(targetId) - } catch (err) { - console.warn( - `[ssh] Failed to reconnect ${targetId} after system resume: ${errorMessage(err)}` - ) - } - })() - } - // Why separate: managed tunnels ride their own connections, not relay sessions. if (getUserDataPath) { void recoverOrcadManagedTunnelsAfterHostResume(getUserDataPath(), { attempts: RESUME_PROBE_ATTEMPTS, @@ -78,10 +22,8 @@ export function registerPowerMonitorReconnect(getUserDataPath?: () => string): v }) } } - powerMonitor.on('suspend', onSuspend) powerMonitor.on('resume', onResume) powerMonitorUnsubscribe = () => { - powerMonitor.off('suspend', onSuspend) powerMonitor.off('resume', onResume) } } diff --git a/src/main/ipc/ssh-ipc-mock-shapes.ts b/src/main/ipc/ssh-ipc-mock-shapes.ts index 4136aa78512f..8be0d14c4312 100644 --- a/src/main/ipc/ssh-ipc-mock-shapes.ts +++ b/src/main/ipc/ssh-ipc-mock-shapes.ts @@ -74,8 +74,6 @@ export type SshIpcMockState = { powerMonitorOnMock: Mock mockSshStore: SshStoreMock mockConnectionManager: SshConnectionManagerMock - mockDeployAndLaunchRelay: Mock - mockForceStopRelayForTarget: Mock mockAcceptSshPtyOutputData: Mock mockAcceptSshPtyOutputExit: Mock mockMux: SshChannelMultiplexerMock @@ -101,8 +99,6 @@ export type SshIpcMockModules = { hostServerConnect: SshIpcMockModule sshConnectionStore: SshIpcMockModule sshConnectionManager: SshIpcMockModule - sshRelayDeploy: SshIpcMockModule - sshRelayReset: SshIpcMockModule sshChannelMultiplexer: SshIpcMockModule sshPtyProvider: SshIpcMockModule sshFilesystemProvider: SshIpcMockModule diff --git a/src/main/ipc/ssh-ipc-module-mocks.ts b/src/main/ipc/ssh-ipc-module-mocks.ts index 471f602a76de..ca6ef21cb1fe 100644 --- a/src/main/ipc/ssh-ipc-module-mocks.ts +++ b/src/main/ipc/ssh-ipc-module-mocks.ts @@ -36,8 +36,6 @@ export function createSshIpcMocks(): SshIpcMocks { setCallbacks: vi.fn(), callbacksRef: { current: null as unknown } }, - mockDeployAndLaunchRelay: vi.fn(), - mockForceStopRelayForTarget: vi.fn(), mockAcceptSshPtyOutputData: vi.fn().mockResolvedValue({}), mockAcceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), mockMux: { @@ -93,8 +91,6 @@ export function createSshIpcMocks(): SshIpcMocks { powerMonitorOnMock, mockSshStore, mockConnectionManager, - mockDeployAndLaunchRelay, - mockForceStopRelayForTarget, mockAcceptSshPtyOutputData, mockAcceptSshPtyOutputExit, mockMux, @@ -145,14 +141,21 @@ export function createSshIpcMocks(): SshIpcMocks { installSshPtySourceAckPublisher: vi.fn().mockReturnValue(() => {}), installSshPtySourceCancellationPublisher: vi.fn().mockReturnValue(() => {}) }, - // Null keeps today's relay path; the real decision is covered by its own tests. + // A managed host by default; the real decision is covered by its own tests. hostServerConnect: { - decideHostServer: vi.fn(async () => null), + decideHostServer: vi.fn(async (): Promise<{ route: 'managed'; environmentId: string }> => ({ + route: 'managed', + environmentId: 'env-test' + })), recheckWhenManagedFenceClears: vi.fn(), publishHostServerDecisionFailure: vi.fn(), - publishManagedServerConnect: vi.fn(), - recordRelayDecision: vi.fn(), - refineRelayTerminalDecision: vi.fn(async () => {}) + publishManagedServerConnect: vi.fn((targetId: string) => ({ + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0 + })), + publishUnservedHostServer: vi.fn(() => new Error('This SSH host isn’t supported')) }, sshConnectionStore: { isRuntimeOwnedSshTarget: (target: { owner?: unknown }) => target.owner !== undefined, @@ -179,12 +182,7 @@ export function createSshIpcMocks(): SshIpcMocks { } } }, - sshRelayDeploy: { - deployAndLaunchRelay: mockDeployAndLaunchRelay - }, - sshRelayReset: { - forceStopRelayForTarget: mockForceStopRelayForTarget - }, + sshChannelMultiplexer: { SshChannelMultiplexer: class MockSshChannelMultiplexer { constructor() { diff --git a/src/main/ipc/ssh-ipc-test-harness.ts b/src/main/ipc/ssh-ipc-test-harness.ts index 88b83c701193..881e6f2626ff 100644 --- a/src/main/ipc/ssh-ipc-test-harness.ts +++ b/src/main/ipc/ssh-ipc-test-harness.ts @@ -1,4 +1,4 @@ -import { expect, vi } from 'vitest' +import { vi } from 'vitest' import type { Mock } from 'vitest' import { registerSshHandlers, resetSshHandlerStateForTests } from './ssh' import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' @@ -16,8 +16,6 @@ import type { SshPortForwardManagerMock } from './ssh-ipc-mock-shapes' -export type RelayDisposeCallback = (reason: 'shutdown' | 'connection_lost') => void - /** Lease + consumer-recovery slice of the app store the SSH handlers write through. */ export type SshLeaseStoreMock = { getRepos: () => unknown[] @@ -41,12 +39,6 @@ export type SshLeaseStoreMock = { export type MockBrowserWindow = { isDestroyed: () => boolean; webContents: { send: Mock } } -export type RelayLaunchResultMock = { - transport: { write: Mock; onData: Mock; onClose: Mock } - platform: string - serverBuildId: string -} - export type SshIpcHarness = { relayBuildId: string ipcTestSource: SshIpcTestSource @@ -57,11 +49,6 @@ export type SshIpcHarness = { createConnectionManagerMock: () => SshConnectionManagerMock /** Replacement forwarders never exercise the await-able removal path. */ createPortForwardManagerMock: () => Omit - relayReconnectDelaysMs: readonly number[] - relayLostStabilizedMs: number - createRelayLaunchResult: () => RelayLaunchResultMock - getLatestRelayDisposeCallback: () => RelayDisposeCallback - useSlowRelayLaunchOnce: (delayMs: number) => void reset: () => Promise } @@ -74,8 +61,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { powerMonitorOnMock, mockSshStore, mockConnectionManager, - mockDeployAndLaunchRelay, - mockForceStopRelayForTarget, mockAcceptSshPtyOutputData, mockAcceptSshPtyOutputExit, mockMux, @@ -145,28 +130,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { setCallbacks: vi.fn(), callbacksRef: { current: null as unknown } }) - const relayReconnectDelaysMs = [500, 1000, 2000, 4000, 8000, 15_000] as const - const relayLostStabilizedMs = 5_000 - const createRelayLaunchResult = () => ({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: relayBuildId - }) - const getLatestRelayDisposeCallback = (): RelayDisposeCallback => { - const calls = mockMux.onDispose.mock.calls - const callback = calls.at(-1)?.[0] as RelayDisposeCallback | undefined - expect(callback).toBeDefined() - return callback! - } - const useSlowRelayLaunchOnce = (delayMs: number): void => { - mockDeployAndLaunchRelay.mockImplementationOnce( - () => - new Promise((resolve) => { - setTimeout(() => resolve(createRelayLaunchResult()), delayMs) - }) - ) - } - const reset = async (): Promise => { await resetSshHandlerStateForTests() handlers.clear() @@ -203,15 +166,9 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { mockConnectionManager.disconnectAll.mockReset() mockConnectionManager.setCallbacks.mockReset() mockConnectionManager.callbacksRef.current = null - mockForceStopRelayForTarget.mockReset().mockResolvedValue(undefined) mockAcceptSshPtyOutputData.mockReset().mockResolvedValue({}) mockAcceptSshPtyOutputExit.mockReset().mockResolvedValue(undefined) - mockDeployAndLaunchRelay.mockReset().mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: relayBuildId - }) mockMux.dispose.mockReset() mockMux.isDisposed.mockReset().mockReturnValue(false) mockMux.onNotification.mockReset() @@ -272,11 +229,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { createMockWindow, createConnectionManagerMock, createPortForwardManagerMock, - relayReconnectDelaysMs, - relayLostStabilizedMs, - createRelayLaunchResult, - getLatestRelayDisposeCallback, - useSlowRelayLaunchOnce, reset } } diff --git a/src/main/ipc/ssh-managed-server-move-conversion.test.ts b/src/main/ipc/ssh-managed-server-move-conversion.test.ts deleted file mode 100644 index d045165bc48a..000000000000 --- a/src/main/ipc/ssh-managed-server-move-conversion.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { OrcadMigrationManifest } from '../../shared/orcad-migration-manifest' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import { listEnvironments } from '../../shared/runtime-environment-store' -import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types' -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' -import { Store } from '../persistence/loading-store/store' -import { fakeOrcadMigrationDestination } from '../ssh/orcad-migration-destination-fake' -import { assessOrcadMigrationTerminals } from '../ssh/orcad-migration-terminal-gate' -import { SshConnectionStore } from '../ssh/ssh-connection-store' - -const mocks = vi.hoisted(() => { - const state: { targetStore: unknown } = { targetStore: null } - return { state, deploy: vi.fn() } -}) -vi.mock('../ssh/ssh-target-registry', () => ({ - getSshConnectionManager: () => ({}), - getSshTargetRegistryStore: () => mocks.state.targetStore, - hasRegisteredDirectSshAuthority: () => false -})) -vi.mock('../ssh/orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) -vi.mock('../ssh/orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: vi.fn() })) -vi.mock('./ssh-connect-flow', () => ({ connectTarget: vi.fn() })) -vi.mock('./ssh-terminate-sessions', () => ({ terminateSshTargetSessions: vi.fn() })) -vi.mock('./ssh-session-teardown', () => ({ teardownSshTargetTransport: vi.fn() })) -vi.mock('./ssh-host-server-connect', () => ({ publishRelayTerminalsStatus: vi.fn() })) - -const { convertSshTargetToManagedOrcad } = await import('../ssh/orcad-runtime-conversion') -const { moveSshHostToManagedServer } = await import('./ssh-managed-server-move') - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 2 -} -const HOST_ID = `ssh:${TARGET.id}` as const -const WORKTREE = 'repo-1::/srv/app' - -let userDataPath: string -let store: Store -let destination: ReturnType - -beforeEach(() => { - vi.resetAllMocks() - userDataPath = mkdtempSync(join(tmpdir(), 'orcad-move-')) - store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') }) - store.addSshTarget(TARGET) - store.addRepo({ - id: 'repo-1', - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: TARGET.id - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnectionStore wraps the real test store it is given. - mocks.state.targetStore = new SshConnectionStore(store as never) - destination = fakeOrcadMigrationDestination() - mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => { - const id = getManagedOrcadFenceEnvironmentId(store.getSshTarget(TARGET.id))! - if (!listEnvironments(path).some((entry) => entry.id === id)) { - addManagedOrcadEnvironment(path, { - id, - name: args.name, - pairingCode: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768/', - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }), - orcadDeployment: { - sshTargetId: TARGET.id, - sshTargetGeneration: 2, - localPort: 46_768, - remotePort: 6_768 - } - }) - } - return { outcome: 'created', environment: {}, activeVersion: '1.0.0' } - }) -}) - -afterEach(async () => { - await closeTestStores() - rmSync(userDataPath, { recursive: true, force: true }) -}) - -/** Open relay terminal tabs: each tab, layout leaf and pane incarnation names its relay PTY. */ -function openRelayTerminals(count: number): void { - const terminals = Array.from({ length: count }, (_, index) => ({ - tabId: `tab-term-${index + 1}`, - leafId: `11111111-1111-4111-8111-11111111111${index + 1}`, - relayPtyId: `pty2:relay:${index + 1}` - })) - store.setWorkspaceSession( - { - ...store.getWorkspaceSession(HOST_ID), - activeRepoId: 'repo-1', - activeWorktreeId: WORKTREE, - activeTabId: terminals[0].tabId, - tabsByWorktree: { - [WORKTREE]: terminals.map(({ tabId, relayPtyId }, index) => ({ - id: tabId, - ptyId: `${HOST_ID}@@${relayPtyId}`, - worktreeId: WORKTREE, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: 1 - })) - }, - terminalLayoutsByTabId: Object.fromEntries( - terminals.map(({ tabId, leafId, relayPtyId }) => [ - tabId, - { - root: { type: 'leaf' as const, leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: `${HOST_ID}@@${relayPtyId}` } - } - ]) - ), - terminalPtyIncarnationsByPaneKey: Object.fromEntries( - terminals.map(({ tabId, leafId }) => [`${tabId}:${leafId}`, `incarnation-${tabId}`]) - ), - activeWorktreeIdsOnShutdown: [WORKTREE] - }, - HOST_ID - ) - for (const { tabId, leafId, relayPtyId } of terminals) { - store.upsertSshRemotePtyLease({ - targetId: TARGET.id, - ptyId: relayPtyId, - worktreeId: WORKTREE, - tabId, - leafId, - state: 'detached' - }) - } -} - -function moveDeps() { - let status: SshManagedServerStatus | undefined - return { - getTarget: (targetId: string) => store.getSshTarget(targetId), - // The relay acknowledged stopping every terminal. - terminate: vi.fn(async (targetId: string) => { - const leases = store.getSshRemotePtyLeases(targetId) - for (const lease of leases) { - store.markSshRemotePtyLease(targetId, lease.ptyId, 'terminated') - } - return { terminated: leases.length, unverifiable: 0 } - }), - // The reconnect's server decision: its census (no relay session, so the host's census, which - // found no relay endpoint, decides with the leases) keeps the relay or lets it convert. - connect: vi.fn(async (targetId: string) => { - const proof = await assessOrcadMigrationTerminals(store, targetId, null, hostIdle) - if (proof.verdict !== 'exited') { - status = { - kind: 'relay', - reason: - proof.verdict === 'live' ? 'relay_terminals_live' : 'relay_terminals_unverifiable', - terminals: proof.ptyIds.length - } - return - } - const result = await convertSshTargetToManagedOrcad(userDataPath, { - sshTargetId: targetId, - name: TARGET.label, - listRelayPtyIds: null, - censusHost: hostIdle, - destinationFor: () => destination, - releaseDirectSession: async () => {} - }) - status = - result.outcome === 'converted' - ? { kind: 'managed', environmentId: result.environment.id } - : { kind: 'relay', reason: 'refused', detail: 'reason' in result ? result.reason : '' } - }), - serverStatus: () => status, - report: vi.fn(), - releaseRelay: vi.fn(async () => {}) - } -} - -async function hostIdle() { - return { verdict: 'exited' as const, count: 0 } -} - -function stagedSession() { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stage receives the migration manifest as its first argument. - const manifest = destination.stage.mock.calls[0]?.[0] as OrcadMigrationManifest - return manifest.payload.dormantState?.workspaceSession -} - -describe('moving a host whose live relay terminals kept it on the relay', () => { - it('stops the terminal, converts, and carries its tab to orcad without the relay PTY', async () => { - openRelayTerminals(1) - const deps = moveDeps() - - await expect(moveSshHostToManagedServer(TARGET.id, deps)).resolves.toMatchObject({ - outcome: 'moved' - }) - expect(deps.terminate).toHaveBeenCalledWith(TARGET.id, expect.any(Function)) - const session = stagedSession() - // No relay PTY id survives, so the tab spawns a fresh shell on the managed server. - expect(Object.values(session?.tabsByWorktree ?? {}).flat()).toMatchObject([ - { id: 'tab-term-1', ptyId: null } - ]) - expect(session?.terminalLayoutsByTabId?.['tab-term-1']?.ptyIdsByLeafId).toBeUndefined() - expect(session?.terminalPtyIncarnationsByPaneKey ?? {}).toEqual({}) - }) - - it('moves two terminals whose stop lost its last reply to a relay that hung up (BUG-15)', async () => { - openRelayTerminals(2) - const deps = moveDeps() - // Both shells died and their leases recorded it, but the second reply was lost. - deps.terminate.mockImplementationOnce(async (targetId: string) => { - store.markSshRemotePtyLease(targetId, 'pty2:relay:1', 'terminated') - store.markSshRemotePtyLease(targetId, 'pty2:relay:2', 'terminated') - throw new Error('Failed to terminate SSH host sessions: pty2:relay:2: Multiplexer disposed') - }) - - await expect(moveSshHostToManagedServer(TARGET.id, deps)).resolves.toMatchObject({ - outcome: 'moved' - }) - expect(deps.releaseRelay).toHaveBeenCalledWith(TARGET.id) - expect(Object.values(stagedSession()?.tabsByWorktree ?? {}).flat()).toMatchObject([ - { id: 'tab-term-1', ptyId: null }, - { id: 'tab-term-2', ptyId: null } - ]) - }) - - it('refuses and refreshes the status when a failed stop left a terminal unproven', async () => { - openRelayTerminals(2) - const deps = moveDeps() - deps.terminate.mockImplementationOnce(async (targetId: string) => { - store.markSshRemotePtyLease(targetId, 'pty2:relay:1', 'terminated') - throw new Error('Failed to terminate SSH host sessions: pty2:relay:2: Multiplexer disposed') - }) - - await expect(moveSshHostToManagedServer(TARGET.id, deps)).resolves.toEqual({ - outcome: 'refused', - verdict: 'unverifiable', - terminals: 1 - }) - // Reconnected on the relay, whose own gate refuses the conversion the same way. - expect(deps.connect).toHaveBeenCalledWith(TARGET.id) - expect(destination.stage).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/ssh-managed-server-move-stop-retention.test.ts b/src/main/ipc/ssh-managed-server-move-stop-retention.test.ts deleted file mode 100644 index ff26ef17b040..000000000000 --- a/src/main/ipc/ssh-managed-server-move-stop-retention.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock( - '../ssh/ssh-previous-relay-terminals', - () => import('../ssh/ssh-previous-relay-census-test-double') -) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { Repo } from '../../shared/repo-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { OrcaRuntimeService } from '../runtime/orca-runtime' -import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' -import { getSshPtyProvider, getPtyIdsForConnection } from './pty' -import { ptyIncarnationById } from './pty/provider/ownership-state' -import { setCurrentRuntime } from './ssh-ipc-context' -import { createSshIpcHarness } from './ssh-ipc-test-harness' -import { terminateSshTargetSessions } from './ssh-terminate-sessions' - -const { mockSshStore, mockPtyProvider } = mocks -const worktreeId = 'repo::/srv/repo' -const leafId = '11111111-1111-4111-8111-111111111111' -const stoppedShell = 'ssh:ssh-1@@pty2:epoch:1' -const userExitedShell = 'ssh:ssh-1@@pty2:epoch:2' -const repo: Repo = { - id: 'repo', - path: '/srv/repo', - connectionId: 'ssh-1', - displayName: 'repo', - badgeColor: 'gray', - addedAt: 1 -} - -function sessionWith(ptyIds: Record): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [worktreeId]: Object.entries(ptyIds).map(([tabId, ptyId], sortOrder) => ({ - id: tabId, - worktreeId, - ptyId, - title: `Terminal ${sortOrder + 1}`, - customTitle: null, - color: null, - sortOrder, - createdAt: 1 - })) - }, - terminalLayoutsByTabId: Object.fromEntries( - Object.entries(ptyIds).map(([tabId, ptyId]) => [ - tabId, - { - root: { type: 'leaf', leafId: `${leafId.slice(0, -1)}${tabId.at(-1)}` }, - activeLeafId: `${leafId.slice(0, -1)}${tabId.at(-1)}`, - expandedLeafId: null, - ptyIdsByLeafId: { [`${leafId.slice(0, -1)}${tabId.at(-1)}`]: ptyId } - } - ]) - ) - } -} - -describe("a move's stop keeps the saved SSH session it is about to convert", () => { - const harness = createSshIpcHarness(mocks) - beforeEach(harness.reset) - afterEach(() => setCurrentRuntime(undefined)) - - it('keeps the stopped tab through its real exit, certifies the death, and closes an earlier exit', async () => { - let session = sessionWith({ 'tab-1': stoppedShell, 'tab-2': userExitedShell }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake supplies the session and repo methods exit retirement reads. - const store = withDurableRuntimeStore({ - getRepos: () => [repo], - getRepo: () => repo, - getWorkspaceSessionHostIds: () => ['ssh:ssh-1'], - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - session = next - }, - flushOrThrow: vi.fn() - }) as never - const runtime = new OrcaRuntimeService(store) - for (const [tabId, ptyId] of [ - ['tab-1', stoppedShell], - ['tab-2', userExitedShell] - ] as const) { - const tabLeafId = `${leafId.slice(0, -1)}${tabId.at(-1)}` - runtime.registerPty(ptyId, worktreeId, 'ssh-1', { - tabId, - leafId: tabLeafId, - incarnationId: `inc-${tabId}` - }) - ptyIncarnationById.set(ptyId, `inc-${tabId}`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a real OrcaRuntimeService. - setCurrentRuntime(runtime as never) - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([stoppedShell]) - - // The user's own exit lands before the move stops anything: it closes its tab as always. - await runtime.onPtyExit(userExitedShell, 0, 'inc-tab-2', { hostExitConfirmed: true }) - expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) - - // The relay reports the stopped shell's real exit while its shutdown is in flight. - mockPtyProvider.shutdown.mockImplementation(async (ptyId: string) => { - await runtime.onPtyExit(ptyId, 0, 'inc-tab-1', { hostExitConfirmed: true }) - }) - const stopped: string[] = [] - await terminateSshTargetSessions('ssh-1', { - intentionalStop: 'replaced', - onStopped: (ptyId) => stopped.push(ptyId) - }) - - expect(stopped).toEqual([stoppedShell]) - expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['tab-1']) - expect(runtime.getPtyLivenessVerdict(stoppedShell)?.status).toBe('exited') - }, 120_000) -}) diff --git a/src/main/ipc/ssh-managed-server-move.test.ts b/src/main/ipc/ssh-managed-server-move.test.ts deleted file mode 100644 index fb30f525a3d4..000000000000 --- a/src/main/ipc/ssh-managed-server-move.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types' - -vi.mock('./ssh-connect-flow', () => ({ connectTarget: vi.fn() })) -vi.mock('./ssh-terminate-sessions', () => ({ terminateSshTargetSessions: vi.fn() })) -vi.mock('./ssh-session-teardown', () => ({ teardownSshTargetTransport: vi.fn() })) - -const { moveSshHostToManagedServer } = await import('./ssh-managed-server-move') - -const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' } -const live = (terminals: number): SshManagedServerStatus => ({ - kind: 'relay', - reason: 'relay_terminals_live', - terminals -}) - -/** `afterConnect` is what the reconnect's own decision (and its census) recorded. */ -function deps(options: { unverifiable?: number; afterConnect?: SshManagedServerStatus } = {}) { - const calls: string[] = [] - let status: SshManagedServerStatus | undefined = live(2) - return { - calls, - getTarget: vi.fn(() => target), - terminate: vi.fn(async (_targetId: string, _onStopped: (appPtyId: string) => void) => { - calls.push('terminate') - return { terminated: 2, unverifiable: options.unverifiable ?? 0 } - }), - connect: vi.fn(async () => { - calls.push('connect') - status = options.afterConnect ?? { kind: 'managed', environmentId: 'env-1' } - }), - serverStatus: vi.fn(() => status), - report: vi.fn(), - releaseRelay: vi.fn(async () => { - calls.push('release') - }) - } -} - -describe('moving an SSH host to its managed server on request', () => { - it('names the shells it stopped, even when a later stop fails, so only those tabs restart', async () => { - const move = deps({ afterConnect: live(1) }) - move.terminate.mockImplementationOnce(async (_targetId, onStopped) => { - onStopped('ssh:ssh-1@@pty-1') - throw new Error('Failed to terminate SSH host sessions: pty-2: mux down') - }) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ - outcome: 'refused', - verdict: 'live', - terminals: 1, - stoppedPtyIds: ['ssh:ssh-1@@pty-1'] - }) - }) - - it('stops the relay terminals, then lets the reconnect prove them exited and convert', async () => { - const move = deps() - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ - outcome: 'moved', - environmentId: 'env-1' - }) - // No census of its own: the connect's decision runs it while holding the raw 'connected'. - expect(move.calls).toEqual(['terminate', 'connect']) - expect(move.report).toHaveBeenCalledWith('ssh-1', 'moved') - }) - - it('refuses without converting when the stop could not reach every terminal', async () => { - const move = deps({ - unverifiable: 1, - afterConnect: { kind: 'relay', reason: 'relay_terminals_unverifiable', terminals: 1 } - }) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ - outcome: 'refused', - verdict: 'unverifiable', - terminals: 1 - }) - expect(move.report).toHaveBeenCalledWith('ssh-1', 'refused_unverifiable') - }) - - it('reports the reconnect census verdict when it keeps the relay', async () => { - const stillLive = deps({ afterConnect: live(1) }) - await expect(moveSshHostToManagedServer('ssh-1', stillLive)).resolves.toEqual({ - outcome: 'refused', - verdict: 'live', - terminals: 1 - }) - expect(stillLive.report).toHaveBeenCalledWith('ssh-1', 'refused_live') - - const unproven = deps({ - afterConnect: { kind: 'relay', reason: 'relay_terminals_unverifiable', terminals: 3 } - }) - await expect(moveSshHostToManagedServer('ssh-1', unproven)).resolves.toEqual({ - outcome: 'refused', - verdict: 'unverifiable', - terminals: 3 - }) - }) - - it('reports a connect that kept the relay for another reason', async () => { - const move = deps({ afterConnect: { kind: 'relay', reason: 'refused', detail: 'blocked' } }) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ outcome: 'stayed' }) - expect(move.report).toHaveBeenCalledWith('ssh-1', 'stayed') - }) - - it('reattaches the relay first when preserved terminals need one to be stopped', async () => { - const move = deps() - move.terminate.mockRejectedValueOnce( - new Error(`${SSH_TERMINATE_RECONNECT_REQUIRED}: reconnect`) - ) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toMatchObject({ - outcome: 'moved' - }) - expect(move.calls).toEqual(['connect', 'terminate', 'connect']) - }) - - it('detaches a relay a failed stop left up, so the reconnect decides again', async () => { - const move = deps() - // BUG-15: the second terminal's reply was lost to a relay that hung up on its last exit. - move.terminate.mockRejectedValueOnce( - new Error('Failed to terminate SSH host sessions: pty2:a:3: Multiplexer disposed') - ) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toMatchObject({ - outcome: 'moved' - }) - expect(move.calls).toEqual(['release', 'connect']) - }) - - it('leaves the relay of a stop that succeeded to the reconnect', async () => { - const move = deps() - await moveSshHostToManagedServer('ssh-1', move) - expect(move.releaseRelay).not.toHaveBeenCalled() - }) - - it('reports the stop’s own unverifiable count when the reconnect itself fails', async () => { - const move = deps({ unverifiable: 2 }) - move.connect.mockRejectedValueOnce(new Error('auth failed')) - await expect(moveSshHostToManagedServer('ssh-1', move)).resolves.toEqual({ - outcome: 'refused', - verdict: 'unverifiable', - terminals: 2 - }) - }) -}) diff --git a/src/main/ipc/ssh-managed-server-move.ts b/src/main/ipc/ssh-managed-server-move.ts deleted file mode 100644 index 3b89256c516c..000000000000 --- a/src/main/ipc/ssh-managed-server-move.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * The user's "Move to managed server": stop the host's relay terminals, then reconnect so the - * connect-time decision proves them exited with its own census and runs the conversion. - */ -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import type { SshManagedServerMoveResult } from '../../shared/ssh-managed-server-move' -import type { - SshManagedServerStatus, - SshTarget, - SshTerminateSessionsResult -} from '../../shared/ssh-types' -import { getSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { - trackSshHostServerMove, - type SshHostServerMoveOutcome -} from '../ssh/ssh-host-server-telemetry' -import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { connectTarget } from './ssh-connect-flow' -import { teardownSshTargetTransport } from './ssh-session-teardown' -import { runTargetLifecycle } from './ssh-target-lifecycle-queue' -import { terminateSshTargetSessions } from './ssh-terminate-sessions' - -export type SshManagedServerMoveDeps = { - getTarget: (targetId: string) => SshTarget | undefined - /** Stops the relay terminals as main's own restart, reporting each shell it stopped. */ - terminate: ( - targetId: string, - onStopped: (appPtyId: string) => void - ) => Promise - connect: (targetId: string) => Promise - serverStatus: (targetId: string) => SshManagedServerStatus | undefined - report: (targetId: string, outcome: SshHostServerMoveOutcome) => void - /** Detaches a relay session a failed stop left up, keeping its terminals' leases. */ - releaseRelay: (targetId: string) => Promise -} - -export async function moveSshHostToManagedServer( - targetId: string, - deps: SshManagedServerMoveDeps = defaultMoveDeps() -): Promise { - let result: SshManagedServerMoveResult | null = null - try { - result = await moveHost(targetId, deps) - return result - } finally { - deps.report(targetId, moveOutcome(result)) - } -} - -function moveOutcome(result: SshManagedServerMoveResult | null): SshHostServerMoveOutcome { - if (!result) { - return 'failed' - } - return result.outcome === 'refused' ? `refused_${result.verdict}` : result.outcome -} - -async function moveHost( - targetId: string, - deps: SshManagedServerMoveDeps -): Promise { - if (!deps.getTarget(targetId)) { - throw new Error(`SSH target "${targetId}" not found`) - } - // Why the reconnect's census decides, not the stop: a stop can fail after its shells died (a - // relay that hung up on its last exit), and the connect's decision runs the census the - // conversion trusts, while that connect holds the raw transport's 'connected'. - const stoppedPtyIds = new Set() - const stopped = await stopRelayTerminals(targetId, deps, (appPtyId) => - stoppedPtyIds.add(appPtyId) - ).catch((error: unknown) => { - console.warn('[ssh] Stopping relay terminals for the move failed; asking the census:', error) - return null - }) - if (!stopped) { - // The failed stop left the relay up; a live session would make the reconnect a no-op refresh. - await deps.releaseRelay(targetId) - } - const status = await deps.connect(targetId).then( - () => deps.serverStatus(targetId), - (error: unknown) => { - console.warn('[ssh] Reconnecting for the move failed:', error) - return undefined - } - ) - if (status?.kind === 'managed') { - return { outcome: 'moved', environmentId: status.environmentId } - } - const restart = stoppedPtyIds.size > 0 ? { stoppedPtyIds: [...stoppedPtyIds] } : {} - // Why: an unreached shell is never evidence that it exited (ssh-execution-boundary.md). - if (stopped && stopped.unverifiable > 0) { - return { - outcome: 'refused', - verdict: 'unverifiable', - terminals: stopped.unverifiable, - ...restart - } - } - if (status?.kind === 'relay' && status.reason === 'relay_terminals_live') { - return { outcome: 'refused', verdict: 'live', terminals: status.terminals ?? 0, ...restart } - } - if (status?.kind === 'relay' && status.reason === 'relay_terminals_unverifiable') { - return { - outcome: 'refused', - verdict: 'unverifiable', - terminals: status.terminals ?? 0, - ...restart - } - } - return { outcome: 'stayed', ...restart } -} - -/** Mirrors the renderer's terminate: preserved shells need a fresh relay before they can be stopped. */ -async function stopRelayTerminals( - targetId: string, - deps: SshManagedServerMoveDeps, - onStopped: (appPtyId: string) => void -): Promise { - try { - return await deps.terminate(targetId, onStopped) - } catch (error) { - if (!(error instanceof Error) || !error.message.includes(SSH_TERMINATE_RECONNECT_REQUIRED)) { - throw error - } - await deps.connect(targetId) - return deps.terminate(targetId, onStopped) - } -} - -function defaultMoveDeps(): SshManagedServerMoveDeps { - return { - getTarget: (targetId) => getSshTargetRegistryStore()!.getTarget(targetId), - // Why 'replaced': the move restarts these terminals, so main and every viewer keep their tabs. - terminate: (targetId, onStopped) => - terminateSshTargetSessions(targetId, { intentionalStop: 'replaced', onStopped }), - connect: connectTarget, - serverStatus: getSshHostServerStatus, - report: (targetId, outcome) => trackSshHostServerMove(outcome, knownSshHostPlatform(targetId)), - releaseRelay: (targetId) => - runTargetLifecycle(targetId, () => - teardownSshTargetTransport(targetId, (session) => session.detachAndPersist()) - ) - } -} diff --git a/src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts b/src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts index 43fb243f54da..67a57c87f511 100644 --- a/src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts +++ b/src/main/ipc/ssh-managed-server-unlink-broadcast.test.ts @@ -18,15 +18,13 @@ vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ getSshTargetRegistryStore: () => null })) -const { clearPublishedManagedServer, relayStateOverrides } = - await import('./ssh-renderer-broadcast') +const { clearPublishedManagedServer } = await import('./ssh-renderer-broadcast') const { getSshHostServerStatus, setSshHostServerStatus } = await import('../ssh/ssh-host-server-status') describe('clearing a host’s managed server once it is unlinked', () => { beforeEach(() => { vi.clearAllMocks() - relayStateOverrides.clear() }) it('forgets the environment and republishes the connection without it', () => { @@ -49,19 +47,6 @@ describe('clearing a host’s managed server once it is unlinked', () => { ) }) - it('strips the environment from a relay override too', () => { - relayStateOverrides.set('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - managedServer: { kind: 'managed', environmentId: 'env-1' } - }) - clearPublishedManagedServer('ssh-1') - expect(relayStateOverrides.get('ssh-1')).not.toHaveProperty('managedServer') - expect(mocks.notify.mock.calls[0]?.[1]).not.toHaveProperty('managedServer') - }) - it('only clears when the host has no connection to republish', () => { setSshHostServerStatus('ssh-1', { kind: 'managed', environmentId: 'env-1' }) mocks.getState.mockReturnValue(undefined) diff --git a/src/main/ipc/ssh-plain-ssh-connect.test.ts b/src/main/ipc/ssh-plain-ssh-connect.test.ts deleted file mode 100644 index 65583ca681cb..000000000000 --- a/src/main/ipc/ssh-plain-ssh-connect.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import { - RelayRuntimeLadderRun, - RemoteRuntimeUnavailableError -} from '../ssh/ssh-relay-runtime-resolution' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { mockSshStore, mockConnectionManager, mockDeployAndLaunchRelay } = mocks - -function noexecHome(): RemoteRuntimeUnavailableError { - const run = new RelayRuntimeLadderRun('ssh-1', null, true) - run.refused('A', 'noexec') - return new RemoteRuntimeUnavailableError(run) -} - -function connectedStates(send: ReturnType): SshConnectionState[] { - return ( - send.mock.calls - .filter(([channel]) => channel === 'ssh:state-changed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh:state-changed always carries { targetId, state }. - .map(([, payload]) => (payload as { state: SshConnectionState }).state) - .filter((state) => state.status === 'connected') - ) -} - -describe('ssh:connect when no Orca runtime can run on the host', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockWindow } = harness - - beforeEach(harness.reset) - - it('connects in plain SSH mode, publishes the reason, and keeps it across a refresh', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = { usesSystemSshTransport: () => false, shell: vi.fn(), sftp: vi.fn() } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockDeployAndLaunchRelay.mockRejectedValue(noexecHome()) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const [connected] = connectedStates(mockWindow.webContents.send) - expect(connected?.plainSsh).toEqual({ - reason: 'home_noexec', - message: expect.stringContaining('the home directory is mounted noexec') - }) - - // A window reactivation re-fires ssh:connect; it must not redeploy and kill the plain shells. - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/ipc/ssh-port-forward-handlers.ts b/src/main/ipc/ssh-port-forward-handlers.ts index 5b936fff5e36..6154cee79d7b 100644 --- a/src/main/ipc/ssh-port-forward-handlers.ts +++ b/src/main/ipc/ssh-port-forward-handlers.ts @@ -3,7 +3,6 @@ import { enrichSshForwardEntries, getWorktreeIdsForConnection } from '../ports/ssh-advertised-url-enrichment' -import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager, getCurrentMainWindow, @@ -101,8 +100,7 @@ export function registerSshPortForwardHandlers(): void { }) ipcMain.handle('ssh:listDetectedPorts', (_event, args: { targetId: string }) => { - const session = activeSessions.get(args.targetId) - const ports = session?.getPortScanner()?.getDetectedPorts(args.targetId) ?? [] - return enrichDetected(args.targetId, ports) + // Detected ports came from the relay's port scanner; a managed host reports them through its runtime. + return enrichDetected(args.targetId, []) }) } diff --git a/src/main/ipc/ssh-pty-consumer-identity.test.ts b/src/main/ipc/ssh-pty-consumer-identity.test.ts deleted file mode 100644 index de2d3abb4c56..000000000000 --- a/src/main/ipc/ssh-pty-consumer-identity.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import type { SshTarget } from '../../shared/ssh-types' -import { getSshPtyConsumerRecovery } from '../ssh/ssh-pty-consumer-recovery' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { mockSshStore, mockConnectionManager, mockMux } = mocks - -describe('SSH IPC handlers', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockStore } = harness - - beforeEach(harness.reset) - - describe('SSH PTY consumer identity across failed connects', () => { - function makeTarget(id: string): SshTarget { - return { id, label: 'Server', host: 'example.com', port: 22, username: 'deploy' } - } - - function markConnected(targetId: string): void { - mockConnectionManager.getState.mockReturnValue({ - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - } - - it('reclaims the consumer identity after a failed transport connect', async () => { - const targetId = 'ssh-consumer-identity-connect-failure' - let settleLeasePersistence!: () => void - mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce( - () => - new Promise((resolve) => { - settleLeasePersistence = resolve - }) - ) - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) - - const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) as Promise - const failure = expect(failedConnect).rejects.toThrow('transport refused') - const settled = vi.fn() - void failedConnect.then(settled, settled) - - await vi.waitFor(() => - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'detached') - ) - expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalled() - // Why: the connect rejection is gated on the durable 'detached' write, so the retry it - // triggers cannot re-mark leases 'attached' ahead of the abandoned session's release. - expect(settled).not.toHaveBeenCalled() - - settleLeasePersistence() - await failure - const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId - expect(claimedId).toEqual(expect.any(String)) - - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - await handlers.get('ssh:connect')!(null, { targetId }) - - expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) - expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( - expect.objectContaining({ targetId, clientInstanceId: claimedId }) - ) - }) - - it('releases the abandoned leases before a fast reconnect re-owns them', async () => { - const targetId = 'ssh-consumer-identity-fast-reconnect' - const order: string[] = [] - let settleLeaseRelease!: () => void - mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce((_id: string, state: string) => { - order.push(`leases:${state}`) - return new Promise((resolve) => { - settleLeaseRelease = () => { - order.push(`leases:${state}:persisted`) - resolve() - } - }) - }) - mockStore.upsertSshPtyConsumerRecovery.mockImplementation(async () => { - order.push('recovery:upsert') - }) - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) - - const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) - const failure = expect(failedConnect).rejects.toThrow('transport refused') - await vi.waitFor(() => expect(order).toContain('leases:detached')) - expect(order).not.toContain('leases:detached:persisted') - - settleLeaseRelease() - await failure - - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - await handlers.get('ssh:connect')!(null, { targetId }) - - // Why: the reclaimed owner is only re-persisted after the abandoned 'detached' write landed, - // so no late release can strand the reconnected leases in 'detached'. - expect(order).toEqual(['leases:detached', 'leases:detached:persisted', 'recovery:upsert']) - }) - - it('holds a retry that starts while the detach write is still pending', async () => { - const targetId = 'ssh-consumer-identity-pending-retry' - const order: string[] = [] - let settleLeaseRelease!: () => void - mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce((_id: string, state: string) => { - order.push(`leases:${state}`) - return new Promise((resolve) => { - settleLeaseRelease = () => { - order.push(`leases:${state}:persisted`) - resolve() - } - }) - }) - mockStore.upsertSshPtyConsumerRecovery.mockImplementation(async () => { - order.push('recovery:upsert') - }) - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) - - const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) - const failure = expect(failedConnect).rejects.toThrow('transport refused') - await vi.waitFor(() => expect(order).toContain('leases:detached')) - - // Retry mid-write: it must not mint a session or re-own leases while the release is pending. - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - const retry = handlers.get('ssh:connect')!(null, { targetId }) as Promise - const retrySettled = vi.fn() - void retry.then(retrySettled, retrySettled) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(retrySettled).not.toHaveBeenCalled() - expect(order).not.toContain('leases:detached:persisted') - - settleLeaseRelease() - await failure - // Why: the retry folds onto the still-latched attempt rather than starting a second connect, - // so it inherits that attempt's failure instead of racing its teardown. - await expect(retry).rejects.toThrow('transport refused') - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(order).toEqual(['leases:detached', 'leases:detached:persisted']) - - await handlers.get('ssh:connect')!(null, { targetId }) - - expect(order).toEqual(['leases:detached', 'leases:detached:persisted', 'recovery:upsert']) - }) - - it('replaces a live session whose detach write keeps failing', async () => { - const targetId = 'ssh-consumer-identity-detach-write-failure' - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - await handlers.get('ssh:connect')!(null, { targetId }) - const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId - expect(claimedId).toEqual(expect.any(String)) - - // Why a permanent reject, not once: it proves the failed session is gone rather than merely - // retried — a second connect that still holds it would fail on the same write again. - mockStore.markSshRemotePtyLeasesAsync.mockImplementation((_id: string, state: string) => - state === 'detached' - ? Promise.reject(new Error('lease write failed')) - : Promise.resolve(undefined) - ) - await expect(handlers.get('ssh:connect')!(null, { targetId })).rejects.toThrow( - 'lease write failed' - ) - - mockConnectionManager.connect.mockClear() - await handlers.get('ssh:connect')!(null, { targetId }) - - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - // Why: the abandoned session still released its identity synchronously, so the replacement - // reclaims the owner instead of minting a new one. - expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) - }) - - it('resumes the remembered owner lease after a failed establish', async () => { - const targetId = 'ssh-consumer-identity-establish-failure' - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) - mockConnectionManager.connect.mockResolvedValue({}) - markConnected(targetId) - // Why: fail the first request after the consumer session opens, so establish() rejects with an - // owner lease already remembered — the state a retry must be able to resume from. - const openClientResponse = await mockMux.request('pty.openClient') - mockMux.request.mockImplementationOnce(() => Promise.resolve(openClientResponse)) - mockMux.request.mockImplementationOnce(() => - Promise.reject(new Error('relay handshake aborted')) - ) - mockStore.markSshRemotePtyLeasesAsync.mockRejectedValueOnce( - new Error('lease persistence failed') - ) - - try { - await expect(handlers.get('ssh:connect')!(null, { targetId })).rejects.toThrow( - 'relay handshake aborted' - ) - await vi.waitFor(() => expect(warn).toHaveBeenCalled()) - const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId - expect(claimedId).toEqual(expect.any(String)) - - mockMux.request.mockClear() - await handlers.get('ssh:connect')!(null, { targetId }) - - expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) - expect(mockMux.request).toHaveBeenCalledWith( - 'pty.openClient', - expect.objectContaining({ - clientInstanceId: claimedId, - resume: { ownerGeneration: 1, ownerLease: 'ipc-test-owner' } - }), - expect.anything() - ) - } finally { - warn.mockRestore() - } - }) - }) -}) diff --git a/src/main/ipc/ssh-relay-lost-backoff.ts b/src/main/ipc/ssh-relay-lost-backoff.ts deleted file mode 100644 index 0b92e45605a3..000000000000 --- a/src/main/ipc/ssh-relay-lost-backoff.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { SshConnectionStatus } from '../../shared/ssh-types' - -// Why: without backoff, a relay channel that keeps dying reconnects as fast as the network allows, hammering local + remote sshd; track attempts and back off to end the loop recoverably. -export type RelayLostBackoffState = { - attempts: number - reconnectTimer: ReturnType | null - stabilizedTimer: ReturnType | null -} -export const relayLostBackoff = new Map() -export const RELAY_LOST_MAX_ATTEMPTS = 6 -export const RELAY_LOST_BASE_DELAY_MS = 500 -export const RELAY_LOST_MAX_DELAY_MS = 15_000 -// Why: a reconnect whose mux dies within this window was a flap, not a recovery — don't reset the attempt counter. 5s covers provider re-registration + PTY reattach. -export const RELAY_LOST_STABILIZED_MS = 5_000 -// Why: transport states the SSH ladder never leaves on its own — waiting for a relay redeploy past one of these is an unbounded loop. -export const TRANSPORT_TERMINAL_STATUSES = new Set([ - 'disconnected', - 'auth-failed', - 'reconnection-failed', - 'error' -]) - -export function clearRelayLostBackoff(targetId: string): void { - const state = relayLostBackoff.get(targetId) - if (state?.reconnectTimer) { - clearTimeout(state.reconnectTimer) - } - if (state?.stabilizedTimer) { - clearTimeout(state.stabilizedTimer) - } - relayLostBackoff.delete(targetId) -} diff --git a/src/main/ipc/ssh-relay-reset-resume.test.ts b/src/main/ipc/ssh-relay-reset-resume.test.ts deleted file mode 100644 index b93d9f430991..000000000000 --- a/src/main/ipc/ssh-relay-reset-resume.test.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import { - SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, - type SshConnectionState, - type SshTarget -} from '../../shared/ssh-types' -import { clearProviderPtyState, deletePtyOwnership, getPtyIdsForConnection } from './pty' -import { quitTeardownStartGate } from '../quit-teardown-start-gate' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { - powerMonitorOnMock, - mockSshStore, - mockConnectionManager, - mockForceStopRelayForTarget, - mockMux, - mockPortForwardManager -} = mocks - -describe('SSH IPC handlers', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockStore } = harness - - beforeEach(harness.reset) - - it('ssh:resetRelay force-stops the remote relay and expires tracked leases', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }, - { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' }, - { targetId: 'ssh-1', ptyId: 'pty-superseded', state: 'expired', supersededBy: 'pty-9' }, - { targetId: 'ssh-1', ptyId: 'pty-recycled', state: 'expired', relayIdRecycled: true } - ]) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-2']) - - await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - - expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) - expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') - // Every already-`expired` lease is skipped on its raw state, marked or not: reset retires the - // routes this force-stop invalidated, and an expired lease has none left to retire. It is also - // never upgraded to `terminated` — a killed relay makes its PTYs unreachable, not proven dead. - for (const ptyId of ['pty-expired', 'pty-superseded', 'pty-recycled']) { - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', ptyId, 'expired') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', ptyId, 'terminated') - } - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - // A force-stop that threw observed nothing about the remote shells, so expiring their leases - // would record a verdict Orca never obtained (docs/reference/ssh-execution-boundary.md). - it('ssh:resetRelay keeps leases alive when the force-stop never reported a result', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }, - { targetId: 'ssh-1', ptyId: 'pty-2', state: 'attached' } - ]) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockForceStopRelayForTarget.mockRejectedValueOnce(new Error('channel closed')) - - await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'channel closed' - ) - - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() - // The local handles are still stale — only the host-side verdict is withheld. - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-1') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-2') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('ssh:resetRelay clears scoped live PTYs while expiring raw leases', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-lease', state: 'detached' } - ]) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:ssh-1@@pty-live']) - - await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-lease', 'expired') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('retires the captured session when reset forward teardown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( - new Error('forward teardown failed') - ) - - await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'forward teardown failed' - ) - expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') - expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() - - await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledTimes(1) - expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('ssh:resetRelay waits for an in-flight connect before tearing down the session', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - let resolveConnect!: (value: unknown) => void - const connectResult = new Promise((resolve) => { - resolveConnect = resolve - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockReturnValue(connectResult) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - const connectPromise = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - - const resetPromise = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - await Promise.resolve() - - expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() - expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() - - resolveConnect(conn) - await connectPromise - await resetPromise - - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('ssh:resetRelay does not open a transport when shutdown starts while it waits for a connect', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let failConnect!: (error: unknown) => void - const connectResult = new Promise((_resolve, reject) => { - failConnect = reject - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockReturnValue(connectResult) - // Why undefined: reset must fall through to opening its own transport, which is the call under test. - mockConnectionManager.getConnection.mockReturnValue(undefined) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - const connectPromise = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - - // Why admitted first: the gate latches only after reset is already parked behind the connect, so - // the entry fence cannot be what stops it. - const resetPromise = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - await Promise.resolve() - - quitTeardownStartGate.tryStart({ preventDefault() {} }) - failConnect(new Error('transport lost')) - await expect(connectPromise).rejects.toThrow('transport lost') - - await expect(resetPromise).rejects.toThrow('closed for app shutdown') - // Why once: the resumed reset must not open a second transport that would outlive the drain. - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() - }) - - it('ssh:connect waits for an in-flight reset before starting a new connection', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const resetConn = {} - const connectConn = {} - let resolveForceStop!: () => void - const forceStopResult = new Promise((resolve) => { - resolveForceStop = resolve - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(resetConn) - mockConnectionManager.connect.mockResolvedValue(connectConn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockForceStopRelayForTarget.mockReturnValue(forceStopResult) - - const resetPromise = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - const connectPromise = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - - await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1)) - await Promise.resolve() - - expect(mockConnectionManager.connect).not.toHaveBeenCalled() - - resolveForceStop() - await resetPromise - await connectPromise - - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) - }) - - it('ssh:resetRelay reuses duplicate in-flight resets for the same target', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - let resolveForceStop!: () => void - let activeForceStops = 0 - let maxConcurrentForceStops = 0 - const forceStopResult = new Promise((resolve) => { - resolveForceStop = resolve - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockForceStopRelayForTarget.mockImplementation(async () => { - activeForceStops += 1 - maxConcurrentForceStops = Math.max(maxConcurrentForceStops, activeForceStops) - await forceStopResult - activeForceStops -= 1 - }) - - const firstReset = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - const secondReset = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - - expect(secondReset).toBe(firstReset) - await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1)) - - resolveForceStop() - await Promise.all([firstReset, secondReset]) - - expect(mockForceStopRelayForTarget).toHaveBeenCalledTimes(1) - expect(maxConcurrentForceStops).toBe(1) - expect(mockConnectionManager.disconnect).toHaveBeenCalledTimes(1) - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - it('keeps removal behind an in-flight relay reset', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveForceStop!: () => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.getConnection.mockReturnValue({}) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockForceStopRelayForTarget.mockReturnValueOnce( - new Promise((resolve) => { - resolveForceStop = resolve - }) - ) - - const reset = handlers.get('ssh:resetRelay')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledOnce()) - const removal = handlers.get('ssh:removeTarget')!(null, { - id: 'ssh-1' - }) as Promise - await Promise.resolve() - - expect(mockSshStore.removeTarget).not.toHaveBeenCalled() - resolveForceStop() - await reset - await removal - - expect(mockConnectionManager.disconnect).toHaveBeenCalledTimes(2) - expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') - }) - - it('reconnects on system resume when the relay liveness probe fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockConnectionManager.reconnect.mockImplementation(async (targetId: string) => { - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (id: string, state: SshConnectionState) => void - } - callbacks.onStateChange(targetId, { - targetId, - status: 'reconnecting', - error: null, - reconnectAttempt: 1 - }) - callbacks.onStateChange(targetId, { - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - }) - mockMux.probeLiveness.mockResolvedValue(false) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] - expect(resumeListener).toBeTypeOf('function') - - resumeListener() - - await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')) - // Why: a failed first probe gets one retry before teardown (slow post-wake network). - expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toMatchObject({ - connectionGeneration: 2 - }) - }) - - it('skips reconnect on system resume when the relay link is still alive', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockMux.probeLiveness.mockResolvedValue(true) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] - expect(resumeListener).toBeTypeOf('function') - - resumeListener() - - await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(1)) - // Let the async resume handler settle before asserting no teardown happened. - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(mockConnectionManager.reconnect).not.toHaveBeenCalled() - }) - - it('does not reconnect after resume when the target was disconnected during the probe', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockMux.probeLiveness.mockResolvedValue(false) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] - expect(resumeListener).toBeTypeOf('function') - - resumeListener() - // Why: the probe window is seconds long; a user disconnect during it must - // win — reconnecting afterwards would resurrect the torn-down target. - mockConnectionManager.getConnection.mockReturnValue(undefined) - - await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(mockConnectionManager.reconnect).not.toHaveBeenCalled() - }) - - it('extends active relay grace while the system is suspending', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockMux.notify.mockClear() - - const suspendListener = powerMonitorOnMock.mock.calls.find( - ([event]) => event === 'suspend' - )?.[1] - expect(suspendListener).toBeTypeOf('function') - - suspendListener() - - expect(mockMux.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: 0 - }) - }) - - it('ssh:resetRelay expires active-session leases instead of marking them terminated', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockStore.markSshRemotePtyLeasesAsync.mockClear() - mockStore.markSshRemotePtyLease.mockClear() - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' } - ]) - - await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalledWith('ssh-1', 'terminated') - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') - expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') - }) -}) diff --git a/src/main/ipc/ssh-relay-session-callbacks.ts b/src/main/ipc/ssh-relay-session-callbacks.ts deleted file mode 100644 index 976af9923982..000000000000 --- a/src/main/ipc/ssh-relay-session-callbacks.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { activeSessions } from './ssh-active-relay-sessions' -import { testingTargets } from './ssh-connect-attempt-registry' -import { relayGracePeriodForTarget } from './ssh-connection-state-callbacks' -import { - connectionManager, - currentRuntime, - getCurrentMainWindow, - persistedStore, - portForwardManager -} from './ssh-ipc-context' -import { restorePortForwards } from './ssh-port-forward-persistence' -import { - clearRelayLostBackoff, - RELAY_LOST_BASE_DELAY_MS, - RELAY_LOST_MAX_ATTEMPTS, - RELAY_LOST_MAX_DELAY_MS, - RELAY_LOST_STABILIZED_MS, - relayLostBackoff, - TRANSPORT_TERMINAL_STATUSES -} from './ssh-relay-lost-backoff' -import { - broadcastDetectedPortsFromCurrentWindow, - broadcastSshState, - clearRelayStateOverride, - connectionSupportsFolderDownload, - publishRelayOverride -} from './ssh-renderer-broadcast' - -export function configureRelaySessionCallbacks(session: SshRelaySession): void { - session.setOnTerminalRelayError((tid, err) => { - clearRelayLostBackoff(tid) - if (activeSessions.get(tid)?.getState() !== 'deploying') { - rotateSshProviderAuthority(tid) - } - console.warn( - `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` - ) - publishRelayOverride(getCurrentMainWindow, tid, 'error', err.message, 0) - }) - - session.setOnRelayLost((tid) => { - const s = activeSessions.get(tid) - if (!s) { - return - } - const c = connectionManager?.getConnection(tid) - if (!c) { - return - } - const t = getSshTargetRegistryStore()?.getTarget(tid) - - // Why: bounded exponential backoff — without it, a remote bug that closes every fresh --connect channel becomes an infinite relay-deploy loop. - const state = relayLostBackoff.get(tid) ?? { - attempts: 0, - reconnectTimer: null, - stabilizedTimer: null - } - if (state.stabilizedTimer) { - clearTimeout(state.stabilizedTimer) - state.stabilizedTimer = null - } - if (state.reconnectTimer) { - return - } - rotateSshProviderAuthority(tid) - - // Why: re-deploying the relay rides the SSH transport, so while the transport is itself down no attempt - // can succeed. Waiting at the max delay without consuming the budget keeps a flapping host off the - // manual-reconnect banner, which would tell the user to act on a link that is still auto-recovering. - const transportStatus = connectionManager?.getState(tid)?.status - const transportConnected = transportStatus === 'connected' - if (transportConnected && state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { - console.warn( - `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` - ) - relayLostBackoff.delete(tid) - // Why: surface the failure — a live SSH connection with a dead relay is otherwise invisible (typing in remote terminals just stops working). - publishRelayOverride( - getCurrentMainWindow, - tid, - 'error', - 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', - 0 - ) - return - } - - const scheduleRelayRedeploy = (delay: number, attemptCharged: boolean): void => { - state.reconnectTimer = setTimeout(() => { - state.reconnectTimer = null - relayLostBackoff.set(tid, state) - const liveConn = connectionManager?.getConnection(tid) - if (!liveConn || !activeSessions.has(tid)) { - clearRelayLostBackoff(tid) - return - } - const status = connectionManager?.getState(tid)?.status - if (status === 'connected') { - if (!attemptCharged) { - // Why: waiting is free, but the deploy it defers is real — charge it here so a transport that - // flaps back to 'connected' can't redeploy forever on an uncharged budget. - state.attempts += 1 - } - void s.reconnect(liveConn, relayGracePeriodForTarget(t)) - return - } - if (status === undefined || TRANSPORT_TERMINAL_STATUSES.has(status)) { - // Why: the transport gave up for good; its own state is what the user acts on, so stop waiting for a redeploy that can never run. - clearRelayLostBackoff(tid) - return - } - // Why: still mid-transition — re-arm at the max delay without consuming an attempt. It ends once - // the transport settles: 'connected' redeploys, a terminal status or a dropped session clears above. - scheduleRelayRedeploy(RELAY_LOST_MAX_DELAY_MS, false) - }, delay) - relayLostBackoff.set(tid, state) - } - - if (!transportConnected) { - publishRelayOverride( - getCurrentMainWindow, - tid, - 'reconnecting', - 'Relay channel lost. Reconnecting...', - state.attempts - ) - scheduleRelayRedeploy(RELAY_LOST_MAX_DELAY_MS, false) - console.warn( - `[ssh] Relay channel for ${tid} lost while the SSH transport is ${transportStatus ?? 'unknown'}; waiting ${RELAY_LOST_MAX_DELAY_MS}ms without consuming an attempt` - ) - return - } - - const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS) - state.attempts += 1 - publishRelayOverride( - getCurrentMainWindow, - tid, - 'reconnecting', - 'Relay channel lost. Reconnecting...', - state.attempts - ) - scheduleRelayRedeploy(delay, true) - console.warn( - `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` - ) - }) - - // Why: fires after both establish() and reconnect() reach 'ready'; re-create persisted port forwards so they survive restarts and blips. - session.setOnReady((tid) => { - const state = relayLostBackoff.get(tid) - if (state) { - if (state.stabilizedTimer) { - clearTimeout(state.stabilizedTimer) - } - // Why: stabilization counts post-ready uptime; slow deploy time before `ready` doesn't prove the new relay survived real work. - state.stabilizedTimer = setTimeout(() => { - const current = relayLostBackoff.get(tid) - if (current === state && !current.reconnectTimer) { - relayLostBackoff.delete(tid) - } - }, RELAY_LOST_STABILIZED_MS) - relayLostBackoff.set(tid, state) - } - clearRelayStateOverride(tid) - if (!testingTargets.has(tid)) { - broadcastSshState(getCurrentMainWindow, tid, { - targetId: tid, - status: 'connected', - error: null, - reconnectAttempt: 0, - supportsFolderDownload: connectionSupportsFolderDownload(tid) - }) - } - currentRuntime?.notifySshRelayReady?.(tid) - void restorePortForwards(tid, getCurrentMainWindow) - }) -} - -export function refreshActiveRelaySessions(): void { - if (!persistedStore || !portForwardManager) { - return - } - for (const session of activeSessions.values()) { - session.refreshEnvironment( - getCurrentMainWindow, - persistedStore, - portForwardManager, - currentRuntime, - broadcastDetectedPortsFromCurrentWindow - ) - configureRelaySessionCallbacks(session) - } -} diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 00a58f246dfc..4d2f9f613b25 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -1,11 +1,6 @@ import type { BrowserWindow } from 'electron' import type { SshPortForwardManager } from '../ssh/ssh-port-forward' -import type { - DetectedPort, - EnrichedDetectedPort, - SshConnectionStatus, - SshConnectionState -} from '../../shared/ssh-types' +import type { DetectedPort, EnrichedDetectedPort, SshConnectionState } from '../../shared/ssh-types' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' import { enrichSshDetectedPorts, @@ -14,11 +9,8 @@ import { } from '../ports/ssh-advertised-url-enrichment' import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { getSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' -import { isSshRelayOnHostNodeRuntime } from '../ssh/ssh-host-node-runtime-mode' import { clearSshHostServerStatus, getSshHostServerStatus } from '../ssh/ssh-host-server-status' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager, currentRuntime, @@ -27,8 +19,6 @@ import { portForwardManager } from './ssh-ipc-context' -export const relayStateOverrides = new Map() - export function broadcastSshState( getMainWindow: () => BrowserWindow | null, targetId: string, @@ -50,46 +40,17 @@ export function broadcastSshState( } function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { - const session = activeSessions.get(targetId) - const remotePlatform = - session?.getHostPlatform()?.os ?? session?.getPlainSshSession()?.remotePlatform const authority = getSshProviderAuthority(targetId) - const plainSsh = state.status === 'connected' ? getSshPlainSshMode(targetId) : undefined const managedServer = state.managedServer ?? getSshHostServerStatus(targetId) - // Why the managed check: a host that moved to its managed server no longer runs that relay. - const hostNodeRuntime = - state.status === 'connected' && - !plainSsh && - managedServer?.kind !== 'managed' && - isSshRelayOnHostNodeRuntime(targetId) return { ...state, targetId, providerEpoch: authority.providerEpoch, connectionGeneration: authority.connectionGeneration, - ...(remotePlatform ? { remotePlatform } : {}), - ...(plainSsh ? { plainSsh } : {}), - ...(hostNodeRuntime ? { hostNodeRuntime } : {}), ...(managedServer ? { managedServer } : {}) } } -export function publishRelayOverride( - getMainWindow: () => BrowserWindow | null, - targetId: string, - status: SshConnectionStatus, - error: string | null, - reconnectAttempt: number -): void { - const state = withSshRemotePlatform(targetId, { targetId, status, error, reconnectAttempt }) - relayStateOverrides.set(targetId, state) - broadcastSshState(getMainWindow, targetId, state) -} - -export function clearRelayStateOverride(targetId: string): void { - relayStateOverrides.delete(targetId) -} - export function connectionSupportsFolderDownload(targetId: string): boolean { // Why: connections without an explicit transport are ssh2-shaped; only a confirmed system-SSH transport lacks the SFTP-only capability. return connectionManager?.getConnection(targetId)?.usesSystemSshTransport?.() !== true @@ -102,19 +63,14 @@ export function connectionSupportsFolderDownload(targetId: string): boolean { */ export function clearPublishedManagedServer(targetId: string): void { clearSshHostServerStatus(targetId) - const override = relayStateOverrides.get(targetId) - if (override?.managedServer) { - const { managedServer: _removed, ...rest } = override - relayStateOverrides.set(targetId, rest) - } - const state = relayStateOverrides.get(targetId) ?? connectionManager?.getState(targetId) + const state = connectionManager?.getState(targetId) if (state) { broadcastSshState(getCurrentMainWindow, targetId, state) } } export function getPublicSshState(targetId: string): SshConnectionState | undefined { - const state = relayStateOverrides.get(targetId) ?? connectionManager!.getState(targetId) + const state = connectionManager!.getState(targetId) return state ? withSshRemotePlatform(targetId, state) : undefined } diff --git a/src/main/ipc/ssh-session-teardown.ts b/src/main/ipc/ssh-session-teardown.ts index 38d9a2520134..10b40f021967 100644 --- a/src/main/ipc/ssh-session-teardown.ts +++ b/src/main/ipc/ssh-session-teardown.ts @@ -1,28 +1,20 @@ import type { DirectSshAuthority } from '../../shared/ssh-types' -import type { SshConnection } from '../ssh/ssh-connection' -import type { SshRelaySession } from '../ssh/ssh-relay-session' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { clearSshHostServerStatus } from '../ssh/ssh-host-server-status' -import { recordSshRelayRuntimeStep } from '../ssh/ssh-host-node-runtime-mode' import { isSshConnectionSolelyOwnedBy } from '../ssh/ssh-connection-attribution' -import { activeSessions } from './ssh-active-relay-sessions' import { connectInFlight, invalidateConnectAttempt, isCurrentConnectAttempt } from './ssh-connect-attempt-registry' import { connectionManager, persistedStore, portForwardManager } from './ssh-ipc-context' -import { clearRelayLostBackoff } from './ssh-relay-lost-backoff' -import { clearRelayStateOverride } from './ssh-renderer-broadcast' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' import { errorMessage } from '../../shared/error-message' export async function disconnectRegisteredSshTarget(targetId: string): Promise { invalidateConnectAttempt(targetId) clearSshHostServerStatus(targetId) - await runTargetLifecycle(targetId, () => - teardownSshTargetTransport(targetId, (session) => session.detachAndPersist()) - ) + await runTargetLifecycle(targetId, () => teardownSshTargetTransport(targetId)) } export async function removeRegisteredSshTarget(targetId: string): Promise { @@ -32,11 +24,9 @@ export async function removeRegisteredSshTarget(targetId: string): Promise } invalidateConnectAttempt(targetId) clearSshHostServerStatus(targetId) - recordSshRelayRuntimeStep(targetId, false) await runTargetLifecycle(targetId, async () => { try { - // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. - await teardownSshTargetTransport(targetId, (session) => session.disposeAndPersist()) + await teardownSshTargetTransport(targetId) } catch (err) { // Why: a failed disconnect must not block metadata removal, else the target lingers in the store with uncleaned leases. console.warn(`[ssh] Failed to disconnect removed target ${targetId}: ${errorMessage(err)}`) @@ -66,103 +56,18 @@ export async function removeRegisteredSshTarget(targetId: string): Promise }) } -export async function teardownSshTargetTransport( - targetId: string, - teardown: (session: SshRelaySession) => void | Promise -): Promise { - // Why: start the transport disconnect before session teardown; the async wrapper turns a sync throw into a rejection. - const transportDisconnect = (async () => connectionManager?.disconnect(targetId))() - const [teardownResult, disconnectResult] = await Promise.allSettled([ - activeSessions.has(targetId) - ? teardownActiveSshSession(targetId, teardown) - : // A managed host has no relay session, but its forwards still hold local ports. - (async () => portForwardManager?.removeAllForwards(targetId))(), - transportDisconnect +export async function teardownSshTargetTransport(targetId: string): Promise { + // Why first: local listeners must be released before disconnect completes, else an immediate + // reconnect hits EADDRINUSE. + const [forwards, disconnect] = await Promise.allSettled([ + portForwardManager?.removeAllForwards(targetId), + (async () => connectionManager?.disconnect(targetId))() ]) - if (teardownResult.status === 'rejected') { - throw teardownResult.reason - } - if (disconnectResult.status === 'rejected') { - throw disconnectResult.reason - } -} - -export async function teardownActiveSshSession( - targetId: string, - teardown: (session: SshRelaySession) => void | Promise -): Promise { - const session = activeSessions.get(targetId) - if (!session) { - return + if (forwards.status === 'rejected') { + throw forwards.reason } - let teardownError: { error: unknown } | null = null - try { - // Why: await port teardown so local listeners are released before disconnect/remove completes, else an immediate reconnect hits EADDRINUSE. - await portForwardManager?.removeAllForwards(targetId) - } catch (error) { - teardownError = { error } - } - try { - await teardown(session) - } catch (error) { - teardownError ??= { error } - } - if (activeSessions.get(targetId) === session) { - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) - } - if (teardownError) { - throw teardownError.error - } -} - -// Why: a dropped session must detach, not just leave activeSessions — detach releases the SSH PTY -// consumer identity so the next connect reclaims its owner lease instead of minting a new one. -// Why awaited, and why the map entry outlives the await: a retry can start the moment this returns, -// so it must either find the session (and await this same latched teardown at the existing-session -// path) or find nothing because the 'detached' lease write is already durable. Deleting first lets a -// fast reconnect mark leases 'attached' and then have this session's late 'detached' write clobber it. -export async function abandonFailedSshSession( - targetId: string, - session: SshRelaySession -): Promise { - // Why: detachAndPersist transitions recovery ownership synchronously; only durability is awaited. - try { - await session.detachAndPersist() - } catch (error) { - // Why: a teardown throw must not mask the connect error the caller is about to rethrow. - console.warn(`[ssh] Failed to detach abandoned session for ${targetId}: ${errorMessage(error)}`) - } - if (activeSessions.get(targetId) === session) { - activeSessions.delete(targetId) - } -} - -// Why: a connect cancelled after its transport already opened still owns that transport and its -// unpublished session — nothing else will reach them, so it has to close them itself. Why only the -// connection it minted, and by identity: disconnecting by target id (or closing a transport it merely -// reused) would tear down the replacement connect's live transport. -export async function abandonCancelledConnectAttempt( - targetId: string, - session: SshRelaySession, - mintedConnection: SshConnection | null -): Promise { - // Why the identity guard: every path that removes a session from activeSessions detaches it first, - // so re-detaching a superseded session would only clobber the replacement's lease state. - if (activeSessions.get(targetId) === session) { - await abandonFailedSshSession(targetId, session) - } - if (!mintedConnection) { - return - } - try { - await connectionManager!.disconnectConnection(targetId, mintedConnection) - } catch (error) { - // Why: the caller is about to throw the cancellation; a teardown throw must not replace it. - console.warn( - `[ssh] Failed to disconnect cancelled connect transport for ${targetId}: ${errorMessage(error)}` - ) + if (disconnect.status === 'rejected') { + throw disconnect.reason } } diff --git a/src/main/ipc/ssh-shutdown-drain.ts b/src/main/ipc/ssh-shutdown-drain.ts index 11e9b75c71d5..69e35de07a12 100644 --- a/src/main/ipc/ssh-shutdown-drain.ts +++ b/src/main/ipc/ssh-shutdown-drain.ts @@ -1,12 +1,9 @@ -import { activeSessions } from './ssh-active-relay-sessions' import { connectInFlight, invalidateConnectAttempt, - resetRelayInFlight, testConnectionProbes } from './ssh-connect-attempt-registry' import { connectionManager } from './ssh-ipc-context' -import { teardownActiveSshSession } from './ssh-session-teardown' // Why one budget for the whole sequence rather than one per phase: an invalidated connect only // observes its cancellation at the next checkpoint, and one blocked in the transport handshake can @@ -61,14 +58,8 @@ async function settleTasksWithinMs( return { timedOut: [...pending], errors } } -function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] { +function sshShutdownTasks(): SshShutdownTask[] { return [ - ...targetIds - .filter((targetId) => activeSessions.has(targetId)) - .map((targetId) => ({ - targetId, - promise: teardownActiveSshSession(targetId, (session) => session.detachAndPersist()) - })), { targetId: '*transports', promise: connectionManager?.disconnectAll() ?? Promise.resolve() @@ -76,14 +67,10 @@ function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] { ] } -async function drainSshShutdown( - targetIds: readonly string[], - inFlight: readonly SshShutdownTask[], - detachErrors: readonly unknown[] = [] -): Promise { +async function drainSshShutdown(inFlight: readonly SshShutdownTask[]): Promise { const deadline = Date.now() + SSH_SHUTDOWN_BUDGET_MS const unfinished: SshShutdownUnfinished[] = [] - const errors: unknown[] = [...detachErrors] + const errors: unknown[] = [] const runPhase = async ( phase: SshShutdownPhase, tasks: readonly SshShutdownTask[] @@ -99,12 +86,11 @@ async function drainSshShutdown( return settled.timedOut.length === 0 } - await runPhase('drain', sshShutdownTasks(targetIds)) - // Why a second drain after the join: a connect paused in old-session teardown still publishes its - // replacement session and opens a transport before it reaches the cancellation checkpoint, so the - // first drain can miss both. + await runPhase('drain', sshShutdownTasks()) + // Why a second drain after the join: a connect can open a transport before it reaches the + // cancellation checkpoint, so the first drain can miss it. if (await runPhase('in-flight-join', inFlight)) { - await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()])) + await runPhase('final-drain', sshShutdownTasks()) } if (errors.length > 0 || unfinished.length > 0) { @@ -135,27 +121,11 @@ export function beginSshShutdown(): Promise { targetId, promise: attempt.promise })), - ...[...resetRelayInFlight.entries()].map(([targetId, promise]) => ({ targetId, promise })), ...[...testConnectionProbes].map((promise) => ({ targetId: '*probe', promise })) ] for (const targetId of Array.from(connectInFlight.keys())) { invalidateConnectAttempt(targetId) } - const targetIds = [...activeSessions.keys()] - // Why before any await: this is the whole point of the split. Each session marks its recovery lease - // detached in memory now, and the final flush persists it — the remote PTYs keep running. - const detachErrors: unknown[] = [] - for (const session of activeSessions.values()) { - // Why per-session: this runs synchronously inside a non-async will-quit listener, so one throw - // (teardownProviders -> webContents.send on a destroyed renderer, routine on quit) would escape - // it and skip every later session, the drain assignment, and the store flush that persists all - // of this. Collect and keep going; the drain reports them. - try { - session.beginShutdownDetach() - } catch (error) { - detachErrors.push(error) - } - } - sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors) + sshShutdownDrain = drainSshShutdown(inFlight) return sshShutdownDrain } diff --git a/src/main/ipc/ssh-state-broadcast-fanout.test.ts b/src/main/ipc/ssh-state-broadcast-fanout.test.ts index 5d3ef9c4a65d..7faafee3c79b 100644 --- a/src/main/ipc/ssh-state-broadcast-fanout.test.ts +++ b/src/main/ipc/ssh-state-broadcast-fanout.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -25,157 +23,16 @@ vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) import { registerSshHandlers } from './ssh' import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { - mockSshStore, - mockConnectionManager, - mockAcceptSshPtyOutputData, - mockAcceptSshPtyOutputExit, - mockPtyProvider -} = mocks +const { mockSshStore, mockConnectionManager } = mocks describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) - const { ipcTestSource, handlers, mockStore, mockWindow } = harness + const { mockStore, mockWindow } = harness beforeEach(harness.reset) - it('forwards remote PTY events through the output intake authority', async () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as SshPtyDataCallback | undefined - const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as - | ((payload: { - id: string - code: number - providerGeneration: number - ptyIncarnation: string - }) => void) - | undefined - - onData?.({ - id: 'remote-pty', - data: 'hello', - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty', - source: ipcTestSource - }) - onExit?.({ - id: 'remote-pty', - code: 7, - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty' - }) - - expect(mockAcceptSshPtyOutputData).toHaveBeenCalledWith({ - id: 'remote-pty', - data: 'hello', - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty', - rawLength: 'hello'.length, - transformed: false, - source: ipcTestSource - }) - expect(mockAcceptSshPtyOutputExit).toHaveBeenCalledWith({ - id: 'remote-pty', - code: 7, - providerGeneration: mockPtyProvider.providerGeneration, - ptyIncarnation: 'ipc-test-pty' - }) - expect(runtime.onPtyData).not.toHaveBeenCalled() - expect(runtime.onPtyExit).not.toHaveBeenCalled() - }) - - it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn(), - notifySshStateChanged: vi.fn(), - notifySshRelayReady: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - // Why: paired remote clients only learn SSH state through this hook — - // without it their reconnect overlays never clear (STA-1468). - expect(runtime.notifySshStateChanged).toHaveBeenCalledWith( - 'ssh-1', - expect.objectContaining({ targetId: 'ssh-1', status: 'connected' }) - ) - expect(runtime.notifySshRelayReady).toHaveBeenCalledWith('ssh-1') - }) - - it('keeps runtime-owned SSH state off the renderer while invalidating runtime scans', async () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn(), - invalidateSshWorktreeScanCache: vi.fn(), - notifySshStateChanged: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - mockSshStore.getTarget.mockReturnValue({ - id: 'runtime-ssh-1', - label: 'Runtime host', - host: 'example.com', - port: 22, - username: 'deploy' - } satisfies SshTarget) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'runtime-ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'runtime-ssh-1' }) - - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'ssh:state-changed', - expect.anything() - ) - expect(runtime.invalidateSshWorktreeScanCache).toHaveBeenCalledWith('runtime-ssh-1') - expect(runtime.notifySshStateChanged).not.toHaveBeenCalled() - }) - it('keeps claimed managed-orcad SSH state off clients under the original target id', () => { const runtime = { onPtyData: vi.fn(), diff --git a/src/main/ipc/ssh-target-registry.test.ts b/src/main/ipc/ssh-target-registry.test.ts index 9aa205d7a04e..44eca1388726 100644 --- a/src/main/ipc/ssh-target-registry.test.ts +++ b/src/main/ipc/ssh-target-registry.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -26,14 +24,7 @@ vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) import type { SshTarget } from '../../shared/ssh-types' import { createSshIpcHarness } from './ssh-ipc-test-harness' -const { - mockSshStore, - mockConnectionManager, - mockMux, - mockPortForwardManager, - mockListConfigHosts, - mockResolveConfigHost -} = mocks +const { mockSshStore, mockConnectionManager, mockListConfigHosts, mockResolveConfigHost } = mocks describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) @@ -52,9 +43,9 @@ describe('SSH IPC handlers', () => { expect(channels).toContain('ssh:resolveConfigHost') expect(channels).toContain('ssh:connect') expect(channels).toContain('ssh:disconnect') - expect(channels).toContain('ssh:terminateSessions') - expect(channels).toContain('ssh:moveToManagedServer') - expect(channels).toContain('ssh:resetRelay') + expect(channels).not.toContain('ssh:terminateSessions') + expect(channels).not.toContain('ssh:moveToManagedServer') + expect(channels).not.toContain('ssh:resetRelay') expect(channels).toContain('ssh:getState') expect(channels).toContain('ssh:testConnection') }) @@ -153,36 +144,6 @@ describe('SSH IPC handlers', () => { } }) - it('ssh:removeTarget tears down an active relay before deleting the target', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockPortForwardManager.removeAllForwards.mockClear() - mockConnectionManager.disconnect.mockClear().mockResolvedValue(undefined) - - await handlers.get('ssh:removeTarget')!(null, { id: 'ssh-1' }) - - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - expect(mockMux.dispose).toHaveBeenCalledWith('shutdown') - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'terminated') - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - expect(mockStore.removeSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1') - expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') - }) - it('ssh:importConfig returns imported targets', async () => { const imported: SshTarget[] = [ { id: 'ssh-imp', label: 'staging', host: 'staging.com', port: 22, username: '' } diff --git a/src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts b/src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts deleted file mode 100644 index 4d9cbe130b82..000000000000 --- a/src/main/ipc/ssh-terminate-sessions-previous-relay.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -const { listPreviousRelayPtyIds } = vi.hoisted(() => ({ - listPreviousRelayPtyIds: vi.fn(async (): Promise => null) -})) -vi.mock('../ssh/ssh-legacy-relay-routing', async (importOriginal) => ({ - ...(await importOriginal>()), - listPreviousRelayPtyIds -})) -vi.mock('../ssh/ssh-previous-relay-terminals', () => ({ - isReattachHeldByPreviousRelay: vi.fn(async () => true), - startPreviousRelayCensus: vi.fn(), - clearPreviousRelayCensus: vi.fn() -})) - -import type { SshTarget } from '../../shared/ssh-types' -import { getSshPtyProvider, getPtyIdsForConnection } from './pty' -import { createSshIpcHarness } from './ssh-ipc-test-harness' - -const { mockSshStore, mockConnectionManager, mockPtyProvider } = mocks - -describe('ssh:terminateSessions while an older relay may hold terminals', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockStore } = harness - - beforeEach(() => { - harness.reset() - listPreviousRelayPtyIds.mockReset().mockResolvedValue(null) - }) - - it('keeps a not-found terminal the previous relay may run and reports it unverifiable', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-held', state: 'detached' } - ]) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared IPC mock provider implements the shutdown this path calls. - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockRejectedValue(new Error('PTY "pty-held" not found')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty-held', - 'terminated' - ) - // The final teardown must not bulk-mark the held lease terminated either. - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalledWith('ssh-1', 'terminated') - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') - }) - - // B4: a shell a respawn superseded on its tab still runs on the previous relay, leaseless. - it('stops a shell only an older relay lists, through the provider that routes it there', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - mockConnectionManager.connect.mockResolvedValue({}) - mockStore.getSshRemotePtyLeases.mockReturnValue([]) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared IPC mock provider implements the shutdown this path calls. - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - listPreviousRelayPtyIds.mockResolvedValue(['ssh:ssh-1@@pty2:old:1']) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 1, unverifiable: 0 }) - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty2:old:1', { - immediate: true, - keepHistory: false - }) - }) -}) diff --git a/src/main/ipc/ssh-terminate-sessions.test.ts b/src/main/ipc/ssh-terminate-sessions.test.ts deleted file mode 100644 index 0fc752831ac2..000000000000 --- a/src/main/ipc/ssh-terminate-sessions.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks') - return createSshIpcMocks() -}) - -vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker) -vi.mock('electron', () => mocks.electron) -vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegistry) -vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) -vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) -vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock( - '../ssh/ssh-previous-relay-terminals', - () => import('../ssh/ssh-previous-relay-census-test-double') -) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) -vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) -vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) -vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) -vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) - -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import { SshPtyHeldByPreviousRelayError } from '../providers/ssh-pty-errors' -import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' -import { - clearProviderPtyState, - deletePtyOwnership, - getSshPtyProvider, - getPtyIdsForConnection -} from './pty' -import { createSshIpcHarness } from './ssh-ipc-test-harness' -import { setCurrentRuntime } from './ssh-ipc-context' - -const { mockSshStore, mockConnectionManager, mockPtyProvider, mockPortForwardManager } = mocks - -describe('SSH IPC handlers', () => { - const harness = createSshIpcHarness(mocks) - const { handlers, mockStore } = harness - - beforeEach(harness.reset) - - it('ssh:terminateSessions refuses a removal of a managed server host before ending anything', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy', - orcadFence: { environmentId: 'env-1' } - }) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - expect(() => - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1', forRemoval: true }) - ).toThrow('Settings › Managed servers') - expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() - }) - - it('ssh:terminateSessions preserves tracking when relay shutdown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - mockPtyProvider.shutdown.mockRejectedValue(new Error('mux down')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).rejects.toThrow('Failed to terminate SSH host sessions') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('ssh-1', 'pty-1', 'terminated') - expect(mockConnectionManager.disconnect).not.toHaveBeenCalledWith('ssh-1') - }) - - it('ssh:terminateSessions cleans scoped live PTYs while tombstoning raw leases', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-lease', state: 'detached' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:ssh-1@@pty-live']) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 2, unverifiable: 0 }) - - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', { - immediate: true, - keepHistory: false - }) - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease', { - immediate: true, - keepHistory: false - }) - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-live') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-lease') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-live', 'terminated') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-lease', 'terminated') - }) - - it('keeps reconnect behind the complete terminate-sessions lifecycle', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - let resolveShutdown!: () => void - let resolveForwardRemoval!: () => void - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockReturnValueOnce( - new Promise((resolve) => { - resolveShutdown = resolve - }) - ) - mockPortForwardManager.removeAllForwards.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveForwardRemoval = resolve - }) - ) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const terminate = handlers.get('ssh:terminateSessions')!(null, { - targetId: 'ssh-1' - }) as Promise - await vi.waitFor(() => expect(mockPtyProvider.shutdown).toHaveBeenCalledOnce()) - const reconnect = handlers.get('ssh:connect')!(null, { - targetId: 'ssh-1' - }) as Promise - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - - resolveShutdown() - await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - resolveForwardRemoval() - await terminate - await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) - - await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) - }) - - // Issue #12661: an offline sweep tears down local transport only. Reporting plain success would - // read as "the remote shells are gone" when nobody asked the host. - it('ssh:terminateSessions reports superseded leases as unverifiable without a relay', async () => { - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired', supersededBy: 'pty-2' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) - - expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() - // Still no forced reconnect: a newer lease won this pane, so this route died for good and must - // never block a target the user is trying to remove (#2626). - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty-expired', - 'terminated' - ) - }) - - it('ssh:terminateSessions leaves a recycled relay id out of the reconnect fence', async () => { - // The host listed this id under a different PTY incarnation, so it no longer routes to this - // lease's shell — reconnecting could only aim the stop at a stranger's process. - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-recycled', state: 'expired', relayIdRecycled: true } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - }) - - // An `expired` lease carrying neither retirement mark is an orphan, not a corpse: it records only - // that this client lost its route. Answering `unverifiable` there strands a remote shell the user - // just ordered stopped, when a reconnect is exactly what would reach it. - it('ssh:terminateSessions demands a reconnect for an unmarked expired lease', async () => { - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-orphan', state: 'expired' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).rejects.toThrow(SSH_TERMINATE_RECONNECT_REQUIRED) - - expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty-orphan', - 'terminated' - ) - }) - - it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => { - mockStore.getSshRemotePtyLeases.mockReturnValue([]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 0 }) - }) - - it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-abandoned', { - immediate: true, - keepHistory: false - }) - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'ssh-1', - 'pty-abandoned', - 'terminated' - ) - }) - - it('ssh:terminateSessions stops a shell the relay runs without any lease here', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - mockConnectionManager.connect.mockResolvedValue({}) - mockStore.getSshRemotePtyLeases.mockReturnValue([]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - // A CLI-created terminal: the relay lists it, but no lease or local pane knows it. - mockPtyProvider.listProcesses.mockResolvedValue([{ id: 'ssh:ssh-1@@pty-cli' }]) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toMatchObject({ terminated: 1, unverifiable: 0 }) - - expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-cli', { - immediate: true, - keepHistory: false - }) - }) - - it('ssh:terminateSessions tombstones an expired lease the relay reports gone', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockRejectedValue(new Error('PTY "pty-abandoned" not found')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'ssh-1', - 'pty-abandoned', - 'terminated' - ) - }) - - it('ssh:terminateSessions keeps a lease an older relay holds but no route can stop', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty2:old:1', state: 'detached' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockRejectedValue(new SshPtyHeldByPreviousRelayError('pty2:old:1')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty2:old:1', - 'terminated' - ) - }) - - it('ssh:terminateSessions leaves leases it already proved terminated alone', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-tombstoned', state: 'terminated' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - - expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() - }) - - it('ssh:terminateSessions keeps an expired lease when its shutdown fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([ - { targetId: 'ssh-1', ptyId: 'pty-abandoned', state: 'expired' } - ]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - mockPtyProvider.shutdown.mockRejectedValue(new Error('mux down')) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).rejects.toThrow('Failed to terminate SSH host sessions') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'ssh-1', - 'pty-abandoned', - 'terminated' - ) - }) - - it('ssh:terminateSessions tells the runtime a stopped shell is no longer connected', async () => { - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - }) - mockStore.getSshRemotePtyLeases.mockReturnValue([]) - vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) - vi.mocked(getPtyIdsForConnection).mockReturnValue([ - 'ssh:ssh-1@@pty-lost-exit', - 'ssh:ssh-1@@pty-reported-exit' - ]) - mockPtyProvider.shutdown.mockResolvedValue(undefined) - const runtime = { - getPtyLivenessVerdict: vi.fn((ptyId: string) => - ptyId.endsWith('pty-reported-exit') ? { status: 'exited' } : null - ), - onPtyExit: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminate reads only these two runtime methods. - setCurrentRuntime(runtime as never) - - await expect( - handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toEqual({ terminated: 2, unverifiable: 0 }) - - // The stop sentinel, never a certified death; an exit the relay already reported stands. - expect(runtime.onPtyExit.mock.calls).toEqual([['ssh:ssh-1@@pty-lost-exit', -1]]) - setCurrentRuntime(undefined) - }) -}) diff --git a/src/main/ipc/ssh-terminate-sessions.ts b/src/main/ipc/ssh-terminate-sessions.ts deleted file mode 100644 index 40d8f85a0036..000000000000 --- a/src/main/ipc/ssh-terminate-sessions.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - sshRemotePtyLeaseAllowsReattach, - type SshTerminateSessionsResult -} from '../../shared/ssh-types' -import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../shared/terminal-exit-cause' -import { isSshPtyNotFoundError, SshPtyHeldByPreviousRelayError } from '../providers/ssh-pty-errors' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { isReattachHeldByPreviousRelay } from '../ssh/ssh-previous-relay-terminals' -import { listPreviousRelayPtyIds } from '../ssh/ssh-legacy-relay-routing' -import { - clearProviderPtyState, - deletePtyOwnership, - getPtyIdsForConnection, - getSshPtyProvider -} from './pty' -import { invalidateConnectAttempt } from './ssh-connect-attempt-registry' -import { currentRuntime, persistedStore } from './ssh-ipc-context' -import { ptyIncarnationById } from './pty/provider/ownership-state' -import type { TerminalIntentionalStopKind } from '../runtime/terminal-intentional-stops' -import { teardownSshTargetTransport } from './ssh-session-teardown' -import { runTargetLifecycle } from './ssh-target-lifecycle-queue' -import { errorMessage } from '../../shared/error-message' - -export type SshTerminateSessionsOptions = { - /** Records each stop as main's own, so every viewer keeps the tab through its exit. */ - intentionalStop?: TerminalIntentionalStopKind - /** Each shell this call stopped, reported even when a later one fails. */ - onStopped?: (appPtyId: string) => void -} - -/** Stops every relay terminal on the target and closes its transport (`ssh:terminateSessions`). */ -export async function terminateSshTargetSessions( - targetId: string, - options: SshTerminateSessionsOptions = {} -): Promise { - invalidateConnectAttempt(targetId) - // Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell - // "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue. - let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 } - await runTargetLifecycle(targetId, async () => { - const provider = getSshPtyProvider(targetId) - const leases = persistedStore!.getSshRemotePtyLeases(targetId) - const ptyIdsByRelayId = new Map() - // Why: only leases the app still believes it owns may force a reconnect; a lease whose route - // died for good is swept opportunistically instead, so a target that can no longer answer - // never blocks its own removal (issue #2626, and the renderer tolerates the refusal there). - const ownedRelayIds = new Set() - const trackPtyId = (ptyId: string, owned: boolean): void => { - const relayPtyId = toRelaySshPtyId(targetId, ptyId) - if (!ptyIdsByRelayId.has(relayPtyId)) { - ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(targetId, ptyId)) - } - if (owned) { - ownedRelayIds.add(relayPtyId) - } - } - for (const ptyId of getPtyIdsForConnection(targetId)) { - trackPtyId(ptyId, true) - } - for (const lease of leases) { - if (lease.state === 'terminated') { - continue - } - // Why the predicate and not `state !== 'expired'`: an `expired` lease carrying no - // retirement mark records only that reattach gave up, never that the remote shell died, so - // it is exactly the orphan the user's terminate must reach — and reaching it needs the - // relay, which is what the fence below demands. Only `supersededBy` / `relayIdRecycled` - // prove the route is dead for good, and those stay unowned. - trackPtyId(lease.ptyId, sshRemotePtyLeaseAllowsReattach(lease)) - } - // A shell a relay runs without any lease here (a CLI-created terminal, or one a respawn - // superseded on its tab) is still this host's; shutdown stops an earlier relay's held one there. - for (const ptyId of await listRelayPtyIdsToStop(targetId, provider)) { - trackPtyId(ptyId, false) - } - const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ - relayPtyId, - appPtyId - })) - - if (ownedRelayIds.size > 0 && !provider) { - throw new Error( - `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` - ) - } - const shutdownResults = provider - ? await Promise.allSettled( - ptyIds.map(({ appPtyId }) => - shutdownAs(options, appPtyId, () => - provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) - ) - ) - ) - : [] - if (!provider) { - // Nothing observed these remote shells, so their state is unknown — not "nothing to do". - outcome = { terminated: 0, unverifiable: ptyIds.length } - } - const shutdownFailures: string[] = [] - for (const [index, result] of shutdownResults.entries()) { - const { appPtyId, relayPtyId } = ptyIds[index] - if ( - result.status !== 'fulfilled' && - (result.reason instanceof SshPtyHeldByPreviousRelayError || - (await isReattachHeldByPreviousRelay(targetId, result.reason))) - ) { - // Not found here is not absence while an older build's relay may still run it (#25124). - outcome = { ...outcome, unverifiable: outcome.unverifiable + 1 } - continue - } - if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { - shutdownFailures.push(`${relayPtyId}: ${errorMessage(result.reason)}`) - continue - } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - persistedStore!.markSshRemotePtyLease(targetId, relayPtyId, 'terminated') - reportStoppedPtyToRuntime(appPtyId) - if (result.status === 'fulfilled') { - options.onStopped?.(appPtyId) - } - outcome = { ...outcome, terminated: outcome.terminated + 1 } - } - if (shutdownFailures.length > 0) { - // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. - throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) - } - // Disposal marks every remaining lease terminated; an unverifiable PTY keeps its lease detached, - // since only the leases this run proved terminated were marked so above. - await teardownSshTargetTransport(targetId, (session) => - outcome.unverifiable > 0 ? session.detachAndPersist() : session.disposeAndPersist() - ) - }) - return outcome -} - -/** An unanswered listing adds nothing here; the move's census after the stop still asks the host. */ -async function listRelayPtyIdsToStop( - targetId: string, - provider: ReturnType -): Promise { - if (!provider) { - return [] - } - const [current, previous] = await Promise.all([ - provider.listProcesses().then( - (rows) => rows.map((row) => row.id), - () => [] - ), - listPreviousRelayPtyIds(targetId).catch(() => null) - ]) - return [...current, ...(previous ?? [])] -} - -/** Marks exactly this shell, from just before its shutdown, so earlier exits close normally. */ -async function shutdownAs( - options: SshTerminateSessionsOptions, - appPtyId: string, - shutdown: () => Promise -): Promise { - const settle = options.intentionalStop - ? currentRuntime?.intentionalPtyStops.mark( - appPtyId, - options.intentionalStop, - ptyIncarnationById.get(appPtyId) ?? null - ) - : undefined - let stopped = false - try { - await shutdown() - stopped = true - } finally { - settle?.(stopped) - } -} - -/** - * Why: a relay that hangs up after its last shell stops never sends that exit, which left - * `terminal list` showing the stopped shell as connected. The relay accepting the kill is not an - * observed exit, so this is the stop sentinel, and a real exit already reported is kept. - */ -function reportStoppedPtyToRuntime(appPtyId: string): void { - if (currentRuntime?.getPtyLivenessVerdict(appPtyId)?.status === 'exited') { - return - } - currentRuntime?.onPtyExit(appPtyId, UNVERIFIED_PROCESS_EXIT_CODE) -} diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 8b07ca103b28..cb89f3f656dc 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -11,8 +11,6 @@ vi.mock('./ssh-pty-output-intake-registry', () => mocks.sshPtyOutputIntakeRegist vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) -vi.mock('../ssh/ssh-relay-deploy', () => mocks.sshRelayDeploy) -vi.mock('../ssh/ssh-relay-reset', () => mocks.sshRelayReset) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) vi.mock('../providers/ssh-pty-provider', () => mocks.sshPtyProvider) vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) @@ -23,33 +21,34 @@ vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) -import { RelayVersionMismatchError } from '../ssh/ssh-relay-version-mismatch-error' -import type { SshConnectionState, SshConnectionStatus, SshTarget } from '../../shared/ssh-types' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import type { SshConnectionState, SshTarget } from '../../shared/ssh-types' import { createSshIpcHarness } from './ssh-ipc-test-harness' -import { decideHostServer } from './ssh-host-server-connect' -import { beginSshHostCensus } from '../ssh/ssh-connection-attribution' - -const { - mockSshStore, - mockConnectionManager, - mockDeployAndLaunchRelay, - mockMux, - mockRegisterSshGitProvider, - mockPortForwardManager -} = mocks +import { + decideHostServer, + publishManagedServerConnect, + publishUnservedHostServer +} from './ssh-host-server-connect' +import { beginSshHostCensus, recordSshConnectionOpened } from '../ssh/ssh-connection-attribution' +import type { SshConnection } from '../ssh/ssh-connection' + +const { mockSshStore, mockConnectionManager } = mocks + +const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' +} + +function asTransport(value: { id: string }): SshConnection { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attribution keys on identity only. + return value as unknown as SshConnection +} describe('SSH IPC handlers', () => { const harness = createSshIpcHarness(mocks) - const { - relayBuildId, - handlers, - mockWindow, - relayReconnectDelaysMs, - relayLostStabilizedMs, - getLatestRelayDisposeCallback, - useSlowRelayLaunchOnce - } = harness + const { handlers, mockWindow } = harness beforeEach(harness.reset) @@ -61,339 +60,50 @@ describe('SSH IPC handlers', () => { ) }) - it('ssh:connect calls connection manager', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } + it('publishes a managed connect without opening a relay', async () => { mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) - }) - it('registers the provider before broadcasting connected authority', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 + await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toMatchObject({ + status: 'connected' }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - const connectedIndex = mockWindow.webContents.send.mock.calls.findIndex( - ([channel, payload]) => - channel === 'ssh:state-changed' && - (payload as { state: SshConnectionState }).state.status === 'connected' + expect(publishManagedServerConnect).toHaveBeenCalledWith( + 'ssh-1', + 'env-test', + undefined, + undefined ) - expect(connectedIndex).toBeGreaterThanOrEqual(0) - expect(mockRegisterSshGitProvider.mock.invocationCallOrder[0]).toBeLessThan( - mockWindow.webContents.send.mock.invocationCallOrder[connectedIndex] - ) - expect(mockWindow.webContents.send.mock.calls[connectedIndex]?.[1]).toEqual({ - targetId: 'ssh-1', - state: expect.objectContaining({ - targetId: 'ssh-1', - status: 'connected', - providerEpoch: expect.any(String), - connectionGeneration: 1 - }) - }) - }) - - it('ssh:connect exposes the detected remote platform in public state', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Windows Server', - host: 'windows.example.com', - port: 22, - username: 'deploy' - } - const hostPlatform = { - relayPlatform: 'win32-x64', - os: 'win32', - arch: 'x64', - pathFlavor: 'windows', - commandDialect: 'powershell', - pathSeparator: '\\', - pathDelimiter: ';' - } - mockDeployAndLaunchRelay.mockResolvedValueOnce({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - serverBuildId: relayBuildId, - hostPlatform - }) - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 1, - remotePlatform: 'win32' - }) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 1, - supportsFolderDownload: true, - remotePlatform: 'win32' - } - }) + expect(mockConnectionManager.connect).not.toHaveBeenCalled() }) - it('surfaces relay channel loss while the SSH connection remains alive', async () => { - vi.useFakeTimers() - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' + it('fails a host the managed server does not serve, with no fallback', async () => { + const decision = { + route: 'relay' as const, + reason: 'orcad_unavailable' as const, + detail: 'unsupported_host' } - const conn = {} + const opened = { id: 'decision-transport' } mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const onDispose = mockMux.onDispose.mock.calls[0]?.[0] as - | ((reason: 'shutdown' | 'connection_lost') => void) - | undefined - - onDispose?.('connection_lost') - - const reconnectingState = handlers.get('ssh:getState')!(null, { - targetId: 'ssh-1' - }) as SshConnectionState - expect(reconnectingState).toEqual({ - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: reconnectingState - }) - - await vi.advanceTimersByTimeAsync(500) - - const connectedState = handlers.get('ssh:getState')!(null, { - targetId: 'ssh-1' - }) as SshConnectionState - expect(connectedState).toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: reconnectingState.providerEpoch, - connectionGeneration: reconnectingState.connectionGeneration - }) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - ...connectedState, - supportsFolderDownload: true - } - }) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).toThrow( - 'SSH connection changed; refresh and try again' - ) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow() - } finally { - vi.useRealTimers() - } - }) - - it('rejects a staged mutation after the underlying SSH transport reconnects', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const stagedGeneration = 1 - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'reconnecting', - error: null, - reconnectAttempt: 1 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel reconnecting...', - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', stagedGeneration)).toThrow( - 'SSH connection changed; refresh and try again' - ) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow() - }) - - // Why: reproduces the "Infinite reconnect bug" — when the raw SSH transport - // connects but relay deploy fails permanently (dev build missing the platform - // relay package), doConnect must not leak the transport's premature 'connected' - // to the renderer. The renderer treats 'connected' as "session fully up" and - // remounts SSH panes (-> window.api.ssh.connect); a premature 'connected' on - // every failing attempt drives an unbounded reconnect loop. - it('does not broadcast a premature connected when relay deploy fails', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - // Why: mirror the real SshConnection — connect() drives the raw transport to - // 'connected' via onStateChange BEFORE the relay session establishes. The await - // yields a microtask so this lands after connectTarget records connectInFlight, - // matching the real ssh2 'ready' event (which fires async, post connect() call). - mockConnectionManager.connect.mockImplementation(async () => { - await Promise.resolve() - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connecting', - error: null, - reconnectAttempt: 0 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - supportsFolderDownload: true - }) - return conn + vi.mocked(decideHostServer).mockImplementationOnce(async () => { + recordSshConnectionOpened(asTransport(opened)) + mockConnectionManager.getConnection.mockReturnValue(opened) + return decision }) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.disconnect.mockResolvedValue(undefined) - mockDeployAndLaunchRelay - .mockReset() - .mockRejectedValue( - new Error( - 'Relay package for linux-x64 not found locally. ' + - 'This may be a packaging issue — try reinstalling Orca.' - ) - ) await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).rejects.toThrow( - 'not found locally' + 'This SSH host isn’t supported' ) - - // Main performs exactly one connect + one disconnect per IPC (no main-side loop). - expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) - expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') - - // The renderer must never see 'connected' for a connect whose relay never - // became ready — doConnect broadcasts the authoritative 'connected' only after - // establish() succeeds, which it does not here. - const connectedBroadcasts = mockWindow.webContents.send.mock.calls.filter( - ([channel, payload]) => - channel === 'ssh:state-changed' && - (payload as { state?: SshConnectionState }).state?.status === 'connected' - ) - expect(connectedBroadcasts).toEqual([]) + expect(publishUnservedHostServer).toHaveBeenCalledWith(target, decision) + expect(mockConnectionManager.disconnectConnection).toHaveBeenCalledWith('ssh-1', opened) + expect(mockConnectionManager.connect).not.toHaveBeenCalled() }) - it('holds a raw connected the server decision causes before any relay session exists', async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } + it('holds a raw connected the server decision causes', async () => { const conn = {} mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) mockConnectionManager.getConnection.mockReturnValue(conn) let broadcastsDuringDecision: unknown[] = [] vi.mocked(decideHostServer).mockImplementationOnce(async () => { await Promise.resolve() - // The census dials the shared pool before doConnect has a session. const callbacks = mockConnectionManager.callbacksRef.current as { onStateChange: (targetId: string, state: SshConnectionState) => void } @@ -406,7 +116,7 @@ describe('SSH IPC handlers', () => { broadcastsDuringDecision = mockWindow.webContents.send.mock.calls .filter(([channel]) => channel === 'ssh:state-changed') .map(([, payload]) => payload) - return null + return { route: 'managed', environmentId: 'env-test' } }) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) @@ -416,46 +126,6 @@ describe('SSH IPC handlers', () => { expect(broadcastsDuringDecision.at(-1)).toMatchObject({ state: { status: 'connecting' } }) }) - it("never broadcasts 'connected' while a move's census runs with no relay session", async () => { - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(undefined) - harness.mockStore.getSshRemotePtyLeases.mockReturnValue([]) - let broadcastsDuringCensus: unknown[] = [] - vi.mocked(decideHostServer).mockImplementationOnce(async () => { - await Promise.resolve() - // The move's census is the reconnect's decision, dialing the pool before any session. - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - broadcastsDuringCensus = mockWindow.webContents.send.mock.calls - .filter(([channel]) => channel === 'ssh:state-changed') - .map(([, payload]) => payload) - return null - }) - - await handlers.get('ssh:moveToManagedServer')!(null, { targetId: 'ssh-1' }) - expect(vi.mocked(decideHostServer)).toHaveBeenCalled() - expect(broadcastsDuringCensus.length).toBeGreaterThan(0) - expect(broadcastsDuringCensus).not.toContainEqual( - expect.objectContaining({ state: expect.objectContaining({ status: 'connected' }) }) - ) - }) - it("holds a census's raw 'connected' outside any connect", () => { const end = beginSshHostCensus('ssh-1') try { @@ -477,399 +147,4 @@ describe('SSH IPC handlers', () => { end() } }) - - // Why: guards the fix's scope. A relay version mismatch during a relay reconnect - // strands the session 'idle' in activeSessions (only doConnect deletes it). A later - // transport blip then delivers a raw 'connected' with NO connect in flight — the - // 'deploying-relay' hold must NOT fire there (it would wedge the UI on an eternal - // spinner with every reconnect/reset control disabled). The hold is gated to live - // connects via connectInFlight. - it('does not hold a stray connected as deploying-relay when no connect is in flight', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - // Establish a ready relay session, then lose the relay and fail the reconnect with - // a version mismatch so the session is left stranded 'idle' in activeSessions. - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockDeployAndLaunchRelay - .mockReset() - .mockRejectedValue(new RelayVersionMismatchError('2.0.0', '1.0.0')) - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - - // The terminal relay error is surfaced; the session is now stranded 'idle'. - expect( - (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).status - ).toBe('error') - - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - mockWindow.webContents.send.mockClear() - // A transport blip on the still-live SSH socket auto-recovers to 'connected' with - // no ssh:connect in flight (connectInFlight is empty). - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'reconnecting', - error: null, - reconnectAttempt: 0 - }) - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - // The stray 'connected' is forwarded as-is — never wedged at 'deploying-relay'. - const stateChanges = mockWindow.webContents.send.mock.calls.filter( - ([channel]) => channel === 'ssh:state-changed' - ) - const lastStateChange = stateChanges.at(-1) - expect(lastStateChange).toBeDefined() - expect((lastStateChange![1] as { state: SshConnectionState }).state.status).toBe('connected') - const heldAsDeploying = stateChanges.some( - ([, payload]) => - (payload as { state?: SshConnectionState }).state?.status === 'deploying-relay' - ) - expect(heldAsDeploying).toBe(false) - } finally { - vi.useRealTimers() - } - }) - - it('rebuilds instead of reusing a ready session while relay loss is pending', async () => { - vi.useFakeTimers() - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - const onDispose = mockMux.onDispose.mock.calls[0]?.[0] as - | ((reason: 'shutdown' | 'connection_lost') => void) - | undefined - - onDispose?.('connection_lost') - - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - - mockDeployAndLaunchRelay.mockClear() - mockPortForwardManager.removeAllForwards.mockClear() - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 3 - }) - - expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') - expect(mockDeployAndLaunchRelay).toHaveBeenCalled() - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 3 - }) - } finally { - vi.useRealTimers() - } - }) - - it('keeps counting slow unstable relay reconnects until manual reconnect is required', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - for (const [index, delayMs] of relayReconnectDelaysMs.entries()) { - useSlowRelayLaunchOnce(relayLostStabilizedMs + 1) - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(delayMs + relayLostStabilizedMs + 1) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: index + 2 - }) - } - - getLatestRelayDisposeCallback()('connection_lost') - - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'error', - error: 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: relayReconnectDelaysMs.length + 2 - }) - } finally { - vi.useRealTimers() - } - }) - - describe('relay loss while the SSH transport is down', () => { - const relayLostTarget: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const transportState = (status: SshConnectionStatus): SshConnectionState => ({ - targetId: 'ssh-1', - status, - error: null, - reconnectAttempt: 0 - }) - const setTransportStatus = (status: SshConnectionStatus): void => { - mockConnectionManager.getState.mockReturnValue(transportState(status)) - } - const maxRelayDelayMs = relayReconnectDelaysMs.at(-1)! - const connectWithLiveTransport = async (): Promise => { - mockSshStore.getTarget.mockReturnValue(relayLostTarget) - mockConnectionManager.connect.mockResolvedValue({}) - mockConnectionManager.getConnection.mockReturnValue({}) - setTransportStatus('connected') - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockDeployAndLaunchRelay.mockClear() - } - - it('does not consume attempts or publish the manual-reconnect banner', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - setTransportStatus('reconnecting') - getLatestRelayDisposeCallback()('connection_lost') - - // Well past the whole 6-step ladder: a redeploy cannot ride a dead transport, so nothing is spent. - const fullLadderMs = relayReconnectDelaysMs.reduce((sum, delay) => sum + delay, 0) - await vi.advanceTimersByTimeAsync(fullLadderMs + relayLostStabilizedMs) - - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - - setTransportStatus('connected') - await vi.advanceTimersByTimeAsync(maxRelayDelayMs) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - expect( - (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).status - ).toBe('connected') - } finally { - vi.useRealTimers() - } - }) - - it('stops retrying once the transport reaches a terminal state', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - setTransportStatus('reconnecting') - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(maxRelayDelayMs) - - setTransportStatus('reconnection-failed') - await vi.advanceTimersByTimeAsync(maxRelayDelayMs) - - // The wait loop is gone: only onStateChange's redeploy may revive the relay after this. - setTransportStatus('connected') - await vi.advanceTimersByTimeAsync(maxRelayDelayMs * 4) - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('resets the relay budget when the connection disappears before retry', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - getLatestRelayDisposeCallback()('connection_lost') - - mockConnectionManager.getConnection.mockReturnValue(undefined) - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - - mockConnectionManager.getConnection.mockReturnValue({}) - getLatestRelayDisposeCallback()('connection_lost') - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual( - expect.objectContaining({ reconnectAttempt: 1 }) - ) - } finally { - vi.useRealTimers() - } - }) - - it('still reaches the manual-reconnect banner when the transport is healthy', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - mockDeployAndLaunchRelay.mockRejectedValue(new Error('relay refused')) - getLatestRelayDisposeCallback()('connection_lost') - for (const delayMs of relayReconnectDelaysMs) { - await vi.advanceTimersByTimeAsync(delayMs) - } - - expect( - (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).error - ).toBe('Relay channel kept dropping. Click Reconnect on the SSH target before retrying.') - } finally { - vi.useRealTimers() - } - }) - - it('restores the full relay budget once the transport reconnects', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - try { - await connectWithLiveTransport() - mockDeployAndLaunchRelay.mockRejectedValue(new Error('relay refused')) - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - callbacks.onStateChange('ssh-1', transportState('reconnecting')) - callbacks.onStateChange('ssh-1', transportState('connected')) - await vi.advanceTimersByTimeAsync(0) - - // Budget reset: the next loss waits the base delay again instead of the third ladder step. - mockDeployAndLaunchRelay.mockClear() - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - }) - - it('reuses a fast relay reconnect after the post-ready stabilization window', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const target: SshTarget = { - id: 'ssh-1', - label: 'Server', - host: 'example.com', - port: 22, - username: 'deploy' - } - const conn = {} - mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockResolvedValue(conn) - mockConnectionManager.getConnection.mockReturnValue(conn) - mockConnectionManager.getState.mockReturnValue({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - try { - await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - - getLatestRelayDisposeCallback()('connection_lost') - await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - - await vi.advanceTimersByTimeAsync(relayLostStabilizedMs + 1) - mockDeployAndLaunchRelay.mockClear() - mockPortForwardManager.removeAllForwards.mockClear() - - await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: expect.any(String), - connectionGeneration: 2 - }) - expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 816423e1aa6d..a3fa33d6f1b9 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1,18 +1,8 @@ -import { - AiVaultSearchRequestSchema, - AiVaultSearchStatusRequestSchema -} from '../../shared/ai-vault-search-contract' import { ipcMain, type BrowserWindow } from 'electron' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' import { SshConnectionManager } from '../ssh/ssh-connection-manager' -import type { SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' -import type { - SshAiVaultRelayListParams, - SshAiVaultRelayTitleParams -} from '../../shared/ssh-ai-vault-relay' import { SshPortForwardManager } from '../ssh/ssh-port-forward' -import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { getSshTargetRegistryStore, @@ -41,17 +31,10 @@ import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' -import { activeSessions } from './ssh-active-relay-sessions' import { installManagedOrcadStartStatus } from './runtime-environment-managed-tunnel' -import { - registerAdvertisedUrlRefresh, - unregisterAdvertisedUrlRefresh -} from './ssh-advertised-url-refresh' import { connectInFlight, credentialRequestedForTarget, - pendingTransportReconnects, - resetRelayInFlight, testConnectionProbes, testingTargets } from './ssh-connect-attempt-registry' @@ -73,9 +56,7 @@ import { } from './ssh-ipc-context' import { registerSshPortForwardHandlers } from './ssh-port-forward-handlers' import { persistPortForwardsWithUnrestored } from './ssh-port-forward-persistence' -import { clearRelayLostBackoff, relayLostBackoff } from './ssh-relay-lost-backoff' -import { refreshActiveRelaySessions } from './ssh-relay-session-callbacks' -import { broadcastPortForwards, relayStateOverrides } from './ssh-renderer-broadcast' +import { broadcastPortForwards } from './ssh-renderer-broadcast' import { resetSshShutdownDrain } from './ssh-shutdown-drain' import { registerSshTargetCrudHandlers } from './ssh-target-crud-handlers' import { targetLifecycleInFlight } from './ssh-target-lifecycle-queue' @@ -95,9 +76,6 @@ const SSH_IPC_CHANNELS = [ 'ssh:resolveConfigHost', 'ssh:connect', 'ssh:disconnect', - 'ssh:terminateSessions', - 'ssh:moveToManagedServer', - 'ssh:resetRelay', 'ssh:getState', 'ssh:needsPassphrasePrompt', 'ssh:testConnection', @@ -108,74 +86,6 @@ const SSH_IPC_CHANNELS = [ 'ssh:listDetectedPorts' ] as const -export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { - if (isRuntimeOwnedSshTargetId(targetId)) { - return null - } - return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null -} - -export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { - return [...activeSessions.values()].flatMap((session) => { - if (isRuntimeOwnedSshTargetId(session.targetId)) { - return [] - } - const info = session.getAiVaultHostInfo() - return info ? [info] : [] - }) -} - -export async function requestActiveSshSessionSearch( - targetId: string, - method: string, - params: unknown -): Promise { - if (isRuntimeOwnedSshTargetId(targetId)) { - throw new Error('SSH target belongs to another runtime') - } - const session = activeSessions.get(targetId) - if (!session) { - throw new Error('SSH relay is not ready') - } - if (method === 'aiVault.searchSessions') { - return session.requestSessionSearch(method, AiVaultSearchRequestSchema.parse(params)) - } - if (method === 'aiVault.searchStatus') { - return session.requestSessionSearch(method, AiVaultSearchStatusRequestSchema.parse(params)) - } - throw new Error('Unknown session search method') -} - -export async function requestActiveSshAiVaultSessionList( - targetId: string, - params: SshAiVaultRelayListParams, - options: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { - if (isRuntimeOwnedSshTargetId(targetId)) { - return null - } - const session = activeSessions.get(targetId) - if (!session) { - throw new Error('SSH relay is not ready') - } - return session.requestAiVaultSessionList(params, options) -} - -export async function requestActiveSshAiVaultSessionTitles( - targetId: string, - params: SshAiVaultRelayTitleParams, - options: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { - if (isRuntimeOwnedSshTargetId(targetId)) { - return null - } - const session = activeSessions.get(targetId) - if (!session) { - throw new Error('SSH relay is not ready') - } - return session.requestAiVaultSessionTitles(params, options) -} - export function registerSshHandlers( store: Store, getMainWindow: () => BrowserWindow | null, @@ -193,7 +103,6 @@ export function registerSshHandlers( setPersistedStore(store) reconcileManagedOrcadSshTargets(getAppEnvironment().getPath('userData'), store) installOrcadMigrationScrollbackRetention(getAppEnvironment().getPath('userData'), store) - registerAdvertisedUrlRefresh(getCurrentMainWindow) installManagedOrcadStartStatus() registerCredentialHandler() @@ -218,7 +127,6 @@ export function registerSshHandlers( broadcastPortForwards(getCurrentMainWindow, entry.connectionId) } }) - refreshActiveRelaySessions() registerPowerMonitorReconnect(() => getAppEnvironment().getPath('userData')) registerSshBrowseHandler(() => connectionManager) setSshConnectionManagerResolver(() => connectionManager) @@ -234,28 +142,16 @@ export function registerSshHandlers( } export async function resetSshHandlerStateForTests(): Promise { - unregisterAdvertisedUrlRefresh() unregisterPowerMonitorReconnect() for (const ch of SSH_IPC_CHANNELS) { ipcMain.removeHandler(ch) } ipcMain.removeHandler('ssh:submitCredential') - // Why: allSettled — a rejected disposal write must not abort the rest of the reset and leak state into the next test. - await Promise.allSettled( - [...activeSessions.values()].map((session) => session.disposeAndPersist()) - ) - activeSessions.clear() - for (const targetId of relayLostBackoff.keys()) { - clearRelayLostBackoff(targetId) - } - relayStateOverrides.clear() connectInFlight.clear() targetLifecycleInFlight.clear() - pendingTransportReconnects.clear() resetSshConnectionGenerations() resetSshProviderAuthorities() - resetRelayInFlight.clear() testingTargets.clear() testConnectionProbes.clear() credentialRequestedForTarget.clear() diff --git a/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts index 6c646285d4bb..f80b7394ad1e 100644 --- a/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts +++ b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { fixture } from './profile-state-delayed-authority-fixture' -import { removeSshPtyConsumerOwnerRecovery } from '../../ssh/ssh-pty-consumer-recovery' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ @@ -43,43 +42,6 @@ describe('reserved SSH persistence', () => { expect(ownerWrites[1]?.payload).toContain('next-owner') }) - it('retries a failed consumer removal through durability even after memory is already empty', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await store.upsertSshPtyConsumerRecovery(recovery) - const gate = authority.pause() - const removal = expect( - removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) - ).rejects.toThrow('disk refused') - await gate.started.promise - gate.finish.reject(new Error('disk refused')) - await removal - expect(readState().sshPtyConsumerRecoveries).toHaveLength(1) - await removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) - expect(readState().sshPtyConsumerRecoveries).toEqual([]) - }) - - it('does not let an old consumer remove the newer owner ahead of it in the write queue', async () => { - const { store, authority, readState } = await fixture() - await store.upsertSshPtyConsumerRecovery(recovery) - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const older = store.flushPendingOrThrowAsync() - await gate.started.promise - const replacement = store.upsertSshPtyConsumerRecovery({ - ...recovery, - clientInstanceId: 'next-owner' - }) - const removal = removeSshPtyConsumerOwnerRecovery( - recovery.targetId, - recovery.clientInstanceId, - store - ) - gate.finish.resolve() - await Promise.all([older, replacement, removal]) - expect(readState().sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('next-owner') - }) - it('does not detach a newer replacement lease while waiting for the writer', async () => { const { store, authority, readState } = await fixture() const lease = { targetId: recovery.targetId, ptyId: 'relay-pty', state: 'attached' as const } diff --git a/src/main/providers/ssh-directory-legacy-compatibility.test.ts b/src/main/providers/ssh-directory-legacy-compatibility.test.ts deleted file mode 100644 index f6234efaee0d..000000000000 --- a/src/main/providers/ssh-directory-legacy-compatibility.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { readSshDirectoryBounded } from './ssh-directory-listing' - -function fixture() { - const listeners = new Map) => void>() - const mux = { - request: vi.fn(), - notify: vi.fn(), - isDisposed: () => false, - onDispose: () => () => {}, - onNotificationByMethod: ( - method: string, - callback: (params: Record) => void - ) => { - listeners.set(method, callback) - return () => listeners.delete(method) - } - } - mux.request.mockRejectedValueOnce( - Object.assign(new Error('old host'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements all mux methods used by the response reader. - return { mux: mux as unknown as SshChannelMultiplexer, mock: mux, listeners } -} - -const entries = [{ name: 'README.md', isDirectory: false, isSymlink: false }] - -it('preserves small old plain replies on system SSH without SFTP', async () => { - const f = fixture() - f.mock.request.mockResolvedValueOnce(entries) - await expect(readSshDirectoryBounded(f.mux, '/remote')).resolves.toEqual(entries) - expect(f.mock.request).toHaveBeenLastCalledWith('fs.readDir', { - dirPath: '/remote', - __streamResponse: true - }) -}) - -it('preserves old streamed replies without SFTP', async () => { - const f = fixture() - const encoded = Buffer.from(JSON.stringify(entries)) - f.mock.request.mockImplementationOnce(async () => { - f.listeners.get('git.responseChunk')?.({ - streamId: 7, - seq: 0, - data: encoded.toString('base64') - }) - f.listeners.get('git.responseEnd')?.({ streamId: 7 }) - return { __orcaGitResponseStream: { streamId: 7, totalBytes: encoded.length, chunkCount: 1 } } - }) - await expect(readSshDirectoryBounded(f.mux, '/remote')).resolves.toEqual(entries) -}) - -it('prefers bounded SFTP to legacy producer allocation', async () => { - const f = fixture() - const fallback = vi.fn().mockResolvedValue(entries) - await expect(readSshDirectoryBounded(f.mux, '/remote', fallback)).resolves.toEqual(entries) - expect(f.mock.request).toHaveBeenCalledTimes(1) - expect(fallback).toHaveBeenCalledOnce() -}) - -it('does not fall back for arbitrary failures', async () => { - const f = fixture() - f.mock.request.mockReset().mockRejectedValue(new Error('permission denied')) - const fallback = vi.fn() - await expect(readSshDirectoryBounded(f.mux, '/remote', fallback)).rejects.toThrow( - 'permission denied' - ) - expect(f.mock.request).toHaveBeenCalledTimes(1) - expect(fallback).not.toHaveBeenCalled() -}) - -it('rejects complete old replies that exceed metadata capacity', async () => { - const f = fixture() - f.mock.request.mockResolvedValueOnce(Array.from({ length: 100001 }, () => entries[0])) - await expect(readSshDirectoryBounded(f.mux, '/remote')).rejects.toThrow('too large') -}) - -it('rejects oversized streamed old replies before accepting chunks', async () => { - const f = fixture() - f.mock.request.mockResolvedValueOnce({ - __orcaGitResponseStream: { streamId: 7, totalBytes: 17 * 1024 * 1024, chunkCount: 1 } - }) - await expect(readSshDirectoryBounded(f.mux, '/remote')).rejects.toThrow('retention budget') - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 7 }) -}) - -it('propagates legacy errors without another fallback', async () => { - const f = fixture() - f.mock.request.mockRejectedValueOnce(new Error('legacy permission denied')) - await expect(readSshDirectoryBounded(f.mux, '/remote')).rejects.toThrow( - 'legacy permission denied' - ) - expect(f.mock.request).toHaveBeenCalledTimes(2) -}) diff --git a/src/main/providers/ssh-directory-listing-options.test.ts b/src/main/providers/ssh-directory-listing-options.test.ts deleted file mode 100644 index 546fecbe116a..000000000000 --- a/src/main/providers/ssh-directory-listing-options.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mkdtemp, mkdir, rm, symlink } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { readSshDirectoryBounded, readSshDirectoryWithSftpFallback } from './ssh-directory-listing' -import { withSftpDirectoryHandles } from './sftp-directory-test-fixture' -import { readSftpDirectory } from './ssh-sftp-directory-listing' -import { readRelayDirectoryBounded } from '../../relay/fs-directory-listing' -import { listSshFiles } from './ssh-file-listing' - -function muxFixture() { - const mock = { - request: vi.fn().mockResolvedValue([]), - notify: vi.fn(), - isDisposed: () => false, - onDispose: () => () => {}, - onNotificationByMethod: () => () => {} - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These are the stream reader's only multiplexer operations. - return { mock, mux: mock as unknown as SshChannelMultiplexer } -} -function sftpFixture() { - const mock = { - readdir: vi.fn((_path, cb) => - cb(null, [ - { filename: 'linked', attrs: { isSymbolicLink: () => true, isDirectory: () => false } } - ]) - ), - stat: vi.fn((_path, cb) => cb(null, { isDirectory: () => true })), - end: vi.fn() - } - return { mock, sftp: withSftpDirectoryHandles(mock) } -} - -describe('integrated bounded inventory options', () => { - it('forwards both listing preferences without serializing cancellation signals', async () => { - const { mux, mock } = muxFixture() - const signal = new AbortController().signal - await listSshFiles(mux, '/root', { includeIgnored: false, followSymlinks: true, signal }) - expect(mock.request).toHaveBeenCalledWith( - 'fs.listFiles', - { rootPath: '/root', includeIgnored: false, followSymlinks: true, __streamResponse: true }, - { signal, timeoutMs: undefined } - ) - await readSshDirectoryBounded(mux, '/root', undefined, { followSymlinks: false }) - expect(mock.request).toHaveBeenLastCalledWith('fs.readDirBounded', { - dirPath: '/root', - followSymlinks: false, - __streamResponse: true - }) - }) - it('preserves the symlink preference on a legacy relay without SFTP', async () => { - const { mux, mock } = muxFixture() - mock.request.mockRejectedValueOnce( - Object.assign(new Error('Method not found'), { code: -32601 }) - ) - await readSshDirectoryBounded(mux, '/root', undefined, { followSymlinks: false }) - expect(mock.request).toHaveBeenLastCalledWith('fs.readDir', { - dirPath: '/root', - followSymlinks: false, - __streamResponse: true - }) - }) - it('does not probe link targets on either SFTP route when disabled', async () => { - const { sftp, mock } = sftpFixture() - expect(await readSftpDirectory(sftp, '/root', { followSymlinks: false })).toEqual([ - { name: 'linked', isDirectory: false, isSymlink: true } - ]) - expect(mock.stat).not.toHaveBeenCalled() - const { mux, mock: transport } = muxFixture() - transport.request.mockRejectedValue( - Object.assign(new Error('Method not found'), { code: -32601 }) - ) - const fallback = sftpFixture() - expect( - await readSshDirectoryWithSftpFallback(mux, '/root', async () => fallback.sftp, { - followSymlinks: true - }) - ).toEqual([{ name: 'linked', isDirectory: true, isSymlink: true }]) - expect(fallback.mock.stat).toHaveBeenCalledTimes(1) - expect(fallback.mock.end).toHaveBeenCalledTimes(1) - }) - it('preserves complete relay directory classification with opt-in links', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-integrated-dir-')) - try { - await mkdir(join(root, 'target')) - await symlink(join(root, 'target'), join(root, 'linked'), 'dir') - expect( - (await readRelayDirectoryBounded(root, undefined, { followSymlinks: false })).find( - (entry) => entry.name === 'linked' - )?.isDirectory - ).toBe(false) - expect( - (await readRelayDirectoryBounded(root, undefined, { followSymlinks: true })).find( - (entry) => entry.name === 'linked' - )?.isDirectory - ).toBe(true) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) -}) diff --git a/src/main/providers/ssh-directory-listing.ts b/src/main/providers/ssh-directory-listing.ts deleted file mode 100644 index 0b5fdf44412e..000000000000 --- a/src/main/providers/ssh-directory-listing.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { DirEntry } from '../../shared/filesystem-entry-types' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { validateDirectoryListing } from '../../shared/directory-listing-budget' -import { readSftpDirectory } from './ssh-sftp-filesystem-provider' -import type { SftpFactory } from './ssh-filesystem-download' - -export function readSshDirectoryWithSftpFallback( - mux: SshChannelMultiplexer, - dirPath: string, - createSftp?: SftpFactory, - options?: { followSymlinks?: boolean } -): Promise { - return readSshDirectoryBounded( - mux, - dirPath, - createSftp - ? async () => { - const sftp = await createSftp() - try { - return await readSftpDirectory(sftp, dirPath, options) - } finally { - sftp.end() - } - } - : undefined, - options - ) -} - -export async function readSshDirectoryBounded( - mux: SshChannelMultiplexer, - dirPath: string, - fallback?: () => Promise, - options?: { followSymlinks?: boolean } -) { - try { - return validateDirectoryListing( - await requestGitStreamable( - mux, - 'fs.readDirBounded', - { dirPath, ...options }, - { maxResponseBytes: 16 * 1024 * 1024 } - ) - ) - } catch (error) { - if (isMethodNotFoundError(error)) { - if (fallback) { - return fallback() - } - // Old hosts allocate before replying; bound transport retention and validate the complete result. - return validateDirectoryListing( - await requestGitStreamable( - mux, - 'fs.readDir', - { dirPath, ...options }, - { - maxResponseBytes: 16 * 1024 * 1024 - } - ) - ) - } - throw error - } -} diff --git a/src/main/providers/ssh-directory-streaming-real.test.ts b/src/main/providers/ssh-directory-streaming-real.test.ts deleted file mode 100644 index eeb649c733da..000000000000 --- a/src/main/providers/ssh-directory-streaming-real.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { generateKeyPairSync } from 'node:crypto' -import { Client, Server } from 'ssh2' -import type { SFTPWrapper } from 'ssh2' -import { describe, expect, it } from 'vitest' -import { readDirectoryEntriesViaSftp } from './ssh-filesystem-provider-sftp' -import { readSftpDirectory } from './ssh-sftp-directory-listing' - -async function createServer() { - const { privateKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, - publicKeyEncoding: { type: 'pkcs1', format: 'pem' } - }) - let reads = 0 - let closes = 0 - const positions = new Map() - const server = new Server({ hostKeys: [privateKey] }, (connection) => { - connection - .on('authentication', (context) => context.accept()) - .on('ready', () => { - connection.on('session', (accept) => { - accept().on('sftp', (acceptSftp) => { - const stream = acceptSftp() - stream.on('OPENDIR', (id, path) => { - positions.set(path, 0) - stream.handle(id, Buffer.from(path)) - }) - stream.on('READDIR', (id, handle) => { - reads++ - const key = handle.toString() - const start = positions.get(key) ?? 0 - if (start >= 1000) { - stream.status(id, 1) - return - } - positions.set(key, start + 100) - stream.name( - id, - Array.from({ length: 100 }, (_, offset) => ({ - filename: `file-${start + offset}.txt`, - longname: '', - attrs: { mode: 0o100644, size: 0, uid: 0, gid: 0, atime: 0, mtime: 0 } - })) - ) - }) - stream.on('CLOSE', (id, handle) => { - closes++ - positions.delete(handle.toString()) - stream.status(id, 0) - }) - }) - }) - }) - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('No fixture port') - } - const client = new Client() - await new Promise((resolve, reject) => { - client.once('ready', resolve).once('error', reject) - client.connect({ - host: '127.0.0.1', - port: address.port, - username: 'fixture', - password: 'fixture' - }) - }) - const sftp = await new Promise((resolve, reject) => - client.sftp((error, value) => (error ? reject(error) : resolve(value))) - ) - return { - sftp, - counts: () => ({ reads, closes }), - close: async () => { - sftp.end() - client.end() - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ) - } - } -} - -describe('real SFTP directory packet contract', () => { - it('stops server enumeration after one packet and closes the remote handle', async () => { - const fixture = await createServer() - try { - for await (const entry of readDirectoryEntriesViaSftp(fixture.sftp, '/early')) { - expect(entry.filename).toBe('file-0.txt') - break - } - expect(fixture.counts()).toEqual({ reads: 1, closes: 1 }) - expect(await readSftpDirectory(fixture.sftp, '/complete')).toHaveLength(1000) - expect(fixture.counts()).toEqual({ reads: 12, closes: 2 }) - } finally { - await fixture.close() - } - }) -}) diff --git a/src/main/providers/ssh-directory-streaming.test.ts b/src/main/providers/ssh-directory-streaming.test.ts deleted file mode 100644 index 8d0d55a13c64..000000000000 --- a/src/main/providers/ssh-directory-streaming.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SFTPWrapper } from 'ssh2' -import { readDirectoryEntriesViaSftp } from './ssh-filesystem-provider-sftp' -import { readSftpDirectory } from './ssh-sftp-directory-listing' - -function fixture(packets: string[][]) { - let next = 0 - const handle = Buffer.from('directory') - const sftp = { - opendir: vi.fn((_path, callback) => callback(null, handle)), - readdir: vi.fn((_handle, callback) => - next < packets.length - ? callback( - null, - packets[next++].map((filename) => ({ - filename, - attrs: { isSymbolicLink: () => false, isDirectory: () => false } - })) - ) - : callback(Object.assign(new Error('EOF'), { code: 1 })) - ), - close: vi.fn((_handle, callback) => callback(null)) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements all three handle operations used by the reader. - return { mock: sftp, sftp: sftp as unknown as SFTPWrapper } -} - -describe('SFTP directory handle ownership', () => { - it('does not request later packets after a consumer stops', async () => { - const { sftp, mock } = fixture([['first'], ['second']]) - for await (const entry of readDirectoryEntriesViaSftp(sftp, '/folder')) { - expect(entry.filename).toBe('first') - break - } - expect(mock.readdir).toHaveBeenCalledTimes(1) - expect(mock.close).toHaveBeenCalledTimes(1) - }) - - it('continues through empty filtered packets until the protocol EOF error', async () => { - const { sftp, mock } = fixture([[], ['.', '..'], ['visible']]) - expect((await readSftpDirectory(sftp, '/folder')).map((entry) => entry.name)).toEqual([ - 'visible' - ]) - expect(mock.close).toHaveBeenCalledTimes(1) - }) - - it('rejects capacity before fetching the remaining million-entry directory', async () => { - const name = 'x'.repeat(1000) - const { sftp, mock } = fixture(Array.from({ length: 1000 }, () => Array(100).fill(name))) - await expect(readSftpDirectory(sftp, '/folder')).rejects.toThrow('too large') - expect(mock.readdir.mock.calls.length).toBeLessThan(50) - expect(mock.close).toHaveBeenCalledTimes(1) - }) - - it('closes after cancellation between packets', async () => { - const { sftp, mock } = fixture([['first'], ['second']]) - const controller = new AbortController() - await expect( - (async () => { - for await (const _entry of readDirectoryEntriesViaSftp(sftp, '/folder', { - signal: controller.signal - })) { - controller.abort(new Error('closed')) - } - })() - ).rejects.toThrow('closed') - expect(mock.close).toHaveBeenCalledTimes(1) - }) -}) - -it('bounds a silent CLOSE after early stop and ignores its late callback', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([['first']]) - let lateClose: (() => void) | undefined - mock.close.mockImplementation((_handle, callback) => { - lateClose = () => callback(null) - }) - const iterator = readDirectoryEntriesViaSftp(sftp, '/folder') - await iterator.next() - const stopped = iterator.return(undefined) - await vi.advanceTimersByTimeAsync(5000) - await stopped - expect(mock.close).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - lateClose?.() - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it('preserves cancellation when CLOSE never acknowledges', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([['first'], ['second']]) - mock.close.mockImplementation(() => {}) - const controller = new AbortController() - const iterator = readDirectoryEntriesViaSftp(sftp, '/folder', { signal: controller.signal }) - await iterator.next() - controller.abort(new Error('original cancellation')) - const rejected = expect(iterator.next()).rejects.toThrow('original cancellation') - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(mock.close).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it('closes a late OPENDIR handle even after cancellation settled', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([]) - let lateOpen: (() => void) | undefined - mock.opendir.mockImplementation((_path, callback) => { - lateOpen = () => callback(null, Buffer.from('late-handle')) - }) - mock.close.mockImplementation(() => {}) - const controller = new AbortController() - const pending = readDirectoryEntriesViaSftp(sftp, '/folder', { - signal: controller.signal - }).next() - const rejected = expect(pending).rejects.toThrow('original cancellation') - controller.abort(new Error('original cancellation')) - await vi.advanceTimersByTimeAsync(5000) - await rejected - lateOpen?.() - expect(mock.close).toHaveBeenCalledWith(Buffer.from('late-handle'), expect.any(Function)) - await vi.advanceTimersByTimeAsync(5000) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it('preserves capacity failure when CLOSE never acknowledges', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([['x'.repeat(5 * 1024 * 1024)]]) - mock.close.mockImplementation(() => {}) - const rejected = expect(readSftpDirectory(sftp, '/folder')).rejects.toThrow('too large') - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(mock.close).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it('rejects explicit failed CLOSE and retires the persistent channel', async () => { - const { sftp, mock } = fixture([]) - const end = vi.fn() - Object.assign(sftp, { end }) - const failure = new Error('CLOSE failed') - mock.close.mockImplementation((_handle, callback) => callback(failure)) - await expect(readSftpDirectory(sftp, '/folder')).rejects.toBe(failure) - expect(end).toHaveBeenCalledOnce() -}) - -it('rejects EOF CLOSE timeout without claiming acknowledgement; late callback stays inert', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([]) - const end = vi.fn() - Object.assign(sftp, { end }) - let lateClose: (() => void) | undefined - mock.close.mockImplementation((_handle, callback) => { - lateClose = () => callback(null) - }) - const rejected = expect(readSftpDirectory(sftp, '/folder')).rejects.toThrow('CLOSE timed out') - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(end).toHaveBeenCalledOnce() - lateClose?.() - expect(end).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) - -it.each(['EOF callback', 'CLOSE callback'])( - 'preserves abort reason during final %s', - async (boundary) => { - const { sftp, mock } = fixture([]) - const controller = new AbortController() - const reason = new Error('original final cancellation') - if (boundary === 'EOF callback') { - mock.readdir.mockImplementation((_handle, callback) => { - callback(null, false) - controller.abort(reason) - }) - } else { - mock.close.mockImplementation((_handle, callback) => { - controller.abort(reason) - callback(null) - }) - } - await expect(readSftpDirectory(sftp, '/folder', { signal: controller.signal })).rejects.toBe( - reason - ) - } -) - -it('preserves a consumer capacity failure when CLOSE explicitly fails', async () => { - const { sftp, mock } = fixture([['x'.repeat(5 * 1024 * 1024)]]) - mock.close.mockImplementation((_handle, callback) => callback(new Error('cleanup failed'))) - await expect(readSftpDirectory(sftp, '/folder')).rejects.toThrow('too large') -}) - -it('keeps the persistent channel for acknowledged normal EOF', async () => { - const { sftp } = fixture([['visible']]) - const end = vi.fn() - Object.assign(sftp, { end }) - await expect(readSftpDirectory(sftp, '/folder')).resolves.toHaveLength(1) - expect(end).not.toHaveBeenCalled() -}) - -it('cancels a silent symlink STAT and closes its directory handle', async () => { - vi.useFakeTimers() - try { - const { sftp, mock } = fixture([['linked']]) - mock.readdir.mockImplementationOnce((_handle, callback) => - callback(null, [ - { - filename: 'linked', - attrs: { isSymbolicLink: () => true, isDirectory: () => false } - } - ]) - ) - const stat = vi.fn() - Object.assign(sftp, { stat }) - const controller = new AbortController() - const reason = new Error('canceled during STAT') - const pending = readSftpDirectory(sftp, '/folder', { signal: controller.signal }) - await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) - const rejected = expect(pending).rejects.toBe(reason) - controller.abort(reason) - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(mock.close).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } -}) diff --git a/src/main/providers/ssh-file-listing.ts b/src/main/providers/ssh-file-listing.ts deleted file mode 100644 index c2439bae7199..000000000000 --- a/src/main/providers/ssh-file-listing.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { sshFilesystemListingParams } from './ssh-filesystem-listing-params' -import { FileInventoryBudget, FILE_INVENTORY_MAX_BYTES } from '../../shared/file-inventory-budget' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IFilesystemProvider } from './types' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' - -export async function listSshFiles( - mux: SshChannelMultiplexer, - rootPath: string, - options?: Parameters[1] -): Promise { - const params = sshFilesystemListingParams(rootPath, options) - // Why #7721: the signal lets a workspace switch send rpc.cancel so the - // relay aborts the full-tree scan instead of stacking abandoned scans - // that starve interactive fs.readDir/fs.stat on the shared SSH channel. - // Why streamable: a monorepo listing serializes past the relay's 1 MiB control lane, and the - // lane it demotes to is refused under unrelated producer load. Opting in moves it to the bulk - // lane in chunks; an old relay ignores the flag and answers plainly, which the reader detects - // by the sentinel marker being absent. - const result = await requestGitStreamable(mux, 'fs.listFiles', params, { - signal: options?.signal, - maxResponseBytes: - options?.maxResults !== undefined ? 16 * 1024 * 1024 : FILE_INVENTORY_MAX_BYTES - }) - if (!Array.isArray(result) || result.some((path) => typeof path !== 'string')) { - throw new Error('Invalid remote file listing') - } - if (options?.maxResults !== undefined && result.length > options.maxResults) { - throw new Error('Remote file listing exceeds the requested capacity') - } - if (options?.maxResults === undefined && options?.searchQuery === undefined) { - const budget = new FileInventoryBudget() - for (const path of result) { - budget.record(path) - } - } - return result -} diff --git a/src/main/providers/ssh-filesystem-doc-preview.test.ts b/src/main/providers/ssh-filesystem-doc-preview.test.ts deleted file mode 100644 index 540b655cba7a..000000000000 --- a/src/main/providers/ssh-filesystem-doc-preview.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { DocPreviewFileAccessRequest } from '../../shared/doc-preview-file-access' -import { readSshDocPreviewFile } from './ssh-filesystem-doc-preview' - -const request: DocPreviewFileAccessRequest = { - boundaryPath: '/repo', - entryPath: '/repo/docs/index.html', - implicitRootPath: '/repo/docs', - authorizedRootPaths: ['/repo/assets'], - targetPath: '/repo/docs/app.js', - maxTextBytes: 1024, - maxBinaryBytes: 2048 -} - -describe('readSshDocPreviewFile', () => { - it('delegates canonical authorization and reading to the relay host', async () => { - const mux = { request: vi.fn().mockResolvedValue({ content: 'ok', isBinary: false }) } - - await expect(readSshDocPreviewFile(mux as never, request)).resolves.toEqual({ - content: 'ok', - isBinary: false - }) - expect(mux.request).toHaveBeenCalledWith('fs.readDocPreview', request) - }) - - it('fails closed when the relay predates scoped preview reads', async () => { - const mux = { - request: vi - .fn() - .mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - } - - await expect(readSshDocPreviewFile(mux as never, request)).rejects.toThrow( - 'Reconnect the SSH target' - ) - }) -}) diff --git a/src/main/providers/ssh-filesystem-doc-preview.ts b/src/main/providers/ssh-filesystem-doc-preview.ts deleted file mode 100644 index 004a09f92db1..000000000000 --- a/src/main/providers/ssh-filesystem-doc-preview.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { - DocPreviewFileAccessRequest, - DocPreviewFileAccessResult -} from '../../shared/doc-preview-file-access' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' - -const REMOTE_DOC_PREVIEW_UPDATE_REQUIRED = - 'Secure document previews require a newer SSH relay. Reconnect the SSH target and try again.' - -export async function readSshDocPreviewFile( - mux: SshChannelMultiplexer, - request: DocPreviewFileAccessRequest -): Promise { - try { - return (await mux.request('fs.readDocPreview', request)) as DocPreviewFileAccessResult - } catch (error) { - if (isMethodNotFoundError(error)) { - throw new Error(REMOTE_DOC_PREVIEW_UPDATE_REQUIRED) - } - throw error - } -} diff --git a/src/main/providers/ssh-filesystem-download.test.ts b/src/main/providers/ssh-filesystem-download.test.ts deleted file mode 100644 index 1017f1056088..000000000000 --- a/src/main/providers/ssh-filesystem-download.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { DirectoryTransferBudget } from '../ssh/ssh-directory-transfer-budget' -import { withSftpDirectoryHandles } from './sftp-directory-test-fixture' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { downloadFolderViaSftp } from './ssh-filesystem-download' - -type SftpEntryKind = 'directory' | 'file' | 'symlink' | 'fifo' - -function sftpStats(kind: SftpEntryKind) { - return { - size: 0, - mode: 0, - uid: 0, - gid: 0, - atime: 0, - mtime: 0, - isDirectory: () => kind === 'directory', - isFile: () => kind === 'file', - isSymbolicLink: () => kind === 'symlink', - isFIFO: () => kind === 'fifo', - isSocket: () => false, - isBlockDevice: () => false, - isCharacterDevice: () => false - } -} - -function sftpEntry(filename: string, kind: SftpEntryKind) { - return { filename, longname: filename, attrs: sftpStats(kind) } -} - -describe('downloadFolderViaSftp', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function createDestination(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - roots.push(root) - return join(root, 'src') - } - - it('uses exclusive local reservations instead of guessing destination case sensitivity', async () => { - const destination = await createDestination() - let transferCount = 0 - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('A.txt', 'file'), sftpEntry('a.txt', 'file')]) - ), - fastGet: vi.fn((_source: string, _destination: string, callback: (err?: Error) => void) => { - transferCount += 1 - if (transferCount === 1) { - void writeFile(join(destination, 'a.txt'), 'claimed').then(() => callback()) - return - } - callback() - }), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp(async () => withSftpDirectoryHandles(sftp), '/remote/src', destination) - ).rejects.toThrow("Remote entries map to the same local name 'a.txt'") - expect(sftp.fastGet).toHaveBeenCalledTimes(1) - }) - - it('rejects special remote entries before SFTP tries to open them', async () => { - const destination = await createDestination() - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('build.pipe', 'fifo')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp(async () => withSftpDirectoryHandles(sftp), '/remote/src', destination) - ).rejects.toThrow("Cannot download unsupported remote entry 'build.pipe'") - expect(sftp.fastGet).not.toHaveBeenCalled() - }) - - it('rejects a file symlink that could escape the selected remote tree', async () => { - const destination = await createDestination() - const sftp = { - stat: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'file')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('creds', 'symlink')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp(async () => withSftpDirectoryHandles(sftp), '/remote/src', destination) - ).rejects.toThrow("Cannot download symbolic link 'creds'") - // The link target could be /etc/passwd; rejecting from directory-entry - // metadata means it is never followed with stat or opened by fastGet. - expect(sftp.stat).toHaveBeenCalledTimes(1) - expect(sftp.fastGet).not.toHaveBeenCalled() - }) - - it('sanitizes extended Windows device names in nested entries', async () => { - const destination = await createDestination() - const records = vi.spyOn(DirectoryTransferBudget.prototype, 'record') - const releases = vi.spyOn(DirectoryTransferBudget.prototype, 'release') - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('CONIN$', 'file'), sftpEntry('COM¹.txt', 'file')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp(async () => withSftpDirectoryHandles(sftp), '/remote/src', destination) - ).rejects.toThrow("Remote entries map to the same local name 'download'") - expect(sftp.fastGet).not.toHaveBeenCalled() - expect(records).toHaveBeenCalledTimes(3) - expect(releases).toHaveBeenCalledWith( - records.mock.results.reduce((bytes, record) => bytes + record.value, 0), - 3 - ) - records.mockRestore() - releases.mockRestore() - }) - - it('preserves legal POSIX backslashes in opaque SFTP child names', async () => { - const destination = await createDestination() - const sourcePath = '/remote/parent\\literal' - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('..\\secret.txt', 'file')]) - ), - fastGet: vi.fn((_source: string, _destination: string, callback: (err?: Error) => void) => - callback() - ), - end: vi.fn() - } - - await downloadFolderViaSftp( - async () => withSftpDirectoryHandles(sftp), - sourcePath, - destination, - { - windowsRemotePaths: false - } - ) - - expect(sftp.fastGet).toHaveBeenCalledWith( - '/remote/parent\\literal/..\\secret.txt', - join(destination, '.._secret.txt'), - expect.any(Function) - ) - }) - - it('rejects Windows-path traversal names when the remote host is Windows', async () => { - const destination = await createDestination() - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('..\\secret.txt', 'file')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp( - async () => withSftpDirectoryHandles(sftp), - 'C:/remote/src', - destination, - { - windowsRemotePaths: true - } - ) - ).rejects.toThrow("Invalid remote directory entry '..\\secret.txt'") - expect(sftp.fastGet).not.toHaveBeenCalled() - }) - - it('waits for active SFTP file handles to quiesce when canceled', async () => { - const destination = await createDestination() - let fastGetCallback: ((error?: Error) => void) | undefined - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('first.txt', 'file'), sftpEntry('second.txt', 'file')]) - ), - fastGet: vi.fn((_source: string, _destination: string, callback: (error?: Error) => void) => { - fastGetCallback = callback - }), - end: vi.fn() - } - const controller = new AbortController() - - const result = downloadFolderViaSftp( - async () => withSftpDirectoryHandles(sftp), - '/remote/src', - destination, - { - signal: controller.signal - } - ) - await vi.waitFor(() => expect(sftp.fastGet).toHaveBeenCalledTimes(1)) - controller.abort(new Error('renderer closed')) - - let settled = false - void result.then( - () => { - settled = true - }, - () => { - settled = true - } - ) - await Promise.resolve() - expect(settled).toBe(false) - fastGetCallback?.(new Error('channel closed')) - - await expect(result).rejects.toThrow('renderer closed') - expect(sftp.fastGet).toHaveBeenCalledTimes(1) - expect(sftp.end).toHaveBeenCalledTimes(1) - }) - - it('cancels a pending SFTP directory read', async () => { - const destination = await createDestination() - let readDirCallback: ((error?: Error) => void) | undefined - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (error?: Error) => void) => { - readDirCallback = callback - }), - fastGet: vi.fn(), - end: vi.fn() - } - const controller = new AbortController() - - const result = downloadFolderViaSftp( - async () => withSftpDirectoryHandles(sftp), - '/remote/src', - destination, - { - signal: controller.signal - } - ) - await vi.waitFor(() => expect(sftp.readdir).toHaveBeenCalledTimes(1)) - controller.abort(new Error('renderer closed')) - readDirCallback?.(new Error('channel closed')) - - await expect(result).rejects.toThrow('renderer closed') - expect(sftp.fastGet).not.toHaveBeenCalled() - expect(sftp.end).toHaveBeenCalledTimes(1) - }) -}) - -it.each(['EOF', 'CLOSE'])( - 'does not create an empty destination when cancelled at %s', - async (boundary) => { - const root = await mkdtemp(join(tmpdir(), 'orca-sftp-final-abort-')) - const destination = join(root, 'target') - const controller = new AbortController() - const reason = new Error('cancel at final boundary') - const sftp = withSftpDirectoryHandles({ - stat: (_path: string, callback: (error: undefined, stats: unknown) => void) => - callback(undefined, sftpStats('directory')), - readdir: (_path: string, callback: (error: undefined, entries: unknown) => void) => - callback(undefined, []), - end: vi.fn() - }) - sftp.readdir = (_handle, callback) => { - callback(Object.assign(new Error('EOF'), { code: 1 }), []) - } - if (boundary === 'EOF') { - sftp.readdir = (_handle, callback) => { - callback(Object.assign(new Error('EOF'), { code: 1 }), []) - controller.abort(reason) - } - } else { - sftp.close = (_handle, callback) => { - controller.abort(reason) - callback(null) - } - } - try { - await expect( - downloadFolderViaSftp(async () => sftp, '/empty', destination, { - signal: controller.signal - }) - ).rejects.toBe(reason) - const { access } = await import('node:fs/promises') - await expect(access(destination)).rejects.toMatchObject({ code: 'ENOENT' }) - } finally { - await rm(root, { recursive: true, force: true }) - } - } -) diff --git a/src/main/providers/ssh-filesystem-download.ts b/src/main/providers/ssh-filesystem-download.ts deleted file mode 100644 index 97ef78e1a916..000000000000 --- a/src/main/providers/ssh-filesystem-download.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { DirectoryTransferBudget } from '../ssh/ssh-directory-transfer-budget' -import { mkdir, open } from 'node:fs/promises' -import { join } from 'node:path' -import type { FileEntryWithStats, SFTPWrapper } from 'ssh2' - -import { - isWindowsAbsolutePathLike, - normalizeRuntimePathSeparators -} from '../../shared/cross-platform-path' -import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { - fastGetViaSftp, - readDirectoryEntriesViaSftp, - statViaSftp -} from './ssh-filesystem-provider-sftp' - -export type SftpFactory = (options?: { signal?: AbortSignal }) => Promise - -/** When known, windowsRemotePaths drives remote path joining; omit uses path-shape heuristics. */ -export type FolderDownloadOptions = { signal?: AbortSignal; windowsRemotePaths?: boolean } - -const DOWNLOAD_UNAVAILABLE_MESSAGE = - 'Remote folder download is unavailable. Reconnect the SSH target and retry.' - -function isEEXIST(error: unknown): boolean { - return ( - error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST' - ) -} - -async function reserveLocalFile(localPath: string, localName: string): Promise { - try { - const handle = await open(localPath, 'wx') - await handle.close() - } catch (error) { - if (isEEXIST(error)) { - throw new Error(`Remote entries map to the same local name '${localName}'`) - } - throw error - } -} - -function remotePathsAreWindows(sourceDir: string, windowsRemotePaths?: boolean): boolean { - // Why: remote path rules belong to the SSH host. Prefer the known host platform - // and only fall back to string shape when the provider could not supply it. - if (windowsRemotePaths !== undefined) { - return windowsRemotePaths - } - return isWindowsAbsolutePathLike(sourceDir) -} - -function joinSftpChildPath( - sourceDir: string, - childName: string, - windowsRemotePaths?: boolean -): string { - const windowsPath = remotePathsAreWindows(sourceDir, windowsRemotePaths) - if ( - !childName || - childName === '.' || - childName === '..' || - childName.includes('/') || - (windowsPath && childName.includes('\\')) - ) { - throw new Error(`Invalid remote directory entry '${childName}'`) - } - const normalizedSource = windowsPath ? normalizeRuntimePathSeparators(sourceDir) : sourceDir - return `${normalizedSource.replace(/\/+$/g, '')}/${childName}` -} - -function classifySftpEntry(entry: FileEntryWithStats): 'directory' | 'file' { - if (entry.attrs.isSymbolicLink()) { - // Why: following either file or directory links can escape the selected tree; - // local symlink creation is also not portable across Orca's supported hosts. - throw new Error(`Cannot download symbolic link '${entry.filename}'`) - } - if (entry.attrs.isDirectory()) { - return 'directory' - } - if (entry.attrs.isFile()) { - return 'file' - } - throw new Error(`Cannot download unsupported remote entry '${entry.filename}'`) -} - -async function downloadDirectoryTree( - sftp: SFTPWrapper, - sourceDir: string, - destinationDir: string, - signal?: AbortSignal, - windowsRemotePaths?: boolean, - budget = new DirectoryTransferBudget(), - depth = 0 -): Promise { - signal?.throwIfAborted() - const usedLocalNames = new Set() - const plannedEntries: { - remoteName: string - kind: 'directory' | 'file' - localName: string - }[] = [] - let retainedBytes = budget.record([sourceDir, destinationDir], depth) - let retainedEntries = 1 - try { - for await (const entry of readDirectoryEntriesViaSftp(sftp, sourceDir, { signal })) { - const localName = sanitizeLocalDownloadFilename(entry.filename) - retainedBytes += budget.record([sourceDir, destinationDir, entry.filename, localName], depth) - retainedEntries++ - if (usedLocalNames.has(localName)) { - throw new Error(`Remote entries map to the same local name '${localName}'`) - } - usedLocalNames.add(localName) - plannedEntries.push({ - remoteName: entry.filename, - kind: classifySftpEntry(entry), - localName - }) - } - - signal?.throwIfAborted() - await mkdir(destinationDir, { recursive: false }) - signal?.throwIfAborted() - for (const { remoteName, kind, localName } of plannedEntries) { - signal?.throwIfAborted() - const remotePath = joinSftpChildPath(sourceDir, remoteName, windowsRemotePaths) - const localPath = join(destinationDir, localName) - if (kind === 'directory') { - await downloadDirectoryTree( - sftp, - remotePath, - localPath, - signal, - windowsRemotePaths, - budget, - depth + 1 - ) - continue - } - // Why: filesystem semantics belong to the selected volume, not the host OS; - // an exclusive placeholder prevents case/Unicode aliases from overwriting. - await reserveLocalFile(localPath, localName) - await fastGetViaSftp(sftp, remotePath, localPath, { signal }) - } - signal?.throwIfAborted() - } finally { - budget.release(retainedBytes, retainedEntries) - } -} - -export async function downloadFileViaSftp( - createSftp: SftpFactory | undefined, - sourcePath: string, - destinationPath: string -): Promise { - if (!createSftp) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - const sftp = await createSftp() - try { - await fastGetViaSftp(sftp, sourcePath, destinationPath) - } finally { - sftp.end() - } -} - -export async function downloadFolderViaSftp( - createSftp: SftpFactory | undefined, - sourcePath: string, - destinationPath: string, - options?: FolderDownloadOptions -): Promise { - if (!createSftp) { - throw new Error(DOWNLOAD_UNAVAILABLE_MESSAGE) - } - const signal = options?.signal - signal?.throwIfAborted() - const sftp = await createSftp({ signal }) - let ended = false - const endSftp = (): void => { - if (!ended) { - ended = true - try { - sftp.end() - } catch { - // Why: cleanup is best-effort and must not mask the transfer or abort error. - } - } - } - signal?.addEventListener('abort', endSftp, { once: true }) - try { - const rootStats = await statViaSftp(sftp, sourcePath, { signal }) - if (!rootStats.isDirectory()) { - throw new Error('Cannot download a file as a folder') - } - await downloadDirectoryTree( - sftp, - sourcePath, - destinationPath, - signal, - options?.windowsRemotePaths - ) - signal?.throwIfAborted() - } finally { - signal?.removeEventListener('abort', endSftp) - endSftp() - } -} diff --git a/src/main/providers/ssh-filesystem-file-upload.ts b/src/main/providers/ssh-filesystem-file-upload.ts deleted file mode 100644 index 6fa83145d692..000000000000 --- a/src/main/providers/ssh-filesystem-file-upload.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import { uploadFile as uploadFileViaSftp } from '../ssh/sftp-upload' -import type { FileUploadSession } from './types' - -export type SftpFactory = () => Promise - -export type SshRawTransferOptions = { - downloadFile?: (sourcePath: string, destinationPath: string) => Promise - openFileUploadSession?: () => Promise - writeBuffer?: ( - remotePath: string, - contents: Buffer, - options: { append: boolean; exclusive: boolean } - ) => Promise -} - -export async function openSshFileUploadSession( - createSftp?: SftpFactory, - rawTransfer?: SshRawTransferOptions -): Promise { - if (rawTransfer?.openFileUploadSession) { - return rawTransfer.openFileUploadSession() - } - if (!createSftp) { - throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') - } - const sftp = await createSftp() - return { - // Why: one session covers the whole import so normal SSH keeps its prior - // channel count even when a directory contains many files. - uploadFile: (sourcePath, destinationPath, options) => - uploadFileViaSftp(sftp, sourcePath, destinationPath, options), - close: () => sftp.end() - } -} diff --git a/src/main/providers/ssh-filesystem-listing-params.ts b/src/main/providers/ssh-filesystem-listing-params.ts deleted file mode 100644 index 61bb308c3baf..000000000000 --- a/src/main/providers/ssh-filesystem-listing-params.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { IFilesystemProvider } from './types' - -export function sshFilesystemListingParams( - rootPath: string, - options?: Parameters[1] -): Record { - return { - rootPath, - ...(options?.candidatePaths === undefined ? {} : { candidatePaths: options.candidatePaths }), - ...(options?.excludePaths?.length ? { excludePaths: options.excludePaths } : {}), - ...(options?.maxResults === undefined ? {} : { maxResults: options.maxResults }), - ...(options?.searchQuery === undefined ? {} : { searchQuery: options.searchQuery }), - ...(options?.includeIgnored === undefined ? {} : { includeIgnored: options.includeIgnored }), - ...(options?.followSymlinks === undefined ? {} : { followSymlinks: options.followSymlinks }) - } -} diff --git a/src/main/providers/ssh-filesystem-path-existence.ts b/src/main/providers/ssh-filesystem-path-existence.ts deleted file mode 100644 index 08570fc17b3c..000000000000 --- a/src/main/providers/ssh-filesystem-path-existence.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { isENOENT } from '../ipc/filesystem-path-containment' -import { - capturePathExistence, - requirePathExistenceResults, - validatePathExistenceBatch, - type PathExistenceResult -} from '../../shared/path-existence-batch' -import { probeSshPathExistenceBatchCapability } from './ssh-filesystem-provider-capabilities' - -export async function readSshPathExistenceBatch( - mux: SshChannelMultiplexer, - paths: string[], - stat: (path: string) => Promise -): Promise { - validatePathExistenceBatch(paths) - if (await probeSshPathExistenceBatchCapability(mux)) { - try { - return requirePathExistenceResults( - await mux.request('fs.pathsExist', { filePaths: paths }), - paths.length - ) - } catch (error) { - if (!isMethodNotFoundError(error)) { - throw error - } - } - } - return Promise.all( - paths.map((path) => - capturePathExistence(async () => { - try { - await stat(path) - return true - } catch (error) { - if (isENOENT(error)) { - return false - } - throw error - } - }) - ) - ) -} diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.test.ts b/src/main/providers/ssh-filesystem-provider-capabilities.test.ts deleted file mode 100644 index f00f8a965d07..000000000000 --- a/src/main/providers/ssh-filesystem-provider-capabilities.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - probeSshQuickOpenSearchCapability, - probeSshRangedReadCapability -} from './ssh-filesystem-provider-capabilities' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' - -describe('SSH Quick Open capability probe', () => { - it.each([1, 2, 3, 4])( - 'separates query and modern capabilities on version %s', - async (version) => { - const mux = new SshChannelMultiplexer({ write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }) - const request = vi - .spyOn(mux, 'request') - .mockResolvedValue({ quickOpenSearchVersion: version }) - try { - await expect(probeSshQuickOpenSearchCapability(mux, undefined, 1)).resolves.toBe(true) - await expect(probeSshQuickOpenSearchCapability(mux)).resolves.toBe(version >= 3) - expect(request).toHaveBeenCalledTimes(1) - expect(request).toHaveBeenCalledWith('fs.getCapabilities', undefined, { - signal: undefined, - timeoutMs: 5_000 - }) - } finally { - mux.dispose() - } - } - ) - - it('lets callers treat a missing capability as legacy', async () => { - const mux = { - request: vi - .fn() - .mockRejectedValue( - Object.assign(new Error('method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - } - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) - expect(mux.request).toHaveBeenCalledTimes(1) - }) - - it('does not downgrade transport failures to legacy behavior', async () => { - const mux = { request: vi.fn().mockRejectedValue(new Error('connection closed')) } - await expect(probeSshQuickOpenSearchCapability(mux as never)).rejects.toThrow( - 'connection closed' - ) - }) -}) - -describe('SSH filesystem capability document', () => { - // fs.getCapabilities answers every feature at once. Probing per feature would - // spend an extra round trip per connection for a document already in hand. - it('is fetched once for every feature probe on a connection', async () => { - const mux = { - request: vi.fn().mockResolvedValue({ quickOpenSearchVersion: 3, rangedReadVersion: 1 }) - } - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(true) - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) - expect(mux.request).toHaveBeenCalledTimes(1) - }) - - it('reads each feature independently off the shared document', async () => { - const mux = { request: vi.fn().mockResolvedValue({ quickOpenSearchVersion: 3 }) } - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(true) - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(false) - }) - - // A transport failure is not a verdict about the host, so it must not be the - // cached answer for the rest of the connection's life. - it('retries after a failed fetch instead of caching the failure', async () => { - const mux = { - request: vi - .fn() - .mockRejectedValueOnce(new Error('connection closed')) - .mockResolvedValue({ rangedReadVersion: 1 }) - } - await expect(probeSshRangedReadCapability(mux as never)).rejects.toThrow('connection closed') - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - // One document now backs every feature, so a caller walking away from its own - // probe must not evict the fetch a second feature is still waiting on -- - // otherwise an aborted quick-open probe costs the ranged-read probe a round - // trip, and a caller that keeps aborting re-fetches forever. - it('keeps the in-flight document when one caller aborts its own probe', async () => { - let settle: (value: Record) => void = () => {} - const mux = { - request: vi - .fn() - .mockImplementationOnce( - () => - new Promise>((resolve) => { - settle = resolve - }) - ) - .mockResolvedValue({ rangedReadVersion: 1 }) - } - const controller = new AbortController() - const abandoned = probeSshQuickOpenSearchCapability(mux as never, controller.signal) - const patient = probeSshRangedReadCapability(mux as never) - controller.abort() - await expect(abandoned).rejects.toThrow('client_disconnected') - - settle({ rangedReadVersion: 1 }) - await expect(patient).resolves.toBe(true) - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) - expect(mux.request).toHaveBeenCalledTimes(1) - }) - - it('retries when the fetch fails after its only waiter aborts', async () => { - let rejectFetch: (error: Error) => void = () => {} - const mux = { - request: vi - .fn() - .mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectFetch = reject - }) - ) - .mockResolvedValue({ rangedReadVersion: 1 }) - } - const controller = new AbortController() - const abandoned = probeSshRangedReadCapability(mux as never, controller.signal) - controller.abort() - await expect(abandoned).rejects.toThrow('client_disconnected') - - rejectFetch(new Error('connection closed')) - await new Promise((resolve) => setImmediate(resolve)) - - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(true) - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('treats a non-object response as no capabilities', async () => { - const mux = { request: vi.fn().mockResolvedValue('yes') } - await expect(probeSshRangedReadCapability(mux as never)).resolves.toBe(false) - await expect(probeSshQuickOpenSearchCapability(mux as never)).resolves.toBe(false) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.ts b/src/main/providers/ssh-filesystem-provider-capabilities.ts deleted file mode 100644 index 800999c30535..000000000000 --- a/src/main/providers/ssh-filesystem-provider-capabilities.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { QUICK_OPEN_SEARCH_VERSION } from '../../shared/quick-open-path-search' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { waitForSshCapabilityProbe } from './ssh-capability-probe-waiter' - -/** `null` means the host has no capability document at all: it predates - * `fs.getCapabilities`, or answered with something that is not an object. - * Either way every feature reads as unsupported. */ -type RelayFsCapabilities = Record | null - -const capabilitiesByMux = new WeakMap>() - -/** One `fs.getCapabilities` per multiplexer, shared by every feature probe -- - * the response is a single document, so fetching it once per feature would - * spend a round trip for nothing. Keyed by the multiplexer, which is replaced - * on reconnect, so a host upgraded behind a reconnect is re-probed. - * - * A failed fetch is evicted so the next probe retries; a resolved one is not, - * because a relay does not gain methods without a new connection. */ -function readSshFsCapabilities( - mux: SshChannelMultiplexer, - signal?: AbortSignal -): Promise { - const cached = capabilitiesByMux.get(mux) - const probe = - cached ?? - mux - .request('fs.getCapabilities', undefined, { timeoutMs: 5_000 }) - .then((result) => - typeof result === 'object' && result !== null ? (result as Record) : null - ) - .catch((error) => { - if (isMethodNotFoundError(error)) { - return null - } - throw error - }) - if (!cached) { - capabilitiesByMux.set(mux, probe) - void probe.catch(() => { - if (capabilitiesByMux.get(mux) === probe) { - capabilitiesByMux.delete(mux) - } - }) - } - return waitForSshCapabilityProbe(probe, signal) -} - -export function probeSshQuickOpenSearchCapability( - mux: SshChannelMultiplexer, - signal?: AbortSignal, - minimumVersion = QUICK_OPEN_SEARCH_VERSION -): Promise { - return readSshFsCapabilities(mux, signal).then( - (capabilities) => - typeof capabilities?.quickOpenSearchVersion === 'number' && - capabilities.quickOpenSearchVersion >= minimumVersion - ) -} - -export function probeSshRangedReadCapability( - mux: SshChannelMultiplexer, - signal?: AbortSignal -): Promise { - return readSshFsCapabilities(mux, signal).then( - (capabilities) => capabilities?.rangedReadVersion === 1 - ) -} - -export function probeSshPathExistenceBatchCapability(mux: SshChannelMultiplexer): Promise { - return readSshFsCapabilities(mux).then( - (capabilities) => capabilities?.pathExistenceBatchVersion === 1 - ) -} diff --git a/src/main/providers/ssh-filesystem-provider-download-folder.test.ts b/src/main/providers/ssh-filesystem-provider-download-folder.test.ts deleted file mode 100644 index 9166fde83b44..000000000000 --- a/src/main/providers/ssh-filesystem-provider-download-folder.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { withSftpDirectoryHandles } from './sftp-directory-test-fixture' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtemp, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { SshFilesystemProvider } from './ssh-filesystem-provider' - -type SftpEntryKind = 'directory' | 'file' | 'symlink' - -function sftpStats(kind: SftpEntryKind) { - return { - size: 0, - mode: 0, - uid: 0, - gid: 0, - atime: 0, - mtime: 0, - isDirectory: () => kind === 'directory', - isFile: () => kind === 'file', - isSymbolicLink: () => kind === 'symlink', - isFIFO: () => false, - isSocket: () => false, - isBlockDevice: () => false, - isCharacterDevice: () => false - } -} - -function sftpEntry(filename: string, kind: SftpEntryKind) { - return { filename, longname: filename, attrs: sftpStats(kind) } -} - -function createMockMux() { - return { - request: vi.fn(), - onNotification: vi.fn(() => () => {}) - } -} - -describe('SshFilesystemProvider downloadFolder', () => { - let mux: ReturnType - let provider: SshFilesystemProvider - const localDownloadRoots: string[] = [] - - beforeEach(() => { - mux = createMockMux() - provider = new SshFilesystemProvider('conn-1', mux as never) - }) - - afterEach(async () => { - await Promise.all( - localDownloadRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) - ) - }) - - it('omits folder capability without SFTP while retaining raw file download', async () => { - const downloadFile = vi.fn().mockResolvedValue(undefined) - provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { downloadFile }) - - expect(provider.downloadFolder).toBeUndefined() - await provider.downloadFile('/remote/report.pdf', '/downloads/report.pdf') - expect(downloadFile).toHaveBeenCalledWith('/remote/report.pdf', '/downloads/report.pdf') - }) - - it('downloads a recursive tree through one SFTP session', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - localDownloadRoots.push(root) - const sftp = { - stat: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'file')) - ), - readdir: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback( - undefined, - remotePath === '/remote/src' - ? [sftpEntry('index.ts', 'file'), sftpEntry('lib', 'directory')] - : [sftpEntry('a.ts', 'file')] - ) - ), - fastGet: vi.fn((_source: string, _destination: string, callback: (err?: Error) => void) => - callback() - ), - end: vi.fn() - } - const createSftp = vi.fn(async () => withSftpDirectoryHandles(sftp)) - provider = new SshFilesystemProvider('conn-1', mux as never, createSftp) - const destination = join(root, 'src') - - await provider.downloadFolder!('/remote/src', destination) - - expect(createSftp).toHaveBeenCalledTimes(1) - expect(sftp.fastGet.mock.calls.map(([source, local]) => [source, local])).toEqual([ - ['/remote/src/index.ts', join(destination, 'index.ts')], - ['/remote/src/lib/a.ts', join(destination, 'lib', 'a.ts')] - ]) - await expect(stat(join(destination, 'lib'))).resolves.toMatchObject({}) - expect(sftp.end).toHaveBeenCalledTimes(1) - expect(mux.request).not.toHaveBeenCalled() - }) - - it('rejects directory symlinks without following them', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - localDownloadRoots.push(root) - const sftp = { - stat: vi.fn( - (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('linked-dir', 'symlink')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture multiplexer implements the request and notification methods used by this provider. - provider = new SshFilesystemProvider('conn-1', mux as never, async () => - withSftpDirectoryHandles(sftp) - ) - - await expect(provider.downloadFolder!('/remote/src', join(root, 'src'))).rejects.toThrow( - "Cannot download symbolic link 'linked-dir'" - ) - - expect(sftp.fastGet).not.toHaveBeenCalled() - expect(sftp.end).toHaveBeenCalledTimes(1) - }) - - it('rejects remote names that sanitize to the same local filename', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - localDownloadRoots.push(root) - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, [sftpEntry('a:b.txt', 'file'), sftpEntry('a?b.txt', 'file')]) - ), - fastGet: vi.fn(), - end: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture multiplexer implements the request and notification methods used by this provider. - provider = new SshFilesystemProvider('conn-1', mux as never, async () => - withSftpDirectoryHandles(sftp) - ) - - await expect(provider.downloadFolder!('/remote/src', join(root, 'src'))).rejects.toThrow( - "Remote entries map to the same local name 'a_b.txt'" - ) - - expect(sftp.fastGet).not.toHaveBeenCalled() - expect(sftp.end).toHaveBeenCalledTimes(1) - }) - - it('cancels a pending SFTP session open', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ssh-folder-download-')) - localDownloadRoots.push(root) - const createSftp = vi.fn( - async (options?: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(new Error('open canceled')), { - once: true - }) - }) - ) - provider = new SshFilesystemProvider('conn-1', mux as never, createSftp) - const controller = new AbortController() - - const result = provider.downloadFolder!('/remote/src', join(root, 'src'), { - signal: controller.signal - }) - await vi.waitFor(() => expect(createSftp).toHaveBeenCalledTimes(1)) - controller.abort(new Error('renderer closed')) - - await expect(result).rejects.toThrow('open canceled') - expect(createSftp).toHaveBeenCalledWith({ signal: controller.signal }) - expect(mux.request).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-range.test.ts b/src/main/providers/ssh-filesystem-provider-range.test.ts deleted file mode 100644 index 3dee006d59d0..000000000000 --- a/src/main/providers/ssh-filesystem-provider-range.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { SshFilesystemProvider } from './ssh-filesystem-provider' -import { FileRangeReadUnsupportedError } from './filesystem-provider-contract' -import { FileRangeReadRequestError, MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' - -function methodNotFound(): Error { - const err = new Error('Method not found: fs.readFileRange') as Error & { code?: number } - err.code = -32601 - return err -} - -function providerWith(request: ReturnType): SshFilesystemProvider { - const mux = { request, onNotification: () => () => {} } as unknown as SshChannelMultiplexer - return new SshFilesystemProvider('conn-1', mux) -} - -describe('SshFilesystemProvider.readFileRange', () => { - it('decodes the base64 window the relay returned', async () => { - const payload = Buffer.from('hello') - const request = vi.fn().mockResolvedValue({ - base64: payload.toString('base64'), - bytesRead: payload.length - }) - const result = await providerWith(request).readFileRange('/x.jsonl', 7, 5) - expect(result.bytes).toEqual(payload) - expect(result.bytesRead).toBe(5) - expect(request).toHaveBeenCalledWith( - 'fs.readFileRange', - { filePath: '/x.jsonl', position: 7, length: 5 }, - undefined - ) - }) - - it('reports a short window as read without complaint', async () => { - const payload = Buffer.from('tail') - const request = vi.fn().mockResolvedValue({ - base64: payload.toString('base64'), - bytesRead: payload.length - }) - const result = await providerWith(request).readFileRange('/x.jsonl', 0, 64) - expect(result.bytesRead).toBe(4) - expect(result.bytes).toEqual(payload) - }) - - it('forwards an abort signal to the multiplexer', async () => { - const controller = new AbortController() - const request = vi.fn().mockResolvedValue({ base64: '', bytesRead: 0 }) - await providerWith(request).readFileRange('/x.jsonl', 0, 4, { signal: controller.signal }) - expect(request).toHaveBeenCalledWith('fs.readFileRange', expect.anything(), { - signal: controller.signal - }) - }) - - // Throwing beats a whole-file fallback here: a tailing caller issues several - // reads per snapshot, so falling back per call is quadratic on a growing file. - it('throws a typed unsupported error against a relay without the method', async () => { - const request = vi.fn().mockRejectedValue(methodNotFound()) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 4)).rejects.toBeInstanceOf( - FileRangeReadUnsupportedError - ) - }) - - it('propagates a non-capability error unchanged', async () => { - const request = vi.fn().mockRejectedValue(new Error('EACCES: permission denied')) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 4)).rejects.toThrow(/EACCES/) - }) - - // The client runs the host's own validator, so a request the host would - // refuse fails as a typed error instead of an opaque -32000 a round trip later. - describe('request validation', () => { - it.each([ - ['a negative position', -1, 4], - ['a fractional position', 0.5, 4], - ['a zero length', 0, 0], - ['a negative length', 0, -1], - ['an over-cap length', 0, MAX_FILE_RANGE_READ_BYTES + 1], - ['a window past safe-integer offsets', Number.MAX_SAFE_INTEGER, 2] - ])('rejects %s without a round trip', async (_label, position, length) => { - const request = vi.fn() - await expect( - providerWith(request).readFileRange('/x.jsonl', position, length) - ).rejects.toBeInstanceOf(FileRangeReadRequestError) - expect(request).not.toHaveBeenCalled() - }) - }) - - // The remote is untrusted for framing: a count disagreeing with the payload - // would shift every downstream offset while looking like a success. - it('rejects a byte count that disagrees with the payload', async () => { - const request = vi.fn().mockResolvedValue({ - base64: Buffer.from('ab').toString('base64'), - bytesRead: 9 - }) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 10)).rejects.toThrow( - /inconsistent byte count/ - ) - }) - - it('rejects a byte count larger than the requested length', async () => { - const payload = Buffer.from('abcdef') - const request = vi.fn().mockResolvedValue({ - base64: payload.toString('base64'), - bytesRead: payload.length - }) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 2)).rejects.toThrow( - /inconsistent byte count/ - ) - }) - - it('rejects a malformed response', async () => { - const request = vi.fn().mockResolvedValue({ bytesRead: 3 }) - await expect(providerWith(request).readFileRange('/x.jsonl', 0, 3)).rejects.toThrow( - /malformed response/ - ) - }) -}) - -describe('SshFilesystemProvider.supportsFileRangeRead', () => { - it('is true when the relay advertises the capability', async () => { - const request = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) - await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(true) - }) - - it('is false when the relay omits it', async () => { - const request = vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1 }) - await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(false) - }) - - it('is false when the relay predates fs.getCapabilities', async () => { - const request = vi.fn().mockRejectedValue(methodNotFound()) - await expect(providerWith(request).supportsFileRangeRead()).resolves.toBe(false) - }) - - it('probes once per connection and caches the answer', async () => { - const request = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) - const provider = providerWith(request) - await provider.supportsFileRangeRead() - await provider.supportsFileRangeRead() - expect(request).toHaveBeenCalledTimes(1) - }) - - // The cache is keyed by the multiplexer, which is replaced on reconnect, so a - // second connection must not inherit the first host's answer. - it('does not share the answer across connections', async () => { - const legacy = vi.fn().mockResolvedValue({ quickOpenSearchVersion: 1 }) - const current = vi.fn().mockResolvedValue({ rangedReadVersion: 1 }) - await expect(providerWith(legacy).supportsFileRangeRead()).resolves.toBe(false) - await expect(providerWith(current).supportsFileRangeRead()).resolves.toBe(true) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts deleted file mode 100644 index 82defb9d0cce..000000000000 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshMultiplexerRequestOptions } from '../ssh/ssh-channel-multiplexer' -import { SshFilesystemProvider } from './ssh-filesystem-provider' -import { SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS } from '../ssh/ssh-file-stream-inactivity-deadline' -import { publishSystemResume, publishSystemSuspend } from '../system-power-lifecycle' - -type MockMultiplexer = { - request: ReturnType - _response: ReturnType - notify: ReturnType - onNotification: ReturnType - onNotificationByMethod: ReturnType - onDispose: ReturnType - dispose: ReturnType - isDisposed: ReturnType - _emitMethod: (method: string, params: Record) => void - _listenerCount: () => number -} - -function createMockMux(): MockMultiplexer { - const response = vi.fn().mockResolvedValue(undefined) - const methodHandlers = new Map) => void>>() - const disposeHandlers = new Set<(reason: 'shutdown' | 'connection_lost') => void>() - return { - _response: response, - request: vi.fn( - async ( - method: string, - params?: Record, - options?: SshMultiplexerRequestOptions - ) => { - const result: unknown = await response(method, params) - options?.beforeResolve?.(result) - return result - } - ), - notify: vi.fn(), - onNotification: vi.fn(), - onNotificationByMethod: vi.fn( - (method: string, handler: (params: Record) => void) => { - let set = methodHandlers.get(method) - if (!set) { - set = new Set() - methodHandlers.set(method, set) - } - set.add(handler) - return () => set!.delete(handler) - } - ), - onDispose: vi.fn((handler: (reason: 'shutdown' | 'connection_lost') => void) => { - disposeHandlers.add(handler) - return () => { - disposeHandlers.delete(handler) - } - }), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false), - _emitMethod: (method, params) => { - const set = methodHandlers.get(method) - if (set) { - for (const handler of Array.from(set)) { - handler(params) - } - } - }, - _listenerCount: () => - disposeHandlers.size + - [...methodHandlers.values()].reduce((count, handlers) => count + handlers.size, 0) - } -} - -describe('SshFilesystemProvider readFile streaming', () => { - let mux: MockMultiplexer - let provider: SshFilesystemProvider - - beforeEach(() => { - publishSystemResume() - mux = createMockMux() - provider = new SshFilesystemProvider('conn-1', mux as never) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('returns empty metadata and releases its stream listeners', async () => { - mux._response.mockResolvedValue({ totalSize: 0, isBinary: false, empty: true }) - const result = await provider.readFile('/home/user/empty.txt') - expect(result).toEqual({ content: '', isBinary: false }) - expect(mux._listenerCount()).toBe(0) - }) - - it('streams via fs.readFileStream and reassembles utf-8 text', async () => { - const text = 'hello world' - const totalSize = Buffer.byteLength(text, 'utf-8') - mux._response.mockImplementation(async (method: string) => { - if (method !== 'fs.readFileStream') { - throw new Error(`unexpected method ${method}`) - } - // The relay schedules chunks after publishing metadata. - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 0, - data: Buffer.from(text, 'utf-8').toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 1 }) - }) - return { - streamId: 1, - totalSize, - isBinary: false, - chunkEncoding: 'base64', - resultEncoding: 'utf-8' - } - }) - - const result = await provider.readFile('/home/user/file.txt') - expect(mux.request.mock.calls[0]?.slice(0, 2)).toEqual([ - 'fs.readFileStream', - { - filePath: '/home/user/file.txt', - flowControl: 'ack' - } - ]) - expect(result).toEqual({ content: text, isBinary: false }) - }) - - it('falls back to legacy fs.readFile on -32601 method-not-found', async () => { - const legacyResult = { content: 'legacy', isBinary: false } - mux._response.mockImplementation(async (method: string) => { - if (method === 'fs.readFileStream') { - const err = new Error('Method not found') as Error & { code: number } - err.code = -32601 - throw err - } - if (method === 'fs.readFile') { - return legacyResult - } - throw new Error(`unexpected method ${method}`) - }) - - const result = await provider.readFile('/home/user/file.txt') - expect(mux.request).toHaveBeenCalledWith('fs.readFile', { filePath: '/home/user/file.txt' }) - expect(result).toEqual(legacyResult) - }) - - it('rejects when chunk arrives out of order', async () => { - const totalSize = 256 * 1024 * 2 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 1, - data: Buffer.alloc(256 * 1024).toString('base64') - }) - }) - return { - streamId: 1, - totalSize, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - } - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/out-of-order/i) - }) - - it('rejects when totalSize exceeds client cap without allocating', async () => { - mux._response.mockResolvedValue({ - streamId: 1, - totalSize: 51 * 1024 * 1024, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/exceeds client cap/i) - expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 1 }) - }) - - it('applies a caller binary cap before allocating the stream buffer', async () => { - mux._response.mockResolvedValue({ - streamId: 2, - totalSize: 2, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - }) - - await expect(provider.readFile('/home/x.bin', { maxBinaryBytes: 1 })).rejects.toThrow( - /exceeds client cap/i - ) - expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 2 }) - }) - - it('rejects on fs.streamError notification', async () => { - const totalSize = 1024 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamError', { - streamId: 7, - code: 'ENOENT', - message: 'gone' - }) - }) - return { - streamId: 7, - totalSize, - isBinary: false, - chunkEncoding: 'base64', - resultEncoding: 'utf-8' - } - }) - await expect(provider.readFile('/home/x.txt')).rejects.toThrow(/gone/) - }) - - it('cancels and cleans up a stream that stalls after metadata', async () => { - vi.useFakeTimers() - mux._response.mockResolvedValue({ - streamId: 9, - totalSize: 1, - isBinary: false, - resultEncoding: 'utf-8' - }) - - const read = provider.readFile('/home/x.txt') - const rejection = expect(read).rejects.toThrow(/file stream stalled/i) - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS) - - await rejection - expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 9 }) - expect(mux._listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('keeps a long stream alive while chunks continue arriving', async () => { - vi.useFakeTimers() - const chunkSize = 256 * 1024 - mux._response.mockResolvedValue({ - streamId: 10, - totalSize: chunkSize + 1, - isBinary: true, - resultEncoding: 'base64' - }) - - const read = provider.readFile('/home/x.bin') - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - mux._emitMethod('fs.streamChunk', { - streamId: 10, - seq: 0, - data: Buffer.alloc(chunkSize).toString('base64') - }) - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - mux._emitMethod('fs.streamChunk', { - streamId: 10, - seq: 1, - data: Buffer.alloc(1).toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 10 }) - - await expect(read).resolves.toEqual({ - content: Buffer.alloc(chunkSize + 1).toString('base64'), - isBinary: true - }) - expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) - expect(mux._listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('grants an active stream a fresh inactivity window after system resume', async () => { - vi.useFakeTimers() - mux._response.mockResolvedValue({ - streamId: 11, - totalSize: 1, - isBinary: false, - resultEncoding: 'utf-8' - }) - - const read = provider.readFile('/home/x.txt') - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - publishSystemSuspend() - vi.setSystemTime(Date.now() + 60 * 60_000) - publishSystemResume() - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - - expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) - mux._emitMethod('fs.streamChunk', { - streamId: 11, - seq: 0, - data: Buffer.from('x').toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 11 }) - await expect(read).resolves.toEqual({ content: 'x', isBinary: false }) - - publishSystemResume() - expect(mux._listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('keeps metadata received during suspend paused until resume', async () => { - vi.useFakeTimers() - mux._response.mockResolvedValue({ - streamId: 12, - totalSize: 1, - isBinary: false, - resultEncoding: 'utf-8' - }) - - publishSystemSuspend() - const read = provider.readFile('/home/x.txt') - const outcome = vi.fn() - void read.then( - () => outcome('resolved'), - () => outcome('rejected') - ) - await vi.advanceTimersByTimeAsync(60 * 60_000) - - expect(outcome).not.toHaveBeenCalled() - expect(mux.notify).not.toHaveBeenCalledWith('fs.cancelStream', expect.anything()) - publishSystemResume() - await vi.advanceTimersByTimeAsync(SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS - 1) - mux._emitMethod('fs.streamChunk', { - streamId: 12, - seq: 0, - data: Buffer.from('x').toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 12 }) - - await expect(read).resolves.toEqual({ content: 'x', isBinary: false }) - expect(mux._listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('rejects on chunk count mismatch at streamEnd', async () => { - const totalSize = 256 * 1024 * 3 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 0, - data: Buffer.alloc(256 * 1024).toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 1 }) - }) - return { - streamId: 1, - totalSize, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - } - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/count mismatch/i) - }) - - it('rejects a short final chunk instead of zero-filling the buffer', async () => { - // Two declared chunks, but the final one delivers a single byte. The chunk - // count matches (2), so the old code resolved with a zero-filled tail. The - // exact-length check must reject this. - const totalSize = 256 * 1024 * 2 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 0, - data: Buffer.alloc(256 * 1024).toString('base64') - }) - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 1, - data: Buffer.alloc(1).toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 1 }) - }) - return { - streamId: 1, - totalSize, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - } - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i) - }) - - it('rejects a short non-final chunk before later chunks arrive', async () => { - const totalSize = 256 * 1024 * 2 - mux._response.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 0, - data: Buffer.alloc(1).toString('base64') - }) - mux._emitMethod('fs.streamChunk', { - streamId: 1, - seq: 1, - data: Buffer.alloc(256 * 1024).toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 1 }) - }) - return { - streamId: 1, - totalSize, - isBinary: true, - chunkEncoding: 'base64', - resultEncoding: 'base64' - } - }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/length mismatch/i) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts b/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts deleted file mode 100644 index 375ed34848d1..000000000000 --- a/src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { registerSshFilesystemWatch, type WatchRegistration } from './ssh-filesystem-provider-watch' - -describe('registerSshFilesystemWatch waiters', () => { - it('removes ten thousand cancelled callers while one registration anchor remains', async () => { - let resolveWatch: () => void = () => undefined - const mux = { - request: vi.fn( - () => - new Promise((resolve) => { - resolveWatch = resolve - }) - ), - notify: vi.fn() - } as unknown as SshChannelMultiplexer - const registrations = new Map() - const watch = (signal?: AbortSignal) => - registerSshFilesystemWatch({ - mux, - registrations, - rootPath: '/home/user/project', - callback: vi.fn(), - signal, - disposed: () => false - }) - const anchor = watch() - const controllers = Array.from({ length: 10_000 }, () => new AbortController()) - const cancelled = controllers.map((controller) => - watch(controller.signal).catch((error) => error) - ) - - for (const controller of controllers) { - controller.abort() - } - await Promise.all(cancelled) - - expect(registrations.values().next().value?.setupWaiters.waiterCount).toBe(1) - resolveWatch() - await expect(anchor).resolves.toEqual(expect.any(Function)) - expect(registrations.values().next().value?.setupWaiters.waiterCount).toBe(0) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-watch.ts b/src/main/providers/ssh-filesystem-provider-watch.ts deleted file mode 100644 index 95ef73632eb7..000000000000 --- a/src/main/providers/ssh-filesystem-provider-watch.ts +++ /dev/null @@ -1,262 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' - -let nextRemoteWatchId = 1 - -export type WatchRegistration = { - rootPath: string - callbacks: Set<(events: FsChangeEvent[]) => void> - terminalCallbacks: Map<(events: FsChangeEvent[]) => void, (error: Error) => void> - setupWaiters: PromiseSettlementWaiters - setupAbortController: AbortController - remoteWatchId: number | null - ready: boolean - stopping: boolean - unwatchSent: boolean -} - -function createWatchAbortError(): Error { - const error = new Error('Request "fs.watch" was cancelled') as Error & { name: string } - error.name = 'AbortError' - return error -} - -async function awaitSetupWithOptionalAbort( - setupWaiters: PromiseSettlementWaiters, - signal?: AbortSignal -): Promise { - await setupWaiters.wait({ - signal, - createAbortError: createWatchAbortError - }) -} - -export async function registerSshFilesystemWatch(args: { - mux: SshChannelMultiplexer - disposed: () => boolean - registrations: Map - rootPath: string - callback: (events: FsChangeEvent[]) => void - onTerminalError?: (error: Error) => void - signal?: AbortSignal -}): Promise<() => void> { - if (args.disposed()) { - throw new Error('SSH filesystem provider disposed') - } - if (args.signal?.aborted) { - throw createWatchAbortError() - } - - const rootKey = sshFilesystemWatchKey(args.rootPath) - let registration = args.registrations.get(rootKey) - if (registration) { - if (registration.stopping) { - throw createWatchAbortError() - } - registration.callbacks.add(args.callback) - registration.terminalCallbacks.set(args.callback, args.onTerminalError ?? (() => undefined)) - try { - // Why: each caller may leave a shared setup independently; registration - // ownership decides whether the physical relay request should stop. - await awaitSetupWithOptionalAbort(registration.setupWaiters, args.signal) - assertActiveWatch(args, registration) - return createSshFilesystemWatchUnsubscribe(args, registration) - } catch (error) { - releaseSshFilesystemWatchCallback(args, registration) - throw error - } - } - - const callbacks = new Set<(events: FsChangeEvent[]) => void>([args.callback]) - const setupAbortController = new AbortController() - const remoteWatchId = nextRemoteWatchId++ - if (!Number.isSafeInteger(nextRemoteWatchId)) { - nextRemoteWatchId = 1 - } - registration = { - rootPath: args.rootPath, - callbacks, - terminalCallbacks: new Map([[args.callback, args.onTerminalError ?? (() => undefined)]]), - setupAbortController, - remoteWatchId, - ready: false, - stopping: false, - unwatchSent: false, - setupWaiters: new PromiseSettlementWaiters(Promise.resolve()) - } - const createdRegistration = registration - // Why: the shared registration, not its first caller, owns relay setup. - // This keeps a same-root joiner alive when the original caller disconnects. - const setupPromise = args.mux - .request( - 'fs.watch', - { rootPath: args.rootPath, watchId: remoteWatchId }, - { signal: setupAbortController.signal } - ) - .then( - () => { - createdRegistration.ready = true - if ( - createdRegistration.stopping || - createdRegistration.callbacks.size === 0 || - args.disposed() || - args.registrations.get(rootKey) !== createdRegistration - ) { - if (args.registrations.get(rootKey) === createdRegistration) { - args.registrations.delete(rootKey) - } - sendSshFilesystemUnwatchOnce(args.mux, createdRegistration) - } - }, - (error) => { - if (args.registrations.get(rootKey) === createdRegistration) { - args.registrations.delete(rootKey) - } - throw error - } - ) - registration.setupWaiters = new PromiseSettlementWaiters(setupPromise) - args.registrations.set(rootKey, registration) - try { - await awaitSetupWithOptionalAbort(registration.setupWaiters, args.signal) - assertActiveWatch(args, registration) - return createSshFilesystemWatchUnsubscribe(args, registration) - } catch (error) { - releaseSshFilesystemWatchCallback(args, registration) - throw error - } -} - -export function notifySshFilesystemUnwatch(mux: SshChannelMultiplexer, rootPath: string): void { - try { - mux.notify('fs.unwatch', { rootPath }) - } catch {} -} - -export async function closeSshFilesystemWatch( - mux: SshChannelMultiplexer, - registrations: Map, - rootPath: string -): Promise { - const rootKey = sshFilesystemWatchKey(rootPath) - try { - await mux.request('fs.unwatchAndWait', { rootPath }) - } catch (error) { - if (!isMethodNotFoundError(error)) { - throw error - } - throw new Error('Remote watcher teardown is unavailable. Reconnect the SSH target and retry.') - } - registrations.get(rootKey)?.callbacks.clear() - registrations.get(rootKey)?.terminalCallbacks.clear() - registrations.delete(rootKey) -} - -export function failSshFilesystemWatchRegistration( - registrations: Map, - rootPath: string, - remoteWatchId: number, - error: Error -): void { - const rootKey = sshFilesystemWatchKey(rootPath) - const registration = registrations.get(rootKey) - if (!registration || registration.remoteWatchId !== remoteWatchId) { - return - } - registrations.delete(rootKey) - registration.stopping = true - registration.unwatchSent = true - const terminalCallbacks = Array.from(registration.terminalCallbacks.values()) - registration.callbacks.clear() - registration.terminalCallbacks.clear() - for (const onTerminalError of terminalCallbacks) { - try { - onTerminalError(error) - } catch (callbackError) { - console.error('[ssh-fs] terminal watch callback failed', callbackError) - } - } -} - -function assertActiveWatch( - args: { - disposed: () => boolean - registrations: Map - rootPath: string - }, - registration: WatchRegistration -): void { - if ( - args.disposed() || - args.registrations.get(sshFilesystemWatchKey(args.rootPath)) !== registration - ) { - throw new Error('SSH filesystem provider disposed') - } -} - -function createSshFilesystemWatchUnsubscribe( - args: { - mux: SshChannelMultiplexer - registrations: Map - rootPath: string - callback: (events: FsChangeEvent[]) => void - }, - registration: WatchRegistration -): () => void { - return () => { - releaseSshFilesystemWatchCallback(args, registration) - } -} - -function releaseSshFilesystemWatchCallback( - args: { - mux: SshChannelMultiplexer - registrations: Map - rootPath: string - callback: (events: FsChangeEvent[]) => void - }, - registration: WatchRegistration -): void { - registration.callbacks.delete(args.callback) - registration.terminalCallbacks.delete(args.callback) - const rootKey = sshFilesystemWatchKey(args.rootPath) - if (registration.callbacks.size > 0 || args.registrations.get(rootKey) !== registration) { - return - } - registration.stopping = true - if (registration.ready) { - args.registrations.delete(rootKey) - sendSshFilesystemUnwatchOnce(args.mux, registration) - } else { - // Keep the cancelling registration indexed until its request settles so a - // new same-root setup cannot race an old late success and be unwatched. - registration.setupAbortController.abort() - } -} - -export function stopSshFilesystemWatchRegistration( - mux: SshChannelMultiplexer, - registration: WatchRegistration -): void { - registration.stopping = true - registration.setupAbortController.abort() - sendSshFilesystemUnwatchOnce(mux, registration) -} - -function sendSshFilesystemUnwatchOnce( - mux: SshChannelMultiplexer, - registration: WatchRegistration -): void { - if (registration.unwatchSent) { - return - } - registration.unwatchSent = true - notifySshFilesystemUnwatch(mux, registration.rootPath) -} - -function sshFilesystemWatchKey(rootPath: string): string { - return normalizeRuntimePathForComparison(rootPath) -} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts deleted file mode 100644 index 0bd3a4464928..000000000000 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ /dev/null @@ -1,907 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { PassThrough } from 'node:stream' -import { SshFilesystemProvider } from './ssh-filesystem-provider' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' - -type MockMultiplexer = { - request: ReturnType - notify: ReturnType - onNotification: ReturnType - onNotificationByMethod: ReturnType - onDispose: ReturnType - dispose: ReturnType - isDisposed: ReturnType - _methodHandlers: Map) => void>> - _emitMethod: (method: string, params: Record) => void -} - -function createMockMux(): MockMultiplexer { - const methodHandlers = new Map) => void>>() - return { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - onNotificationByMethod: vi.fn( - (method: string, handler: (params: Record) => void) => { - let set = methodHandlers.get(method) - if (!set) { - set = new Set() - methodHandlers.set(method, set) - } - set.add(handler) - return () => set!.delete(handler) - } - ), - onDispose: vi.fn(() => () => {}), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false), - _methodHandlers: methodHandlers, - _emitMethod: (method, params) => { - const set = methodHandlers.get(method) - if (set) { - for (const handler of Array.from(set)) { - handler(params) - } - } - } - } -} - -describe('SshFilesystemProvider', () => { - let mux: MockMultiplexer - let provider: SshFilesystemProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshFilesystemProvider('conn-1', mux as never) - }) - - describe('readDir', () => { - it('sends fs.readDir request', async () => { - const entries = [ - { name: 'src', isDirectory: true, isSymlink: false }, - { name: 'README.md', isDirectory: false, isSymlink: false } - ] - mux.request.mockResolvedValue(entries) - - const result = await provider.readDir('/home/user/project') - expect(mux.request).toHaveBeenCalledWith('fs.readDirBounded', { - dirPath: '/home/user/project', - __streamResponse: true - }) - expect(result).toEqual(entries) - }) - }) - - describe('readTerminalArtifact', () => { - it('sends fs.readTerminalArtifact request with verification metadata', async () => { - mux.request.mockResolvedValue({ content: '{}', isBinary: false }) - - await expect( - provider.readTerminalArtifact('/tmp/result.json', { - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - ).resolves.toEqual({ content: '{}', isBinary: false }) - expect(mux.request).toHaveBeenCalledWith('fs.readTerminalArtifact', { - filePath: '/tmp/result.json', - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - }) - - it('asks the user to reconnect when the relay is too old for artifact reads', async () => { - mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - - await expect( - provider.readTerminalArtifact('/tmp/result.json', { - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - ).rejects.toThrow('Reconnect the SSH target') - }) - }) - - describe('writeFile', () => { - it('sends fs.writeFile request', async () => { - await provider.writeFile('/home/user/file.txt', 'new content') - expect(mux.request).toHaveBeenCalledWith('fs.writeFile', { - filePath: '/home/user/file.txt', - content: 'new content' - }) - }) - }) - - describe('writeTerminalArtifact', () => { - it('returns the verified artifact write stat', async () => { - mux.request.mockResolvedValue({ stat: { type: 'file', size: 2, mtime: 3 } }) - - await expect( - provider.writeTerminalArtifact('/tmp/result.json', '{}', { - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - ).resolves.toEqual({ type: 'file', size: 2, mtime: 3 }) - expect(mux.request).toHaveBeenCalledWith('fs.writeTerminalArtifact', { - filePath: '/tmp/result.json', - content: '{}', - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - }) - - it('asks the user to reconnect when the relay is too old for artifact writes', async () => { - mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - - await expect( - provider.writeTerminalArtifact('/tmp/result.json', '{}', { - expectedRealPath: '/tmp/result.json', - expectedStatIdentity: '1:2:3:4', - maxBytes: 1024 - }) - ).rejects.toThrow('Reconnect the SSH target') - }) - }) - - describe('getTempDir', () => { - it('reads and caches the remote temp directory from the relay', async () => { - mux.request.mockResolvedValue('/var/folders/remote') - - await expect(provider.getTempDir()).resolves.toBe('/var/folders/remote') - await expect(provider.getTempDir()).resolves.toBe('/var/folders/remote') - - expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).toHaveBeenCalledWith('fs.tempDir', {}) - }) - - it('falls back to /tmp when connected to an older relay', async () => { - mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - - await expect(provider.getTempDir()).resolves.toBe('/tmp') - }) - }) - - describe('writeFileBase64', () => { - it('writes decoded bytes through SFTP', async () => { - const written: Buffer[] = [] - const writeStream = { - on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), - off: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), - end: vi.fn((buffer: Buffer) => { - written.push(buffer) - const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] - closeHandler?.() - }), - destroy: vi.fn() - } - const sftp = { - createWriteStream: vi.fn(() => writeStream), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await provider.writeFileBase64('/home/user/logo.png', 'cG5n') - - expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'wx' }) - expect(written).toEqual([Buffer.from('png')]) - expect(sftp.end).toHaveBeenCalled() - expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything()) - }) - - it('writes decoded bytes through raw transfer when provided', async () => { - const writeBuffer = vi.fn().mockResolvedValue(undefined) - provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { writeBuffer }) - - await provider.writeFileBase64('/home/user/logo.png', 'cG5n') - await provider.writeFileBase64Chunk('/home/user/logo.png', 'bW9yZQ==', true) - - expect(writeBuffer).toHaveBeenNthCalledWith(1, '/home/user/logo.png', Buffer.from('png'), { - append: false, - exclusive: true - }) - expect(writeBuffer).toHaveBeenNthCalledWith(2, '/home/user/logo.png', Buffer.from('more'), { - append: true, - exclusive: false - }) - }) - - it('can append decoded chunks through SFTP', async () => { - const writeStream = { - on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), - off: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), - end: vi.fn((_buffer: Buffer) => { - const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1] - closeHandler?.() - }), - destroy: vi.fn() - } - const sftp = { - createWriteStream: vi.fn(() => writeStream), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await provider.writeFileBase64Chunk('/home/user/logo.png', 'cG5n', true) - - expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'a' }) - expect(sftp.end).toHaveBeenCalled() - }) - }) - - describe('downloadFile', () => { - it('downloads raw bytes through raw transfer when provided', async () => { - const downloadFile = vi.fn().mockResolvedValue(undefined) - provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { downloadFile }) - - await provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip') - - expect(downloadFile).toHaveBeenCalledWith('/home/user/archive.zip', '/tmp/archive.zip') - }) - - it('downloads raw bytes through SFTP and closes the session', async () => { - const sftp = { - fastGet: vi.fn( - (_source: string, _destination: string, callback: (err?: Error | null) => void) => - callback() - ), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip') - - expect(sftp.fastGet).toHaveBeenCalledWith( - '/home/user/archive.zip', - '/tmp/archive.zip', - expect.any(Function) - ) - expect(sftp.end).toHaveBeenCalled() - }) - - it('closes the SFTP session when raw download fails', async () => { - const sftp = { - fastGet: vi.fn( - (_source: string, _destination: string, callback: (err?: Error | null) => void) => - callback(new Error('download failed')) - ), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await expect( - provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip') - ).rejects.toThrow('download failed') - - expect(sftp.end).toHaveBeenCalled() - }) - }) - - describe('openFileUploadSession', () => { - it('uses one SFTP session for multiple files and closes it once', async () => { - const createWriteStream = vi.fn(() => { - const stream = new PassThrough() - stream.resume() - return stream - }) - const sftp = { createWriteStream, end: vi.fn() } - const createSftp = vi.fn().mockResolvedValue(sftp) - provider = new SshFilesystemProvider('conn-1', mux as never, createSftp as never) - const dir = mkdtempSync(join(tmpdir(), 'orca-sftp-upload-session-')) - const first = join(dir, 'first.txt') - const second = join(dir, 'second.txt') - writeFileSync(first, 'first') - writeFileSync(second, 'second') - - try { - const session = await provider.openFileUploadSession() - await session.uploadFile(first, '/remote/first.txt', { exclusive: true }) - await session.uploadFile(second, '/remote/second.txt', { exclusive: true }) - session.close() - - expect(createSftp).toHaveBeenCalledOnce() - expect(createWriteStream).toHaveBeenCalledTimes(2) - expect(sftp.end).toHaveBeenCalledOnce() - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('uses the transport-owned upload session when provided', async () => { - const uploadSession = { uploadFile: vi.fn(), close: vi.fn() } - const openFileUploadSession = vi.fn().mockResolvedValue(uploadSession) - provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { - openFileUploadSession - }) - - await expect(provider.openFileUploadSession()).resolves.toBe(uploadSession) - expect(openFileUploadSession).toHaveBeenCalledOnce() - }) - }) - - describe('createDirNoClobber', () => { - it('sends fs.createDirNoClobber request', async () => { - await provider.createDirNoClobber('/home/user/new-dir') - - expect(mux.request).toHaveBeenCalledWith('fs.createDirNoClobber', { - dirPath: '/home/user/new-dir' - }) - }) - }) - - describe('stat', () => { - it('sends fs.stat request', async () => { - const statResult = { size: 1024, type: 'file', mtime: 1234567890 } - mux.request.mockResolvedValue(statResult) - - const result = await provider.stat('/home/user/file.txt') - expect(mux.request).toHaveBeenCalledWith('fs.stat', { filePath: '/home/user/file.txt' }) - expect(result).toEqual(statResult) - }) - }) - - describe('lstat', () => { - it('sends fs.lstat request', async () => { - const statResult = { size: 12, type: 'symlink', mtime: 1234567890 } - mux.request.mockResolvedValue(statResult) - - const result = await provider.lstat('/home/user/link.txt') - expect(mux.request).toHaveBeenCalledWith('fs.lstat', { filePath: '/home/user/link.txt' }) - expect(result).toEqual(statResult) - }) - - it('falls back to SFTP lstat when connected to an older relay', async () => { - mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) - const sftp = { - lstat: vi.fn((_path: string, callback: (err: Error | undefined, stats: unknown) => void) => - callback(undefined, { - size: 12, - mtime: 1234567, - isDirectory: () => false, - isSymbolicLink: () => true - }) - ), - end: vi.fn() - } - provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never) - - await expect(provider.lstat('/home/user/link.txt')).resolves.toEqual({ - size: 12, - type: 'symlink', - mtime: 1234567000 - }) - expect(sftp.lstat).toHaveBeenCalledWith('/home/user/link.txt', expect.any(Function)) - expect(sftp.end).toHaveBeenCalled() - }) - }) - - it('scanWorkspaceSpace sends an abortable bulk scan request', async () => { - const result = { - sizeBytes: 1024, - skippedEntryCount: 0, - topLevelItems: [], - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - } - const controller = new AbortController() - mux.request.mockResolvedValue(result) - - await expect( - provider.scanWorkspaceSpace('/home/user/project', { signal: controller.signal }) - ).resolves.toBe(result) - expect(mux.request).toHaveBeenCalledWith( - 'fs.workspaceSpaceScan', - { rootPath: '/home/user/project' }, - { signal: controller.signal, timeoutMs: 130000 } - ) - }) - - it('deletePath sends fs.deletePath request', async () => { - await provider.deletePath('/home/user/file.txt') - expect(mux.request).toHaveBeenCalledWith('fs.deletePath', { targetPath: '/home/user/file.txt' }) - }) - - it('createFile sends fs.createFile request', async () => { - await provider.createFile('/home/user/new.txt') - expect(mux.request).toHaveBeenCalledWith('fs.createFile', { filePath: '/home/user/new.txt' }) - }) - - it('createDir sends fs.createDir request', async () => { - await provider.createDir('/home/user/newdir') - expect(mux.request).toHaveBeenCalledWith('fs.createDir', { dirPath: '/home/user/newdir' }) - }) - - it('rename sends fs.rename request', async () => { - await provider.rename('/home/old.txt', '/home/new.txt') - expect(mux.request).toHaveBeenCalledWith('fs.rename', { - oldPath: '/home/old.txt', - newPath: '/home/new.txt' - }) - }) - - it('renameNoClobber sends fs.renameNoClobber request', async () => { - await provider.renameNoClobber('/home/old.txt', '/home/new.txt') - expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', { - oldPath: '/home/old.txt', - newPath: '/home/new.txt' - }) - }) - - it('renameNoClobber fails closed when the relay lacks safe rename support', async () => { - mux.request.mockRejectedValueOnce( - Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - - await expect(provider.renameNoClobber('/home/old.txt', '/home/new.txt')).rejects.toThrow( - 'Remote safe rename is unavailable' - ) - expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).toHaveBeenCalledWith('fs.renameNoClobber', { - oldPath: '/home/old.txt', - newPath: '/home/new.txt' - }) - }) - - it('copy sends fs.copy request', async () => { - await provider.copy('/home/src.txt', '/home/dst.txt') - expect(mux.request).toHaveBeenCalledWith('fs.copy', { - source: '/home/src.txt', - destination: '/home/dst.txt' - }) - }) - - it('realpath sends fs.realpath request', async () => { - mux.request.mockResolvedValue('/home/user/real/path') - const result = await provider.realpath('/home/user/link') - expect(result).toBe('/home/user/real/path') - }) - - it('search sends fs.search request with all options', async () => { - const searchResult = { files: [], totalMatches: 0, truncated: false } - mux.request.mockResolvedValue(searchResult) - - const opts = { - query: 'TODO', - rootPath: '/home/user/project', - caseSensitive: true - } - const result = await provider.search(opts) - expect(mux.request).toHaveBeenCalledWith('fs.search', opts, { signal: undefined }) - expect(result).toEqual(searchResult) - }) - - // Why #12547: a monorepo listing does not fit one control-lane frame, so the request opts into - // response streaming. An old relay ignores `__streamResponse` and answers plainly, which is the - // plain-array case each of these asserts. - it('listFiles sends a streamable fs.listFiles request', async () => { - mux.request.mockResolvedValue(['src/index.ts', 'package.json']) - const result = await provider.listFiles('/home/user/project') - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { - rootPath: '/home/user/project', - __streamResponse: true - }) - expect(result).toEqual(['src/index.ts', 'package.json']) - }) - - it('listFiles forwards listing and query options', async () => { - mux.request.mockResolvedValue([]) - await provider.listFiles('/home/user/project', { - excludePaths: ['/home/user/project/worktrees/b'], - maxResults: 20_000, - searchQuery: 'target' - }) - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { - rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'], - maxResults: 20_000, - searchQuery: 'target', - __streamResponse: true - }) - }) - - it('listFiles omits excludePaths when empty', async () => { - mux.request.mockResolvedValue([]) - await provider.listFiles('/home/user/project', { excludePaths: [] }) - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { - rootPath: '/home/user/project', - __streamResponse: true - }) - }) - - it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => { - mux.request.mockResolvedValue([]) - const controller = new AbortController() - await provider.listFiles('/home/user/project', { signal: controller.signal }) - expect(mux.request).toHaveBeenCalledWith( - 'fs.listFiles', - { rootPath: '/home/user/project', __streamResponse: true }, - { signal: controller.signal, timeoutMs: undefined } - ) - }) - - describe('watch', () => { - it('uses a registration-owned cancellation signal for the mux fs.watch request', async () => { - mux.request.mockResolvedValue(undefined) - const controller = new AbortController() - const callback = vi.fn() - - await provider.watch('/home/user/project', callback, { signal: controller.signal }) - - expect(mux.request).toHaveBeenCalledWith( - 'fs.watch', - { rootPath: '/home/user/project', watchId: expect.any(Number) }, - { signal: expect.any(AbortSignal) } - ) - const physicalSignal = mux.request.mock.calls[0][2]?.signal - expect(physicalSignal).not.toBe(controller.signal) - expect(physicalSignal?.aborted).toBe(false) - }) - - it('rejects promptly when the watch signal aborts during setup', async () => { - let resolveWatch: () => void = () => {} - mux.request.mockImplementationOnce( - (_method, _params, options?: { signal?: AbortSignal }) => - new Promise((resolve, reject) => { - resolveWatch = resolve - options?.signal?.addEventListener( - 'abort', - () => { - const error = new Error('Request "fs.watch" was cancelled') as Error & { - name: string - } - error.name = 'AbortError' - reject(error) - }, - { once: true } - ) - }) - ) - const controller = new AbortController() - const pendingWatch = provider.watch('/home/user/project', vi.fn(), { - signal: controller.signal - }) - - controller.abort() - - await expect(pendingWatch).rejects.toMatchObject({ name: 'AbortError' }) - expect(mux.request.mock.calls[0][2]?.signal?.aborted).toBe(true) - resolveWatch() - }) - - it('keeps shared setup alive when only its first caller aborts', async () => { - let resolveWatch: () => void = () => {} - let physicalSignal: AbortSignal | undefined - mux.request.mockImplementationOnce( - (_method, _params, options?: { signal?: AbortSignal }) => - new Promise((resolve) => { - resolveWatch = resolve - physicalSignal = options?.signal - }) - ) - const firstController = new AbortController() - const first = vi.fn() - const second = vi.fn() - - const firstWatch = provider.watch('/home/user/project', first, { - signal: firstController.signal - }) - const secondWatch = provider.watch('/home/user/project', second) - firstController.abort() - - await expect(firstWatch).rejects.toMatchObject({ name: 'AbortError' }) - expect(physicalSignal?.aborted).toBe(false) - - resolveWatch() - const unsubscribeSecond = await secondWatch - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - - expect(first).not.toHaveBeenCalled() - expect(second).toHaveBeenCalledWith(events) - unsubscribeSecond() - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { - rootPath: '/home/user/project' - }) - }) - - it('forwards fs.changed notifications to watch callback', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - - expect(callback).toHaveBeenCalledWith(events) - }) - - it('invalidates only the matching watch generation after relay recovery terminates', async () => { - const firstTerminal = vi.fn() - const secondTerminal = vi.fn() - await provider.watch('/home/user/project', vi.fn(), { - onTerminalError: firstTerminal - }) - await provider.watch('/home/user/project', vi.fn(), { - onTerminalError: secondTerminal - }) - const notifHandler = mux.onNotification.mock.calls[0][0] - const firstWatchId = mux.request.mock.calls[0][1].watchId as number - - notifHandler('fs.watchFailed', { - rootPath: '/home/user/project', - watchId: firstWatchId + 1, - message: 'stale failure' - }) - expect(firstTerminal).not.toHaveBeenCalled() - - notifHandler('fs.watchFailed', { - rootPath: '/home/user/project', - watchId: firstWatchId, - message: 'bounded recovery failed' - }) - expect(firstTerminal).toHaveBeenCalledWith( - expect.objectContaining({ message: 'bounded recovery failed' }) - ) - expect(secondTerminal).toHaveBeenCalledTimes(1) - - const replacementTerminal = vi.fn() - await provider.watch('/home/user/project', vi.fn(), { - onTerminalError: replacementTerminal - }) - const replacementWatchId = mux.request.mock.calls[1][1].watchId as number - expect(replacementWatchId).not.toBe(firstWatchId) - - notifHandler('fs.watchFailed', { - rootPath: '/home/user/project', - watchId: firstWatchId, - message: 'late stale failure' - }) - expect(replacementTerminal).not.toHaveBeenCalled() - }) - - it('fans out same-root watch events and unwatches only after the last subscriber', async () => { - const first = vi.fn() - const second = vi.fn() - const unsubFirst = await provider.watch('/home/user/project', first) - const unsubSecond = await provider.watch('/home/user/project', second) - - expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).toHaveBeenCalledWith( - 'fs.watch', - { rootPath: '/home/user/project', watchId: expect.any(Number) }, - { signal: expect.any(AbortSignal) } - ) - - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - expect(first).toHaveBeenCalledWith(events) - expect(second).toHaveBeenCalledWith(events) - - unsubFirst() - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - notifHandler('fs.changed', { events }) - expect(second).toHaveBeenCalledTimes(2) - - unsubSecond() - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - }) - - it('shares equivalent Windows spellings under one provider watch generation', async () => { - const first = vi.fn() - const second = vi.fn() - const firstTerminal = vi.fn() - const secondTerminal = vi.fn() - const unsubFirst = await provider.watch('C:\\Repo', first, { - onTerminalError: firstTerminal - }) - const unsubSecond = await provider.watch('c:/repo/', second, { - onTerminalError: secondTerminal - }) - - expect(mux.request).toHaveBeenCalledTimes(1) - const watchId = mux.request.mock.calls[0][1].watchId as number - const notification = mux.onNotification.mock.calls[0][0] - notification('fs.changed', { - events: [{ kind: 'update', absolutePath: 'c:/repo/file.ts' }] - }) - expect(first).toHaveBeenCalledTimes(1) - expect(second).toHaveBeenCalledTimes(1) - - unsubFirst() - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: 'C:\\Repo' }) - notification('fs.watchFailed', { - rootPath: 'c:/repo', - watchId, - message: 'bounded recovery failed' - }) - expect(firstTerminal).not.toHaveBeenCalled() - expect(secondTerminal).toHaveBeenCalledTimes(1) - - unsubSecond() - }) - - it('shares an in-flight same-root watch setup across concurrent subscribers', async () => { - let resolveWatch: () => void = () => {} - mux.request.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveWatch = resolve - }) - ) - const first = vi.fn() - const second = vi.fn() - - const firstWatch = provider.watch('/home/user/project', first) - const secondWatch = provider.watch('/home/user/project', second) - - expect(mux.request).toHaveBeenCalledTimes(1) - resolveWatch() - const [unsubFirst, unsubSecond] = await Promise.all([firstWatch, secondWatch]) - - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - expect(first).toHaveBeenCalledWith(events) - expect(second).toHaveBeenCalledWith(events) - - unsubFirst() - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - unsubSecond() - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - }) - - it('does not retain a watch listener when fs.watch setup fails', async () => { - mux.request.mockRejectedValueOnce(new Error('watch unavailable')) - const first = vi.fn() - await expect(provider.watch('/home/user/project', first)).rejects.toThrow('watch unavailable') - - const second = vi.fn() - await provider.watch('/home/user/project', second) - - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - expect(first).not.toHaveBeenCalled() - expect(second).toHaveBeenCalledWith(events) - }) - - it('does not forward sibling paths with matching prefixes', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [ - { kind: 'update', absolutePath: '/home/user/project-old/file.ts' }, - { kind: 'update', absolutePath: '/home/user/project2/file.ts' } - ] - }) - - expect(callback).not.toHaveBeenCalled() - }) - - it('matches Windows and UNC watch roots case-insensitively', async () => { - const driveCallback = vi.fn() - const uncCallback = vi.fn() - await provider.watch('C:\\Repo', driveCallback) - await provider.watch('//Server/Share/Repo', uncCallback) - - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [ - { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' }, - { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' } - ] - }) - - expect(driveCallback).toHaveBeenCalledWith([ - { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' } - ]) - expect(uncCallback).toHaveBeenCalledWith([ - { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' } - ]) - }) - - it('sends fs.unwatch when last listener unsubscribes', async () => { - const callback = vi.fn() - const unsub = await provider.watch('/home/user/project', callback) - unsub() - - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - }) - - it('awaits acknowledged watcher teardown for destructive cleanup', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - mux.request.mockClear() - - await provider.closeWatch('/home/user/project') - - expect(mux.request).toHaveBeenCalledWith('fs.unwatchAndWait', { - rootPath: '/home/user/project' - }) - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - }) - expect(callback).not.toHaveBeenCalled() - }) - - it('fails destructive teardown closed on an older relay', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - mux.request.mockRejectedValueOnce( - Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - - await expect(provider.closeWatch('/home/user/project')).rejects.toThrow( - 'Reconnect the SSH target' - ) - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { - rootPath: '/home/user/project' - }) - const notifHandler = mux.onNotification.mock.calls[0][0] - const events = [{ kind: 'update' as const, absolutePath: '/home/user/project/file.ts' }] - notifHandler('fs.changed', { events }) - expect(callback).toHaveBeenCalledWith(events) - }) - - it('sends fs.unwatch for active roots when disposed', async () => { - const callback = vi.fn() - await provider.watch('/home/user/project', callback) - - provider.dispose() - - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - }) - expect(callback).not.toHaveBeenCalled() - }) - - it('unwatches when disposed while fs.watch setup is still resolving', async () => { - let resolveWatch: () => void = () => {} - mux.request.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveWatch = resolve - }) - ) - const callback = vi.fn() - const pendingWatch = provider.watch('/home/user/project', callback) - - provider.dispose() - resolveWatch() - - await expect(pendingWatch).rejects.toThrow('SSH filesystem provider disposed') - expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' }) - const notifHandler = mux.onNotification.mock.calls[0][0] - notifHandler('fs.changed', { - events: [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }] - }) - expect(callback).not.toHaveBeenCalled() - }) - - it('does not send fs.unwatch while other roots are watched', async () => { - const cb1 = vi.fn() - const cb2 = vi.fn() - const unsub1 = await provider.watch('/home/user/project-a', cb1) - await provider.watch('/home/user/project-b', cb2) - - unsub1() - expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { - rootPath: '/home/user/project-b' - }) - }) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts deleted file mode 100644 index 18c185402f12..000000000000 --- a/src/main/providers/ssh-filesystem-provider.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { readSshDirectoryWithSftpFallback } from './ssh-directory-listing' -import { readSshMarkdownDocuments } from './ssh-markdown-document-listing' -import { readSshPathExistenceBatch } from './ssh-filesystem-path-existence' -import type { PathExistenceResult } from '../../shared/path-existence-batch' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' -import { uploadBuffer } from '../ssh/sftp-upload' -import { listSshFiles } from './ssh-file-listing' -import { lstatViaSftp } from './ssh-filesystem-provider-sftp' -import { - downloadFileViaSftp, - downloadFolderViaSftp, - type SftpFactory -} from './ssh-filesystem-download' -import { openSshFileUploadSession, type SshRawTransferOptions } from './ssh-filesystem-file-upload' -import { - closeSshFilesystemWatch, - registerSshFilesystemWatch, - stopSshFilesystemWatchRegistration, - type WatchRegistration -} from './ssh-filesystem-provider-watch' -import type { - IFilesystemProvider, - FileRangeReadResult, - FileReadLimits, - FileStat, - FileReadResult, - FileUploadSession, - TerminalArtifactAccessOptions -} from './types' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-types' -import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' -import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' -import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { - probeSshQuickOpenSearchCapability, - probeSshRangedReadCapability -} from './ssh-filesystem-provider-capabilities' -import { readSshFileRange } from './ssh-filesystem-range-read' -import { - readSshTerminalArtifact, - writeSshTerminalArtifact -} from './ssh-filesystem-terminal-artifact' -import { readSshDocPreviewFile } from './ssh-filesystem-doc-preview' -const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 -export class SshFilesystemProvider implements IFilesystemProvider { - private connectionId: string - private mux: SshChannelMultiplexer - private watchListeners = new Map() - private unsubscribeNotifications: (() => void) | null = null - private tempDirPromise: Promise | null = null - private disposed = false - private loggedStreamFallback = false - readonly downloadFolder?: IFilesystemProvider['downloadFolder'] - - constructor( - connectionId: string, - mux: SshChannelMultiplexer, - private readonly createSftp?: SftpFactory, - private readonly rawTransfer?: SshRawTransferOptions, - hostPlatform?: RemoteHostPlatform - ) { - this.connectionId = connectionId - this.mux = mux - - if (createSftp) { - // Why: system SSH has raw single-file transfer but no ssh2 SFTP channel; - // omitting this method makes folder capability truthful at the provider boundary. - // windowsRemotePaths is provider-owned (from host platform), not a caller option. - const windowsRemotePaths = hostPlatform ? isWindowsRemoteHost(hostPlatform) : undefined - this.downloadFolder = (sourcePath, destinationPath, options) => - downloadFolderViaSftp(createSftp, sourcePath, destinationPath, { - ...options, - windowsRemotePaths - }) - } - - this.unsubscribeNotifications = mux.onNotification((method, params) => - routeSshFilesystemWatchNotification(this.watchListeners, method, params) - ) - } - - dispose(): void { - if (this.disposed) { - return - } - this.disposed = true - if (this.unsubscribeNotifications) { - this.unsubscribeNotifications() - this.unsubscribeNotifications = null - } - for (const registration of this.watchListeners.values()) { - stopSshFilesystemWatchRegistration(this.mux, registration) - } - this.watchListeners.clear() - } - - getConnectionId(): string { - return this.connectionId - } - - async readDir(dirPath: string, options?: { followSymlinks?: boolean }): Promise { - return readSshDirectoryWithSftpFallback(this.mux, dirPath, this.createSftp, options) - } - - async readFile(filePath: string, limits?: FileReadLimits): Promise { - // Why: streaming is the default path so previews above the legacy single- - // frame budget (~12 MB after base64) don't hit MAX_MESSAGE_SIZE. Old relays - // that don't implement fs.readFileStream surface as MethodNotFound; we fall - // back to the legacy single-shot fs.readFile (which retains the old 10 MB - // cap on those hosts). - try { - return await readFileViaStream(this.mux, filePath, limits) - } catch (err) { - if (isMethodNotFoundError(err)) { - if (!this.loggedStreamFallback) { - this.loggedStreamFallback = true - console.warn( - '[ssh-fs] Relay does not implement fs.readFileStream; falling back to fs.readFile (10 MB cap)' - ) - } - return (await this.mux.request('fs.readFile', { filePath })) as FileReadResult - } - throw err - } - } - - readDocPreviewFile( - request: Parameters>[0] - ): ReturnType> { - return readSshDocPreviewFile(this.mux, request) - } - - readFileRange( - filePath: string, - position: number, - length: number, - options?: { signal?: AbortSignal } - ): Promise { - return readSshFileRange(this.mux, filePath, position, length, options?.signal) - } - - supportsFileRangeRead(options?: { signal?: AbortSignal }): Promise { - return probeSshRangedReadCapability(this.mux, options?.signal) - } - - readTerminalArtifact( - filePath: string, - options: TerminalArtifactAccessOptions - ): Promise { - return readSshTerminalArtifact(this.mux, filePath, options) - } - - writeTerminalArtifact( - filePath: string, - content: string, - options: TerminalArtifactAccessOptions - ): Promise { - return writeSshTerminalArtifact(this.mux, filePath, content, options) - } - - async downloadFile(sourcePath: string, destinationPath: string): Promise { - // Why: system SSH targets cannot open an ssh2-owned SFTP channel. - if (this.rawTransfer?.downloadFile) { - await this.rawTransfer.downloadFile(sourcePath, destinationPath) - return - } - await downloadFileViaSftp(this.createSftp, sourcePath, destinationPath) - } - - async openFileUploadSession(): Promise { - return openSshFileUploadSession(this.createSftp, this.rawTransfer) - } - - async getTempDir(): Promise { - this.tempDirPromise ??= this.mux.request('fs.tempDir', {}).then( - (result) => result as string, - (err) => { - this.tempDirPromise = null - if (isMethodNotFoundError(err)) { - return '/tmp' - } - throw err - } - ) - return this.tempDirPromise - } - - async writeFile(filePath: string, content: string): Promise { - await this.mux.request('fs.writeFile', { filePath, content }) - } - - async writeFileBase64(filePath: string, contentBase64: string): Promise { - await this.writeFileBase64Chunk(filePath, contentBase64, false) - } - - async writeFileBase64Chunk( - filePath: string, - contentBase64: string, - append: boolean - ): Promise { - const contents = Buffer.from(contentBase64, 'base64') - if (this.rawTransfer?.writeBuffer) { - await this.rawTransfer.writeBuffer(filePath, contents, { append, exclusive: !append }) - return - } - if (!this.createSftp) { - throw new Error('remote_binary_upload_unavailable') - } - const sftp = await this.createSftp() - try { - // Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes - // directly so runtime uploads do not corrupt images, PDFs, or archives. - await uploadBuffer(sftp, contents, filePath, { - append, - exclusive: !append - }) - } finally { - sftp.end() - } - } - - pathsExist(filePaths: string[]): Promise { - return readSshPathExistenceBatch(this.mux, filePaths, (path) => this.stat(path)) - } - - async stat(filePath: string): Promise { - return (await this.mux.request('fs.stat', { filePath })) as FileStat - } - - async lstat(filePath: string): Promise { - try { - return (await this.mux.request('fs.lstat', { filePath })) as FileStat - } catch (err) { - if (!isMethodNotFoundError(err)) { - throw err - } - if (!this.createSftp) { - throw new Error('remote_lstat_unavailable') - } - const sftp = await this.createSftp() - try { - // Why: older relays predate fs.lstat, but SFTP can still preserve - // symlink identity for orphaned-worktree safety checks. - return await lstatViaSftp(sftp, filePath) - } finally { - sftp.end() - } - } - } - - async scanWorkspaceSpace( - rootPath: string, - options?: { signal?: AbortSignal } - ): Promise { - return (await this.mux.request( - 'fs.workspaceSpaceScan', - { rootPath }, - { signal: options?.signal, timeoutMs: WORKSPACE_SPACE_SCAN_TIMEOUT_MS } - )) as WorkspaceSpaceDirectoryScanResult - } - - async deletePath(targetPath: string, recursive?: boolean): Promise { - await this.mux.request('fs.deletePath', { targetPath, recursive }) - } - - async createFile(filePath: string): Promise { - await this.mux.request('fs.createFile', { filePath }) - } - - async createDir(dirPath: string): Promise { - await this.mux.request('fs.createDir', { dirPath }) - } - - async createDirNoClobber(dirPath: string): Promise { - await this.mux.request('fs.createDirNoClobber', { dirPath }) - } - - async rename(oldPath: string, newPath: string): Promise { - await this.mux.request('fs.rename', { oldPath, newPath }) - } - - async renameNoClobber(oldPath: string, newPath: string): Promise { - try { - await this.mux.request('fs.renameNoClobber', { oldPath, newPath }) - } catch (err) { - if (isMethodNotFoundError(err)) { - // Why: falling back to raw fs.rename can silently clobber the target on - // older relays. Fail closed and let reconnect deploy the safe relay. - throw new Error('Remote safe rename is unavailable. Reconnect the SSH target and retry.') - } - throw err - } - } - - async copy(source: string, destination: string): Promise { - await this.mux.request('fs.copy', { source, destination }) - } - - async realpath(filePath: string): Promise { - return (await this.mux.request('fs.realpath', { filePath })) as string - } - - async search(opts: SearchOptions, options?: { signal?: AbortSignal }): Promise { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fs.search returns the relay's SearchResult contract; signal stays in local transport options. - return (await this.mux.request('fs.search', opts, { signal: options?.signal })) as SearchResult - } - - async listFiles( - rootPath: string, - options?: Parameters[1] - ): Promise { - return listSshFiles(this.mux, rootPath, options) - } - - listMarkdownDocuments = (rootPath: string, options?: { signal?: AbortSignal }) => - readSshMarkdownDocuments(this.mux, rootPath, options?.signal, () => - this.listFiles(rootPath, { signal: options?.signal }) - ) - - supportsQuickOpenSearch = ( - options: { signal?: AbortSignal; minimumVersion?: number } = {} - ): Promise => - probeSshQuickOpenSearchCapability(this.mux, options.signal, options.minimumVersion) - async watch( - rootPath: string, - callback: (events: FsChangeEvent[]) => void, - options?: { signal?: AbortSignal; onTerminalError?: (error: Error) => void } - ): Promise<() => void> { - return registerSshFilesystemWatch({ - mux: this.mux, - disposed: () => this.disposed, - registrations: this.watchListeners, - rootPath, - callback, - onTerminalError: options?.onTerminalError, - signal: options?.signal - }) - } - - async closeWatch(rootPath: string): Promise { - await closeSshFilesystemWatch(this.mux, this.watchListeners, rootPath) - } -} diff --git a/src/main/providers/ssh-filesystem-range-read.ts b/src/main/providers/ssh-filesystem-range-read.ts deleted file mode 100644 index 77ec0872363f..000000000000 --- a/src/main/providers/ssh-filesystem-range-read.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { validateFileRangeRequest } from '../../shared/file-range-read' -import { - FileRangeReadUnsupportedError, - type FileRangeReadResult -} from './filesystem-provider-contract' - -export async function readSshFileRange( - mux: SshChannelMultiplexer, - filePath: string, - rawPosition: number, - rawLength: number, - signal?: AbortSignal -): Promise { - // Same validator the host runs, so an out-of-contract request fails locally - // as a typed error instead of costing a round trip and coming back as an - // opaque -32000. - const { position, length } = validateFileRangeRequest(rawPosition, rawLength) - let result: unknown - try { - // No timeoutMs: the multiplexer's 30s default is right for a bounded - // MAX_FILE_RANGE_READ_BYTES window on a slow link. The capability probe - // pins a shorter one because it gates a UI decision, not a data read. - result = await mux.request( - 'fs.readFileRange', - { filePath, position, length }, - signal ? { signal } : undefined - ) - } catch (err) { - // Why throw rather than read the whole file here: a tailing caller issues - // several reads per snapshot, so a per-call fallback is quadratic on a - // growing transcript. Callers probe once and snapshot instead. - if (isMethodNotFoundError(err)) { - throw new FileRangeReadUnsupportedError() - } - throw err - } - const payload = result as { base64?: unknown; bytesRead?: unknown } | null - if (typeof payload?.base64 !== 'string' || typeof payload.bytesRead !== 'number') { - throw new Error('fs.readFileRange returned a malformed response') - } - const bytes = Buffer.from(payload.base64, 'base64') - // The remote is untrusted for framing: a count that disagrees with the - // payload would silently shift every downstream offset. - if ( - !Number.isSafeInteger(payload.bytesRead) || - payload.bytesRead < 0 || - payload.bytesRead > length || - payload.bytesRead !== bytes.length - ) { - throw new Error('fs.readFileRange returned an inconsistent byte count') - } - return { bytes, bytesRead: payload.bytesRead } -} diff --git a/src/main/providers/ssh-filesystem-terminal-artifact.ts b/src/main/providers/ssh-filesystem-terminal-artifact.ts deleted file mode 100644 index f4088df46a91..000000000000 --- a/src/main/providers/ssh-filesystem-terminal-artifact.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import type { FileReadResult, FileStat, TerminalArtifactAccessOptions } from './types' - -export async function readSshTerminalArtifact( - mux: SshChannelMultiplexer, - filePath: string, - options: TerminalArtifactAccessOptions -): Promise { - try { - return (await mux.request('fs.readTerminalArtifact', { - filePath, - expectedRealPath: options.expectedRealPath, - expectedStatIdentity: options.expectedStatIdentity, - maxBytes: options.maxBytes - })) as FileReadResult - } catch (err) { - if (isMethodNotFoundError(err)) { - throw new Error( - 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' - ) - } - throw err - } -} - -export async function writeSshTerminalArtifact( - mux: SshChannelMultiplexer, - filePath: string, - content: string, - options: TerminalArtifactAccessOptions -): Promise { - let result: { stat?: FileStat } - try { - result = (await mux.request('fs.writeTerminalArtifact', { - filePath, - content, - expectedRealPath: options.expectedRealPath, - expectedStatIdentity: options.expectedStatIdentity, - maxBytes: options.maxBytes - })) as { stat?: FileStat } - } catch (err) { - if (isMethodNotFoundError(err)) { - throw new Error( - 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' - ) - } - throw err - } - if (!result.stat) { - throw new Error('terminal_file_grant_stale') - } - return result.stat -} diff --git a/src/main/providers/ssh-filesystem-watch-notifications.test.ts b/src/main/providers/ssh-filesystem-watch-notifications.test.ts deleted file mode 100644 index d50b32bc8265..000000000000 --- a/src/main/providers/ssh-filesystem-watch-notifications.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' -import type { WatchRegistration } from './ssh-filesystem-provider-watch' - -function registration(rootPath: string, ...callbacks: ((events: FsChangeEvent[]) => void)[]) { - return { - rootPath, - callbacks: new Set(callbacks), - terminalCallbacks: new Map(), - remoteWatchId: 1, - ready: true, - stopping: false, - unwatchSent: false - } as unknown as WatchRegistration -} - -function changed(...paths: string[]): FsChangeEvent[] { - return paths.map((absolutePath) => ({ kind: 'update', absolutePath }) as FsChangeEvent) -} - -function route(registrations: Map, events: FsChangeEvent[]): void { - routeSshFilesystemWatchNotification(registrations, 'fs.changed', { events }) -} - -// Reference: the pre-change routing, which called isPathInsideOrEqual per pair and -// so re-normalized the candidate for every root. Not circular for what this asserts -// -- the change under test is that one shared normalization of each candidate still -// matches every root the per-pair normalization did. -function referenceRoute(roots: string[], events: FsChangeEvent[]): Record { - const result: Record = {} - for (const rootPath of roots) { - const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath)) - if (matching.length > 0) { - result[rootPath] = matching.map((event) => event.absolutePath) - } - } - return result -} - -describe('routeSshFilesystemWatchNotification fs.changed fan-out', () => { - it('delivers only the events inside each root', () => { - const alpha = vi.fn() - const beta = vi.fn() - const registrations = new Map([ - ['/repo/alpha', registration('/repo/alpha', alpha)], - ['/repo/beta', registration('/repo/beta', beta)] - ]) - - route( - registrations, - changed( - '/repo/alpha/src/a.ts', - '/repo/beta/src/b.ts', - '/repo/alpha/src/c.ts', - '/elsewhere/d.ts' - ) - ) - - expect(alpha).toHaveBeenCalledTimes(1) - expect(alpha.mock.calls[0][0].map((event: FsChangeEvent) => event.absolutePath)).toEqual([ - '/repo/alpha/src/a.ts', - '/repo/alpha/src/c.ts' - ]) - expect(beta.mock.calls[0][0].map((event: FsChangeEvent) => event.absolutePath)).toEqual([ - '/repo/beta/src/b.ts' - ]) - }) - - // Why: the fix hoists normalization out of the inner loop, so the risk is that a - // shared pre-normalized candidate stops matching a root it used to match. - it('routes identically to per-pair normalization', () => { - const roots = ['/repo/alpha', '/repo/alpha-extra', '/repo/beta/', '/repo'] - const events = changed( - '/repo/alpha/src/a.ts', - '/repo/alpha-extra/src/b.ts', - '/repo/beta/src/c.ts', - '/repo/alpha', - '/repo//alpha//src//d.ts', - '/repo/gamma/e.ts', - '/elsewhere/f.ts' - ) - const seen: Record = {} - const registrations = new Map( - roots.map((rootPath) => [ - rootPath, - registration(rootPath, (delivered) => { - seen[rootPath] = delivered.map((event) => event.absolutePath) - }) - ]) - ) - - route(registrations, events) - - expect(seen).toEqual(referenceRoute(roots, events)) - }) - - // Why: normalizeRuntimePathForComparison is not idempotent for WSL UNC paths, so - // a candidate normalized once up front must still match a root normalized once. - it('matches WSL UNC roots whose case survives only a single fold', () => { - const received = vi.fn() - const registrations = new Map([ - ['wsl', registration('\\\\wsl.localhost\\Ubuntu\\home\\User\\Repo', received)] - ]) - - route(registrations, changed('//wsl$/UBUNTU/home/User/Repo/src/a.ts')) - - expect(received).toHaveBeenCalledTimes(1) - expect(received.mock.calls[0][0][0].absolutePath).toBe('//wsl$/UBUNTU/home/User/Repo/src/a.ts') - }) - - // Why: macOS emits NFD names while stored roots are often NFC; both spell the - // same directory, and the shared candidate must still fold into the root. - it('matches a root recorded in NFC against NFD event paths', () => { - const received = vi.fn() - const registrations = new Map([['nfc', registration('/repo/café'.normalize('NFC'), received)]]) - - route(registrations, changed('/repo/café/src/a.ts'.normalize('NFD'))) - - expect(received).toHaveBeenCalledTimes(1) - }) - - it('does not treat a sibling with a shared prefix as inside the root', () => { - const received = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) - - route(registrations, changed('/repo/alphabet/src/a.ts')) - - expect(received).not.toHaveBeenCalled() - }) - - it('delivers the root itself to a watcher on that root', () => { - const received = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) - - route(registrations, changed('/repo/alpha')) - - expect(received).toHaveBeenCalledTimes(1) - }) - - it('notifies every callback registered on a shared root', () => { - const first = vi.fn() - const second = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', first, second)]]) - - route(registrations, changed('/repo/alpha/src/a.ts')) - - expect(first).toHaveBeenCalledTimes(1) - expect(second).toHaveBeenCalledTimes(1) - }) - - it('skips callbacks entirely when no event is inside the root', () => { - const received = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) - - route(registrations, changed('/elsewhere/a.ts', '/repo/beta/b.ts')) - - expect(received).not.toHaveBeenCalled() - }) - - it('ignores methods other than fs.changed and fs.watchFailed', () => { - const received = vi.fn() - const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) - - routeSshFilesystemWatchNotification(registrations, 'pty.data', { - events: changed('/repo/alpha/src/a.ts') - }) - - expect(received).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/ssh-filesystem-watch-notifications.ts b/src/main/providers/ssh-filesystem-watch-notifications.ts deleted file mode 100644 index aed30f16473f..000000000000 --- a/src/main/providers/ssh-filesystem-watch-notifications.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import { - createNormalizedPathInsideOrEqualMatcher, - normalizeRuntimePathForComparison -} from '../../shared/cross-platform-path' -import { - failSshFilesystemWatchRegistration, - type WatchRegistration -} from './ssh-filesystem-provider-watch' - -export function routeSshFilesystemWatchNotification( - registrations: Map, - method: string, - params: Record -): void { - if (method === 'fs.changed') { - const events = params.events as FsChangeEvent[] - // Why normalize once: isPathInsideOrEqual NFC-normalizes both sides, so the - // nested fan-out re-normalized every event path once per watch root. - const normalizedEvents = events.map((event) => ({ - event, - normalizedPath: normalizeRuntimePathForComparison(event.absolutePath) - })) - for (const registration of registrations.values()) { - const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(registration.rootPath) - const matching = normalizedEvents - .filter(({ normalizedPath }) => isInsideRoot(normalizedPath)) - .map(({ event }) => event) - if (matching.length > 0) { - for (const callback of registration.callbacks) { - callback(matching) - } - } - } - return - } - if (method !== 'fs.watchFailed') { - return - } - const rootPath = typeof params.rootPath === 'string' ? params.rootPath : null - const watchId = typeof params.watchId === 'number' ? params.watchId : null - if (rootPath && watchId !== null) { - const message = - typeof params.message === 'string' ? params.message : 'remote file watcher failed' - failSshFilesystemWatchRegistration(registrations, rootPath, watchId, new Error(message)) - } -} diff --git a/src/main/providers/ssh-listing-compatibility.test.ts b/src/main/providers/ssh-listing-compatibility.test.ts deleted file mode 100644 index 903834aa89ec..000000000000 --- a/src/main/providers/ssh-listing-compatibility.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { listSshFiles } from './ssh-file-listing' -import { describe, expect, it, vi } from 'vitest' -import { readSshMarkdownDocuments } from './ssh-markdown-document-listing' -import { readSshDirectoryBounded } from './ssh-directory-listing' -import { SshFilesystemProvider } from './ssh-filesystem-provider' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' - -function muxFixture(result: unknown, error?: Error) { - const mock = { - request: error ? vi.fn().mockRejectedValue(error) : vi.fn().mockResolvedValue(result), - notify: vi.fn(), - onNotification: vi.fn(() => () => {}), - onNotificationByMethod: vi.fn(() => () => {}), - onDispose: vi.fn(() => () => {}), - isDisposed: () => false - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The reader uses only these request, notification, and disposal operations. - return { mock, mux: mock as unknown as SshChannelMultiplexer } -} -const unsupported = () => Object.assign(new Error('Method not found'), { code: -32601 }) - -describe('SSH listing compatibility', () => { - it('accepts late full-inventory files in old plain replies without a count cap', async () => { - const paths = Array.from({ length: 25002 }, (_, i) => `src/file-${i}.ts`) - const { mux, mock } = muxFixture(paths) - expect((await listSshFiles(mux, '/repo')).at(-1)).toBe('src/file-25001.ts') - expect(mock.request.mock.calls[0][1].maxResults).toBeUndefined() - const limited = muxFixture(paths.slice(0, 3)) - expect(await listSshFiles(limited.mux, '/repo', { maxResults: 3 })).toHaveLength(3) - }) - - it('keeps complete small old-peer Markdown inventories useful', async () => { - const { mux } = muxFixture(undefined, unsupported()) - const loadLegacy = vi.fn().mockResolvedValue(['source.ts', 'docs/README.md']) - const result = await readSshMarkdownDocuments(mux, '/repo', undefined, loadLegacy) - expect(result.map((document) => document.relativePath)).toEqual(['docs/README.md']) - expect(loadLegacy).toHaveBeenCalledTimes(1) - }) - - it('keeps late Markdown files in large old-peer source inventories', async () => { - const paths = Array.from({ length: 25_002 }, (_, index) => `src/file-${index}.ts`) - paths.push('docs/late.md') - const { mux, mock } = muxFixture(paths) - mock.request.mockRejectedValueOnce(unsupported()) - const provider = new SshFilesystemProvider('legacy', mux) - await expect(provider.listMarkdownDocuments('/repo')).resolves.toEqual([ - { - filePath: '/repo/docs/late.md', - relativePath: 'docs/late.md', - basename: 'late.md', - name: 'late' - } - ]) - expect(mock.request).toHaveBeenLastCalledWith('fs.listFiles', { - rootPath: '/repo', - __streamResponse: true - }) - provider.dispose() - }) - - it('still rejects an old-peer inventory with too many Markdown documents', async () => { - const { mux } = muxFixture(undefined, unsupported()) - await expect( - readSshMarkdownDocuments(mux, '/repo', undefined, async () => Array(20_001).fill('source.md')) - ).rejects.toThrow('Workspace is too large') - }) - - it('uses bounded SFTP fallback for old directory peers and preserves failures', async () => { - const { mux } = muxFixture(undefined, unsupported()) - const fallback = vi - .fn() - .mockResolvedValue([{ name: 'folder', isDirectory: true, isSymlink: false }]) - expect(await readSshDirectoryBounded(mux, '/repo', fallback)).toHaveLength(1) - const failure = muxFixture(undefined, new Error('Permission denied')) - await expect(readSshDirectoryBounded(failure.mux, '/repo', fallback)).rejects.toThrow( - 'Permission denied' - ) - expect(fallback).toHaveBeenCalledTimes(1) - }) - - it('validates new-peer directory and Markdown metadata before exposing it', async () => { - const directory = muxFixture([ - { name: 'x'.repeat(5 * 1024 * 1024), isDirectory: false, isSymlink: false } - ]) - await expect(readSshDirectoryBounded(directory.mux, '/repo')).rejects.toThrow() - const markdown = muxFixture( - Array.from({ length: 20_001 }, () => ({ - filePath: '/repo/a.md', - relativePath: 'a.md', - basename: 'a.md', - name: 'a' - })) - ) - await expect(readSshMarkdownDocuments(markdown.mux, '/repo')).rejects.toThrow( - 'Workspace is too large' - ) - }) -}) diff --git a/src/main/providers/ssh-markdown-document-listing.ts b/src/main/providers/ssh-markdown-document-listing.ts deleted file mode 100644 index d3b6be57b8b8..000000000000 --- a/src/main/providers/ssh-markdown-document-listing.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { markdownDocumentsFromRelativePaths } from '../../shared/markdown-document-paths' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import type { MarkdownDocument } from '../../shared/filesystem-entry-types' -import { assertMarkdownDocumentsWithinLimit } from '../../shared/markdown-document-listing-limits' - -export async function readSshMarkdownDocuments( - mux: SshChannelMultiplexer, - rootPath: string, - signal?: AbortSignal, - loadLegacy?: () => Promise -): Promise { - let result: unknown - try { - result = await requestGitStreamable( - mux, - 'fs.listMarkdownDocuments', - { rootPath }, - { signal, maxResponseBytes: 16 * 1024 * 1024 } - ) - } catch (error) { - if (isMethodNotFoundError(error)) { - if (loadLegacy) { - return markdownDocumentsFromRelativePaths(rootPath, await loadLegacy()) - } - throw new Error('Markdown discovery requires an updated SSH relay. Reconnect and retry.') - } - throw error - } - assertMarkdownDocumentsWithinLimit(result) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared validator checked every document field and the aggregate budget. - return result as MarkdownDocument[] -} diff --git a/src/main/providers/ssh-plain-shell-pty-provider.test.ts b/src/main/providers/ssh-plain-shell-pty-provider.test.ts deleted file mode 100644 index 4b223eb5578e..000000000000 --- a/src/main/providers/ssh-plain-shell-pty-provider.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' -import type { ClientChannel, PseudoTtyOptions } from 'ssh2' -import { SshPlainShellPtyProvider } from './ssh-plain-shell-pty-provider' - -class FakeShellChannel extends EventEmitter { - readonly stderr = new EventEmitter() - readonly writes: string[] = [] - readonly setWindow = vi.fn() - readonly signal = vi.fn() - readonly close = vi.fn() - write(data: string): boolean { - this.writes.push(data) - return true - } -} - -const MODE = { reason: 'no_runtime', message: 'No runtime here.' } - -function createProvider(options: { posixHost?: boolean } = {}) { - const channels: FakeShellChannel[] = [] - const opened: PseudoTtyOptions[] = [] - const provider = new SshPlainShellPtyProvider( - 'target-1', - async (pty) => { - opened.push(pty) - const channel = new FakeShellChannel() - channels.push(channel) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fake implements every ClientChannel member the provider touches. - return channel as unknown as ClientChannel - }, - MODE, - options.posixHost ?? true, - 7 - ) - const data: { id: string; data: string; providerGeneration: number }[] = [] - const exits: { id: string; code: number }[] = [] - provider.onData((payload) => data.push(payload)) - provider.onExit((payload) => exits.push(payload)) - return { provider, channels, opened, data, exits } -} - -describe('SshPlainShellPtyProvider', () => { - it('opens a pty-req shell at the requested size and shows the plain SSH reason', async () => { - const { provider, channels, opened, data } = createProvider() - const result = await provider.spawn({ cols: 120, rows: 40, cwd: "/srv/it's" }) - - expect(opened).toEqual([{ cols: 120, rows: 40, term: 'xterm-256color' }]) - expect(result.id).toMatch(/^ssh:target-1@@plain-/) - expect(result.incarnationId).toEqual(expect.any(String)) - expect(result.sessionExpired).toBeUndefined() - expect(data[0]).toMatchObject({ id: result.id, providerGeneration: 7 }) - expect(data[0]?.data).toContain('No runtime here.') - expect(channels[0]?.writes).toEqual([` cd -- '/srv/it'\\''s'\n`]) - }) - - it('keeps a home-relative cwd expandable by leaving the tilde unquoted', async () => { - const { provider, channels } = createProvider() - await provider.spawn({ cols: 80, rows: 24, cwd: "~/it's" }) - await provider.spawn({ cols: 80, rows: 24, cwd: '~' }) - expect(channels[0]?.writes).toEqual([` cd -- ~/'it'\\''s'\n`]) - expect(channels[1]?.writes).toEqual([' cd -- ~\n']) - }) - - it('does not type a POSIX cd into a Windows host shell', async () => { - const { provider, channels } = createProvider({ posixHost: false }) - await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\work' }) - expect(channels[0]?.writes).toEqual([]) - }) - - it('forwards output, input and resize over the channel', async () => { - const { provider, channels, data } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - const channel = channels[0]! - - // A multi-byte character split across chunks must not decode as replacement characters. - const euro = Buffer.from('€', 'utf8') - channel.emit('data', euro.subarray(0, 1)) - channel.emit('data', euro.subarray(1)) - channel.stderr.emit('data', Buffer.from('err')) - expect(data.slice(1).map((d) => d.data)).toEqual(['€', 'err']) - - expect(provider.write(id, 'ls\r')).toBe(true) - expect(provider.writeWithSettlement(id, 'x')).toEqual({ outcome: 'accepted' }) - expect(channel.writes).toEqual(['ls\r', 'x']) - - provider.resize(id, 100, 30) - expect(channel.setWindow).toHaveBeenCalledWith(30, 100, 0, 0) - await expect(provider.getAppliedSize(id)).resolves.toEqual({ cols: 100, rows: 30 }) - }) - - it('reports exited only when the host sent an exit status', async () => { - const { provider, channels, exits } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - channels[0]!.emit('exit', 3) - channels[0]!.emit('close') - - expect(exits).toEqual([expect.objectContaining({ id, code: 3, providerGeneration: 7 })]) - await expect(provider.probePtyLiveness(id)).resolves.toBe(false) - }) - - it('treats a close without an exit status as unverifiable, not exited', async () => { - const { provider, channels, exits, data } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - channels[0]!.emit('close') - - expect(exits).toEqual([]) - expect(data.at(-1)?.data).toContain('unverifiable') - await expect(provider.probePtyLiveness(id)).resolves.toBeNull() - expect(provider.writeWithSettlement(id, 'x')).toEqual({ - outcome: 'refused', - reason: 'provider_unavailable' - }) - }) - - it('reports an operator close as an exit once the channel closes', async () => { - const { provider, channels, exits } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - await provider.shutdown(id) - expect(channels[0]!.close).toHaveBeenCalled() - channels[0]!.emit('close') - expect(exits).toEqual([expect.objectContaining({ id, code: 0 })]) - }) - - it('leaves open shells unverifiable when the transport is disposed', async () => { - const { provider, channels, exits } = createProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - provider.dispose() - channels[0]!.emit('close') - - expect(exits).toEqual([]) - await expect(provider.probePtyLiveness(id)).resolves.toBeNull() - await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('not active') - }) - - it('leaves a PTY it never minted unverifiable, since an earlier relay may still run it', async () => { - const { provider } = createProvider() - await expect(provider.probePtyLiveness('ssh:target-1@@prior-relay-pty')).resolves.toBeNull() - }) - - it('starts a fresh shell for a reattach request and says the session expired', async () => { - const { provider } = createProvider() - const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:target-1@@pty-3' }) - expect(result.sessionExpired).toBe(true) - }) - - it('refuses relay-only launches with the plain SSH reason', async () => { - const { provider, opened } = createProvider() - await expect(provider.spawn({ cols: 80, rows: 24, launchAgent: 'claude' })).rejects.toThrow( - 'needs the Orca remote server' - ) - await expect(provider.spawn({ cols: 80, rows: 24, attachOnly: true })).rejects.toThrow( - '(no_runtime)' - ) - expect(opened).toEqual([]) - }) -}) diff --git a/src/main/providers/ssh-plain-shell-pty-provider.ts b/src/main/providers/ssh-plain-shell-pty-provider.ts deleted file mode 100644 index 2272bfda36bd..000000000000 --- a/src/main/providers/ssh-plain-shell-pty-provider.ts +++ /dev/null @@ -1,320 +0,0 @@ -/** - * PTY provider for plain SSH mode (design D6 rung D): each terminal is one ssh2 session channel - * with pty-req + shell. Nothing persists on the host, so a lost channel is `unverifiable` and a - * reconnect starts a fresh shell. - */ -import { randomUUID } from 'node:crypto' -import { StringDecoder } from 'node:string_decoder' -import type { ClientChannel, PseudoTtyOptions } from 'ssh2' -import { WRITE_ACCEPTED, type WriteSettlement } from '../../shared/pty-write-settlement' -import { PlainSshUnsupportedError } from '../ssh/ssh-plain-ssh-mode' -import type { SshPlainSshMode } from '../../shared/ssh-types' -import { toAppSshPtyId } from './ssh-pty-id' -import type { - SshPtyDataCallback, - SshPtyExitCallback, - SshPtyReplayCallback -} from './ssh-pty-provider-contract' -import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' - -export type PlainShellOpener = (pty: PseudoTtyOptions) => Promise - -type PlainShell = { - channel: ClientChannel - incarnation: string - cwd: string - cols: number - rows: number - exitCode: number | null - closeRequested: boolean -} - -const TERM = 'xterm-256color' -// Why: sshd reports a signalled shell as exit-signal, not exit-status; mirror the shell's 128+n. -const SIGNALLED_EXIT_CODE = 129 - -function quotePosixPath(path: string): string { - return `'${path.replace(/'/g, `'\\''`)}'` -} - -// Why: a quoted `~` is literal, so the home prefix stays outside the quotes to expand. -function posixCdTarget(path: string): string { - if (path === '~') { - return '~' - } - return path.startsWith('~/') ? `~/${quotePosixPath(path.slice(2))}` : quotePosixPath(path) -} - -export function plainSshTerminalNotice(mode: SshPlainSshMode): string { - return `\x1b[2m[Orca] Plain SSH terminal: ${mode.message}\x1b[0m\r\n` -} - -const LOST_NOTICE = - '\r\n\x1b[2m[Orca] The SSH connection closed before the shell reported an exit; its state on ' + - 'the host is unverifiable. Plain SSH terminals are not restored on reconnect.\x1b[0m\r\n' - -export class SshPlainShellPtyProvider implements IPtyProvider { - private readonly shells = new Map() - // Why only confirmed exits answer absent: an id this provider never minted may still run on an earlier relay. - private readonly exited = new Set() - private readonly dataListeners = new Set() - private readonly exitListeners = new Set() - private disposed = false - - constructor( - private readonly connectionId: string, - private readonly openShell: PlainShellOpener, - private readonly mode: SshPlainSshMode, - private readonly posixHost: boolean, - readonly providerGeneration: number - ) {} - - async spawn(opts: PtySpawnOptions): Promise { - if (this.disposed) { - throw new Error('SSH connection is not active') - } - if (opts.agentSessionEnsure || opts.agentSessionCreateOperationId || opts.launchAgent) { - throw new PlainSshUnsupportedError('Launching agents', this.mode) - } - if (opts.attachOnly) { - throw new PlainSshUnsupportedError('Reattaching a terminal session', this.mode) - } - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - const cols = Math.max(1, opts.cols) - const rows = Math.max(1, opts.rows) - const channel = await this.openShell({ cols, rows, term: TERM }) - if (this.disposed) { - channel.close() - throw new Error('SSH connection is not active') - } - const id = toAppSshPtyId(this.connectionId, `plain-${randomUUID()}`) - const shell: PlainShell = { - channel, - incarnation: randomUUID(), - cwd: opts.cwd ?? '', - cols, - rows, - exitCode: null, - closeRequested: false - } - this.shells.set(id, shell) - this.wireChannel(id, shell) - this.emitData(id, shell, plainSshTerminalNotice(this.mode)) - if (opts.cwd && this.posixHost) { - // Why: a shell channel has no cwd request; a leading space keeps it out of most histories. - channel.write(` cd -- ${posixCdTarget(opts.cwd)}\n`) - } - if (opts.command && opts.commandDelivery === 'provider') { - channel.write(`${opts.command}\n`) - } - return { - id, - incarnationId: shell.incarnation, - ...(opts.sessionId ? { sessionExpired: true } : {}) - } - } - - private wireChannel(id: string, shell: PlainShell): void { - const stdout = new StringDecoder('utf8') - const stderr = new StringDecoder('utf8') - shell.channel.on('data', (chunk: Buffer) => this.emitData(id, shell, stdout.write(chunk))) - shell.channel.stderr?.on('data', (chunk: Buffer) => - this.emitData(id, shell, stderr.write(chunk)) - ) - shell.channel.on('exit', (code: number | null) => { - shell.exitCode = typeof code === 'number' ? code : SIGNALLED_EXIT_CODE - }) - // Why: channel errors surface as close; the close handler owns the verdict. - shell.channel.on('error', () => {}) - shell.channel.on('close', () => this.settleClose(id, shell)) - } - - private settleClose(id: string, shell: PlainShell): void { - if (this.shells.get(id) !== shell) { - return - } - this.shells.delete(id) - if (shell.exitCode === null && !shell.closeRequested) { - // Why: no exit-status means nothing proved the shell ended; report it lost, never exited. - this.emitData(id, shell, LOST_NOTICE) - return - } - this.exited.add(id) - const payload = { - id, - code: shell.exitCode ?? 0, - providerGeneration: this.providerGeneration, - ptyIncarnation: shell.incarnation, - incarnationId: shell.incarnation - } - for (const listener of this.exitListeners) { - listener(payload) - } - } - - private emitData(id: string, shell: PlainShell, data: string): void { - if (!data) { - return - } - const payload = { - id, - data, - providerGeneration: this.providerGeneration, - ptyIncarnation: shell.incarnation - } - for (const listener of this.dataListeners) { - listener(payload) - } - } - - async attach(id: string): Promise { - if (!this.shells.has(id)) { - throw new Error(`PTY ${id} not found`) - } - } - - hasPty = (id: string): boolean => this.shells.has(id) - - probePtyLiveness = async (id: string): Promise => { - if (this.shells.has(id)) { - return true - } - return this.exited.has(id) ? false : null - } - - write(id: string, data: string): boolean { - const shell = this.shells.get(id) - if (!shell) { - return false - } - shell.channel.write(data) - return true - } - - writeWithSettlement(id: string, data: string): WriteSettlement { - const shell = this.shells.get(id) - if (!shell) { - return { outcome: 'refused', reason: 'provider_unavailable' } - } - try { - shell.channel.write(data) - return WRITE_ACCEPTED - } catch { - return { - outcome: 'unverifiable', - reason: 'endpoint_write_threw', - bytesHandedToTransport: false - } - } - } - - resize(id: string, cols: number, rows: number): void { - const shell = this.shells.get(id) - if (!shell || cols < 1 || rows < 1) { - return - } - shell.channel.setWindow(rows, cols, 0, 0) - shell.cols = cols - shell.rows = rows - } - - getAppliedSize = async (id: string): Promise<{ cols: number; rows: number } | null> => { - const shell = this.shells.get(id) - return shell ? { cols: shell.cols, rows: shell.rows } : null - } - - async shutdown(id: string): Promise { - const shell = this.shells.get(id) - if (!shell) { - return - } - shell.closeRequested = true - shell.channel.close() - } - - async sendSignal(id: string, signal: string): Promise { - this.shells.get(id)?.channel.signal(signal.replace(/^SIG/, '')) - } - - async getCwd(id: string): Promise { - return this.shells.get(id)?.cwd ?? '' - } - - async getInitialCwd(id: string): Promise { - return this.shells.get(id)?.cwd ?? '' - } - - async clearBuffer(): Promise {} - async resetInputModes(): Promise {} - acknowledgeDataEvent(): void {} - - async hasChildProcesses(): Promise { - return false - } - - async getForegroundProcess(): Promise { - return null - } - - async serialize(): Promise { - return '[]' - } - - async revive(): Promise {} - - async listProcesses(): Promise { - return [...this.shells].map(([id, shell]) => ({ - id, - incarnationId: shell.incarnation, - cwd: shell.cwd, - title: 'ssh' - })) - } - - async getDefaultShell(): Promise { - return '' - } - - async getProfiles(): Promise<{ name: string; path: string }[]> { - return [] - } - - supportsAgentSessionClaims = (): boolean => false - supportsAgentSessionCreateOperations = (): boolean => false - canProvideAuthoritativeBufferSnapshot = (): boolean => false - - onData(callback: SshPtyDataCallback): () => void { - this.dataListeners.add(callback) - return () => this.dataListeners.delete(callback) - } - - onReplay(_callback: SshPtyReplayCallback): () => void { - return () => {} - } - - onExit(callback: SshPtyExitCallback): () => void { - this.exitListeners.add(callback) - return () => this.exitListeners.delete(callback) - } - - /** Transport teardown: every open shell becomes unverifiable; no exit is reported. */ - dispose(): void { - if (this.disposed) { - return - } - this.disposed = true - const open = [...this.shells.values()] - this.shells.clear() - for (const shell of open) { - try { - shell.channel.close() - } catch { - // The transport is already gone; nothing left to release. - } - } - this.dataListeners.clear() - this.exitListeners.clear() - } -} diff --git a/src/main/providers/ssh-sftp-directory-listing.ts b/src/main/providers/ssh-sftp-directory-listing.ts deleted file mode 100644 index eb8436ea015a..000000000000 --- a/src/main/providers/ssh-sftp-directory-listing.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import type { DirEntry } from '../../shared/filesystem-entry-types' -import { DirectoryListingBudget } from '../../shared/directory-listing-budget' -import { sortDirEntries } from '../../shared/file-name-sort' -import { readDirectoryEntriesViaSftp, statViaSftp } from './ssh-filesystem-provider-sftp' - -export async function readSftpDirectory( - sftp: SFTPWrapper, - path: string, - options?: { followSymlinks?: boolean; signal?: AbortSignal } -): Promise { - const budget = new DirectoryListingBudget() - const mapped: DirEntry[] = [] - for await (const entry of readDirectoryEntriesViaSftp(sftp, path, options)) { - budget.record(entry.filename) - const isSymlink = entry.attrs.isSymbolicLink() - let isDirectory = entry.attrs.isDirectory() - if (isSymlink && options?.followSymlinks !== false) { - isDirectory = await statViaSftp(sftp, `${path.replace(/\/$/, '')}/${entry.filename}`, options) - .then((stats) => stats.isDirectory()) - .catch(() => false) - } - mapped.push({ name: entry.filename, isDirectory, isSymlink }) - } - options?.signal?.throwIfAborted() - return sortDirEntries(mapped) -} diff --git a/src/main/providers/ssh-sftp-filesystem-channel.ts b/src/main/providers/ssh-sftp-filesystem-channel.ts deleted file mode 100644 index aaef5c9c2c17..000000000000 --- a/src/main/providers/ssh-sftp-filesystem-channel.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import type { SftpFactory } from './ssh-filesystem-download' -import { isSftpDirectoryChannelRetired } from './ssh-sftp-directory-close' - -export class SftpFilesystemChannel { - private sftpPromise: Promise | null = null - private disposed = false - - constructor(private readonly createSftp: SftpFactory) {} - - dispose(): void { - this.disposed = true - const pending = this.sftpPromise - this.sftpPromise = null - void pending?.then( - (sftp) => sftp.end(), - () => {} - ) - } - - async get(): Promise { - if (this.disposed) { - throw new Error('SSH connection is not active') - } - if (!this.sftpPromise) { - const opening = this.createSftp().then((sftp) => { - if (isSftpDirectoryChannelRetired(sftp)) { - throw new Error('SFTP factory returned a retired directory channel') - } - // Why: a closed channel must not be reused; the next call reopens one. - sftp.once('close', () => { - if (this.sftpPromise === opening) { - this.sftpPromise = null - } - }) - return sftp - }) - opening.catch(() => { - if (this.sftpPromise === opening) { - this.sftpPromise = null - } - }) - this.sftpPromise = opening - } - const opening = this.sftpPromise - const sftp = await opening - if (isSftpDirectoryChannelRetired(sftp)) { - if (this.sftpPromise === opening) { - this.sftpPromise = null - } - return this.get() - } - return sftp - } -} diff --git a/src/main/providers/ssh-sftp-filesystem-provider.test.ts b/src/main/providers/ssh-sftp-filesystem-provider.test.ts deleted file mode 100644 index f2093ce48dee..000000000000 --- a/src/main/providers/ssh-sftp-filesystem-provider.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' -import type { SFTPWrapper } from 'ssh2' -import { SshSftpFilesystemProvider, toSftpPath } from './ssh-sftp-filesystem-provider' -import { downloadFolderViaSftp } from './ssh-filesystem-download' -import type * as SshFilesystemDownload from './ssh-filesystem-download' - -vi.mock('./ssh-filesystem-download', async (importOriginal) => ({ - ...(await importOriginal()), - downloadFolderViaSftp: vi.fn(async () => {}) -})) - -type Node = { kind: 'file'; content: Buffer } | { kind: 'dir' } | { kind: 'link'; target: string } -type Callback = (err: Error | null, value?: unknown) => void - -function stats(node: Node, size = 0) { - return { - size, - mtime: 1_700_000_000, - isDirectory: () => node.kind === 'dir', - isSymbolicLink: () => node.kind === 'link', - isFile: () => node.kind === 'file' - } -} - -function noSuchFile(): Error { - return Object.assign(new Error('No such file'), { code: 2 }) -} - -/** In-memory SFTP server double covering the calls the provider makes. */ -class FakeSftp extends EventEmitter { - readonly nodes = new Map() - readonly end = vi.fn() - posixRename = true - - private resolve(path: string): Node | undefined { - const node = this.nodes.get(path) - return node?.kind === 'link' ? this.nodes.get(node.target) : node - } - - private sizeOf(node: Node): number { - return node.kind === 'file' ? node.content.length : 0 - } - - private directoryReads = new WeakSet() - - opendir(path: string, cb: Callback): void { - cb(null, Buffer.from(path)) - } - close(_handle: Buffer, cb: Callback): void { - cb(null) - } - - readdir(handle: Buffer, cb: Callback): void { - if (this.directoryReads.has(handle)) { - return cb(null, false) - } - this.directoryReads.add(handle) - const path = handle.toString() - const prefix = `${path}/` - const entries = [...this.nodes] - .filter(([p]) => p.startsWith(prefix) && !p.slice(prefix.length).includes('/')) - .map(([p, node]) => ({ filename: p.slice(prefix.length), attrs: stats(node) })) - cb(null, entries) - } - - stat(path: string, cb: Callback): void { - const node = this.resolve(path) - return node ? cb(null, stats(node, this.sizeOf(node))) : cb(noSuchFile()) - } - - lstat(path: string, cb: Callback): void { - const node = this.nodes.get(path) - return node ? cb(null, stats(node, this.sizeOf(node))) : cb(noSuchFile()) - } - - readFile(path: string, cb: Callback): void { - const node = this.resolve(path) - return node?.kind === 'file' ? cb(null, node.content) : cb(noSuchFile()) - } - - writeFile(path: string, data: string | Buffer, cb: Callback): void { - this.nodes.set(path, { kind: 'file', content: Buffer.from(data) }) - cb(null) - } - - appendFile(path: string, data: Buffer, cb: Callback): void { - const node = this.nodes.get(path) - const prior = node?.kind === 'file' ? node.content : Buffer.alloc(0) - this.nodes.set(path, { kind: 'file', content: Buffer.concat([prior, data]) }) - cb(null) - } - - unlink(path: string, cb: Callback): void { - this.nodes.delete(path) - cb(null) - } - - rmdir(path: string, cb: Callback): void { - const hasChildren = [...this.nodes.keys()].some((p) => p.startsWith(`${path}/`)) - if (hasChildren) { - cb(Object.assign(new Error('Failure'), { code: 4 })) - return - } - this.nodes.delete(path) - cb(null) - } - - mkdir(path: string, cb: Callback): void { - this.nodes.set(path, { kind: 'dir' }) - cb(null) - } - - rename(from: string, to: string, cb: Callback): void { - if (this.nodes.has(to)) { - cb(Object.assign(new Error('Failure'), { code: 4 })) - return - } - this.nodes.set(to, this.nodes.get(from)!) - this.nodes.delete(from) - cb(null) - } - - ext_openssh_rename(from: string, to: string, cb: Callback): void { - if (!this.posixRename) { - throw new Error('Server does not support this extended request') - } - this.nodes.set(to, this.nodes.get(from)!) - this.nodes.delete(from) - cb(null) - } - - realpath(path: string, cb: Callback): void { - cb(null, path === '.' ? '/home/me' : path) - } -} - -const MODE = { reason: 'home_noexec', message: 'noexec home' } - -function createProvider() { - const sftp = new FakeSftp() - const createSftp = vi.fn(async () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSftp implements every SFTPWrapper method the provider calls. - return sftp as unknown as SFTPWrapper - }) - const provider = new SshSftpFilesystemProvider('target-1', createSftp, MODE) - return { provider, sftp, createSftp } -} - -describe('SshSftpFilesystemProvider', () => { - it('maps home-relative paths onto the SFTP login directory', () => { - expect(toSftpPath('~')).toBe('.') - expect(toSftpPath('~/src/app')).toBe('src/app') - expect(toSftpPath('/etc/hosts')).toBe('/etc/hosts') - }) - - it('lists a directory sorted, resolving symlinked directories like their target', async () => { - const { provider, sftp, createSftp } = createProvider() - sftp.nodes.set('/w/b.txt', { kind: 'file', content: Buffer.from('b') }) - sftp.nodes.set('/w/src', { kind: 'dir' }) - sftp.nodes.set('/w/link', { kind: 'link', target: '/w/src' }) - sftp.nodes.set('/w/dangling', { kind: 'link', target: '/nowhere' }) - - await expect(provider.readDir('/w')).resolves.toEqual([ - { name: 'link', isDirectory: true, isSymlink: true }, - { name: 'src', isDirectory: true, isSymlink: false }, - { name: 'b.txt', isDirectory: false, isSymlink: false }, - { name: 'dangling', isDirectory: false, isSymlink: true } - ]) - await provider.stat('/w/b.txt') - // One SFTP channel is reused across operations. - expect(createSftp).toHaveBeenCalledTimes(1) - }) - - it('reads text, flags binary, and returns images as base64', async () => { - const { provider, sftp } = createProvider() - sftp.nodes.set('/a.txt', { kind: 'file', content: Buffer.from('héllo') }) - sftp.nodes.set('/a.bin', { kind: 'file', content: Buffer.from([1, 0, 2]) }) - sftp.nodes.set('/a.png', { kind: 'file', content: Buffer.from([137, 80]) }) - - await expect(provider.readFile('/a.txt')).resolves.toEqual({ - content: 'héllo', - isBinary: false - }) - await expect(provider.readFile('/a.bin')).resolves.toEqual({ content: '', isBinary: true }) - await expect(provider.readFile('/a.png')).resolves.toMatchObject({ - content: Buffer.from([137, 80]).toString('base64'), - isImage: true, - mimeType: 'image/png' - }) - }) - - it('reports missing paths as ENOENT so existence checks stay truthful', async () => { - const { provider, sftp } = createProvider() - sftp.nodes.set('/here', { kind: 'file', content: Buffer.from('') }) - await expect(provider.stat('/gone')).rejects.toMatchObject({ code: 'ENOENT' }) - await expect(provider.pathsExist(['/here', '/gone'])).resolves.toEqual([ - { exists: true }, - { exists: false } - ]) - }) - - it('writes, appends, creates and renames files', async () => { - const { provider, sftp } = createProvider() - await provider.writeFile('/f', 'one') - await provider.writeFileBase64Chunk('/f', Buffer.from('two').toString('base64'), true) - expect(sftp.nodes.get('/f')).toEqual({ kind: 'file', content: Buffer.from('onetwo') }) - - await provider.createDir('/d') - await provider.rename('/f', '/d/f') - expect(sftp.nodes.has('/d/f')).toBe(true) - - sftp.posixRename = false - await provider.writeFile('/g', 'g') - await provider.rename('/g', '/h') - expect(sftp.nodes.has('/h')).toBe(true) - await expect(provider.renameNoClobber('/h', '/d/f')).rejects.toThrow('Failure') - }) - - it('deletes files and empty folders but refuses a recursive delete with the reason', async () => { - const { provider, sftp } = createProvider() - sftp.nodes.set('/d', { kind: 'dir' }) - sftp.nodes.set('/d/x', { kind: 'file', content: Buffer.from('x') }) - await expect(provider.deletePath('/d', true)).rejects.toThrow( - 'Deleting a non-empty folder needs the Orca remote server' - ) - await provider.deletePath('/d/x') - await provider.deletePath('/d') - expect(sftp.nodes.size).toBe(0) - }) - - it('refuses relay-only features with the classified reason', async () => { - const { provider } = createProvider() - await expect(provider.watch()).rejects.toThrow('(home_noexec)') - await expect(provider.search()).rejects.toThrow('Search needs the Orca remote server') - await expect(provider.listFiles()).rejects.toThrow('Quick Open') - await expect(provider.copy()).rejects.toThrow('Copying files') - await expect(provider.supportsQuickOpenSearch()).resolves.toBe(false) - }) - - it('downloads folders over SFTP with the host path style', async () => { - const { createSftp } = createProvider() - const windowsProvider = new SshSftpFilesystemProvider('target-1', createSftp, MODE, true) - const signal = new AbortController().signal - await windowsProvider.downloadFolder('~/proj', '/tmp/dest', { signal }) - expect(downloadFolderViaSftp).toHaveBeenCalledWith(createSftp, 'proj', '/tmp/dest', { - signal, - windowsRemotePaths: true - }) - }) - - it('probes the transport with one SFTP round trip and times out a silent one', async () => { - const { provider, sftp } = createProvider() - await expect(provider.probeTransport(1_000)).resolves.toBe(true) - sftp.realpath = () => {} - vi.useFakeTimers() - try { - const probe = provider.probeTransport(5_000) - await vi.advanceTimersByTimeAsync(5_000) - await expect(probe).resolves.toBe(false) - } finally { - vi.useRealTimers() - } - }) - - it('reopens SFTP after the channel closes and ends it on dispose', async () => { - const { provider, sftp, createSftp } = createProvider() - sftp.nodes.set('/x', { kind: 'file', content: Buffer.from('') }) - await provider.stat('/x') - sftp.emit('close') - await provider.stat('/x') - expect(createSftp).toHaveBeenCalledTimes(2) - - provider.dispose() - await Promise.resolve() - expect(sftp.end).toHaveBeenCalled() - await expect(provider.stat('/x')).rejects.toThrow('not active') - }) -}) - -it('retires the persistent directory channel after failed CLOSE and reopens for the next read', async () => { - const first = new FakeSftp() - first.nodes.set('/dir/file.txt', { kind: 'file', content: Buffer.from('content') }) - first.close = (_handle, callback) => callback(new Error('CLOSE rejected')) - const second = new FakeSftp() - second.nodes.set('/dir/file.txt', { kind: 'file', content: Buffer.from('content') }) - const createSftp = vi.fn(async () => { - const next = createSftp.mock.calls.length === 1 ? first : second - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSftp implements the directory operations and channel close event used by this provider. - return next as unknown as SFTPWrapper - }) - const provider = new SshSftpFilesystemProvider('target-1', createSftp, MODE) - await expect(provider.readDir('/dir')).rejects.toThrow('CLOSE rejected') - expect(first.end).toHaveBeenCalledOnce() - await expect(provider.readDir('/dir')).resolves.toEqual([ - { name: 'file.txt', isDirectory: false, isSymlink: false } - ]) - expect(createSftp).toHaveBeenCalledTimes(2) - expect(second.end).not.toHaveBeenCalled() - first.emit('close') - await expect(provider.readDir('/dir')).resolves.toHaveLength(1) - expect(createSftp).toHaveBeenCalledTimes(2) - provider.dispose() -}) - -it('replaces a timed-out channel immediately without waiting for its close event', async () => { - vi.useFakeTimers() - try { - const first = new FakeSftp() - first.close = () => {} - const second = new FakeSftp() - const createSftp = vi.fn(async () => { - const next = createSftp.mock.calls.length === 1 ? first : second - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSftp provides handle operations and channel lifecycle events used by the directory provider. - return next as unknown as SFTPWrapper - }) - const provider = new SshSftpFilesystemProvider('target-1', createSftp, MODE) - const rejected = expect(provider.readDir('/dir')).rejects.toThrow('CLOSE timed out') - await vi.advanceTimersByTimeAsync(5000) - await rejected - expect(first.end).toHaveBeenCalledOnce() - const [a, b] = await Promise.all([provider.readDir('/dir'), provider.readDir('/dir')]) - expect(a).toEqual([]) - expect(b).toEqual([]) - expect(createSftp).toHaveBeenCalledTimes(2) - first.emit('close') - await expect(provider.readDir('/dir')).resolves.toEqual([]) - expect(createSftp).toHaveBeenCalledTimes(2) - provider.dispose() - } finally { - vi.useRealTimers() - } -}) diff --git a/src/main/providers/ssh-sftp-filesystem-provider.ts b/src/main/providers/ssh-sftp-filesystem-provider.ts deleted file mode 100644 index ddbcabe951fa..000000000000 --- a/src/main/providers/ssh-sftp-filesystem-provider.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { readSftpDirectory } from './ssh-sftp-directory-listing' -import { SftpFilesystemChannel } from './ssh-sftp-filesystem-channel' -export { readSftpDirectory } from './ssh-sftp-directory-listing' -/** - * Filesystem provider for plain SSH mode (design D6 rung D): read, list, stat and write over - * one reused SFTP channel. Anything that needs the Orca remote server (search, file lists, - * watching, recursive delete, copy) refuses with that reason instead of hanging on a relay. - */ -import { extname } from 'node:path' -import type { SFTPWrapper, Stats } from 'ssh2' -import { IMAGE_FILE_MIME_TYPES } from '../../shared/image-file-extensions' -import { isBinaryBuffer } from '../../shared/binary-buffer' -import { capturePathExistence, type PathExistenceResult } from '../../shared/path-existence-batch' -import type { SearchResult } from '../../shared/code-search-types' -import type { DirEntry } from '../../shared/filesystem-entry-types' -import type { SshPlainSshMode } from '../../shared/ssh-types' -import { PlainSshUnsupportedError } from '../ssh/ssh-plain-ssh-mode' -import { - downloadFileViaSftp, - downloadFolderViaSftp, - type FolderDownloadOptions, - type SftpFactory -} from './ssh-filesystem-download' -import { fileStatFromSftpStats, lstatViaSftp, statViaSftp } from './ssh-filesystem-provider-sftp' -import type { FileReadLimits, FileReadResult, FileStat, IFilesystemProvider } from './types' - -// Why: same caps as the relay's fs.readFile so previews behave identically. -const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -const SFTP_NO_SUCH_FILE = 2 -const PREVIEW_MIME_TYPES: Record = { - ...IMAGE_FILE_MIME_TYPES, - '.pdf': 'application/pdf' -} - -/** SFTP resolves relative paths against the login directory, which is `~`. */ -export function toSftpPath(path: string): string { - if (path === '~') { - return '.' - } - return path.startsWith('~/') ? path.slice(2) || '.' : path -} - -function errorCode(error: unknown): unknown { - return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined -} - -function normalizeSftpError(error: unknown): Error { - const err = error instanceof Error ? error : new Error(String(error)) - if (errorCode(err) === SFTP_NO_SUCH_FILE) { - return Object.assign(new Error(`ENOENT: no such file or directory: ${err.message}`), { - code: 'ENOENT' - }) - } - return err -} - -export class SshSftpFilesystemProvider implements IFilesystemProvider { - private readonly channel: SftpFilesystemChannel - - constructor( - private readonly connectionId: string, - private readonly createSftp: SftpFactory, - private readonly mode: SshPlainSshMode, - private readonly windowsRemotePaths = false - ) { - this.channel = new SftpFilesystemChannel(createSftp) - } - - getConnectionId(): string { - return this.connectionId - } - - dispose(): void { - this.channel.dispose() - } - - /** Round-trips one SFTP request; a silent transport times out as not alive. */ - async probeTransport(timeoutMs: number): Promise { - let timer: ReturnType | undefined - const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(false), timeoutMs) - }) - try { - return await Promise.race([ - this.realpath('.').then( - () => true, - () => false - ), - timeout - ]) - } finally { - clearTimeout(timer) - } - } - - private async run(op: (sftp: SFTPWrapper) => Promise): Promise { - try { - return await op(await this.channel.get()) - } catch (error) { - throw normalizeSftpError(error) - } - } - - private call( - register: (sftp: SFTPWrapper, cb: (err: Error | null | undefined, value?: T) => void) => void - ): Promise { - return this.run( - (sftp) => - new Promise((resolve, reject) => { - register(sftp, (err, value) => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh2 passes the operation's value with every successful callback; void operations resolve undefined. - err ? reject(err) : resolve(value as T) - ) - }) - ) - } - - private unsupported(feature: string): PlainSshUnsupportedError { - return new PlainSshUnsupportedError(feature, this.mode) - } - - async readDir(dirPath: string, options?: { followSymlinks?: boolean }): Promise { - const path = toSftpPath(dirPath) - return this.run(async (sftp) => { - return readSftpDirectory(sftp, path, options) - }) - } - - async readFile(filePath: string, _limits?: FileReadLimits): Promise { - const path = toSftpPath(filePath) - return this.run(async (sftp) => { - const stats = await statViaSftp(sftp, path) - const mimeType = PREVIEW_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - const buffer = await new Promise((resolve, reject) => - sftp.readFile(path, (err, data) => (err ? reject(err) : resolve(data))) - ) - if (mimeType) { - return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { content: buffer.toString('utf-8'), isBinary: false } - }) - } - - async downloadFile(sourcePath: string, destinationPath: string): Promise { - await downloadFileViaSftp(this.createSftp, toSftpPath(sourcePath), destinationPath) - } - - // Why: the connect broadcast advertises folder download for every ssh2 target, plain SSH included. - async downloadFolder( - sourcePath: string, - destinationPath: string, - options?: FolderDownloadOptions - ): Promise { - await downloadFolderViaSftp(this.createSftp, toSftpPath(sourcePath), destinationPath, { - signal: options?.signal, - windowsRemotePaths: this.windowsRemotePaths - }) - } - - async writeFile(filePath: string, content: string): Promise { - await this.call((sftp, cb) => sftp.writeFile(toSftpPath(filePath), content, cb)) - } - - async writeFileBase64(filePath: string, contentBase64: string): Promise { - const data = Buffer.from(contentBase64, 'base64') - await this.call((sftp, cb) => sftp.writeFile(toSftpPath(filePath), data, cb)) - } - - async writeFileBase64Chunk( - filePath: string, - contentBase64: string, - append: boolean - ): Promise { - const data = Buffer.from(contentBase64, 'base64') - const path = toSftpPath(filePath) - await this.call((sftp, cb) => - append ? sftp.appendFile(path, data, cb) : sftp.writeFile(path, data, cb) - ) - } - - async pathsExist(filePaths: string[]): Promise { - return Promise.all( - filePaths.map((filePath) => - capturePathExistence(() => - this.stat(filePath).then( - () => true, - (error: unknown) => { - if (errorCode(error) === 'ENOENT') { - return false - } - throw error - } - ) - ) - ) - ) - } - - async stat(filePath: string): Promise { - return this.run(async (sftp) => - fileStatFromSftpStats(await statViaSftp(sftp, toSftpPath(filePath))) - ) - } - - async lstat(filePath: string): Promise { - return this.run((sftp) => lstatViaSftp(sftp, toSftpPath(filePath))) - } - - async deletePath(targetPath: string, recursive?: boolean): Promise { - const path = toSftpPath(targetPath) - const stats = await this.run( - (sftp) => - new Promise((resolve, reject) => - sftp.lstat(path, (err, value) => (err ? reject(err) : resolve(value))) - ) - ) - if (!stats.isDirectory()) { - await this.call((sftp, cb) => sftp.unlink(path, cb)) - return - } - try { - await this.call((sftp, cb) => sftp.rmdir(path, cb)) - } catch (error) { - // Why: a non-empty directory needs a recursive walk the relay does atomically on the host. - throw recursive ? this.unsupported('Deleting a non-empty folder') : error - } - } - - async createFile(filePath: string): Promise { - const path = toSftpPath(filePath) - const handle = await this.call((sftp, cb) => sftp.open(path, 'wx', cb)) - await this.call((sftp, cb) => sftp.close(handle, cb)) - } - - async createDir(dirPath: string): Promise { - await this.call((sftp, cb) => sftp.mkdir(toSftpPath(dirPath), cb)) - } - - async createDirNoClobber(dirPath: string): Promise { - await this.createDir(dirPath) - } - - async rename(oldPath: string, newPath: string): Promise { - const from = toSftpPath(oldPath) - const to = toSftpPath(newPath) - await this.call((sftp, cb) => { - try { - // Why: SFTPv3 rename refuses an existing target; the OpenSSH extension overwrites. - sftp.ext_openssh_rename(from, to, cb) - } catch { - sftp.rename(from, to, cb) - } - }) - } - - async renameNoClobber(oldPath: string, newPath: string): Promise { - await this.call((sftp, cb) => sftp.rename(toSftpPath(oldPath), toSftpPath(newPath), cb)) - } - - async copy(): Promise { - throw this.unsupported('Copying files') - } - - async realpath(filePath: string): Promise { - return this.call((sftp, cb) => sftp.realpath(toSftpPath(filePath), cb)) - } - - async search(): Promise { - throw this.unsupported('Search') - } - - async listFiles(): Promise { - throw this.unsupported('Quick Open') - } - - async supportsQuickOpenSearch(): Promise { - return false - } - - async supportsFileRangeRead(): Promise { - return false - } - - async watch(): Promise<() => void> { - throw this.unsupported('Watching files for changes') - } -} diff --git a/src/main/providers/terminal-path-existence-batch.integration.test.ts b/src/main/providers/terminal-path-existence-batch.integration.test.ts index c67b627d6829..21b09788ebe0 100644 --- a/src/main/providers/terminal-path-existence-batch.integration.test.ts +++ b/src/main/providers/terminal-path-existence-batch.integration.test.ts @@ -2,10 +2,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { mkdtemp, writeFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { pathsExistOnRelay } from '../../relay/fs-path-existence' -import { statRelayPath } from '../../relay/fs-path-metadata-requests' -import { readSshPathExistenceBatch } from './ssh-filesystem-path-existence' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' const handlers = vi.hoisted(() => new Map Promise>()) vi.mock('electron', () => ({ ipcMain: { @@ -46,68 +42,3 @@ it('one actual shell IPC handler probes eight distinct temporary files and retai 'Invalid' ) }) -it('one real relay batch serves eight distinct SSH paths after one shared capability probe', async () => { - const paths = await fixture() - const request = vi.fn(async (method: string, params: Record) => - method === 'fs.getCapabilities' ? { pathExistenceBatchVersion: 1 } : pathsExistOnRelay(params) - ) - const scalar = vi.fn((path: string) => statRelayPath({ filePath: path })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. - const mux = { request } as never - expect(await readSshPathExistenceBatch(mux, paths, scalar)).toEqual( - paths.map(() => ({ exists: true })) - ) - expect(request.mock.calls.map((c) => c[0])).toEqual(['fs.getCapabilities', 'fs.pathsExist']) - expect(scalar).not.toHaveBeenCalled() - await rm(paths[0]) - expect(await readSshPathExistenceBatch(mux, [paths[0]], scalar)).toEqual([{ exists: false }]) - await writeFile(paths[0], 'new') - expect(await readSshPathExistenceBatch(mux, [paths[0]], scalar)).toEqual([{ exists: true }]) - expect(request.mock.calls.filter((c) => c[0] === 'fs.getCapabilities')).toHaveLength(1) -}) -it('old relay falls back on the same host without retrying a missing capability document', async () => { - const paths = await fixture() - const request = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - const scalar = vi.fn((path: string) => statRelayPath({ filePath: path })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. - const mux = { request } as never - expect(await readSshPathExistenceBatch(mux, paths, scalar)).toEqual( - paths.map(() => ({ exists: true })) - ) - expect(scalar).toHaveBeenCalledTimes(8) - await readSshPathExistenceBatch(mux, [paths[0]], scalar) - expect(request).toHaveBeenCalledTimes(1) -}) -it('connection failure is neither a missing path nor permission to use local/scalar fallback', async () => { - const scalar = vi.fn() - const request = vi.fn().mockRejectedValue(new Error('connection closed')) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. - const mux = { request } as never - await expect(readSshPathExistenceBatch(mux, ['/remote/path'], scalar)).rejects.toThrow( - 'connection closed' - ) - expect(scalar).not.toHaveBeenCalled() - request - .mockResolvedValueOnce({ pathExistenceBatchVersion: 1 }) - .mockResolvedValueOnce([{ error: 'EACCES denied' }]) - expect(await readSshPathExistenceBatch(mux, ['/remote/path'], scalar)).toEqual([ - { error: 'EACCES denied' } - ]) - expect(request).toHaveBeenCalledTimes(3) -}) -it('malformed batch replies fail rather than manufacturing negative cache entries', async () => { - const scalar = vi.fn() - const request = vi - .fn() - .mockResolvedValueOnce({ pathExistenceBatchVersion: 1 }) - .mockResolvedValueOnce([]) - await expect( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. - readSshPathExistenceBatch({ request } as never, ['/remote/path'], scalar) - ).rejects.toThrow('Invalid path existence response') - expect(scalar).not.toHaveBeenCalled() -}) diff --git a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts deleted file mode 100644 index add68726e79d..000000000000 --- a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FsChangeEvent } from '../../shared/filesystem-entry-types' -import { createSshDisposalError } from '../ssh/ssh-channel-multiplexer' -import { createSshFileExplorerWatchTestConnection } from './ssh-file-explorer-watch-test-connection' - -const { - resolveAuthorizedPathMock, - statMock, - watchInWatcherProcessMock, - closeWatcherInWatcherProcessMock, - getSshFilesystemProviderMock, - providerRegistrationListeners -} = vi.hoisted(() => ({ - resolveAuthorizedPathMock: vi.fn(), - statMock: vi.fn(), - watchInWatcherProcessMock: vi.fn(), - closeWatcherInWatcherProcessMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - providerRegistrationListeners: new Set<(connectionId: string) => void>() -})) - -vi.mock('fs/promises', async () => { - const actual = await vi.importActual>('fs/promises') - return { ...actual, stat: statMock } -}) -vi.mock('./file-watcher-host', () => ({ - closeFileExplorerWatcherInWatcherProcess: closeWatcherInWatcherProcessMock, - watchFileExplorerInWatcherProcess: watchInWatcherProcessMock -})) -vi.mock('../ipc/filesystem-auth', async () => { - const actual = await vi.importActual>('../ipc/filesystem-auth') - return { ...actual, resolveAuthorizedPath: resolveAuthorizedPathMock } -}) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.', - onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { - providerRegistrationListeners.add(listener) - return () => providerRegistrationListeners.delete(listener) - } -})) - -import { - _resetRuntimeFileWatcherLeasesForTests, - awaitRuntimeFileWatcherUnsubscribes, - RuntimeFileCommands -} from './orca-runtime-files' - -const ROOT_PATH = '/home/me/repo' -const CONNECTION_ID = 'conn-1' -const OVERFLOW_EVENTS: FsChangeEvent[] = [{ kind: 'overflow', absolutePath: ROOT_PATH }] -const connections: ReturnType[] = [] -const releases: (() => Promise)[] = [] - -/** Drive the provider-registration hook the way a relay reconnect would. */ -function emitProviderRegistered(connectionId: string): void { - for (const listener of providerRegistrationListeners) { - listener(connectionId) - } -} - -function createRuntimeFileCommands(): RuntimeFileCommands { - return new RuntimeFileCommands({ - getRuntimeId: () => 'runtime-1', - requireStore: () => ({ getRepo: vi.fn(() => undefined) }), - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH })), - resolveRuntimeFileTarget: vi.fn(async () => ({ - worktree: { id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH }, - executionHostId: `ssh:${CONNECTION_ID}` - })), - resolveRuntimeGitTarget: vi.fn(), - openFile: vi.fn() - } as never) -} - -describe('remote file-explorer watch re-arm', () => { - beforeEach(() => { - resolveAuthorizedPathMock.mockReset() - statMock.mockReset() - watchInWatcherProcessMock.mockReset() - closeWatcherInWatcherProcessMock.mockReset() - getSshFilesystemProviderMock.mockReset() - providerRegistrationListeners.clear() - }) - - afterEach(async () => { - for (const { mux } of connections) { - mux.dispose() - } - for (const release of releases.splice(0)) { - await release() - } - await awaitRuntimeFileWatcherUnsubscribes() - _resetRuntimeFileWatcherLeasesForTests() - for (const { provider } of connections.splice(0)) { - provider.dispose() - } - }) - - function connect() { - const connection = createSshFileExplorerWatchTestConnection(ROOT_PATH, CONNECTION_ID) - connections.push(connection) - return connection - } - - async function startInitialWatch() { - const first = connect() - getSshFilesystemProviderMock.mockReturnValue(first.provider) - const watch = vi.spyOn(first.provider, 'watch') - const commands = createRuntimeFileCommands() - const onEvents = vi.fn() - const controller = new AbortController() - const onTerminalError = vi.fn(() => controller.abort()) - const setup = commands.watchFileExplorer( - 'id:wt-1', - onEvents, - onTerminalError, - controller.signal - ) - await vi.waitFor(() => expect(first.countRequests('fs.watch')).toBe(1)) - const finish = async () => { - first.settleWatch() - const release = await setup - releases.push(release) - return release - } - return { first, watch, onEvents, onTerminalError, controller, setup, finish } - } - - it.each(['event', 'terminal error'] as const)( - 'fences the initial provider %s while replacement setup is pending', - async (stage) => { - const { first, onEvents, onTerminalError, controller, finish } = await startInitialWatch() - const release = await finish() - const next = connect() - getSshFilesystemProviderMock.mockReturnValue(next.provider) - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(next.countRequests('fs.watch')).toBe(1)) - if (stage === 'event') { - first.emitChange() - } else { - first.failWatch('late initial failure') - } - expect(onEvents).not.toHaveBeenCalled() - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - next.settleWatch() - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledExactlyOnceWith(OVERFLOW_EVENTS)) - next.emitChange() - expect(onEvents).toHaveBeenCalledTimes(2) - await release() - expect(next.countRequests('fs.unwatch')).toBe(1) - } - ) - - it('fences initial callbacks after same-provider replacement and unsubscribe', async () => { - const { first, watch, onEvents, onTerminalError, finish } = await startInitialWatch() - const release = await finish() - const initialEvent = watch.mock.calls[0]?.[1] - const initialError = watch.mock.calls[0]?.[2]?.onTerminalError - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledExactlyOnceWith(OVERFLOW_EVENTS)) - initialEvent?.([{ kind: 'update', absolutePath: `${ROOT_PATH}/stale.ts` }]) - initialError?.(new Error('old generation failed')) - expect(onEvents).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - first.emitChange() - expect(onEvents).toHaveBeenCalledTimes(2) - await release() - initialEvent?.([{ kind: 'update', absolutePath: `${ROOT_PATH}/closed.ts` }]) - initialError?.(new Error('closed initial watch failed')) - expect(onEvents).toHaveBeenCalledTimes(2) - expect(onTerminalError).not.toHaveBeenCalled() - expect(first.countRequests('fs.unwatch')).toBe(1) - }) - - it('delivers current initial events during setup and genuine current failures after setup', async () => { - const { first, onEvents, onTerminalError, controller, finish } = await startInitialWatch() - first.emitChange() - expect(onEvents).toHaveBeenCalledOnce() - await finish() - first.emitChange() - expect(onEvents).toHaveBeenCalledTimes(2) - first.failWatch('current initial failure') - expect(onTerminalError).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ message: 'current initial failure' }) - ) - expect(controller.signal.aborted).toBe(true) - }) - - it('keeps a completed initial watch armed after typed connection loss', async () => { - const { watch, onEvents, onTerminalError, controller, finish } = await startInitialWatch() - await finish() - watch.mock.calls[0]?.[2]?.onTerminalError?.(createSshDisposalError('connection_lost')) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - const next = connect() - getSshFilesystemProviderMock.mockReturnValue(next.provider) - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(next.countRequests('fs.watch')).toBe(1)) - next.settleWatch() - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledExactlyOnceWith(OVERFLOW_EVENTS)) - }) - - it.each(['connection loss', 'cancellation'] as const)( - 'rejects initial setup on %s and fences callbacks after setup ends', - async (reason) => { - const { first, watch, onEvents, onTerminalError, controller, setup } = - await startInitialWatch() - if (reason === 'connection loss') { - first.mux.dispose('connection_lost') - await expect(setup).rejects.toMatchObject({ code: 'CONNECTION_LOST' }) - } else { - controller.abort() - await expect(setup).rejects.toMatchObject({ name: 'AbortError' }) - } - watch.mock.calls[0]?.[1]([{ kind: 'update', absolutePath: `${ROOT_PATH}/late.ts` }]) - watch.mock.calls[0]?.[2]?.onTerminalError?.(new Error('late setup failure')) - expect(onEvents).not.toHaveBeenCalled() - expect(onTerminalError).not.toHaveBeenCalled() - expect(providerRegistrationListeners.size).toBe(0) - } - ) - - it('reinstalls and resyncs when the connection re-registers its provider', async () => { - // Why: dispose() on transport loss stops the registration without firing onTerminalError, so - // nothing else tells this watch it died. - const firstUnwatch = vi.fn() - const secondUnwatch = vi.fn() - const firstProvider = { watch: vi.fn().mockResolvedValue(firstUnwatch) } - const secondProvider = { watch: vi.fn().mockResolvedValue(secondUnwatch) } - getSshFilesystemProviderMock.mockReturnValue(firstProvider) - const commands = createRuntimeFileCommands() - const onEvents = vi.fn() - - const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents) - expect(firstProvider.watch).toHaveBeenCalledOnce() - - getSshFilesystemProviderMock.mockReturnValue(secondProvider) - emitProviderRegistered(CONNECTION_ID) - await vi.waitFor(() => expect(secondProvider.watch).toHaveBeenCalledOnce()) - - expect(onEvents).toHaveBeenCalledWith(OVERFLOW_EVENTS) - // The dead transport's handle must not be closed against the fresh registration. - expect(firstUnwatch).not.toHaveBeenCalled() - await unsubscribe() - expect(secondUnwatch).toHaveBeenCalledOnce() - expect(firstUnwatch).not.toHaveBeenCalled() - }) - - it('ignores registrations for other connections', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - await commands.watchFileExplorer('id:wt-1', vi.fn()) - emitProviderRegistered('conn-other') - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) - - it('stops re-arming after the watch is released', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) - await unsubscribe() - emitProviderRegistered(CONNECTION_ID) - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) - - it('stops re-arming after the worktree is removed', async () => { - const watch = vi.fn().mockResolvedValue(vi.fn()) - getSshFilesystemProviderMock.mockReturnValue({ watch }) - const commands = createRuntimeFileCommands() - - await commands.watchFileExplorer('id:wt-1', vi.fn()) - commands.forgetFileExplorerWatchersAfterRemoval(ROOT_PATH, CONNECTION_ID) - emitProviderRegistered(CONNECTION_ID) - await Promise.resolve() - - expect(watch).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts b/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts deleted file mode 100644 index 9f2650af743b..000000000000 --- a/src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { IFilesystemProvider } from '../providers/types' -import { - createSshDisposalError, - SSH_MUX_REQUEST_TIMEOUT_CODE -} from '../ssh/ssh-channel-multiplexer' -import { createSshFileExplorerWatchTestConnection as createWatchConnection } from './ssh-file-explorer-watch-test-connection' -import { armSshFileExplorerWatchRearm } from './runtime-file-commands-ssh-file-watcher-rearm' - -type WatchProvider = Pick - -const { getProvider, registrationListeners, rearms } = vi.hoisted(() => ({ - getProvider: vi.fn<(connectionId: string) => WatchProvider | undefined>(), - registrationListeners: new Set<(connectionId: string) => void>(), - rearms: new Map void>>() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getProvider, - onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { - registrationListeners.add(listener) - return () => registrationListeners.delete(listener) - } -})) - -vi.mock('./runtime-file-commands-mobile-file-list-limit', () => ({ - runtimeWatcherReleaseKey: (runtimeId: string, connectionId: string, rootPath: string) => - `${runtimeId}:${connectionId}:${rootPath}`, - sshFileExplorerWatchRearms: rearms -})) - -function registerProvider(provider: WatchProvider): void { - getProvider.mockReturnValue(provider) - for (const listener of registrationListeners) { - listener('ssh-1') - } -} - -function pendingWatch() { - let resolve: (unwatch: () => void) => void = () => undefined - let reject: (error: Error) => void = () => undefined - const promise = new Promise<() => void>((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { watch: vi.fn(() => promise), resolve, reject } -} - -describe('SSH file explorer watcher rearm', () => { - let unsubscribe: (() => Promise) | undefined - const connections: ReturnType[] = [] - - beforeEach(() => { - getProvider.mockReset() - registrationListeners.clear() - rearms.clear() - }) - - afterEach(async () => { - for (const { mux } of connections) { - mux.dispose() - } - await unsubscribe?.() - unsubscribe = undefined - for (const { provider } of connections.splice(0)) { - provider.dispose() - } - expect(registrationListeners.size).toBe(0) - expect(rearms.size).toBe(0) - }) - - function install( - initialProvider: WatchProvider = { watch: vi.fn() }, - initialUnwatch: () => void = vi.fn() - ) { - if (!getProvider.getMockImplementation()) { - getProvider.mockReturnValue(initialProvider) - } - const onEvents = vi.fn() - const controller = new AbortController() - const onTerminalError = vi.fn((_error: Error) => controller.abort()) - const rearm = armSshFileExplorerWatchRearm({ - runtimeId: 'runtime-1', - connectionId: 'ssh-1', - rootPath: '/remote/repo', - callback: onEvents, - onTerminalError, - signal: controller.signal, - initialUnwatch, - initialProvider - }) - unsubscribe = rearm.unsubscribe - return { onEvents, onTerminalError, initialUnwatch, controller } - } - - it('ignores a superseded setup failure and installs the current provider', async () => { - const { onEvents, onTerminalError, controller } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - - const currentUnwatch = vi.fn() - const current = { watch: vi.fn(async () => currentUnwatch) } - registerProvider(current) - old.reject(new Error('previous transport disconnected')) - - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - expect(onEvents).toHaveBeenCalledExactlyOnceWith([ - { kind: 'overflow', absolutePath: '/remote/repo' } - ]) - await unsubscribe?.() - expect(currentUnwatch).toHaveBeenCalledOnce() - }) - - it('replaces an initial watch whose provider changed before rearm was installed', async () => { - const original = pendingWatch() - const initialUnwatch = vi.fn() - const initialSetup = original.watch('/remote/repo', vi.fn()) - const currentUnwatch = vi.fn() - const current = { watch: vi.fn(async () => currentUnwatch) } - registerProvider(current) - original.resolve(initialUnwatch) - await initialSetup - const { onEvents, onTerminalError } = install(original, initialUnwatch) - - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - expect(current.watch).toHaveBeenCalledOnce() - expect(initialUnwatch).not.toHaveBeenCalled() - expect(onTerminalError).not.toHaveBeenCalled() - await unsubscribe?.() - expect(currentUnwatch).toHaveBeenCalledOnce() - expect(initialUnwatch).not.toHaveBeenCalled() - }) - - it('coalesces registrations that arrive before replacement setup starts', async () => { - const { onEvents } = install() - const old = { watch: vi.fn(async () => vi.fn()) } - const current = { watch: vi.fn(async () => vi.fn()) } - registerProvider(old) - registerProvider(current) - - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - expect(old.watch).not.toHaveBeenCalled() - expect(current.watch).toHaveBeenCalledOnce() - }) - - it('closes a superseded successful setup without publishing its refresh', async () => { - const { onEvents, onTerminalError } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - const currentUnwatch = vi.fn() - const current = { watch: vi.fn(async () => currentUnwatch) } - registerProvider(current) - const obsoleteUnwatch = vi.fn() - old.resolve(obsoleteUnwatch) - - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - expect(obsoleteUnwatch).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(onEvents).toHaveBeenCalledOnce() - await unsubscribe?.() - expect(currentUnwatch).toHaveBeenCalledOnce() - }) - - it('reports a genuine current-provider setup failure', async () => { - const { onTerminalError, onEvents } = install() - const error = new Error('current host refused watch') - registerProvider({ - watch: vi.fn(async () => { - throw error - }) - }) - - await vi.waitFor(() => expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(error)) - expect(onEvents).not.toHaveBeenCalled() - }) - - it.each(['setup', 'terminal callback'] as const)( - 'waits for registration after a current-provider %s loses its connection', - async (stage) => { - const { onTerminalError, onEvents, controller } = install() - const lost = pendingWatch() - registerProvider(lost) - await vi.waitFor(() => expect(lost.watch).toHaveBeenCalledOnce()) - const error = createSshDisposalError('connection_lost') - if (stage === 'setup') { - lost.reject(error) - } else { - lost.resolve(vi.fn()) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - lost.watch.mock.calls[0]?.[2]?.onTerminalError?.(error) - } - await new Promise((resolve) => setImmediate(resolve)) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - - const currentUnwatch = vi.fn() - const current = { watch: vi.fn(async () => currentUnwatch) } - registerProvider(current) - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - expect(onEvents).toHaveBeenLastCalledWith([ - { kind: 'overflow', absolutePath: '/remote/repo' } - ]) - await unsubscribe?.() - expect(currentUnwatch).toHaveBeenCalledOnce() - } - ) - - it.each([ - ['shutdown', createSshDisposalError('shutdown')], - [ - 'timeout', - Object.assign(new Error('watch timed out'), { code: SSH_MUX_REQUEST_TIMEOUT_CODE }) - ], - ['uncoded connection message', new Error('CONNECTION_LOST')] - ])('reports a current-provider %s setup failure', async (_label, error) => { - const { onTerminalError } = install() - registerProvider({ - watch: vi.fn(async () => { - throw error - }) - }) - await vi.waitFor(() => expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(error)) - }) - - it.each(['active', 'canceled'] as const)( - 'keeps an %s owner correct through actual multiplexer loss and later registration', - async (owner) => { - const connect = () => { - const connection = createWatchConnection() - connections.push(connection) - return connection - } - const first = connect() - getProvider.mockReturnValue(first.provider) - const initialSetup = first.provider.watch('/remote/repo', vi.fn()) - first.settleWatch() - const { onEvents, onTerminalError, controller } = install(first.provider, await initialSetup) - first.mux.dispose('connection_lost') - first.provider.dispose() - const next = connect() - registerProvider(next.provider) - await vi.waitFor(() => expect(next.countRequests('fs.watch')).toBe(1)) - next.mux.dispose('connection_lost') - await new Promise((resolve) => setImmediate(resolve)) - expect(getProvider('ssh-1')).toBe(next.provider) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - expect(onEvents).not.toHaveBeenCalled() - if (owner === 'canceled') { - await unsubscribe?.() - } - - next.provider.dispose() - const current = connect() - registerProvider(current.provider) - if (owner === 'canceled') { - await new Promise((resolve) => setImmediate(resolve)) - expect(current.countRequests('fs.watch')).toBe(0) - return - } - await vi.waitFor(() => expect(current.countRequests('fs.watch')).toBe(1)) - current.settleWatch() - await vi.waitFor(() => - expect(onEvents).toHaveBeenCalledExactlyOnceWith([ - { kind: 'overflow', absolutePath: '/remote/repo' } - ]) - ) - current.emitChange() - expect(onEvents).toHaveBeenLastCalledWith([ - { kind: 'update', absolutePath: '/remote/repo/current.ts' } - ]) - await unsubscribe?.() - expect(current.countRequests('fs.unwatch')).toBe(1) - current.emitChange() - expect(onEvents).toHaveBeenCalledTimes(2) - } - ) - - it.each(['unregistered', 'replaced'] as const)( - 'ignores a setup failure when its provider is %s between rejection handlers', - async (change) => { - const { onTerminalError, onEvents, controller } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - const current = { watch: vi.fn(async () => vi.fn()) } - const checkedWhileCurrent = vi.fn(() => { - queueMicrotask(() => { - if (change === 'replaced') { - registerProvider(current) - } else { - getProvider.mockReturnValue(undefined) - } - }) - return old - }) - getProvider.mockImplementationOnce(checkedWhileCurrent) - old.reject(new Error('previous setup failed')) - await new Promise((resolve) => setImmediate(resolve)) - - expect(checkedWhileCurrent).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - if (change === 'replaced') { - expect(current.watch).toHaveBeenCalledOnce() - expect(onEvents).toHaveBeenCalledExactlyOnceWith([ - { kind: 'overflow', absolutePath: '/remote/repo' } - ]) - } else { - expect(onEvents).not.toHaveBeenCalled() - } - } - ) - - it('ignores late terminal callbacks from a superseded provider', async () => { - const { onTerminalError, controller } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - const current = { watch: vi.fn(async () => vi.fn()) } - registerProvider(current) - old.watch.mock.calls[0]?.[2]?.onTerminalError?.(new Error('previous watcher stopped')) - old.resolve(vi.fn()) - - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - expect(onTerminalError).not.toHaveBeenCalled() - expect(controller.signal.aborted).toBe(false) - }) - - it('reports a terminal callback from the current provider', async () => { - const { onTerminalError, onEvents } = install() - const current = pendingWatch() - registerProvider(current) - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - current.resolve(vi.fn()) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - const error = new Error('current watcher stopped') - current.watch.mock.calls[0]?.[2]?.onTerminalError?.(error) - - expect(onTerminalError).toHaveBeenCalledExactlyOnceWith(error) - }) - - it('ignores late events from a superseded provider and delivers current events', async () => { - const { onEvents } = install() - const old = pendingWatch() - registerProvider(old) - await vi.waitFor(() => expect(old.watch).toHaveBeenCalledOnce()) - const current = pendingWatch() - registerProvider(current) - old.watch.mock.calls[0]?.[1]([{ kind: 'update', absolutePath: '/remote/repo/old.ts' }]) - old.resolve(vi.fn()) - await vi.waitFor(() => expect(current.watch).toHaveBeenCalledOnce()) - current.resolve(vi.fn()) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - const currentEvents = [{ kind: 'update' as const, absolutePath: '/remote/repo/current.ts' }] - current.watch.mock.calls[0]?.[1](currentEvents) - - expect(onEvents).toHaveBeenCalledTimes(2) - expect(onEvents).toHaveBeenLastCalledWith(currentEvents) - }) - - it('releases the previous subscriber when the same provider is registered again', async () => { - const { onEvents } = install() - const callbacks = new Set[1]>() - const provider = { - watch: vi.fn(async (_root, callback) => { - callbacks.add(callback) - return () => { - callbacks.delete(callback) - } - }) - } - registerProvider(provider) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledOnce()) - registerProvider(provider) - await vi.waitFor(() => expect(onEvents).toHaveBeenCalledTimes(2)) - - expect(callbacks.size).toBe(1) - const events = [{ kind: 'update' as const, absolutePath: '/remote/repo/current.ts' }] - for (const callback of callbacks) { - callback(events) - } - expect(onEvents).toHaveBeenCalledTimes(3) - expect(onEvents).toHaveBeenLastCalledWith(events) - await unsubscribe?.() - expect(callbacks.size).toBe(0) - }) - - it('does not report a setup failure after unsubscribe starts', async () => { - const { onTerminalError, onEvents, initialUnwatch } = install() - const pending = pendingWatch() - registerProvider(pending) - await vi.waitFor(() => expect(pending.watch).toHaveBeenCalledOnce()) - const closed = unsubscribe?.() - pending.reject(new Error('setup canceled during shutdown')) - - await closed - expect(initialUnwatch).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(onEvents).not.toHaveBeenCalled() - }) - - it('closes a replacement that finishes after unsubscribe starts', async () => { - const { onTerminalError, onEvents } = install() - const pending = pendingWatch() - registerProvider(pending) - await vi.waitFor(() => expect(pending.watch).toHaveBeenCalledOnce()) - const closed = unsubscribe?.() - const lateUnwatch = vi.fn() - pending.resolve(lateUnwatch) - - await closed - expect(lateUnwatch).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(onEvents).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/runtime-file-upload-decode.test.ts b/src/main/runtime/runtime-file-upload-decode.test.ts index e70012990a78..cc018eeecf92 100644 --- a/src/main/runtime/runtime-file-upload-decode.test.ts +++ b/src/main/runtime/runtime-file-upload-decode.test.ts @@ -1,7 +1,6 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeFileCommandHost } from './runtime-file-command-host' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' const mocks = vi.hoisted(() => ({ resolvePath: vi.fn(), @@ -33,7 +32,6 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ import { RuntimeFileCommandsWithWriteFileExplorerFile } from './runtime-file-commands-write-file-explorer-file' import { createLocalFilesystemProvider } from '../providers/local-filesystem-provider' -import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' function unexpectedHostCall(): never { throw new Error('Unexpected access beyond the isolated file command') @@ -53,16 +51,6 @@ const destination = join('workspace', 'uploads', 'binary.dat') const target = { executionHostId: 'local', path: destination } let remote = false -function sshProvider(): SshFilesystemProvider { - remote = true - mocks.resolvePath.mockResolvedValue({ ...target, executionHostId: 'ssh:target' }) - const mux = { onNotification: vi.fn(() => () => {}), request: unexpectedHostCall } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: construction only subscribes; writes use the injected raw transfer and any mux request fails. - return new SshFilesystemProvider('target', mux as unknown as SshChannelMultiplexer, undefined, { - writeBuffer: mocks.writeBuffer - }) -} - beforeEach(() => { vi.resetAllMocks() remote = false @@ -152,43 +140,6 @@ describe.each(['whole', 'first', 'append'] as const)('runtime %s base64 write', } ) - it.each(['', 'AAH+/w==', 'AA', '%%%'])( - 'decodes once through the SSH provider for %j', - async (base64) => { - const expected = Buffer.from(base64, 'base64') - const provider = sshProvider() - mocks.provider.mockReturnValue(provider) - const decode = vi.spyOn(Buffer, 'from') - await expect(write(base64)).resolves.toEqual({ ok: true }) - expect( - decode.mock.calls.filter((args) => args.at(0) === base64 && args.at(1) === 'base64') - ).toHaveLength(1) - expect(mocks.expectation).toHaveBeenCalledWith('ssh:target', 'ssh:target', 'target', 7) - expect(mocks.writeBuffer).toHaveBeenCalledWith(destination, expected, { - append: mode === 'append', - exclusive: mode !== 'append' - }) - expect(mocks.authorize).not.toHaveBeenCalled() - expect(mocks.mkdir).not.toHaveBeenCalled() - expect(mocks.writeFile).not.toHaveBeenCalled() - provider.dispose() - } - ) - - it('forwards every full upload byte with one matching base64 Buffer decode', async () => { - mocks.provider.mockReturnValue(sshProvider()) - const bytes = Buffer.alloc(384 * 1024, 0xb7) - const base64 = bytes.toString('base64') - const decode = vi.spyOn(Buffer, 'from') - await write(base64) - expect( - decode.mock.calls.filter((args) => args.at(0) === base64 && args.at(1) === 'base64') - ).toHaveLength(1) - expect(Buffer.isBuffer(mocks.writeBuffer.mock.calls[0][1])).toBe(true) - expect(mocks.writeBuffer.mock.calls[0][1]).toHaveLength(bytes.length) - expect(Buffer.prototype.equals.call(mocks.writeBuffer.mock.calls[0][1], bytes)).toBe(true) - }) - it.each(['resolve', 'expectation', 'provider'] as const)( 'preserves %s failure before decoding or writes', async (stage) => { @@ -231,14 +182,4 @@ describe.each(['whole', 'first', 'append'] as const)('runtime %s base64 write', } } ) - - it('propagates SSH sink errors without a local fallback', async () => { - mocks.provider.mockReturnValue(sshProvider()) - const failure = new Error('SSH sink failed') - mocks.writeBuffer.mockRejectedValue(failure) - await expect(write('AA==')).rejects.toBe(failure) - expect(mocks.authorize).not.toHaveBeenCalled() - expect(mocks.mkdir).not.toHaveBeenCalled() - expect(mocks.writeFile).not.toHaveBeenCalled() - }) }) diff --git a/src/main/runtime/ssh-file-explorer-watch-test-connection.ts b/src/main/runtime/ssh-file-explorer-watch-test-connection.ts deleted file mode 100644 index 9521c175a8cf..000000000000 --- a/src/main/runtime/ssh-file-explorer-watch-test-connection.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' -import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' -import { - encodeJsonRpcFrame, - HEADER_LENGTH, - MessageType, - parseJsonRpcMessage, - type JsonRpcMessage -} from '../ssh/relay-protocol' - -export function createSshFileExplorerWatchTestConnection( - rootPath = '/remote/repo', - connectionId = 'ssh-1' -) { - const written: Buffer[] = [] - let receive: (data: Buffer) => void = () => undefined - let sequence = 1 - const transport: MultiplexerTransport = { - write: (data) => { - written.push(data) - }, - onData: (callback) => { - receive = callback - }, - onClose: () => undefined - } - const mux = new SshChannelMultiplexer(transport) - const provider = new SshFilesystemProvider(connectionId, mux) - const messages = (): JsonRpcMessage[] => - written - .filter((frame) => frame[0] === MessageType.Regular) - .map((frame) => parseJsonRpcMessage(frame.subarray(HEADER_LENGTH))) - const send = (message: JsonRpcMessage): void => { - receive(encodeJsonRpcFrame(message, sequence++, 0)) - } - const watchRequest = () => { - const request = messages().find( - (message) => 'method' in message && message.method === 'fs.watch' - ) - if (!request || !('id' in request) || !('method' in request)) { - throw new Error('No pending fs.watch request') - } - return request - } - return { - mux, - provider, - countRequests: (method: string) => - messages().filter((message) => 'method' in message && message.method === method).length, - settleWatch: () => send({ jsonrpc: '2.0', id: watchRequest().id, result: null }), - emitChange: (absolutePath = `${rootPath}/current.ts`) => - send({ - jsonrpc: '2.0', - method: 'fs.changed', - params: { events: [{ kind: 'update', absolutePath }] } - }), - failWatch: (message: string) => { - const watchId = watchRequest().params?.watchId - if (typeof watchId !== 'number') { - throw new Error('No remote watch id') - } - send({ jsonrpc: '2.0', method: 'fs.watchFailed', params: { rootPath, watchId, message } }) - } - } -} diff --git a/src/main/ssh/orcad-migration-terminal-gate.test.ts b/src/main/ssh/orcad-migration-terminal-gate.test.ts index 684783552a8e..724f46bb414e 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.test.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.test.ts @@ -67,6 +67,30 @@ describe('migration terminal gate', () => { ).resolves.toMatchObject({ verdict: 'unverifiable' }) }) + it('lets an idle host census prove leases an older build left, with no relay session', async () => { + const leftover = store([ + { ptyId: 'pty-attached', state: 'attached' }, + { ptyId: 'pty-detached', state: 'detached' }, + { ptyId: 'pty-expired', state: 'expired' } + ]) + await expect(assessOrcadMigrationTerminals(leftover, 'ssh-1', null, hostIdle)).resolves.toEqual( + { + verdict: 'exited', + provenPtyIds: ['pty-attached', 'pty-detached', 'pty-expired'] + } + ) + await expect( + assessOrcadMigrationTerminals(leftover, 'ssh-1', null, async () => ({ + verdict: 'live', + count: 2 + })) + ).resolves.toMatchObject({ verdict: 'live', hostTerminals: 2 }) + // Without a census the leases still block: silence is never exit. + await expect(assessOrcadMigrationTerminals(leftover, 'ssh-1', null)).resolves.toMatchObject({ + verdict: 'live' + }) + }) + it('blocks an attached lease as live', async () => { await expect( assessOrcadMigrationTerminals( diff --git a/src/main/ssh/orcad-migration-terminal-gate.ts b/src/main/ssh/orcad-migration-terminal-gate.ts index 575c955d9b57..5b197797d41a 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.ts @@ -92,6 +92,11 @@ async function withoutRelaySession( unresolved: SshRemotePtyLease[], censusHost: CensusHostRelayTerminals | null | undefined ): Promise { + // Why the census decides: with no relay session nothing can answer for these leases, which an + // older build left behind; only a host-wide census can prove their terminals exited. + if (censusHost) { + return await hostWideVerdict(leases, censusHost) + } if (attached.length > 0) { return refuse('live', attached, 'terminals on this host are still running') } diff --git a/src/main/ssh/orcad-runtime-conversion.test.ts b/src/main/ssh/orcad-runtime-conversion.test.ts index 254056129f61..b8055e5b21a3 100644 --- a/src/main/ssh/orcad-runtime-conversion.test.ts +++ b/src/main/ssh/orcad-runtime-conversion.test.ts @@ -155,7 +155,8 @@ describe('converting an SSH host into a managed server', () => { it('refuses before touching the host while terminals run or cannot be counted', async () => { store.upsertSshRemotePtyLease({ targetId: TARGET.id, ptyId: 'p', state: 'expired' }) - await expect(convert(null)).resolves.toMatchObject({ + const uncounted = async () => ({ verdict: 'unverifiable' as const, count: 0 }) + await expect(convert(null, uncounted)).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index 0acabed675b0..3067816ae644 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -1,5 +1,4 @@ -// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: prove the conversion's -// terminal gate against the pinned relay already serving the host, then resolve the context +// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: resolve the context // (pinned node.exe, host script), deploy and activate, prove readiness and liveness, reach orcad // through the stdio bridge (this account's sshd refuses forwarding), decommission // through the instance-bound stop request, prove exit, and run a GC pass. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 @@ -18,7 +17,6 @@ import { installHostileHostAppEnvironment } from './ssh-hostile-host-test-harness' import { resolveOrcadRemoteContext } from './orcad-remote-context' -import { proveWindowsRelayTerminalGate } from './orcad-windows-relay-terminal-gate-test-cell' import { proveWindowsStdioBridge } from './orcad-windows-stdio-bridge-test-cell' import { deployOrcad } from './orcad-remote-deploy' import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' @@ -59,7 +57,7 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { async () => { const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '') if (!isWindowsOrcadCellId(descriptor.cell)) { - throw new Error(`${descriptor.cell} runs in ssh-relay-windows-host-lane.test.ts`) + throw new Error(`${descriptor.cell} is not a managed orcad cell`) } const sshTarget = windowsHostSshTarget( descriptor, @@ -71,9 +69,6 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { let conn: SshConnection | null = null try { conn = await connectHostileHost(sshTarget) - // A relay-hosted source first: conversion may proceed only once its terminals exited. - Object.assign(receipt, await proveWindowsRelayTerminalGate(conn, sshTarget.id)) - // After the prelude: its relay deploy re-spies `exec`, which is the same spy, and restores it. exec = vi.spyOn(SshConnection.prototype, 'exec') const context = await resolveOrcadRemoteContext(sshTarget, conn) expect(context.host.os).toBe('win32') diff --git a/src/main/ssh/orcad-windows-relay-terminal-gate-test-cell.ts b/src/main/ssh/orcad-windows-relay-terminal-gate-test-cell.ts deleted file mode 100644 index 55507e97aaa7..000000000000 --- a/src/main/ssh/orcad-windows-relay-terminal-gate-test-cell.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * The conversion's terminal gate against a real relay on a Windows SSH host, for the orcad host - * lane: deploy the pinned relay, run one terminal, prove the gate reads it as live, stop it, and - * prove the gate then reads the host as exited. The relay is asked through `pty.listProcesses`, - * exactly as `orcadMigrationRelayPtyLister` asks it through the SSH PTY provider. - */ -import { randomUUID } from 'node:crypto' -import { expect } from 'vitest' -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import type { SshConnection } from './ssh-connection' -import { deployOnce } from './ssh-hostile-host-test-harness' -import { retrySshOwnerRecoveryWhileBlocked } from './ssh-owner-recovery-retry' -import { openSshPtyConsumerSession } from './ssh-pty-consumer-session' -import { assessOrcadMigrationTerminals } from './orcad-migration-terminal-gate' -import { listPreviousRelayPtyIds } from './ssh-legacy-relay-routing' -import { clearPreviousRelayCensus, startPreviousRelayCensus } from './ssh-previous-relay-terminals' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { - censusSshHostRelaysBeforeSession, - hostTerminalProofFromCensus -} from './ssh-host-relay-terminals-on-connect' - -function relayPtyIds(rows: unknown): string[] { - return Array.isArray(rows) - ? rows.flatMap((row: unknown) => - row && typeof row === 'object' && 'id' in row && typeof row.id === 'string' ? [row.id] : [] - ) - : [] -} - -export async function proveWindowsRelayTerminalGate( - conn: SshConnection, - targetId: string -): Promise> { - const relay = await deployOnce(conn) - expect(relay.error, String(relay.error)).toBeNull() - const deployed = relay.deployed! - const mux = new SshChannelMultiplexer(deployed.transport) - try { - await retrySshOwnerRecoveryWhileBlocked( - () => - openSshPtyConsumerSession(mux, { - clientInstanceId: randomUUID(), - expectedServerBuildId: deployed.serverBuildId - }), - { isCurrent: () => true, onClosed: () => () => {} } - ) - // Earlier relays answer through this deploy's real Windows census, as a connect asks them. - await startPreviousRelayCensus(conn, targetId, deployed) - const lister = Object.assign(async () => relayPtyIds(await mux.request('pty.listProcesses')), { - previous: () => listPreviousRelayPtyIds(targetId) - }) - const noLeases = { getSshRemotePtyLeases: () => [] } - const spawned: unknown = await mux.request('pty.spawn', { cols: 80, rows: 24 }) - const id = relayPtyIds([spawned])[0] ?? '' - expect(id).not.toBe('') - const live = await assessOrcadMigrationTerminals(noLeases, targetId, lister) - expect(live).toMatchObject({ verdict: 'live', ptyIds: [id] }) - await mux.request('pty.shutdown', { id, immediate: true }) - const deadline = Date.now() + 30_000 - while ((await lister()).length > 0 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 500)) - } - // This target's lists are empty now; only the account-wide census may prove the host idle. - const census = async () => - hostTerminalProofFromCensus(await censusSshHostRelaysBeforeSession(conn, targetId)) - expect(await assessOrcadMigrationTerminals(noLeases, targetId, lister)).toMatchObject({ - verdict: 'unverifiable' - }) - const otherDesktop = await liveShellOnAnotherDesktopsRelay(conn, `${targetId}-desktop-b`) - try { - const missed = await assessOrcadMigrationTerminals(noLeases, targetId, lister, census) - expect(missed).toMatchObject({ verdict: 'live' }) - } finally { - await otherDesktop.stop() - } - const exited = await assessOrcadMigrationTerminals(noLeases, targetId, lister, census) - expect(exited).toEqual({ verdict: 'exited', provenPtyIds: [] }) - return { - relayTerminal: id, - gateLive: live.verdict, - gateWithOtherDesktop: 'live', - gateExited: exited.verdict - } - } finally { - clearPreviousRelayCensus(targetId) - mux.dispose() - } -} - -/** Desktop B: a relay for another target id on the same Windows account, running one shell. */ -async function liveShellOnAnotherDesktopsRelay( - conn: SshConnection, - otherTargetId: string -): Promise<{ stop: () => Promise }> { - const deployed = await deployAndLaunchRelay(conn, undefined, 60, otherTargetId) - const mux = new SshChannelMultiplexer(deployed.transport) - await retrySshOwnerRecoveryWhileBlocked( - () => - openSshPtyConsumerSession(mux, { - clientInstanceId: randomUUID(), - expectedServerBuildId: deployed.serverBuildId - }), - { isCurrent: () => true, onClosed: () => () => {} } - ) - const id = relayPtyIds([await mux.request('pty.spawn', { cols: 80, rows: 24 })])[0] ?? '' - expect(id).not.toBe('') - return { - stop: async () => { - try { - await mux.request('pty.shutdown', { id, immediate: true }) - const deadline = Date.now() + 30_000 - while ( - relayPtyIds(await mux.request('pty.listProcesses')).length > 0 && - Date.now() < deadline - ) { - await new Promise((resolve) => setTimeout(resolve, 500)) - } - } finally { - mux.dispose() - } - } - } -} diff --git a/src/main/ssh/relay-native-dependency-coverage.test.ts b/src/main/ssh/relay-native-dependency-coverage.test.ts deleted file mode 100644 index 45834aeba09e..000000000000 --- a/src/main/ssh/relay-native-dependency-coverage.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { build } from 'esbuild' -import { JSONC_PARSER_ESM_ALIAS } from '../../../config/build-plugins/jsonc-parser-esm' -import { describe, expect, it } from 'vitest' -import { RELAY_NATIVE_DEPS } from './ssh-relay-deploy' - -/** - * The relay is a bundle deployed to a host that has none of Orca's node_modules. - * Every native addon it reaches therefore has to be installed by - * `installNativeDeps`, or the relay silently loses that capability forever. - * - * This exists because #15749 moved the Windows process table onto - * `@vscode/windows-process-tree` without adding it here. The relay tests all - * passed: they inject a fake module through - * `__setWindowsProcessTreeLoaderForTests`, so nothing ever exercised the real - * require that the remote host would fail. Assert against the real graph. - */ - -const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..') - -/** - * Native addons the relay imports but deliberately does NOT install, each with - * the reason its absence is safe. Adding an entry is a decision, not a default: - * anything not listed must appear in RELAY_NATIVE_DEPS. - */ -const DEGRADES_WITHOUT_INSTALL: Record = { - '@vscode/windows-process-tree': - 'Windows-only, and both ways of installing it fail. A normal install rebuilds ' + - 'from source because the tarball carries a binding.gyp, and that build fails ' + - 'with MSB8040 (Spectre-mitigated libraries) even on a host that already has ' + - 'MSVC Build Tools — our patch drops that requirement, and pnpm patches do not ' + - 'cross SSH. Skipping the build keeps the tarball binary, which predates the ' + - 'patch and still caps enumeration at 1024 processes, so a busy host gets a ' + - 'truncated table missing its own pid. windows-process-table.ts falls back to a ' + - 'Get-CimInstance scan when the binding is absent. See ' + - 'docs/reference/windows-process-enumeration.md.' -} - -/** Addons are the packages npm has to build or unpack a binary for. */ -function nativeDependencyNames(): string[] { - const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as { - dependencies?: Record - optionalDependencies?: Record - } - const declared = new Set([ - ...Object.keys(pkg.dependencies ?? {}), - ...Object.keys(pkg.optionalDependencies ?? {}) - ]) - return [...declared].filter((name) => { - const dir = join(REPO_ROOT, 'node_modules', name) - return existsSync(join(dir, 'binding.gyp')) || existsSync(join(dir, 'prebuilds')) - }) -} - -/** - * Source files reachable from the relay entry. - * - * Why the metafile and not the emitted bundle: the process table resolves its - * addon through `createRequire`, which esbuild cannot see and minification - * rewrites, so the specifier only survives reliably in the sources. - */ -async function relayReachableSources(): Promise { - const result = await build({ - entryPoints: [join(REPO_ROOT, 'src', 'relay', 'relay.ts')], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - write: false, - metafile: true, - external: ['node-pty', '@parcel/watcher', 'electron'], - alias: JSONC_PARSER_ESM_ALIAS, - define: { 'process.env.NODE_ENV': '"production"' } - }) - return Object.keys(result.metafile.inputs).filter((input) => !input.includes('node_modules')) -} - -describe('relay native dependency coverage', () => { - it('installs every native addon the relay bundle imports', async () => { - const sources = await relayReachableSources() - const text = sources.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')).join('\n') - - const imported = nativeDependencyNames().filter( - (name) => text.includes(`'${name}'`) || text.includes(`"${name}"`) - ) - expect(imported.length).toBeGreaterThan(0) - - const uncovered = imported.filter( - (name) => !(name in RELAY_NATIVE_DEPS) && !(name in DEGRADES_WITHOUT_INSTALL) - ) - expect(uncovered).toEqual([]) - }, 60_000) - - it('keeps every installed native dep at the version the app depends on', () => { - const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as { - dependencies?: Record - optionalDependencies?: Record - } - const declared = { ...pkg.dependencies, ...pkg.optionalDependencies } - for (const [name, version] of Object.entries(RELAY_NATIVE_DEPS)) { - // The relay pins exact versions where the app carries a range, so compare - // the base: a relay on a different version than the app marshals the same - // node-pty/watcher data through a binding nothing else cross-checks. - expect(declared[name]?.replace(/^[\^~]/, ''), `${name} matches the app`).toBe(version) - } - }) -}) diff --git a/src/main/ssh/relay-socket-path-limit.test.ts b/src/main/ssh/relay-socket-path-limit.test.ts index 1208c470d5d5..525f67492fac 100644 --- a/src/main/ssh/relay-socket-path-limit.test.ts +++ b/src/main/ssh/relay-socket-path-limit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-opencode-runtime', () => ({ ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') @@ -69,65 +69,21 @@ vi.mock('./ssh-connection-utils', () => ({ Object.assign(new Error('SSH operation was cancelled'), { name: 'AbortError' }) })) -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' -import { forceStopRelayForTarget } from './ssh-relay-reset' -import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' import { parseShortRelaySocketDir, remoteSocketPathFitsLimit, remoteUnixSocketPathByteLimit, - shortRelayVersionSegment, - SHORT_RELAY_SOCKET_DIR_PREFIX + shortRelayVersionSegment } from './relay-socket-path-limit' -import { supersededRelayEndpointListCommand } from './ssh-relay-superseded-endpoints' import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' const LINUX = getRemoteHostPlatform('linux-x64') const DARWIN = getRemoteHostPlatform('darwin-arm64') const WINDOWS = getRemoteHostPlatform('win32-x64') -// The reporter's host: a managed-hosting container whose $HOME is 45 bytes (#10726). -const LONG_HOME = '/var/www/611f7cf9-f715-49e6-91d9-0ffac1d7c4c0' - /** Matches the version this suite's mocked build reports. */ const RELAY_VERSION_DIR_NAME = 'relay-0.1.0+8d4e15ad63eb' -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - writeFile: vi.fn().mockResolvedValue(undefined), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((event: string, cb: () => void) => { - if (event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() - }) - } as unknown as SshConnection -} - -function launchedSockPath(conn: SshConnection): string { - const launch = vi - .mocked(conn.exec) - .mock.calls.map(([command]) => command as string) - .find((command) => command.includes('--detached')) - return /--sock-path\s+'([^']+)'/.exec(launch ?? '')?.[1] ?? '' -} - describe('remote unix socket path limit', () => { it('uses the per-OS sun_path budget and ignores Windows named pipes', () => { expect(remoteUnixSocketPathByteLimit(LINUX)).toBe(107) @@ -167,96 +123,3 @@ describe('remote unix socket path limit', () => { ).toBeNull() }) }) - -describe('relay launch with a long remote $HOME', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - }) - - it('keeps the launched socket path inside the remote sun_path limit', async () => { - const conn = makeMockConnection() - vi.mocked(execCommand) - .mockReset() - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce(LONG_HOME) - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce( - `ORCA-RELAY-SHORT-SOCKET-DIR ${SHORT_RELAY_SOCKET_DIR_PREFIX}1000/${shortRelayVersionSegment(RELAY_VERSION_DIR_NAME)}` - ) - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') - .mockResolvedValue('') - - // A per-target relay instance id is what pushes the default path past the limit: - // 45-byte $HOME + `/.orca-remote/relay-0.1.0+8d4e15ad63eb` + `/relay-.sock` = 110 bytes. - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'ssh-target-1') - - const sockPath = launchedSockPath(conn) - expect(sockPath).not.toBe('') - expect(Buffer.byteLength(sockPath, 'utf8')).toBeLessThanOrEqual( - remoteUnixSocketPathByteLimit(LINUX) as number - ) - expect(sockPath.startsWith(`${SHORT_RELAY_SOCKET_DIR_PREFIX}1000/`)).toBe(true) - expect(result.sockPath).toBe(sockPath) - // The hashed socket name survives intact, so two targets cannot collide -- and the - // build's version segment sits above it, so the next Orca release binds a path of - // its own instead of the one this relay is still holding. - expect(sockPath).toBe( - `${SHORT_RELAY_SOCKET_DIR_PREFIX}1000/${shortRelayVersionSegment(RELAY_VERSION_DIR_NAME)}/${relaySocketNameForInstanceId('ssh-target-1')}` - ) - expect(shortRelayVersionSegment('relay-0.1.0+next')).not.toBe( - shortRelayVersionSegment(RELAY_VERSION_DIR_NAME) - ) - }) - - it('sweeps superseded relays under the short base too, but never the live one', () => { - const currentShortSocketDir = `${SHORT_RELAY_SOCKET_DIR_PREFIX}1000/${shortRelayVersionSegment(RELAY_VERSION_DIR_NAME)}` - const script = supersededRelayEndpointListCommand({ - remoteHome: LONG_HOME, - currentRelayDir: `${LONG_HOME}/.orca-remote/${RELAY_VERSION_DIR_NAME}`, - sockName: relaySocketNameForInstanceId('ssh-target-1'), - currentShortSocketDir - }) - - // A relocated orphan lives outside $HOME, so the sweep that exists to make orphans - // visible has to look at the short base as well. - expect(script).toContain(`short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`) - expect(script).toContain('"$short_base"/relay-*/"$sock_name"') - expect(script).toContain(`short_current='${currentShortSocketDir}'`) - expect(script).toContain('[ -n "$short_current" ] && [ "$dir" = "$short_current" ] && continue') - }) - - it('leaves the socket in the versioned relay dir when it already fits', async () => { - const conn = makeMockConnection() - vi.mocked(execCommand) - .mockReset() - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') - .mockResolvedValue('') - - await deployAndLaunchRelay(conn) - - expect(launchedSockPath(conn)).toBe( - '/home/user/.orca-remote/relay-0.1.0+8d4e15ad63eb/relay.sock' - ) - }) - - it('force-stop also looks for the socket under the short base', async () => { - const conn = makeMockConnection() - vi.mocked(execCommand).mockReset().mockResolvedValue('') - - await forceStopRelayForTarget(conn, 'ssh-1') - - const script = vi.mocked(execCommand).mock.calls[0]?.[1] as string - expect(script).toContain(`short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`) - expect(script).toContain('"$short_base"/relay-*/"$sock_name"') - }) -}) diff --git a/src/main/ssh/remote-install-gc-termination.test.ts b/src/main/ssh/remote-install-gc-termination.test.ts index 7cf6ff3f2082..e53809421563 100644 --- a/src/main/ssh/remote-install-gc-termination.test.ts +++ b/src/main/ssh/remote-install-gc-termination.test.ts @@ -10,7 +10,6 @@ import type { SshConnection } from './ssh-connection' import { gcOldRelayVersions } from './remote-install-gc' import { execCommand } from './ssh-relay-deploy-helpers' import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' import { REMOTE_INSTALL_ORDER_OK } from './remote-install-previous-version' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -118,13 +117,6 @@ const caches = [ collect: () => gcRelayNativeDepsCache(conn, host, home), listing: `ENTRY ${nativeKey}\nENTRY linux-x64-fedcba9876543210\n__ORCA_NATIVE_CACHE__LIST_OK`, references: '__ORCA_NATIVE_CACHE__REFS_OK' - }, - { - name: 'ripgrep', - collect: () => gcRemoteRipgrepCache(conn, host, home), - listing: - 'ENTRY 0123456789abcdef-linux-x64\nENTRY fedcba9876543210-linux-x64\n__ORCA_RG_CACHE__LIST_OK', - references: '__ORCA_RG_CACHE__REFS_OK' } ] diff --git a/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts index a0b540777e3b..53a6f9c652ac 100644 --- a/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts +++ b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts @@ -1,7 +1,4 @@ -import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' -import type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' /** A bridged SSH CLI whose host the user opted in to control Orca, i.e. the pre-scope behaviour. */ export const CONTROL_GRANTED_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { @@ -16,11 +13,3 @@ export const HOST_BOUND_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { targetId: 'box-1', remoteCliControl: false } - -/** Runs the bridge as a host the user opted in, for tests of the surface such a host reaches. */ -export function runControlGrantedRemoteOrcaCli( - runtime: OrcaRuntimeService, - request: Omit -): Promise { - return runRemoteOrcaCli(runtime, { ...request, callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE }) -} diff --git a/src/main/ssh/ssh-filesystem-stream-retention.test.ts b/src/main/ssh/ssh-filesystem-stream-retention.test.ts deleted file mode 100644 index 15400212f782..000000000000 --- a/src/main/ssh/ssh-filesystem-stream-retention.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { - FileReadCapExceededError, - readFileViaStream, - StreamProtocolError -} from './ssh-filesystem-stream-reader' -import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' -import { - encodeJsonRpcFrame, - MessageType, - parseJsonRpcMessage, - type JsonRpcMessage -} from './relay-protocol' - -const muxes: SshChannelMultiplexer[] = [] -afterEach(() => { - for (const mux of muxes.splice(0)) { - mux.dispose() - } -}) - -function createConnection() { - let receive = (_data: Buffer): void => {} - let sequence = 1 - const sent: JsonRpcMessage[] = [] - const mux = new SshChannelMultiplexer({ - write(data) { - if (data[0] === MessageType.Regular) { - sent.push(parseJsonRpcMessage(data.subarray(13))) - } - }, - onData(callback) { - receive = callback - }, - onClose() {} - }) - muxes.push(mux) - return { - mux, - sent, - feed(...messages: JsonRpcMessage[]) { - receive(Buffer.concat(messages.map((message) => encodeJsonRpcFrame(message, sequence++, 0)))) - } - } -} - -async function collect(): Promise { - if (!global.gc) { - throw new Error('Retention test requires --expose-gc') - } - for (let turn = 0; turn < 8; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)) - global.gc() - } -} - -function sendForeignFrames(connection: ReturnType): WeakRef[] { - const refs: WeakRef[] = [] - const stop = connection.mux.onNotificationByMethod('fs.streamChunk', (params) => { - refs.push(new WeakRef(params)) - }) - for (let index = 0; index < 64; index += 1) { - connection.feed({ - jsonrpc: '2.0', - method: 'fs.streamChunk', - params: { streamId: index + 10, seq: 0, data: 'eA==' } - }) - } - stop() - return refs -} - -it('releases foreign stream frames while its own metadata is still pending', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/held.txt') - const refs = sendForeignFrames(connection) - try { - expect(refs).toHaveLength(64) - await collect() - expect(refs.filter((ref) => ref.deref())).toHaveLength(0) - expect(connection.sent).toHaveLength(1) - } finally { - connection.feed({ - jsonrpc: '2.0', - id: 1, - result: { empty: true, totalSize: 0, isBinary: false } - }) - await pending - } -}) - -it('installs metadata before its own chunk and end in the same decoder turn', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/own.txt') - const text = 'same turn 漢\u0000' - const data = Buffer.from(text) - connection.feed( - { - jsonrpc: '2.0', - id: 1, - result: { streamId: 3, totalSize: data.length, isBinary: false, resultEncoding: 'utf-8' } - }, - { - jsonrpc: '2.0', - method: 'fs.streamChunk', - params: { streamId: 3, seq: 0, data: data.toString('base64') } - }, - { jsonrpc: '2.0', method: 'fs.streamEnd', params: { streamId: 3 } } - ) - await expect(pending).resolves.toEqual({ content: text, isBinary: false }) - expect(connection.sent).toContainEqual({ - jsonrpc: '2.0', - method: 'fs.streamAck', - params: { streamId: 3, seq: 0 } - }) -}) - -it('preserves empty image metadata while ignoring earlier unknown stream identifiers', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/empty.png') - connection.feed( - { jsonrpc: '2.0', method: 'fs.streamChunk', params: { streamId: -1, seq: 0, data: 'eA==' } }, - { - jsonrpc: '2.0', - id: 1, - result: { empty: true, totalSize: 0, isBinary: true, isImage: true, mimeType: 'image/png' } - } - ) - await expect(pending).resolves.toEqual({ - content: '', - isBinary: true, - isImage: true, - mimeType: 'image/png' - }) - expect(connection.sent).toHaveLength(1) -}) - -it('rejects metadata missing its stream identifier', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/invalid.txt') - connection.feed({ jsonrpc: '2.0', id: 1, result: { totalSize: 1, isBinary: false } }) - await expect(pending).rejects.toBeInstanceOf(StreamProtocolError) - expect(connection.sent).toHaveLength(1) -}) - -it.each([-1, 51 * 1024 * 1024])( - 'rejects invalid or oversized totalSize %d and cancels the identified stream', - async (totalSize) => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/invalid.png') - connection.feed({ jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize, isBinary: true } }) - await expect(pending).rejects.toBeInstanceOf(FileReadCapExceededError) - expect(connection.sent).toContainEqual({ - jsonrpc: '2.0', - method: 'fs.cancelStream', - params: { streamId: 3 } - }) - } -) - -it('preserves a tighter caller cap before accepting adjacent data', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/small.txt', { maxTextBytes: 1 }) - connection.feed( - { jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize: 2, isBinary: false } }, - { jsonrpc: '2.0', method: 'fs.streamChunk', params: { streamId: 3, seq: 0, data: 'eHg=' } } - ) - await expect(pending).rejects.toBeInstanceOf(FileReadCapExceededError) - expect(connection.sent).toHaveLength(2) -}) - -it('preserves an adjacent own-stream error after metadata', async () => { - const connection = createConnection() - const pending = readFileViaStream(connection.mux, '/removed.txt') - connection.feed( - { jsonrpc: '2.0', id: 1, result: { streamId: 3, totalSize: 1, isBinary: false } }, - { - jsonrpc: '2.0', - method: 'fs.streamError', - params: { streamId: 3, code: 'ENOENT', message: 'gone' } - } - ) - await expect(pending).rejects.toMatchObject({ code: 'ENOENT', message: 'gone' }) -}) - -it('preserves the provider fallback when an older relay has no streaming method', async () => { - const connection = createConnection() - const provider = new SshFilesystemProvider('test', connection.mux) - const pending = provider.readFile('/legacy.txt') - try { - connection.feed({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }) - await new Promise((resolve) => setImmediate(resolve)) - expect(connection.sent).toContainEqual({ - jsonrpc: '2.0', - id: 2, - method: 'fs.readFile', - params: { filePath: '/legacy.txt' } - }) - connection.feed({ jsonrpc: '2.0', id: 2, result: { content: 'legacy', isBinary: false } }) - await expect(pending).resolves.toEqual({ content: 'legacy', isBinary: false }) - } finally { - provider.dispose() - } -}) - -// Why: the metadata install moved from the mandatory resolve path to the optional -// beforeResolve hook, and the request timer is cleared before that hook runs. A mux -// that ignores the hook must fail the read, not leave it pending with no deadline. -it('fails the read when a multiplexer resolves without running beforeResolve', async () => { - const connection = createConnection() - vi.spyOn(connection.mux, 'request').mockResolvedValue({ - totalSize: 10, - isBinary: false, - streamId: 7 - }) - - await expect(readFileViaStream(connection.mux, '/no-hook.txt')).rejects.toBeInstanceOf( - StreamProtocolError - ) -}) diff --git a/src/main/ssh/ssh-host-cli-deadline.ts b/src/main/ssh/ssh-host-cli-deadline.ts deleted file mode 100644 index fdf64f6e92c5..000000000000 --- a/src/main/ssh/ssh-host-cli-deadline.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { parseRemoteCliArgs } from './ssh-remote-cli-args' -import { clampOrchestrationAskTimeoutMs } from '../../shared/orchestration-ask-timeout' -import { - isSafeTimerDelayMs, - parsePositiveSafeIntegerNumericText, - parsePositiveSafeIntegerText -} from '../../shared/timer-delay' - -const DEFAULT_KILL_TIMEOUT_MS = 10 * 60_000 -const KILL_TIMEOUT_GRACE_MS = 2 * 60_000 - -/** Kill timer for the host CLI subprocess. Long-poll commands carry their wait - * budget in `--timeout-ms`; extend past it so the CLI's own timeout fires - * first and produces a proper error message. */ -export function resolveHostCliKillTimeoutMs(argv: string[]): number { - const parsed = parseRemoteCliArgs(argv) - const rawTimeout = parsed.flags.get('timeout-ms') - if (parsed.commandPath[0] === 'terminal' && parsed.commandPath[1] === 'send') { - const rawWait = parsed.flags.get('wait-submit') - const seconds = - typeof rawWait === 'string' ? parsePositiveSafeIntegerNumericText(rawWait) : null - if (seconds !== null && seconds <= 3600) { - return Math.max(DEFAULT_KILL_TIMEOUT_MS, seconds * 1000 + KILL_TIMEOUT_GRACE_MS) - } - } - if (parsed.commandPath[0] === 'orchestration' && parsed.commandPath[1] === 'ask') { - const explicit = - typeof rawTimeout === 'string' ? parsePositiveSafeIntegerText(rawTimeout) : null - return Math.max( - DEFAULT_KILL_TIMEOUT_MS, - clampOrchestrationAskTimeoutMs(explicit ?? undefined) + KILL_TIMEOUT_GRACE_MS - ) - } - const explicit = - typeof rawTimeout === 'string' ? parsePositiveSafeIntegerNumericText(rawTimeout) : null - // Why: this feeds the kill timer directly, so a post-grace budget outside the - // timer range degrades to the default instead of throwing at spawn time. - const extended = explicit === null ? null : explicit + KILL_TIMEOUT_GRACE_MS - if (extended !== null && isSafeTimerDelayMs(extended)) { - return Math.max(DEFAULT_KILL_TIMEOUT_MS, extended) - } - return DEFAULT_KILL_TIMEOUT_MS -} diff --git a/src/main/ssh/ssh-host-node-runtime-mode.ts b/src/main/ssh/ssh-host-node-runtime-mode.ts deleted file mode 100644 index bbe7c1e8757b..000000000000 --- a/src/main/ssh/ssh-host-node-runtime-mode.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Hosts whose relay last launched on the opt-in host-Node runtime (`legacy`: host Node plus an - * npm install on the host). That path is outside the supported ladder, so the card says so. - */ -const hostNodeTargets = new Set() - -export function recordSshRelayRuntimeStep(targetId: string, hostNode: boolean): void { - if (hostNode) { - hostNodeTargets.add(targetId) - } else { - hostNodeTargets.delete(targetId) - } -} - -export function isSshRelayOnHostNodeRuntime(targetId: string): boolean { - return hostNodeTargets.has(targetId) -} diff --git a/src/main/ssh/ssh-host-provider-set.test.ts b/src/main/ssh/ssh-host-provider-set.test.ts deleted file mode 100644 index 586537d511b6..000000000000 --- a/src/main/ssh/ssh-host-provider-set.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getSshPtyProvider } from '../ipc/pty' -import { - requireGitProviderForHost, - resolveFilesystemRouteForHost, - resolveGitRouteForHost -} from '../providers/execution-host-provider-dispatch' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - getSshGitProvider, - getSshGitProviderGeneration, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-git-dispatch' -import { registerSshHostProviders, retireSshHostProviders } from './ssh-host-provider-set' -import { clearSshPlainSshMode, setSshPlainSshMode } from './ssh-plain-ssh-mode' - -const targetId = 'host-provider-set-target' - -type ProviderSet = Parameters[1] - -function providerSet(withGit = true) { - const disposePty = vi.fn() - const disposeFs = vi.fn() - const stub = { - pty: { dispose: disposePty }, - fs: { dispose: disposeFs }, - git: withGit ? {} : null - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the set module only stores these stubs and calls `dispose`. - const set = stub as unknown as ProviderSet - return { set, disposePty, disposeFs } -} - -describe('SSH host provider set', () => { - afterEach(() => { - retireSshHostProviders(targetId) - clearSshPlainSshMode(targetId) - }) - - it('registers and retires every provider for the host together', () => { - const { set, disposePty, disposeFs } = providerSet() - registerSshHostProviders(targetId, set) - expect(getSshPtyProvider(targetId)).toBe(set.pty) - expect(getSshFilesystemProvider(targetId)).toBe(set.fs) - expect(getSshGitProvider(targetId)).toBe(set.git) - - retireSshHostProviders(targetId) - expect(disposePty).toHaveBeenCalledOnce() - expect(disposeFs).toHaveBeenCalledOnce() - expect(getSshPtyProvider(targetId)).toBeUndefined() - expect(getSshFilesystemProvider(targetId)).toBeUndefined() - expect(getSshGitProvider(targetId)).toBeUndefined() - }) - - it('a set without git clears an earlier git provider and reports git as unsupported', () => { - registerSshHostProviders(targetId, providerSet().set) - const generationBefore = getSshGitProviderGeneration(targetId) - setSshPlainSshMode(targetId, { reason: 'no_runtime', message: 'm' }) - registerSshHostProviders(targetId, providerSet(false).set) - - expect(getSshGitProvider(targetId)).toBeUndefined() - expect(getSshGitProviderGeneration(targetId)).toBeGreaterThan(generationBefore) - expect(resolveFilesystemRouteForHost(`ssh:${targetId}`)).toMatchObject({ kind: 'ssh' }) - expect(resolveGitRouteForHost(`ssh:${targetId}`)).toMatchObject({ provider: null }) - expect(() => requireGitProviderForHost(`ssh:${targetId}`)).toThrow( - 'Git needs the Orca remote server' - ) - clearSshPlainSshMode(targetId) - expect(() => requireGitProviderForHost(`ssh:${targetId}`)).toThrow( - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - ) - }) - - it('an owner whose set was replaced disposes only its own providers and leaves the host', () => { - const stale = providerSet(false) - const { set: current, disposePty: disposeCurrentPty } = providerSet() - registerSshHostProviders(targetId, stale.set) - registerSshHostProviders(targetId, current) - - retireSshHostProviders(targetId, stale.set) - - expect(stale.disposePty).toHaveBeenCalledOnce() - expect(stale.disposeFs).toHaveBeenCalledOnce() - expect(disposeCurrentPty).not.toHaveBeenCalled() - expect(getSshPtyProvider(targetId)).toBe(current.pty) - expect(getSshFilesystemProvider(targetId)).toBe(current.fs) - expect(getSshGitProvider(targetId)).toBe(current.git) - }) -}) diff --git a/src/main/ssh/ssh-host-provider-set.ts b/src/main/ssh/ssh-host-provider-set.ts deleted file mode 100644 index c4727efb03e4..000000000000 --- a/src/main/ssh/ssh-host-provider-set.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * The git, filesystem and terminal providers for one SSH host, registered and removed as a unit - * so the host's routes can never disagree about whether it is connected. - */ -import { getSshPtyProvider, registerSshPtyProvider, unregisterSshPtyProvider } from '../ipc/pty' -import { - getSshFilesystemProvider, - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' -import type { SshGitProvider } from '../providers/ssh-git-provider' -import type { IFilesystemProvider, IPtyProvider } from '../providers/types' - -export type SshHostProviderSet = { - pty: IPtyProvider - fs: IFilesystemProvider - /** `null`: no Orca runtime on the host (plain SSH), so git is unsupported, not unreachable. */ - git: SshGitProvider | null -} - -/** Replaces every provider for the host; a set without git also clears any earlier git provider. */ -export function registerSshHostProviders(targetId: string, providers: SshHostProviderSet): void { - registerSshPtyProvider(targetId, providers.pty) - registerSshFilesystemProvider(targetId, providers.fs) - if (providers.git) { - registerSshGitProvider(targetId, providers.git) - } else { - unregisterSshGitProvider(targetId) - } -} - -/** - * Disposes and removes the host's providers. With `owned`, the caller's own providers are - * disposed, and the host is cleared only if that set is still the registered one. - */ -export function retireSshHostProviders( - targetId: string, - owned?: Pick -): void { - for (const provider of [ - owned?.pty ?? getSshPtyProvider(targetId), - owned?.fs ?? getSshFilesystemProvider(targetId) - ]) { - if (provider && 'dispose' in provider && typeof provider.dispose === 'function') { - provider.dispose() - } - } - if (owned && getSshPtyProvider(targetId) !== owned.pty) { - return - } - unregisterSshPtyProvider(targetId) - unregisterSshFilesystemProvider(targetId) - unregisterSshGitProvider(targetId) -} diff --git a/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts b/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts index 4b8743b97a1d..36fd855e74a5 100644 --- a/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts +++ b/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts @@ -55,13 +55,14 @@ describe('the connect-time relay terminal verdict', () => { await expect(decide(census).verdict).resolves.toMatchObject({ verdict: 'unverifiable' }) }) - it('keeps a lease-backed verdict without asking the host', async () => { + // With no relay session, a lease an older build left proves nothing; the host census decides. + it('lets an idle host census prove a lease an older build left', async () => { const { censusHost, verdict } = decide( { verdict: 'none', count: 0 }, { leases: [{ ptyId: 'a', state: 'attached' }] } ) - await expect(verdict).resolves.toEqual({ verdict: 'live', count: 1 }) - expect(censusHost).not.toHaveBeenCalled() + await expect(verdict).resolves.toEqual({ verdict: 'exited', count: 0 }) + expect(censusHost).toHaveBeenCalledTimes(1) }) // Astra pass 4 §2: this target's empty lists cannot see another desktop's relay on the account. @@ -74,17 +75,12 @@ describe('the connect-time relay terminal verdict', () => { expect(censusHost).toHaveBeenCalledTimes(1) }) - it('attributes terminals this target leases or lists to this desktop', async () => { + it('attributes terminals this target lists to this desktop', async () => { const listed = decide( { verdict: 'live', count: 3 }, { lister: Object.assign(async () => ['pty-1'], { previous: async () => [] }) } ) await expect(listed.verdict).resolves.toEqual({ verdict: 'live', count: 1 }) - const leased = decide( - { verdict: 'live', count: 3 }, - { leases: [{ ptyId: 'a', state: 'attached' }] } - ) - await expect(leased.verdict).resolves.not.toHaveProperty('elsewhere') }) // A session whose relays could not answer proves nothing, and no lease here changes that. diff --git a/src/main/ssh/ssh-host-server-move-offer.test.ts b/src/main/ssh/ssh-host-server-move-offer.test.ts deleted file mode 100644 index c39a762a59d2..000000000000 --- a/src/main/ssh/ssh-host-server-move-offer.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - canOfferManagedServerMove, - relayServerStatus, - shouldToastManagedServerMove -} from './ssh-host-server-move-offer' - -const live = { route: 'relay' as const, reason: 'relay_terminals_live' as const, terminals: 3 } - -describe('offering to move a host whose relay terminals keep it on the relay', () => { - it('offers only when live terminals are the reason', () => { - expect(canOfferManagedServerMove(live)).toBe(true) - expect(canOfferManagedServerMove({ reason: 'relay_terminals_unverifiable' })).toBe(false) - expect(canOfferManagedServerMove({ reason: 'refused' })).toBe(false) - expect(canOfferManagedServerMove({ reason: 'orcad_unavailable' })).toBe(false) - }) - - it('toasts once per host per app version', () => { - expect(shouldToastManagedServerMove({}, live, '1.5.0')).toBe(true) - expect( - shouldToastManagedServerMove( - { managedServerMoveOffered: { appVersion: '1.5.0' } }, - live, - '1.5.0' - ) - ).toBe(false) - expect( - shouldToastManagedServerMove( - { managedServerMoveOffered: { appVersion: '1.4.0' } }, - live, - '1.5.0' - ) - ).toBe(true) - expect( - shouldToastManagedServerMove({}, { reason: 'relay_terminals_unverifiable' }, '1.5.0') - ).toBe(false) - }) - - it('carries the offer and terminal count on the published relay status', () => { - expect(relayServerStatus(live, true)).toEqual({ - kind: 'relay', - reason: 'relay_terminals_live', - terminals: 3, - offerMove: true - }) - expect(relayServerStatus(live, false)).not.toHaveProperty('offerMove') - expect( - relayServerStatus( - { route: 'relay', reason: 'refused', detail: 'An automation runs here.' }, - false - ) - ).toEqual({ kind: 'relay', reason: 'refused', detail: 'An automation runs here.' }) - }) - - it('never offers to move terminals another Orca desktop or session runs', () => { - const elsewhere = { ...live, terminals: 1, terminalsElsewhere: true } - expect(canOfferManagedServerMove(elsewhere)).toBe(false) - expect(shouldToastManagedServerMove({}, elsewhere, '1.5.0')).toBe(false) - expect(relayServerStatus(elsewhere, false)).toEqual({ - kind: 'relay', - reason: 'relay_terminals_live', - terminals: 1, - terminalsElsewhere: true - }) - }) -}) diff --git a/src/main/ssh/ssh-host-server-move-offer.ts b/src/main/ssh/ssh-host-server-move-offer.ts deleted file mode 100644 index 386e7ba0c0c7..000000000000 --- a/src/main/ssh/ssh-host-server-move-offer.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** When a connect that kept the relay offers to move the host now, restarting its terminals. */ -import type { SshManagedServerStatus, SshTarget } from '../../shared/ssh-types' -import type { HostServerOnConnectResult } from './ssh-host-server-on-connect' - -type RelayDecision = Extract - -/** - * Only this desktop's live terminals: an unverifiable census, or terminals another desktop or - * session runs, can't be ended by stopping this target's terminals. - */ -export function canOfferManagedServerMove( - decision: Pick -): boolean { - return decision.reason === 'relay_terminals_live' && !decision.terminalsElsewhere -} - -/** The toast shows once per host per app version; the SSH Hosts action stays regardless. */ -export function shouldToastManagedServerMove( - target: Pick, - decision: Pick, - appVersion: string -): boolean { - return ( - canOfferManagedServerMove(decision) && - target.managedServerMoveOffered?.appVersion !== appVersion - ) -} - -export function relayServerStatus( - decision: RelayDecision, - offerMove: boolean -): Extract { - return { - kind: 'relay', - reason: decision.reason, - ...(decision.detail ? { detail: decision.detail } : {}), - ...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}), - ...(offerMove ? { offerMove: true } : {}), - ...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {}) - } -} diff --git a/src/main/ssh/ssh-host-unserved-status.ts b/src/main/ssh/ssh-host-unserved-status.ts new file mode 100644 index 000000000000..feada869c7ee --- /dev/null +++ b/src/main/ssh/ssh-host-unserved-status.ts @@ -0,0 +1,36 @@ +/** What a connect publishes when the managed server doesn't serve the host; there is no fallback. */ +import type { SshManagedServerStatus } from '../../shared/ssh-types' +import type { HostServerOnConnectResult } from './ssh-host-server-on-connect' + +type UnservedDecision = Extract + +// Why `kind: 'relay'`: the published status keeps its shipped shape; it now means "not served". +export function unservedServerStatus( + decision: UnservedDecision +): Extract { + return { + kind: 'relay', + reason: decision.reason, + ...(decision.detail ? { detail: decision.detail } : {}), + ...(decision.terminals !== undefined ? { terminals: decision.terminals } : {}), + ...(decision.terminalsElsewhere ? { terminalsElsewhere: true } : {}) + } +} + +/** Untranslated twin of the renderer's status line, for logs and callers without the catalog. */ +export function unservedHostServerMessage(decision: UnservedDecision): string { + switch (decision.reason) { + case 'orcad_unavailable': + return 'This SSH host isn’t supported by this version of Orca. To keep using it, install an older version of Orca.' + case 'relay_terminals_live': + case 'relay_terminals_unverifiable': + return 'Terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.' + case 'refused': + return decision.detail ?? 'Orca couldn’t set up its server on this host.' + case 'source_changed': + return 'An older version of Orca changed this host’s projects. Move those changes to its managed server in SSH Hosts settings to continue.' + case 'deferred': + case 'failed': + return 'Orca couldn’t start its server on this host. Try connecting again.' + } +} diff --git a/src/main/ssh/ssh-hostile-host-cells.ts b/src/main/ssh/ssh-hostile-host-cells.ts index 414ed6a4876a..94874f7fcdc1 100644 --- a/src/main/ssh/ssh-hostile-host-cells.ts +++ b/src/main/ssh/ssh-hostile-host-cells.ts @@ -1,7 +1,7 @@ /** * The design D5/D6 hostile-host matrix: each cell is an SSH target (a container, or the runner's * own loopback sshd) and the place the relay runtime ladder must land there. - * `ssh-relay-hostile-hosts.docker.test.ts` drives the real client-side deploy against each one; + * `ssh-hostile-hosts.docker.test.ts` drives the real client-side deploy against each one; * `.github/workflows/ssh-hostile-hosts.yml` runs it. */ import type { NodeRuntimeTarget, ServerTarget } from '../../shared/node-runtime-pin' diff --git a/src/main/ssh/ssh-hostile-host-managed-orcad.ts b/src/main/ssh/ssh-hostile-host-managed-orcad.ts index 84960124a784..f4ed9de73ebe 100644 --- a/src/main/ssh/ssh-hostile-host-managed-orcad.ts +++ b/src/main/ssh/ssh-hostile-host-managed-orcad.ts @@ -1,8 +1,8 @@ /** * The managed-orcad half of a hostile-host cell: on a fresh host, resolve the context, deploy * orcad, and prove it picked the cell's runtime and uploaded no other. An activated server must - * be live; a refused one must be classified as unable to run, so the relay that follows runs the - * pinned ladder and lands on the cell's rung. + * be live; a refused one must be classified as unable to run, which the connect reports as an + * unsupported host. */ import { posix } from 'node:path' import { expect } from 'vitest' @@ -20,7 +20,7 @@ import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-la import { execOrcadRemote } from './orcad-remote-runtime-control' import { decommissionRemoteOrcad } from './orcad-remote-decommission' import type { HostileHostCellCore, ManagedOrcadExpectation } from './ssh-hostile-host-cells' -import { connectHostileHost, deployOnce } from './ssh-hostile-host-test-harness' +import { connectHostileHost } from './ssh-hostile-host-test-harness' import { hostExecStatus, type HostileHostTarget } from './ssh-hostile-host-test-fixture' const EMPTY_CENSUS = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } @@ -53,7 +53,6 @@ export async function proveManagedOrcadCell( code: expected.code }) expect(classifyOrcadHostUnavailable(deployed)).toBe('native_preflight') - await assertRelayFallback(sshTarget, expected.relayRung) return } expect(deployed, JSON.stringify(deployed)).toMatchObject({ outcome: 'installed-and-activated' }) @@ -82,22 +81,6 @@ async function assertOnlyRuntimeUploaded( } } -/** The connect's next step: no runtime setting, but orcad recorded unable to run on this host. */ -async function assertRelayFallback(sshTarget: SshTarget, rung: 'A' | 'B'): Promise { - const { remoteRuntime: _setting, ...withoutSetting } = sshTarget - const conn = await connectHostileHost({ - ...withoutSetting, - managedServerUnavailable: { reason: 'native_preflight', appVersion: 'hostile-hosts' } - }) - try { - const attempt = await deployOnce(conn) - expect(attempt.error).toBeNull() - expect(attempt.settledRung).toBe(rung) - } finally { - await conn.disconnect().catch(() => {}) - } -} - async function assertLiveThenDecommissioned( slot: OrcadSlotOptions, slotDir: string, diff --git a/src/main/ssh/ssh-hostile-host-test-harness.ts b/src/main/ssh/ssh-hostile-host-test-harness.ts index c5733c98ec17..c301594c9867 100644 --- a/src/main/ssh/ssh-hostile-host-test-harness.ts +++ b/src/main/ssh/ssh-hostile-host-test-harness.ts @@ -3,55 +3,14 @@ * SshConnection, observed through the ladder's own calls and a {@link HostileHostObserver}. * The Docker matrix and the Windows SSH-host lanes both run cells through it. */ -import { randomUUID } from 'node:crypto' import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, posix } from 'node:path' -import { expect, vi } from 'vitest' +import { join } from 'node:path' import { setAppEnvironment } from '../../shared/app-environment' -import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' -import { orcadNodeRuntimeExecutable } from '../../shared/orcad-artifacts' -import type { SshRemoteRuntimeRung, SshTarget } from '../../shared/ssh-types' -import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { SshTarget } from '../../shared/ssh-types' import { SshConnection } from './ssh-connection' -import { - hostileHostCellViolations, - parseForbiddenToolLog, - type HostileHostCellCore, - type RungRefusal -} from './ssh-hostile-host-cells' -import type { HostileHostObserver } from './ssh-hostile-host-observer' -import { retrySshOwnerRecoveryWhileBlocked } from './ssh-owner-recovery-retry' -import { openSshPtyConsumerSession } from './ssh-pty-consumer-session' -import { deployAndLaunchRelay, type RelayDeployResult } from './ssh-relay-deploy' -import { pinnedRelayAddonFiles, pinnedRelayNodePath } from './ssh-relay-pinned-node' -import { - RelayRuntimeLadderRun, - RemoteRuntimeUnavailableError -} from './ssh-relay-runtime-resolution' -export type DeployAttempt = { - deployed: RelayDeployResult | null - error: unknown - refusals: RungRefusal[] - settledRung: SshRemoteRuntimeRung | null - run: RelayRuntimeLadderRun | null - /** Every command this deploy sent the host, as the SSH layer received it. */ - commands: string[] -} - -/** Input the shell must evaluate to print `expect`; the echoed keystrokes never contain it. */ -export type TerminalProbe = { input: string; expect: string } - -export const TERMINAL_PROBES = { - posix: { input: 'echo ORCA_HOSTILE_$((6*7))\r', expect: 'ORCA_HOSTILE_42' }, - // Why through cmd.exe: the same line evaluates whether the relay's PTY shell is cmd or PowerShell. - windows: { input: 'cmd /d /c set /a 6*7+100000\r', expect: '100042' } -} as const satisfies Record - -/** Points the app environment at a throwaway userData; returns its cleanup. */ export function installHostileHostAppEnvironment(): () => void { const userData = mkdtempSync(join(tmpdir(), 'orca-hostile-hosts-userdata-')) const { version } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) @@ -72,232 +31,3 @@ export async function connectHostileHost(sshTarget: SshTarget): Promise { - const refused = vi.spyOn(RelayRuntimeLadderRun.prototype, 'refused') - const settle = vi.spyOn(RelayRuntimeLadderRun.prototype, 'settle') - const exec = vi.spyOn(SshConnection.prototype, 'exec') - const attempt: DeployAttempt = { - deployed: null, - error: null, - refusals: [], - settledRung: null, - run: null, - commands: [] - } - try { - attempt.deployed = await deployAndLaunchRelay(conn, undefined, 60) - } catch (error) { - attempt.error = error - } finally { - attempt.refusals = refused.mock.calls.map(([step, reason]) => ({ step, reason })) - attempt.settledRung = settle.mock.calls.at(-1)?.[0] ?? null - const context: unknown = settle.mock.contexts.at(-1) - attempt.run = context instanceof RelayRuntimeLadderRun ? context : null - attempt.commands = exec.mock.calls.map(([command]) => command) - refused.mockRestore() - settle.mockRestore() - exec.mockRestore() - } - return attempt -} - -export function deployErrorText(attempt: DeployAttempt): string | null { - const { error } = attempt - return error ? (error instanceof Error ? error.message : String(error)) : null -} - -export async function assertCell( - cell: HostileHostCellCore, - observer: HostileHostObserver, - attempt: DeployAttempt -): Promise { - const { error } = attempt - const violations = hostileHostCellViolations(cell, { - settledRung: attempt.settledRung, - target: attempt.run?.facts?.target ?? null, - unavailableReason: error instanceof RemoteRuntimeUnavailableError ? error.reason : null, - deployError: deployErrorText(attempt), - refusals: attempt.refusals, - forbiddenToolCalls: parseForbiddenToolLog(await observer.readForbiddenToolLog()) - }) - expect(violations, `${cell.id}: ${violations.join('; ')}`).toEqual([]) -} - -/** Resends the probe this often until the shell evaluates it; typeahead before a prompt can be dropped. */ -const TERMINAL_PROBE_RESEND_MS = 15_000 - -/** - * Opens a PTY as the session owner and waits for the shell to evaluate what it was sent. Returns - * the multiplexer's disposer: until called, the session keeps answering relay keepalives, as the - * app does, so the relay never reaps it as silent while the connection is still up. - */ -export async function assertTerminalEchoes( - deployed: RelayDeployResult, - clientInstanceId: string, - probe: TerminalProbe -): Promise<() => void> { - const mux = new SshChannelMultiplexer(deployed.transport) - let keepOpen = false - try { - // Why retry: the first connect's owner stays held for its grace period, and the app retries too. - await retrySshOwnerRecoveryWhileBlocked( - () => - openSshPtyConsumerSession(mux, { - clientInstanceId, - expectedServerBuildId: deployed.serverBuildId - }), - { isCurrent: () => true, onClosed: () => () => {} } - ) - const output = new Map() - mux.onNotificationByMethod('pty.data', (params) => { - if (typeof params.id === 'string' && typeof params.data === 'string') { - output.set(params.id, (output.get(params.id) ?? '') + params.data) - } - }) - const spawned: unknown = await mux.request('pty.spawn', { cols: 80, rows: 24 }) - const id = - spawned && typeof spawned === 'object' && 'id' in spawned && typeof spawned.id === 'string' - ? spawned.id - : '' - expect(id).not.toBe('') - // Why 90s: a first Windows PowerShell start under ConPTY can take tens of seconds on CI. - const deadline = Date.now() + 90_000 - let nextSendAt = 0 - while (!(output.get(id) ?? '').includes(probe.expect) && Date.now() < deadline) { - if (Date.now() >= nextSendAt) { - mux.notify('pty.data', { id, data: probe.input }) - nextSendAt = Date.now() + TERMINAL_PROBE_RESEND_MS - } - await new Promise((resolve) => setTimeout(resolve, 200)) - } - expect(output.get(id) ?? '').toContain(probe.expect) - await mux.request('pty.shutdown', { id }) - keepOpen = true - return () => mux.dispose() - } finally { - if (!keepOpen) { - mux.dispose() - } - } -} - -export type PinnedRuntimeLayout = { nodePath: string; runtimeDir: string; storeDir: string } - -export function pinnedRuntimeLayout( - deployed: RelayDeployResult, - target: NodeRuntimeTarget -): PinnedRuntimeLayout { - if (!deployed.hostPlatform || !deployed.remoteRelayDir) { - throw new Error('A pinned deploy must report its host and relay directory') - } - // Remote paths join with `/` on every host flavor, Windows included. - const nodePath = pinnedRelayNodePath(deployed.hostPlatform, deployed.remoteRelayDir, target) - let runtimeDir = nodePath - const depth = orcadNodeRuntimeExecutable(target).split('/').length - for (let level = 0; level < depth; level++) { - runtimeDir = posix.dirname(runtimeDir) - } - return { nodePath, runtimeDir, storeDir: posix.dirname(runtimeDir) } -} - -/** - * Plants an older and a newer idle runtime beside the live one and collects with no client pin: - * only the reference and the running relay can keep the live runtime. - */ -async function assertGcKeepsInUseRuntime( - conn: SshConnection, - observer: HostileHostObserver, - deployed: RelayDeployResult, - layout: PinnedRuntimeLayout -): Promise { - const older = `node-${'a'.repeat(64)}` - const newer = `node-${'b'.repeat(64)}` - await observer.plantIdleRuntime(layout.storeDir, older, 'old') - await observer.plantIdleRuntime(layout.storeDir, newer, 'new') - const { hostPlatform, remoteHome } = deployed - if (!hostPlatform || !remoteHome) { - throw new Error('A pinned deploy must report its host and home') - } - let state = 'skipped' - // Why retry: the deploy's own background GC may still hold the store lock. - for (let attempt = 0; attempt < 20 && state !== 'collected'; attempt++) { - const result = await gcRemoteNodeRuntimeStore(conn, hostPlatform, remoteHome, { - currentPins: [] - }) - state = result.state - if (state !== 'collected') { - await new Promise((resolve) => setTimeout(resolve, 1_500)) - } - } - expect(state).toBe('collected') - expect(await observer.isFile(layout.nodePath)).toBe(true) - expect(await observer.exists(`${layout.storeDir}/${newer}`)).toBe(true) - expect(await observer.exists(`${layout.storeDir}/${older}`)).toBe(false) -} - -export type LaunchedCellRun = { - cell: HostileHostCellCore - observer: HostileHostObserver - sshTarget: SshTarget - terminal: TerminalProbe - first: DeployAttempt - firstConn: SshConnection - /** Extra per-deploy checks, e.g. the Windows command audit; `uploaded` is the first deploy. */ - inspectDeploy?: (attempt: DeployAttempt, uploaded: boolean) => void -} - -export type LaunchedCellEvidence = { nodePath: string; nodeSha256: string } - -export async function exerciseLaunchedCell(run: LaunchedCellRun): Promise { - const { cell, observer, sshTarget, terminal, first, firstConn } = run - if (cell.expect.outcome !== 'launched' || !first.deployed || !first.run) { - throw new Error(`${cell.id} did not launch a relay`) - } - // Rung B runs a compat runtime, which is what its store dir, sha and addon slot are keyed by. - const runtime = cell.expect.runtime ?? cell.expect.target - expect(first.run.selfTest).toBe('passed') - expect(first.run.runtimeTransfer).toBe('uploaded') - run.inspectDeploy?.(first, true) - const layout = pinnedRuntimeLayout(first.deployed, runtime) - const { executableSha256 } = pinnedNodeRuntimeAsset(runtime) - expect(posix.basename(layout.runtimeDir)).toBe(`node-${executableSha256}`) - const nodeSha256 = await observer.fileSha256(layout.nodePath) - expect(nodeSha256).toBe(executableSha256) - // Every slot file the relay loads, the Windows bundled ConPTY pair included, landed beside it. - for (const file of pinnedRelayAddonFiles(runtime)) { - expect(await observer.isFile(`${first.deployed.remoteRelayDir}/${file}`), file).toBe(true) - } - // Why one id for both connects: the app reconnects as the same client instance. - const clientInstanceId = randomUUID() - const closeFirstSession = await assertTerminalEchoes(first.deployed, clientInstanceId, terminal) - try { - await assertGcKeepsInUseRuntime(firstConn, observer, first.deployed, layout) - } finally { - closeFirstSession() - } - const before = await observer.fileStamp(layout.nodePath) - await firstConn.disconnect() - - const secondConn = await connectHostileHost(sshTarget) - try { - const second = await deployOnce(secondConn) - await assertCell(cell, observer, second) - expect(second.run?.runtimeTransfer).toBe('cached') - run.inspectDeploy?.(second, false) - expect(await observer.fileStamp(layout.nodePath)).toBe(before) - if (!second.deployed) { - throw new Error(`${cell.id} did not relaunch on the second connect`) - } - const closeSecondSession = await assertTerminalEchoes( - second.deployed, - clientInstanceId, - terminal - ) - closeSecondSession() - } finally { - await secondConn.disconnect() - } - return { nodePath: layout.nodePath, nodeSha256 } -} diff --git a/src/main/ssh/ssh-hostile-hosts.docker.test.ts b/src/main/ssh/ssh-hostile-hosts.docker.test.ts new file mode 100644 index 000000000000..cc1f3033535a --- /dev/null +++ b/src/main/ssh/ssh-hostile-hosts.docker.test.ts @@ -0,0 +1,64 @@ +// Design D5/D6 hostile-host matrix: the real client-side relay deploy against container SSH +// targets, and against a macOS runner's own loopback sshd, asserting which rung of the runtime +// ladder each host lands on, and for cells naming one, the runtime managed orcad deploys on. +// +// Run: ORCA_RUN_SSH_HOSTILE_HOSTS=1 pnpm test src/main/ssh/ssh-hostile-hosts.docker.test.ts +// Needs `pnpm build:relay` and an orcad template holding each selected cell's slot. Docker cells +// need Linux Docker (the no-egress cell dials an internal bridge directly) and +// node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc,linux-x64-musl +// macOS cells need /usr/sbin/sshd and this runner's slot (`pnpm build:orcad-prebuilds`, then +// `--targets darwin-arm64` or `darwin-x64`). Only cells this machine can host run. +// ORCA_SSH_HOSTILE_HOST_CELLS=id,id narrows the run. .github/workflows/ssh-hostile-hosts.yml runs it. +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) + +import { HOSTILE_HOST_CELLS, selectHostileHostCells } from './ssh-hostile-host-cells' +import { proveManagedOrcadCell } from './ssh-hostile-host-managed-orcad' +import { installHostileHostAppEnvironment } from './ssh-hostile-host-test-harness' +import { + hostileHostSshTarget, + startHostileHostTarget, + stopHostileHostTarget, + type HostileHostTarget +} from './ssh-hostile-host-test-fixture' + +const RUN = process.env.ORCA_RUN_SSH_HOSTILE_HOSTS === '1' +const SELECTED = new Set( + RUN ? selectHostileHostCells(process.env.ORCA_SSH_HOSTILE_HOST_CELLS).map((cell) => cell.id) : [] +) +const CELL_TIMEOUT_MS = 15 * 60_000 + +describe('SSH hostile-host managed orcad matrix', () => { + let cleanupAppEnvironment: (() => void) | null = null + + beforeAll(() => { + cleanupAppEnvironment = installHostileHostAppEnvironment() + }) + + afterAll(() => { + cleanupAppEnvironment?.() + }) + + // Why: with every cell skipped the job would pass having deployed nothing. + it.runIf(RUN)('selects at least one cell this machine can host', () => { + expect(SELECTED.size).toBeGreaterThan(0) + }) + + // A fresh host per cell. + for (const cell of HOSTILE_HOST_CELLS.filter((candidate) => candidate.managed)) { + it.skipIf(!SELECTED.has(cell.id))( + `${cell.id} deploys managed orcad on ${cell.managed?.runtime} (${cell.managed?.outcome})`, + async () => { + let target: HostileHostTarget | null = null + try { + target = await startHostileHostTarget(cell) + await proveManagedOrcadCell(cell, target, hostileHostSshTarget(target)) + } finally { + await stopHostileHostTarget(target) + } + }, + CELL_TIMEOUT_MS + ) + } +}) diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts deleted file mode 100644 index 7a503f518677..000000000000 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -// #9819, the client half: what the sweep actually asks the store and the host, and what it does -// with the answers. The rule itself is covered in ssh-relay-pty-ownership-proof.test.ts. -import { describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import type { IPtyProvider } from '../providers/types' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import type { SshRemotePtyLease } from '../../shared/ssh-types' -import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' -import type { PersistedState } from '../../shared/persisted-state-types' -import { - upsertSshRemotePtyLease, - type SshPtyLeaseOperations -} from '../persistence/leasing-ssh-ptys/ssh-pty-lease-operations' -import { - RELAY_PTY_SWEEP_PASS_BUDGET_MS, - sweepOrphanedRelayPtys -} from './ssh-orphan-relay-pty-sweep' -import { RELAY_PTY_SWEEP_MIN_AGE_MS } from '../../shared/ssh-relay-pty-ownership-proof' - -const TARGET = 'target-1' -const OURS = 'client-instance-ours' -// A stable pane id is a UUID; anything else is stripped before supersede can match on it. -const LEAF = '11111111-2222-4333-8444-555555555555' - -const OBSERVATION = { authorityGeneration: 'gen-1', observationEpoch: 1, capturedAgeMs: 0 } - -/** The host looked and saw its own shell owning the terminal: nothing is running in the pane. */ -function idleShell(): ForegroundProcessEvidence { - return { ...OBSERVATION, verdict: 'live', processName: null, shellOwnsEveryTtyProcessGroup: true } -} - -function hostEntry(overrides: Partial = {}): PtyProcessInfo { - return { - id: `ssh:${TARGET}@@pty-1`, - incarnationId: 'inc-1', - cwd: '/home/user', - title: 'zsh', - ownerClientInstanceId: OURS, - hostAgeMs: RELAY_PTY_SWEEP_MIN_AGE_MS * 2, - paneBound: true, - foregroundProcessEvidence: idleShell(), - ...overrides - } -} - -function createHarness( - processes: PtyProcessInfo[], - leases: SshRemotePtyLease[] = [] -): { provider: IPtyProvider; store: Store; shutdown: ReturnType } { - const shutdown = vi.fn().mockResolvedValue(undefined) - const provider = { - listProcesses: vi.fn().mockResolvedValue(processes), - shutdown - } as unknown as IPtyProvider - const store = { - getSshRemotePtyLeases: vi.fn().mockReturnValue(leases), - reconcileSshRemotePtyLeasesForTarget: vi.fn() - } as unknown as Store - return { provider, store, shutdown } -} - -function run( - harness: ReturnType, - overrides: Partial[0]> = {} -): Promise { - return sweepOrphanedRelayPtys({ - targetId: TARGET, - store: harness.store, - provider: harness.provider, - clientInstanceId: OURS, - isSessionOwner: true, - routedPtyIds: [], - shouldContinue: () => true, - ...overrides - }) -} - -function lease(ptyId: string, state: SshRemotePtyLease['state']): SshRemotePtyLease { - return { ptyId, state } as SshRemotePtyLease -} - -describe('sweepOrphanedRelayPtys', () => { - it('stops an attested orphan, fenced on the incarnation the same listing published', async () => { - const harness = createHarness([hostEntry()]) - - await run(harness) - - expect(harness.shutdown).toHaveBeenCalledWith( - `ssh:${TARGET}@@pty-1`, - expect.objectContaining({ immediate: true, expectedIncarnationId: 'inc-1' }) - ) - }) - - it('asks the host to re-check ownership on the one call that cannot be undone', async () => { - // The stop is the only irreversible step in this flow, and until now the whole nine-condition - // rule was enforced only here, on the client that decided to make it. Naming the owner makes - // the host re-decide where the processes actually live. - const harness = createHarness([hostEntry()]) - - await run(harness) - - expect(harness.shutdown).toHaveBeenCalledWith( - `ssh:${TARGET}@@pty-1`, - expect.objectContaining({ expectedOwnerClientInstanceId: OURS }) - ) - }) - - it('does not act on an observation that aged out between the listing and the plan', async () => { - // The listing answered inside the budget, but this pass then spent longer than the evidence is - // good for. Staleness has to degrade to "leave it running". - let clock = 1_000_000 - const harness = createHarness([hostEntry()]) - harness.provider.listProcesses = vi.fn().mockImplementation(async () => { - clock += 1 - return [hostEntry()] - }) - - const pass = (maximumEvidenceAgeMs: number): Promise => - run(harness, { - now: () => clock, - maximumEvidenceAgeMs, - passBudgetMs: 60_000, - shouldContinue: () => { - clock += 20 - return true - } - }) - - await pass(10) - expect(harness.shutdown).not.toHaveBeenCalled() - - // Positive control: the same entry, the same elapsed time, a budget that covers it. - await pass(10_000) - expect(harness.shutdown).toHaveBeenCalledTimes(1) - }) - - it('leaves a PTY the caller just reattached alone', async () => { - const harness = createHarness([hostEntry()]) - - await run(harness, { routedPtyIds: ['pty-1'] }) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it.each([['attached'], ['detached']] as const)( - 'leaves a PTY holding a live %s lease alone', - async (state) => { - const harness = createHarness([hostEntry()], [lease('pty-1', state)]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - } - ) - - it('leaves a PTY with an undelivered stop to the replay pass', async () => { - // The kill-intent journal owns those: it re-fences and retries them, and a second stop issued - // from here would race that decision with weaker evidence. - const tombstoned = { - ...lease('pty-1', 'terminated'), - pendingKill: { requestedAt: 1, incarnationId: 'inc-1', attempts: 0 } - } as SshRemotePtyLease - const harness = createHarness([hostEntry()], [tombstoned]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('leaves a PTY whose lease this client expired alone', async () => { - // The reversal this guards: supersedeSiblingLeasesForPane, dropStalePty and the missing-surface - // refusal all write `expired` precisely BECAUSE they will not stop the remote process. - const harness = createHarness([hostEntry()], [lease('pty-1', 'expired')]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('leaves a PTY whose expired lease names a recycled relay id alone', async () => { - // `relayIdRecycled` is the one expired lease the reattach predicate refuses, so it is the case - // most likely to be mistaken for a licence to kill. The sweep asks a different question: this - // id now names some OTHER incarnation, which makes a stop more dangerous, not less. - const harness = createHarness( - [hostEntry()], - [{ ...lease('pty-1', 'expired'), relayIdRecycled: true }] - ) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('leaves alone a lease the real supersede path expired when a pane re-leased', async () => { - // Drives the actual persistence operation rather than asserting the state by hand, so this - // stays true only while supersede really does leave the predecessor's process running. - const state: PersistedState = { - sshRemotePtyLeases: [ - { - targetId: TARGET, - ptyId: 'pty-1', - state: 'attached', - worktreeId: 'wt-1', - leafId: LEAF, - createdAt: 1, - updatedAt: 1 - } - ] - } as unknown as PersistedState - const operations: SshPtyLeaseOperations = { - state, - toStoredPtyId: (_targetId, ptyId) => ptyId, - toComparablePtyId: (_targetId, ptyId) => ptyId, - clearBindingsForTarget: () => {}, - clearBindingsForLeases: () => false, - flush: () => {}, - runDurableMutation: async (mutate) => mutate().value - } - // The same pane re-leases under a new relay id; pty-1 is expired, never terminated. - upsertSshRemotePtyLease(operations, { - targetId: TARGET, - ptyId: 'pty-2', - state: 'attached', - worktreeId: 'wt-1', - leafId: LEAF - }) - expect(state.sshRemotePtyLeases?.find((entry) => entry.ptyId === 'pty-1')?.state).toBe( - 'expired' - ) - const harness = createHarness([hostEntry()], state.sshRemotePtyLeases ?? []) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('forwards the host foreground observation, so a busy pane is never swept', async () => { - // A `claude` the user launched by hand: Orca registered no agent session, so the entry carries - // no agentSessionOwners and only the host's own observation can save it. - const harness = createHarness([ - hostEntry({ - foregroundProcessEvidence: { - ...OBSERVATION, - verdict: 'live', - processName: 'claude', - shellOwnsEveryTtyProcessGroup: false - } - }) - ]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('bounds the listing and every stop with one connect budget', async () => { - const harness = createHarness([hostEntry()]) - const start = 1_000_000 - - await run(harness, { now: () => start }) - - const deadline = vi.mocked(harness.provider.listProcesses).mock.calls[0]?.[0]?.deadlineMs - expect(deadline).toBe(start + RELAY_PTY_SWEEP_PASS_BUDGET_MS) - expect(harness.shutdown).toHaveBeenCalledWith( - `ssh:${TARGET}@@pty-1`, - expect.objectContaining({ deadlineMs: deadline }) - ) - }) - - it('does sweep a PTY whose lease this client already tombstoned without an order', async () => { - const harness = createHarness([hostEntry()], [lease('pty-1', 'terminated')]) - - await run(harness) - - expect(harness.shutdown).toHaveBeenCalledTimes(1) - }) - - it('asks the host nothing when this connection is not the session owner', async () => { - const harness = createHarness([hostEntry()]) - - await run(harness, { isSessionOwner: false }) - - expect(harness.provider.listProcesses).not.toHaveBeenCalled() - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('stops nothing against a host that publishes no attestation', async () => { - const legacy = hostEntry() - delete legacy.ownerClientInstanceId - delete legacy.hostAgeMs - delete legacy.paneBound - const harness = createHarness([legacy]) - - await run(harness) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) - - it('swallows a failed listing rather than failing the connect it runs on', async () => { - const harness = createHarness([]) - vi.mocked(harness.provider.listProcesses).mockRejectedValue(new Error('relay went away')) - - await expect(run(harness)).resolves.toBeUndefined() - }) - - it('swallows a failed stop and leaves the order to the next connect', async () => { - const harness = createHarness([hostEntry()]) - harness.shutdown.mockRejectedValue(new Error('connection lost')) - - await expect(run(harness)).resolves.toBeUndefined() - }) - - it('abandons the pass when the attempt is superseded mid-flight', async () => { - const harness = createHarness([hostEntry()]) - let alive = true - vi.mocked(harness.provider.listProcesses).mockImplementation(async () => { - alive = false - return [hostEntry()] - }) - - await run(harness, { shouldContinue: () => alive }) - - expect(harness.shutdown).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.ts deleted file mode 100644 index def6de6ebb45..000000000000 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { Store } from '../persistence' -import type { IPtyProvider } from '../providers/types' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { - planRelayPtySweep, - RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS, - RELAY_PTY_SWEEP_MIN_AGE_MS, - type RelayPtyOwnershipEvidence -} from '../../shared/ssh-relay-pty-ownership-proof' - -export type SshOrphanRelayPtySweepArgs = { - targetId: string - store: Store - provider: IPtyProvider - /** This client's persisted consumer identity for the target. */ - clientInstanceId: string - /** True only when the relay granted this connection the negotiated `session-owner` role. */ - isSessionOwner: boolean - /** Relay PTY ids this connect just reattached, plus any the caller otherwise knows are live. */ - routedPtyIds: Iterable - shouldContinue: () => boolean - now?: () => number - minimumHostAgeMs?: number - /** Absolute budget for the whole pass, in ms from its start. */ - passBudgetMs?: number - maximumEvidenceAgeMs?: number -} - -/** The two client-side claims the plan needs, read in one pass over the leases. - * - * `routed` is every relay PTY id this client still has a route to: a live lease, an id this - * connect reattached, or a stop it recorded and has not delivered. - * - * `expired` is separate on purpose. It is written by four paths, and every one of them - * deliberately leaves the remote process running: a pane re-leasing under a new relay id, a pane - * surface missing from the layout, a retired reattach, and a reattach the HOST answered "not - * found" for. Only the second of those is reached with the process provably alive — the layout - * refusal runs after `pty.attach` already succeeded (`restoreReattachedPtyRuntime`), which is - * precisely why its own comment reads "topology absence alone is not authority to kill a - * process". A reattach that failed on the transport writes nothing at all: it early-returns as - * `reattachAttemptsExhausted` and the lease stays `attached`, hence routed. - * - * Folding it into `routed` would work, but it would also lose the reason in the skip log, and this - * is the distinction the sweep most needs to be able to explain. - * - * Deliberately the raw state rather than `sshRemotePtyLeaseAllowsReattach`: that predicate answers - * "may this lease be reattached", and this asks "may this client stop the process". Every non- - * `terminated` state answers no either way, so sorting the marked leases (`supersededBy`, - * `relayIdRecycled`) into `routed` instead would move nothing but the skip reason — and both marks - * are written by paths that leave the remote process running on purpose, so they must keep - * refusing the stop rather than authorizing one. */ -function clientClaims(args: SshOrphanRelayPtySweepArgs): { - routed: Set - expired: Set -} { - const routed = new Set(args.routedPtyIds) - const expired = new Set() - for (const lease of args.store.getSshRemotePtyLeases(args.targetId)) { - if (lease.state === 'expired') { - expired.add(lease.ptyId) - } else if (lease.state !== 'terminated') { - routed.add(lease.ptyId) - } - if (lease.pendingKill) { - routed.add(lease.ptyId) - } - } - return { routed, expired } -} - -function toEvidence( - targetId: string, - process: Awaited>[number] -): RelayPtyOwnershipEvidence { - return { - ptyId: toRelaySshPtyId(targetId, process.id), - ...(process.incarnationId ? { incarnationId: process.incarnationId } : {}), - ...(process.ownerClientInstanceId - ? { ownerClientInstanceId: process.ownerClientInstanceId } - : {}), - ...(typeof process.hostAgeMs === 'number' ? { hostAgeMs: process.hostAgeMs } : {}), - ...(typeof process.paneBound === 'boolean' ? { paneBound: process.paneBound } : {}), - ...(process.agentSessionOwners ? { agentSessionOwners: process.agentSessionOwners } : {}), - ...(process.foregroundProcessEvidence - ? { foregroundProcessEvidence: process.foregroundProcessEvidence } - : {}) - } -} - -/** One budget for the whole pass, because this is opportunistic cleanup bolted onto the most - * latency-sensitive and most failure-prone path in the app (#14830, #17830). Without it the - * listing and up to eight stops inherit the mux default and connect waits on all of them. - * Overrunning it yields an empty pass — the same outcome as finding nothing, never a failed - * connect. */ -export const RELAY_PTY_SWEEP_PASS_BUDGET_MS = 5_000 - -/** Stops the relay PTYs this client can prove it created and has since lost every route to. - * - * Runs after reattach, so a PTY this connect reclaimed is already routed and can never be a - * candidate. Best-effort and never throws: it is opportunistic cleanup on the connect path, and a - * failed connection is a much worse outcome than a slot left leaked for another session. - * - * Costs one `pty.listProcesses` per connect. That is the price of reconciling at all — there is no - * cheaper question than asking the authoritative host what it is holding. */ -export async function sweepOrphanedRelayPtys(args: SshOrphanRelayPtySweepArgs): Promise { - if (!args.isSessionOwner || !args.clientInstanceId || !args.shouldContinue()) { - return - } - const now = args.now ?? Date.now - const deadlineMs = now() + (args.passBudgetMs ?? RELAY_PTY_SWEEP_PASS_BUDGET_MS) - try { - const processes = await args.provider.listProcesses({ deadlineMs }) - // The instant the host's observations reached this client. Every later step — reading the - // leases, planning, issuing the stops — ages them, and the plan has to see that age. - const listedAtMs = now() - if (!args.shouldContinue() || now() >= deadlineMs) { - return - } - const claims = clientClaims(args) - const plan = planRelayPtySweep( - processes.map((process) => toEvidence(args.targetId, process)), - { - clientInstanceId: args.clientInstanceId, - isSessionOwner: args.isSessionOwner, - routedPtyIds: claims.routed, - expiredLeasePtyIds: claims.expired, - minimumHostAgeMs: args.minimumHostAgeMs ?? RELAY_PTY_SWEEP_MIN_AGE_MS, - evidenceAgeSinceListingMs: Math.max(0, now() - listedAtMs), - maximumEvidenceAgeMs: args.maximumEvidenceAgeMs ?? RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS - } - ) - if (plan.sweep.length === 0) { - return - } - await Promise.all( - plan.sweep.map(async (target) => { - if (!args.shouldContinue()) { - return - } - try { - // Two fences, both enforced by the host that owns the process. The incarnation stops a - // relay that renumbered its ids between the read and this call from hitting a stranger; - // the owner id makes the host re-check the ownership rule itself, so the one irreversible - // call in this flow is not authorized by the client alone. - await args.provider.shutdown(toAppSshPtyId(args.targetId, target.ptyId), { - immediate: true, - deadlineMs, - expectedIncarnationId: target.incarnationId, - expectedOwnerClientInstanceId: args.clientInstanceId - }) - console.log( - `[ssh-orphan-sweep] stopped orphaned relay PTY ${args.targetId}/${target.ptyId}` - ) - } catch (err) { - // Unverifiable, not failed: the next connect re-reads the inventory and decides again. - console.warn( - `[ssh-orphan-sweep] stop for ${args.targetId}/${target.ptyId} is unverifiable: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } - }) - ) - } catch (err) { - console.warn( - `[ssh-orphan-sweep] pass on ${args.targetId} stopped early: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } -} diff --git a/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts b/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts deleted file mode 100644 index 9bb42a2cb276..000000000000 --- a/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -// The one test that spans both halves of the sweep. Every other test in this feature asserts on a -// hand-written `ForegroundProcessEvidence` literal, which is exactly how a foreground-only idle -// predicate survived review: the literals said `shellIsForeground: true` for an idle shell because -// that is what the author believed, and nothing ever produced one from a real process table. -// -// So this runs the REAL publisher (`resolveAgentForegroundProcessesBatch` -> -// `toForegroundProcessEvidence`, what `pty.listProcesses` calls) against the REAL client reader -// (`planRelayPtySweep`), over `ps` output captured verbatim from a Linux container driving a real -// `bash -i` on a real pty. The fixtures below are transcripts, not constructions. -// -// Read the shell's own row in each fixture. In `background` and `ctrlz` it is -// `pgid == tpgid`, `Ss+` — byte-identical to `idle`. That is the defect: a foreground-only -// predicate cannot see a job the user backgrounded or suspended, and the stop it authorizes -// SIGKILLs every process group on the tty. -import { describe, expect, it } from 'vitest' -import { - resolveAgentForegroundProcessesBatch, - toForegroundProcessEvidence -} from '../providers/agent-foreground-process-batch' -import { parseStrictProcessTableRows } from '../../shared/process-table-snapshot' -import { - planRelayPtySweep, - RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS, - RELAY_PTY_SWEEP_MIN_AGE_MS, - type RelayPtySweepContext -} from '../../shared/ssh-relay-pty-ownership-proof' - -const OURS = 'client-instance-ours' - -/** `ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=` on debian:bookworm-slim, one capture per pane - * state, each with a `bash -i` on a pty forked by the harness. */ -const CAPTURES = { - /** Nothing running. The only sweepable state. */ - idle: { - rootPid: 3150, - table: [ - ' 1 0 1 -1 Ss python3 /work/.ptycap.py', - ' 3150 1 3150 3150 Ss+ bash -i', - ' 3151 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** `sleep 300` in the foreground. The shell's tpgid moved off its own pgid. */ - foreground: { - rootPid: 3155, - table: [ - ' 1 0 1 -1 Ss python3 /work/.ptycap.py', - ' 3155 1 3155 3156 Ss bash -i', - ' 3156 3155 3156 3156 S+ sleep 300', - ' 3157 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** `sleep 300 &`. The shell reads IDENTICALLY to `idle`; only the job's own row differs. */ - background: { - rootPid: 3152, - table: [ - ' 1 0 1 -1 Ss python3 /work/.ptycap.py', - ' 3152 1 3152 3152 Ss+ bash -i', - ' 3153 3152 3153 3152 S sleep 300', - ' 3154 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** `sleep 300` then Ctrl-Z. The shell again reads IDENTICALLY to `idle`. */ - ctrlz: { - rootPid: 3158, - table: [ - ' 1 0 1 -1 Ss python3 /work/.ptycap.py', - ' 3158 1 3158 3158 Ss+ bash -i', - ' 3159 3158 3159 3158 T sleep 300', - ' 3160 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** `set +m; sleep 300 &`. With job control OFF the job does not get its own process group — it - * keeps the SHELL's pgid. So the tty carries exactly one process group, and that group is - * running a build. Reproduced independently on a real Ubuntu host through an Orca pane. */ - setMinusMBackground: { - rootPid: 12, - table: [ - ' 1 0 1 -1 Ss /bin/bash /work/run.sh', - ' 11 1 1 -1 S python3 /work/pty-scenario.py setm_background', - ' 12 11 12 12 Ss+ bash -i', - ' 13 12 12 12 S+ sleep 300', - ' 14 11 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** A `set +m` job that drops its controlling terminal (`ioctl(TIOCNOTTY)` with no `setsid`). It - * keeps the shell's pgid, reports `tpgid == -1`, and is absent from `ps -t ` and from every - * tty-keyed index — while `killpg(shellPgid)` still reaches it. */ - nottyGroupMember: { - rootPid: 16, - table: [ - ' 1 0 1 -1 Ss /bin/bash /work/run.sh', - ' 15 1 1 -1 S python3 /work/pty-scenario.py notty_member', - ' 16 15 16 16 Ss+ bash -i', - ' 17 16 16 -1 S python3 -c import fcntl,os,time;fd=os.open("/dev/tty",os.O_RDWR);fcntl.ioctl(fd,0x5422);os.close(fd);time.sleep(300)', - ' 18 15 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - }, - /** A `set +m` job that double-forks. pid 22 keeps the shell's pgid and tty but reparented to pid - * 1, so the ppid walk from `rootPid` never reaches it and it can never be named. */ - doubleForkedGroupMember: { - rootPid: 20, - table: [ - ' 1 0 1 -1 Ss /bin/bash /work/run.sh', - ' 19 1 1 -1 S python3 /work/pty-scenario.py double_fork', - ' 20 19 20 20 Ss+ bash -i', - ' 22 1 20 20 S+ python3 -c import os,sys,time;p=os.fork() if p: print("GRANDCHILD:%d"%p);sys.stdout.flush();os._exit(0) time.sleep(300)', - ' 23 19 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' - ] - } -} as const - -function context(overrides: Partial = {}): RelayPtySweepContext { - return { - clientInstanceId: OURS, - isSessionOwner: true, - routedPtyIds: new Set(), - expiredLeasePtyIds: new Set(), - minimumHostAgeMs: RELAY_PTY_SWEEP_MIN_AGE_MS, - evidenceAgeSinceListingMs: 0, - maximumEvidenceAgeMs: RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS, - ...overrides - } -} - -/** Everything the host does between reading `ps` and putting a record on the wire. */ -async function publish( - capture: { rootPid: number; table: readonly string[] }, - capturedAgeMs = 0 -): Promise> { - const rows = parseStrictProcessTableRows(capture.table.join('\n')) - const [result] = await resolveAgentForegroundProcessesBatch( - [{ rootPid: capture.rootPid, fallbackProcess: 'bash' }], - { rows } - ) - return toForegroundProcessEvidence(result, { - authorityGeneration: 'relay-generation-1', - observationEpoch: 1, - capturedAgeMs - }) -} - -/** Everything the client does with that record. Returns the plan for one orphan entry. */ -async function planFor( - capture: { rootPid: number; table: readonly string[] }, - overrides: { capturedAgeMs?: number; context?: Partial } = {} -): Promise> { - return planRelayPtySweep( - [ - { - ptyId: 'pty-1', - incarnationId: 'inc-1', - ownerClientInstanceId: OURS, - hostAgeMs: RELAY_PTY_SWEEP_MIN_AGE_MS * 2, - paneBound: true, - foregroundProcessEvidence: await publish(capture, overrides.capturedAgeMs) - } - ], - context(overrides.context) - ) -} - -function skipReason(plan: ReturnType): string | undefined { - return plan.skipped.find((entry) => entry.ptyId === 'pty-1')?.reason -} - -describe('what the host publishes about a pane, read by the sweep', () => { - it('records that a backgrounded and a suspended shell are indistinguishable at tpgid/pgid', () => { - // The premise of the whole file. If this ever fails, the fixtures drifted and every verdict - // below is testing something other than the defect. Pids differ between captures, so the - // comparison is of the shell row's signature: who its parent is, whether it leads its own - // process group, whether that group owns the terminal, and its state flags. - const shellRowSignature = (capture: { rootPid: number; table: readonly string[] }): string => { - const row = parseStrictProcessTableRows(capture.table.join('\n')).find( - (candidate) => candidate.pid === capture.rootPid - )! - return [ - `ppid=${row.ppid}`, - `leadsOwnGroup=${row.pgid === row.pid}`, - `ownsTerminal=${row.tpgid === row.pgid}`, - `stat=${row.stat}` - ].join(' ') - } - - expect(shellRowSignature(CAPTURES.idle)).toBe( - 'ppid=1 leadsOwnGroup=true ownsTerminal=true stat=Ss+' - ) - expect(shellRowSignature(CAPTURES.background)).toBe(shellRowSignature(CAPTURES.idle)) - expect(shellRowSignature(CAPTURES.ctrlz)).toBe(shellRowSignature(CAPTURES.idle)) - expect(shellRowSignature(CAPTURES.foreground)).not.toBe(shellRowSignature(CAPTURES.idle)) - - // Same premise for the `set +m` captures, minus `ppid`: their harness keeps its parent alive - // rather than reparenting the shell to init, and the ppid is the one field of the signature - // the predicate never reads. - const paneRowSignature = (capture: { rootPid: number; table: readonly string[] }): string => - shellRowSignature(capture).split(' ').slice(1).join(' ') - expect(paneRowSignature(CAPTURES.setMinusMBackground)).toBe(paneRowSignature(CAPTURES.idle)) - expect(paneRowSignature(CAPTURES.nottyGroupMember)).toBe(paneRowSignature(CAPTURES.idle)) - expect(paneRowSignature(CAPTURES.doubleForkedGroupMember)).toBe(paneRowSignature(CAPTURES.idle)) - }) - - it('sweeps an idle shell', async () => { - const evidence = await publish(CAPTURES.idle) - expect(evidence).toMatchObject({ - verdict: 'live', - processName: null, - shellOwnsEveryTtyProcessGroup: true - }) - - const plan = await planFor(CAPTURES.idle) - expect(plan.sweep).toEqual([{ ptyId: 'pty-1', incarnationId: 'inc-1' }]) - }) - - it('never sweeps a pane running a foreground job', async () => { - const evidence = await publish(CAPTURES.foreground) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.foreground) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('never sweeps a pane holding a backgrounded job', async () => { - // `sleep 300 &`, i.e. `pnpm build &` or `npm run dev &`. The shell handed the terminal back, - // so the pane looks idle; the job is alive in its own process group on the same tty and a - // stop would SIGKILL it. - const evidence = await publish(CAPTURES.background) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.background) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('never sweeps a pane holding a Ctrl-Z suspended job', async () => { - const evidence = await publish(CAPTURES.ctrlz) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.ctrlz) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - // The tty is not the unit the stop operates on. `forceKillPosixPtyProcessGroups` collects the - // groups on the tty and then `killpg`s each one, so anything sharing the shell's pgid dies with - // it — including members the tty index cannot see at all. All three captures below reproduce on - // real Linux: before the group-membership half of the predicate they published - // `shellOwnsEveryTtyProcessGroup: true`, planned a SWEEP, and the planted pid was GONE after the - // real `forceKillPosixPtyProcessGroups` call. - it('never sweeps a pane whose background job shares the shell pgid under `set +m`', async () => { - // pid 13 is `sleep 300` — stand in `pnpm build`. Its pgid IS the shell's, so the tty carries - // exactly one process group and the tty half of the predicate reads the pane as idle. - const rows = parseStrictProcessTableRows(CAPTURES.setMinusMBackground.table.join('\n')) - const tty = rows.filter((row) => row.tpgid === CAPTURES.setMinusMBackground.rootPid) - expect(new Set(tty.map((row) => row.pgid))).toEqual(new Set([12])) - expect(tty.map((row) => row.pid)).toEqual([12, 13]) - - const evidence = await publish(CAPTURES.setMinusMBackground) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.setMinusMBackground) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('never sweeps a pane whose group member dropped the controlling terminal', async () => { - // pid 17 kept the shell's pgid and called `ioctl(TIOCNOTTY)`, so it reports `tpgid == -1`, - // never appears in `ps -t `, and no tty-shaped index — not process groups, not pids — - // can observe it. `killpg(16)` reaches it regardless. - const rows = parseStrictProcessTableRows(CAPTURES.nottyGroupMember.table.join('\n')) - expect(rows.filter((row) => row.tpgid === 16).map((row) => row.pid)).toEqual([16]) - expect(rows.filter((row) => row.pgid === 16).map((row) => row.pid)).toEqual([16, 17]) - - const evidence = await publish(CAPTURES.nottyGroupMember) - expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.nottyGroupMember) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('never sweeps a pane whose group member double-forked away from the shell', async () => { - // pid 22 reparented to pid 1, so the ppid walk from rootPid cannot reach it and the named- - // process backstop can never fire. It still holds the shell's pgid. - const rows = parseStrictProcessTableRows(CAPTURES.doubleForkedGroupMember.table.join('\n')) - expect(rows.find((row) => row.pid === 22)).toMatchObject({ ppid: 1, pgid: 20, tpgid: 20 }) - - const evidence = await publish(CAPTURES.doubleForkedGroupMember) - expect(evidence).toMatchObject({ processName: null, shellOwnsEveryTtyProcessGroup: false }) - - const plan = await planFor(CAPTURES.doubleForkedGroupMember) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host does not attest an idle shell') - }) - - it('refuses an observation older than the pass it would authorize', async () => { - // Same idle capture that sweeps above; only its age differs. Staleness degrades to "leave it - // running", never to "stop it". - const stale = await planFor(CAPTURES.idle, { - capturedAgeMs: RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS + 1 - }) - expect(stale.sweep).toEqual([]) - expect(skipReason(stale)).toBe('host foreground observation is too old to authorize a stop') - - // And the client's own share of the age counts: a host stamp inside the budget still ages out - // while this pass reads leases and plans. - const agedOnTheClient = await planFor(CAPTURES.idle, { - capturedAgeMs: RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS, - context: { evidenceAgeSinceListingMs: 1 } - }) - expect(agedOnTheClient.sweep).toEqual([]) - expect(skipReason(agedOnTheClient)).toBe( - 'host foreground observation is too old to authorize a stop' - ) - }) - - it('never sweeps when the host itself is too degraded to answer', async () => { - // `main` has since added `recoverRemoteTerminalRuntime`, a self-driven reconnect on relay - // node-pty failure — a sweep trigger that fires exactly when the host is unwell. The publisher - // has to fail closed there: a capture that cannot locate the shell is `unverifiable`, which is - // its own verdict and never collapses into "idle" (docs/reference/ssh-execution-boundary.md). - const evidence = await publish({ rootPid: 999_999, table: CAPTURES.idle.table }) - expect(evidence).toMatchObject({ verdict: 'unverifiable', reason: 'root_missing' }) - - const plan = await planFor({ rootPid: 999_999, table: CAPTURES.idle.table }) - expect(plan.sweep).toEqual([]) - expect(skipReason(plan)).toBe('host could not observe the pane foreground process') - }) - - it('still reclaims a shell whose work really did finish', async () => { - // The feature must not degrade into a no-op. The background fixture's job is gone; what is - // left is the same orphaned shell, and it is swept. - const finished = { - rootPid: CAPTURES.background.rootPid, - table: CAPTURES.background.table.filter((line) => !line.includes('sleep 300')) - } - const plan = await planFor(finished) - expect(plan.sweep).toEqual([{ ptyId: 'pty-1', incarnationId: 'inc-1' }]) - }) -}) diff --git a/src/main/ssh/ssh-owner-admission-blocked-error.ts b/src/main/ssh/ssh-owner-admission-blocked-error.ts deleted file mode 100644 index 35055cb4880c..000000000000 --- a/src/main/ssh/ssh-owner-admission-blocked-error.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Why a typed error instead of raw transport classification: another authenticated connection holds -// the PTY session owner claim, and no relay redeploy or backoff attempt can reconcile that. The -// reconnect ladder has to stop and say what actually happened rather than blame the link. - -export class SshOwnerAdmissionBlockedError extends Error { - readonly name = 'SshOwnerAdmissionBlockedError' - - constructor(targetId: string, options?: { cause?: unknown }) { - super( - `Another connection currently owns the remote terminals for ${targetId}. ` + - `Disconnect that client, or wait for it to release ownership, before reconnecting.`, - options - ) - } -} - -export function isSshOwnerAdmissionBlockedError( - err: unknown -): err is SshOwnerAdmissionBlockedError { - return err instanceof SshOwnerAdmissionBlockedError -} diff --git a/src/main/ssh/ssh-pending-pty-kill-replay.test.ts b/src/main/ssh/ssh-pending-pty-kill-replay.test.ts deleted file mode 100644 index 95296aa6434b..000000000000 --- a/src/main/ssh/ssh-pending-pty-kill-replay.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay' -import { SshPtyAbsentFromRelayError } from '../providers/ssh-pty-errors' -import type { SshPendingPtyKillEntry } from '../../shared/ssh-pending-pty-kill' -import { - isSshPendingPtyKillExpired, - prunePendingSshPtyKills, - SSH_PENDING_PTY_KILL_TTL_MS -} from '../../shared/ssh-pending-pty-kill' -import type { Store } from '../persistence' -import type { IPtyProvider } from '../providers/types' - -const TARGET = 'ssh-1' -const NOW = 1_800_000_000_000 - -type HostPty = { relayPtyId: string; incarnationId?: string } - -/** Models the real store rather than returning a fixed list: `getSshRemotePtyKillIntents` applies - * the same TTL filter production does, and the prune actually deletes. A stub that ignored `now` - * would make every TTL assertion below pass for the wrong reason. */ -function createStoreStub(entries: SshPendingPtyKillEntry[]): { - store: Store - cleared: string[] - terminated: string[] - expired: string[] - recycled: string[] - attempts: string[] - remaining: () => string[] -} { - let backing = [...entries] - const cleared: string[] = [] - const terminated: string[] = [] - const expired: string[] = [] - const recycled: string[] = [] - const attempts: string[] = [] - const store = { - getSshRemotePtyKillIntents: vi.fn((_target: string, now: number) => - prunePendingSshPtyKills(backing, now) - ), - pruneExpiredSshRemotePtyKillIntents: vi.fn((_target: string, now: number) => { - backing = backing.filter((item) => !isSshPendingPtyKillExpired(item.intent, now)) - }), - clearSshRemotePtyKillIntent: vi.fn((_target: string, ptyId: string) => { - backing = backing.filter((item) => item.ptyId !== ptyId) - cleared.push(ptyId) - }), - markSshRemotePtyLease: vi.fn( - (_target: string, ptyId: string, state: string, options?: { relayIdRecycled?: true }) => { - if (state === 'terminated') { - terminated.push(ptyId) - } else if (state === 'expired') { - expired.push(ptyId) - if (options?.relayIdRecycled) { - recycled.push(ptyId) - } - } - } - ), - noteSshRemotePtyKillReplayAttempt: vi.fn((_target: string, ptyId: string) => { - attempts.push(ptyId) - }) - } as unknown as Store - return { - store, - cleared, - terminated, - expired, - recycled, - attempts, - remaining: () => backing.map((item) => item.ptyId) - } -} - -/** A relay that lists `hostPtys`, and drops an id from that listing once it is shut down. */ -function createProviderStub(hostPtys: HostPty[], overrides: Partial = {}) { - const live = new Map(hostPtys.map((pty) => [pty.relayPtyId, pty.incarnationId])) - const shutdown = vi.fn(async (appPtyId: string) => { - live.delete(appPtyId.replace(`ssh:${TARGET}@@`, '')) - }) - const listProcesses = vi.fn(async () => - Array.from(live, ([relayPtyId, incarnationId]) => ({ - id: `ssh:${TARGET}@@${relayPtyId}`, - ...(incarnationId ? { incarnationId } : {}) - })) - ) - return { - provider: { listProcesses, shutdown, ...overrides } as unknown as IPtyProvider, - listProcesses, - shutdown - } -} - -function entry(relayPtyId: string, incarnationId: string, requestedAt = NOW) { - return { ptyId: relayPtyId, intent: { requestedAt, incarnationId, attempts: 0 } } -} - -describe('replayPendingSshPtyKills', () => { - beforeEach(() => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - it('does not talk to the host when nothing is pending', async () => { - const { store } = createStoreStub([]) - const { provider, listProcesses } = createProviderStub([]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(listProcesses).not.toHaveBeenCalled() - }) - - // This is the leak: yesterday nothing retried, and the shell stayed up on the user's box. - it('replays the undelivered stop against the same PTY the kill was aimed at', async () => { - const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider, shutdown } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-a' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-1', { - immediate: true, - expectedIncarnationId: 'inc-a' - }) - expect(cleared).toEqual(['pty-1']) - expect(terminated).toEqual(['pty-1']) - }) - - // An offline close across a relaunch never learned the incarnation; the epoch-scoped id is the fence. - it('replays an epoch-scoped stop that carries no incarnation', async () => { - const relayPtyId = 'pty2:epoch-a:4' - const { store, cleared, terminated } = createStoreStub([ - { ptyId: relayPtyId, intent: { requestedAt: NOW, attempts: 0 } } - ]) - const { provider, shutdown } = createProviderStub([{ relayPtyId, incarnationId: 'inc-live' }]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).toHaveBeenCalledWith(`ssh:ssh-1@@${relayPtyId}`, { - immediate: true, - expectedIncarnationId: undefined - }) - expect(cleared).toEqual([relayPtyId]) - expect(terminated).toEqual([relayPtyId]) - }) - - // #16970: a redeployed relay renumbers from pty-1, so this id now names someone else's shell. - it('refuses to kill a recycled relay id and expires the lease that named it', async () => { - const { store, cleared, terminated, expired, recycled } = createStoreStub([ - entry('pty-1', 'inc-a') - ]) - const { provider, shutdown } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-fresh' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).not.toHaveBeenCalled() - expect(cleared).toEqual(['pty-1']) - // Declining to kill is only half of it: the reattach one step later fences on paneKey/tabId and - // never on incarnation, so an untouched lease would bind the user's old pane to that stranger. - expect(expired).toEqual(['pty-1']) - // `expired` alone no longer buys that: it also names orphans the reattach is meant to re-adopt, - // so the recycling has to be recorded on the lease itself. - expect(recycled).toEqual(['pty-1']) - // `expired`, not `terminated` — losing the route is not evidence the shell died. - expect(terminated).toEqual([]) - }) - - it('retires on the host reporting the PTY absent, with the tombstone that earns', async () => { - const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider, shutdown } = createProviderStub([ - { relayPtyId: 'pty-2', incarnationId: 'inc-b' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).not.toHaveBeenCalled() - expect(cleared).toEqual(['pty-1']) - expect(terminated).toEqual(['pty-1']) - }) - - // The TTL is owned by the durable prune, not by a branch in the decision function. This asserts - // the order is actually deleted and never aimed at whatever holds the id now — and it can only - // pass if the prune honours `now`, because the store stub applies the real TTL rules. - it('deletes an order past its TTL instead of replaying it, and never touches the host', async () => { - const { store, cleared, terminated, expired, remaining } = createStoreStub([ - entry('pty-1', 'inc-a') - ]) - const { provider, shutdown, listProcesses } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-a' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW + SSH_PENDING_PTY_KILL_TTL_MS + 1 - }) - expect(remaining()).toEqual([]) - expect(shutdown).not.toHaveBeenCalled() - expect(listProcesses).not.toHaveBeenCalled() - // Ageing out observes nothing, so it may not move the lease either way. - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - expect(expired).toEqual([]) - }) - - // #16763's lesson: `isPtyAlreadyGoneError` matches message text such as /Session not found/i, - // which a transport failure could wear. A tombstone must never rest on that — only on a listing. - it('does not tombstone on a shutdown error that merely looks like absence', async () => { - const { store, cleared, terminated, remaining } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], { - shutdown: vi.fn(async () => { - throw new SshPtyAbsentFromRelayError('SSH_SESSION_EXPIRED: pty-1') - }) - }) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - expect(remaining()).toEqual(['pty-1']) - }) - - // Best-effort background work on the connect path: a disk hiccup must not fail the connection. - it('never rejects into its caller when persistence throws', async () => { - const { store } = createStoreStub([entry('pty-1', 'inc-a')]) - vi.mocked(store.noteSshRemotePtyKillReplayAttempt).mockImplementation(() => { - throw new Error('disk full') - }) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }]) - await expect( - replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - ).resolves.toBeUndefined() - }) - - it('never rejects into its caller when the very first store read throws', async () => { - const { store } = createStoreStub([entry('pty-1', 'inc-a')]) - vi.mocked(store.pruneExpiredSshRemotePtyKillIntents).mockImplementation(() => { - throw new Error('disk full') - }) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }]) - await expect( - replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - ).resolves.toBeUndefined() - }) - - it('keeps the order when the replayed stop is itself unverifiable', async () => { - const { store, cleared, terminated, attempts } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], { - shutdown: vi.fn(async () => { - throw new Error('socket closed') - }) - }) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(attempts).toEqual(['pty-1']) - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - }) - - // `pty.shutdown` returns the same empty success for a PTY the relay never had, so only a fresh - // inventory that omits the id is a death certificate. - it('keeps the order when the host still lists the PTY after a resolved shutdown', async () => { - const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], { - shutdown: vi.fn(async () => {}) - }) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - }) - - // Wire skew: a host predating the published PTY incarnation cannot answer the fence. - it('defers against a host that publishes no PTY incarnation', async () => { - const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider, shutdown } = createProviderStub([{ relayPtyId: 'pty-1' }]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).not.toHaveBeenCalled() - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - }) - - it('keeps every order when the inventory itself fails', async () => { - const { store, cleared, terminated } = createStoreStub([ - entry('pty-1', 'inc-a'), - entry('pty-2', 'inc-b') - ]) - const { provider } = createProviderStub([], { - listProcesses: vi.fn(async () => { - throw new Error('relay unreachable') - }) - }) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(cleared).toEqual([]) - expect(terminated).toEqual([]) - }) - - it('delivers nothing once the connection attempt is superseded', async () => { - const { store, cleared } = createStoreStub([entry('pty-1', 'inc-a'), entry('pty-2', 'inc-b')]) - const { provider, shutdown } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-a' }, - { relayPtyId: 'pty-2', incarnationId: 'inc-b' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => false, - now: () => NOW - }) - expect(shutdown).not.toHaveBeenCalled() - expect(cleared).toEqual([]) - }) - - // Bounded by wave, not by order: one inventory per wave of 4 plus one to prove the batch. The - // per-wave read is what keeps a stop's identity evidence within one round trip of the stop. - it('reads one inventory per wave plus one to confirm, not two per pending stop', async () => { - const pending = Array.from({ length: 12 }, (_, index) => entry(`pty-${index}`, `inc-${index}`)) - const { store, terminated } = createStoreStub(pending) - const { provider, listProcesses, shutdown } = createProviderStub( - pending.map((item) => ({ relayPtyId: item.ptyId, incarnationId: item.intent.incarnationId })) - ) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(shutdown).toHaveBeenCalledTimes(12) - expect(listProcesses).toHaveBeenCalledTimes(12 / 4 + 1) - expect(terminated).toHaveLength(12) - }) - - it('costs one inventory and one confirmation for a single pending stop', async () => { - const { store } = createStoreStub([entry('pty-1', 'inc-a')]) - const { provider, listProcesses } = createProviderStub([ - { relayPtyId: 'pty-1', incarnationId: 'inc-a' } - ]) - await replayPendingSshPtyKills({ - targetId: TARGET, - store, - provider, - shouldContinue: () => true, - now: () => NOW - }) - expect(listProcesses).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/ssh/ssh-pending-pty-kill-replay.ts b/src/main/ssh/ssh-pending-pty-kill-replay.ts deleted file mode 100644 index 90688aa45d85..000000000000 --- a/src/main/ssh/ssh-pending-pty-kill-replay.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { Store } from '../persistence' -import type { IPtyProvider } from '../providers/types' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { - decideSshPendingPtyKill, - type SshPendingPtyKillEntry, - type SshPendingPtyKillObservation, - type SshPendingPtyKillRetirement -} from '../../shared/ssh-pending-pty-kill' - -export type SshPendingPtyKillReplayArgs = { - targetId: string - store: Store - provider: IPtyProvider - shouldContinue: () => boolean - now?: () => number -} - -/** Stops issued per inventory read. Every stop in a wave is dispatched concurrently, immediately - * after the read that fenced it, so no kill is ever aimed with evidence older than one round trip. */ -const REPLAY_WAVE_SIZE = 4 - -type HostInventory = Map - -/** Relay pty id -> the incarnation the host published for it, `undefined` when it published none. */ -async function readHostInventory(args: SshPendingPtyKillReplayArgs): Promise { - const processes = await args.provider.listProcesses() - const inventory: HostInventory = new Map() - for (const process of processes) { - inventory.set(toRelaySshPtyId(args.targetId, process.id), process.incarnationId) - } - return inventory -} - -/** Retires one order. - * - * Both tombstoning reasons are derived from a successful `pty.listProcesses`, never from an error - * predicate: `isPtyAlreadyGoneError` matches on message text (`/Session not found/i`), which a - * transport failure could wear, and a tombstone written on that would bury a live remote shell. - * A recycled id gets `expired`, not `terminated` — the client has lost its route to that lease, - * which is all that was observed; nothing says the shell it named ever died. */ -function retire( - args: SshPendingPtyKillReplayArgs, - relayPtyId: string, - reason: SshPendingPtyKillRetirement -): void { - args.store.clearSshRemotePtyKillIntent(args.targetId, relayPtyId) - if (reason === 'host-reports-absent' || reason === 'stop-confirmed') { - args.store.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') - } else if (reason === 'relay-id-recycled') { - // Why this must happen: the reattach one step later fences only on paneKey/tabId, never on - // incarnation. Leaving this lease reattachable hands the user's old pane to whatever process - // now holds the recycled id. `expired` alone no longer excludes it — that state also covers - // orphans the reattach is now meant to re-adopt — so the recycling is recorded explicitly. - args.store.markSshRemotePtyLease(args.targetId, relayPtyId, 'expired', { - relayIdRecycled: true - }) - } - console.log( - `[ssh-pending-kill] retired stop for ${args.targetId}/${relayPtyId} (${reason.replace(/-/g, ' ')})` - ) -} - -function observe(inventory: HostInventory, relayPtyId: string): SshPendingPtyKillObservation { - return { - hostListsPty: inventory.has(relayPtyId), - hostIncarnationId: inventory.get(relayPtyId) - } -} - -/** Applies every recorded order against one inventory, and returns the ones it says are safe to - * stop. Retirement side effects happen here, against that same fresh evidence. */ -function selectReplayTargets( - args: SshPendingPtyKillReplayArgs, - inventory: HostInventory, - now: number, - /** Orders already dispatched this pass. Skipped whole: the shutdown they were issued is what - * removes them from the next inventory, so re-deciding would retire them a second time. */ - attempted: ReadonlySet -): SshPendingPtyKillEntry[] { - const replayable: SshPendingPtyKillEntry[] = [] - for (const entry of args.store.getSshRemotePtyKillIntents(args.targetId, now)) { - if (attempted.has(entry.ptyId)) { - continue - } - const decision = decideSshPendingPtyKill(entry.intent, observe(inventory, entry.ptyId), now) - if (decision.action === 'retire') { - retire(args, entry.ptyId, decision.reason) - } else if (decision.action === 'defer') { - console.warn( - `[ssh-pending-kill] deferring stop for ${args.targetId}/${entry.ptyId}: ${decision.reason}` - ) - } else { - replayable.push(entry) - } - } - return replayable -} - -/** Issues one recorded stop. Returns false when it did not reach the host, in which case the order - * stays: a rejected RPC observes nothing, and the next handshake's inventory will answer. - * - * Re-checks the fence here rather than trusting the selection pass, so the identity proof and the - * irreversible call sit next to each other and cannot drift apart if this loop is ever reshaped. - * Current relays enforce that fence atomically at shutdown; older relays ignore the additive field - * and retain this inventory check as their mixed-version fallback. */ -async function deliverReplay( - args: SshPendingPtyKillReplayArgs, - entry: SshPendingPtyKillEntry, - inventory: HostInventory, - now: number -): Promise { - if ( - decideSshPendingPtyKill(entry.intent, observe(inventory, entry.ptyId), now).action !== 'replay' - ) { - return false - } - args.store.noteSshRemotePtyKillReplayAttempt(args.targetId, entry.ptyId) - try { - await args.provider.shutdown(toAppSshPtyId(args.targetId, entry.ptyId), { - immediate: true, - expectedIncarnationId: entry.intent.incarnationId - }) - return true - } catch (err) { - console.warn( - `[ssh-pending-kill] replay for ${args.targetId}/${entry.ptyId} is unverifiable: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return false - } -} - -/** Confirms delivered stops against one fresh inventory. - * - * Why re-list at all: the relay answers `pty.shutdown` for a PTY it never had with the same empty - * success, so a resolved RPC alone is not a death certificate. Absence from a live listing is. */ -async function confirmDelivered( - args: SshPendingPtyKillReplayArgs, - awaitingProof: readonly string[] -): Promise { - if (awaitingProof.length === 0) { - return - } - const inventory = await readHostInventory(args) - for (const relayPtyId of awaitingProof) { - if (!inventory.has(relayPtyId)) { - retire(args, relayPtyId, 'stop-confirmed') - } else { - console.warn( - `[ssh-pending-kill] ${args.targetId}/${relayPtyId} is still live after a replayed stop` - ) - } - } -} - -/** Replays every stop this client could not deliver to `targetId`, now that it is reachable again. - * - * Runs before reattach so a PTY that dies here is never re-adopted as a live pane. Costs nothing - * when nothing is pending. When something is, it re-reads the inventory once per wave rather than - * once per batch: the fence and the stops it authorises are then never more than one round trip - * apart, preserving the safest available behavior against older relays that ignore the shutdown - * incarnation field. - * - * Never throws. It is best-effort work on the connect path, and `establish()` treats a throw here - * as a failed connection. */ -export async function replayPendingSshPtyKills(args: SshPendingPtyKillReplayArgs): Promise { - const now = args.now ?? Date.now - try { - // Inside the guard with everything else: these touch persistence, and a disk hiccup must not - // turn best-effort cleanup into a failed SSH connection. - args.store.pruneExpiredSshRemotePtyKillIntents(args.targetId, now()) - if (args.store.getSshRemotePtyKillIntents(args.targetId, now()).length === 0) { - return - } - const attempted = new Set() - const awaitingProof: string[] = [] - while (args.shouldContinue()) { - // No inventory means no fence, and an unfenced replay can kill a shell nobody asked to close. - const inventory = await readHostInventory(args) - const selected = selectReplayTargets(args, inventory, now(), attempted) - const wave = selected.slice(0, REPLAY_WAVE_SIZE) - if (wave.length === 0) { - break - } - for (const entry of wave) { - attempted.add(entry.ptyId) - } - const delivered = await Promise.all( - wave.map(async (entry) => - (await deliverReplay(args, entry, inventory, now())) ? entry.ptyId : null - ) - ) - for (const relayPtyId of delivered) { - if (relayPtyId !== null) { - awaitingProof.push(relayPtyId) - } - } - if (selected.length <= REPLAY_WAVE_SIZE) { - // This wave took everything the inventory offered, so another read would only confirm that. - break - } - } - if (args.shouldContinue()) { - await confirmDelivered(args, awaitingProof) - } - } catch (err) { - console.warn( - `[ssh-pending-kill] replay pass on ${args.targetId} stopped early; stops stay pending: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } -} diff --git a/src/main/ssh/ssh-plain-ssh-session.ts b/src/main/ssh/ssh-plain-ssh-session.ts deleted file mode 100644 index 74e7b19644ec..000000000000 --- a/src/main/ssh/ssh-plain-ssh-session.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Registers the plain SSH providers for a target whose runtime ladder ended at rung D, feeds - * their output through the same SSH output intake the relay uses, and removes them again. - */ -import type { SshPlainSshMode, SshRemotePlatform } from '../../shared/ssh-types' -import { isCurrentPtyExit } from '../ipc/pty' -import { - acceptSshPtyOutputData, - acceptSshPtyOutputExit, - allocateSshPtyProviderGeneration, - closeSshPtyOutputGeneration -} from '../ipc/ssh-pty-output-intake-registry' -import { SshPlainShellPtyProvider } from '../providers/ssh-plain-shell-pty-provider' -import type { SshPtyExitCallback } from '../providers/ssh-pty-provider-contract' -import { SshSftpFilesystemProvider } from '../providers/ssh-sftp-filesystem-provider' -import type { SshConnection } from './ssh-connection' -import { registerSshHostProviders, retireSshHostProviders } from './ssh-host-provider-set' -import { - clearSshPlainSshMode, - plainSshModeFromRuntimeUnavailable, - setSshPlainSshMode -} from './ssh-plain-ssh-mode' -import type { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' - -type PlainSshExitPayload = Parameters[0] - -export class SshPlainSshModeSession { - private left = false - - private constructor( - readonly targetId: string, - readonly mode: SshPlainSshMode, - private readonly ptyProvider: SshPlainShellPtyProvider, - private readonly fsProvider: SshSftpFilesystemProvider, - /** OS the runtime ladder detected before it gave up; agent launches quote for it. */ - readonly remotePlatform: SshRemotePlatform | undefined - ) {} - - static enter(args: { - targetId: string - connection: SshConnection - error: RemoteRuntimeUnavailableError - onExitAccepted: (payload: PlainSshExitPayload) => void - }): SshPlainSshModeSession { - const { targetId, connection } = args - const mode = plainSshModeFromRuntimeUnavailable(args.error) - const generation = allocateSshPtyProviderGeneration() - const windowsHost = args.error.data.host.platform === 'win32' - const ptyProvider = new SshPlainShellPtyProvider( - targetId, - (pty) => connection.shell(pty), - mode, - !windowsHost, - generation - ) - const fsProvider = new SshSftpFilesystemProvider( - targetId, - (options) => connection.sftp(options), - mode, - windowsHost - ) - const detected = args.error.data.host.platform - const session = new SshPlainSshModeSession( - targetId, - mode, - ptyProvider, - fsProvider, - detected === 'linux' || detected === 'darwin' || detected === 'win32' ? detected : undefined - ) - ptyProvider.onData((payload) => { - if (session.left) { - return - } - void acceptSshPtyOutputData({ - id: payload.id, - data: payload.data, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation, - rawLength: payload.data.length, - transformed: false - }).catch(() => {}) - }) - ptyProvider.onExit((payload) => { - if (session.left || !isCurrentPtyExit(payload)) { - return - } - void acceptSshPtyOutputExit({ - id: payload.id, - code: payload.code, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation - }) - .then(() => args.onExitAccepted(payload)) - .catch(() => {}) - }) - setSshPlainSshMode(targetId, mode) - registerSshHostProviders(targetId, { pty: ptyProvider, fs: fsProvider, git: null }) - return session - } - - probeTransport(timeoutMs: number): Promise { - return this.left ? Promise.resolve(false) : this.fsProvider.probeTransport(timeoutMs) - } - - /** Transport gone or session torn down: open shells stay unverifiable, never exited. */ - leave(): void { - if (this.left) { - return - } - this.left = true - clearSshPlainSshMode(this.targetId) - closeSshPtyOutputGeneration(this.ptyProvider.providerGeneration, 'connection_lost') - retireSshHostProviders(this.targetId, { pty: this.ptyProvider, fs: this.fsProvider }) - } -} diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts deleted file mode 100644 index f903d71acf0f..000000000000 --- a/src/main/ssh/ssh-port-scanner.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - PortScanner, - SSH_PORT_SCAN_BASE_INTERVAL_MS, - SSH_PORT_SCAN_MAX_INTERVAL_MS, - type PortScannerWindowVisibility -} from './ssh-port-scanner' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import type { DetectedPort } from '../../shared/ssh-types' - -type VisibilityHarness = { - visibility: PortScannerWindowVisibility - setVisible: (visible: boolean) => void - listenerCount: () => number -} - -function createVisibilityHarness(initiallyVisible: boolean): VisibilityHarness { - let visible = initiallyVisible - const listeners = new Set<() => void>() - return { - visibility: { - isWindowVisible: () => visible, - onWindowBecameVisible: (listener) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } - } - }, - setVisible: (next) => { - const wasVisible = visible - visible = next - if (!wasVisible && next) { - for (const listener of Array.from(listeners)) { - listener() - } - } - }, - listenerCount: () => listeners.size - } -} - -function port(portNumber: number): DetectedPort { - return { port: portNumber, host: '127.0.0.1', pid: 100 + portNumber, processName: 'node' } -} - -function createMux(ports: () => DetectedPort[]): { - mux: SshChannelMultiplexer - request: ReturnType -} { - const request = vi.fn(async () => ({ ports: ports(), platform: 'linux' })) - return { mux: { request } as unknown as SshChannelMultiplexer, request } -} - -const BASE = SSH_PORT_SCAN_BASE_INTERVAL_MS -const MAX = SSH_PORT_SCAN_MAX_INTERVAL_MS - -describe('PortScanner', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('sends zero ports.detect requests while the window is hidden, then scans immediately on show', async () => { - const harness = createVisibilityHarness(false) - const { mux, request } = createMux(() => [port(3000)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, vi.fn()) - - await vi.advanceTimersByTimeAsync(10 * 60_000) - expect(request).not.toHaveBeenCalled() - - harness.setVisible(true) - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(1) - - scanner.dispose() - }) - - it('polls at the base cadence while visible when results keep changing', async () => { - const harness = createVisibilityHarness(true) - let next = 3000 - const { mux, request } = createMux(() => [port(next++)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, vi.fn()) - - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(1) - - // 5 simulated base intervals -> 5 more scans (no backoff while changing). - await vi.advanceTimersByTimeAsync(5 * BASE) - expect(request).toHaveBeenCalledTimes(6) - - scanner.dispose() - }) - - it('doubles the interval up to the cap while unchanged and resets to base on a change', async () => { - const harness = createVisibilityHarness(true) - let ports = [port(3000)] - const { mux, request } = createMux(() => ports) - const onChanged = vi.fn() - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, onChanged) - - // t=0: first scan is a change (empty -> {3000}) and stays at base. - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(1) - expect(onChanged).toHaveBeenCalledTimes(1) - - // t=12s: unchanged -> next wait doubles to 24s. - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(2) - - // t=24s: nothing (waiting until t=36s). - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(2) - - // t=36s: unchanged -> next wait caps at 30s (not 48s). - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(3) - - // t=65.999s: still waiting. - await vi.advanceTimersByTimeAsync(MAX - 1) - expect(request).toHaveBeenCalledTimes(3) - - // t=66s: unchanged -> next wait remains capped at 30s. - await vi.advanceTimersByTimeAsync(1) - expect(request).toHaveBeenCalledTimes(4) - - await vi.advanceTimersByTimeAsync(MAX - 1) - expect(request).toHaveBeenCalledTimes(4) - await vi.advanceTimersByTimeAsync(1) - expect(request).toHaveBeenCalledTimes(5) - expect(onChanged).toHaveBeenCalledTimes(1) - - // A changed result resets the cadence back to base. - ports = [port(3000), port(4000)] - await vi.advanceTimersByTimeAsync(MAX) - expect(request).toHaveBeenCalledTimes(6) - expect(onChanged).toHaveBeenCalledTimes(2) - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(7) - expect(scanner.getDetectedPorts('t1').map((p) => p.port)).toEqual([3000, 4000]) - - scanner.dispose() - }) - - it('resets backoff and scans immediately when scanning restarts (reconnect/session-ready)', async () => { - const harness = createVisibilityHarness(true) - const { mux, request } = createMux(() => [port(3000)]) - const scanner = new PortScanner(harness.visibility) - const onChanged = vi.fn() - scanner.startScanning('t1', mux, onChanged) - - // Back off: scans at t=0, t=12s, t=36s -> interval now 48s. - await vi.advanceTimersByTimeAsync(3 * BASE) - expect(request).toHaveBeenCalledTimes(3) - - // Reconnect paths call startScanning again: immediate scan, base cadence. - scanner.startScanning('t1', mux, onChanged) - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(4) - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(5) - - scanner.dispose() - }) - - it('parks when the window hides mid-run and resumes with an immediate scan on show', async () => { - const harness = createVisibilityHarness(true) - let next = 3000 - const { mux, request } = createMux(() => [port(next++)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, vi.fn()) - - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(2) - - harness.setVisible(false) - await vi.advanceTimersByTimeAsync(30 * 60_000) - expect(request).toHaveBeenCalledTimes(2) - - harness.setVisible(true) - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(3) - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(4) - - scanner.dispose() - }) - - it('never overlaps a slow in-flight request and resumes the chain after it settles', async () => { - const harness = createVisibilityHarness(true) - let resolveFirst: ((value: { ports: DetectedPort[]; platform: string }) => void) | null = null - const request = vi.fn( - () => - new Promise<{ ports: DetectedPort[]; platform: string }>((resolve) => { - resolveFirst = resolve - }) - ) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', { request } as unknown as SshChannelMultiplexer, vi.fn()) - expect(request).toHaveBeenCalledTimes(1) - - // The chain waits for the in-flight request instead of stacking more. - await vi.advanceTimersByTimeAsync(10 * BASE) - expect(request).toHaveBeenCalledTimes(1) - - resolveFirst!({ ports: [port(3000)], platform: 'linux' }) - await vi.advanceTimersByTimeAsync(BASE) - expect(request).toHaveBeenCalledTimes(2) - - scanner.dispose() - }) - - it('stopScanning halts polling and detaches the visibility listener', async () => { - const harness = createVisibilityHarness(true) - const { mux, request } = createMux(() => [port(3000)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('t1', mux, vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(request).toHaveBeenCalledTimes(1) - expect(harness.listenerCount()).toBe(1) - - scanner.stopScanning('t1') - expect(harness.listenerCount()).toBe(0) - await vi.advanceTimersByTimeAsync(10 * 60_000) - harness.setVisible(false) - harness.setVisible(true) - await vi.advanceTimersByTimeAsync(10 * 60_000) - expect(request).toHaveBeenCalledTimes(1) - expect(scanner.getDetectedPorts('t1')).toEqual([]) - }) - - it('aborts in-flight requests before stopping or replacing a target scan', async () => { - const harness = createVisibilityHarness(true) - const signals: AbortSignal[] = [] - const request = vi.fn( - (_method: string, _params?: Record, options?: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - const signal = options?.signal - if (!signal) { - reject(new Error('missing request signal')) - return - } - signals.push(signal) - signal.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - ) - const mux = { request } as unknown as SshChannelMultiplexer - const scanner = new PortScanner(harness.visibility) - - scanner.startScanning('t1', mux, vi.fn()) - expect(signals).toHaveLength(1) - expect(signals[0].aborted).toBe(false) - - scanner.startScanning('t1', mux, vi.fn()) - expect(signals).toHaveLength(2) - expect(signals[0].aborted).toBe(true) - expect(signals[1].aborted).toBe(false) - - scanner.stopScanning('t1') - expect(signals[1].aborted).toBe(true) - await vi.advanceTimersByTimeAsync(10 * BASE) - expect(request).toHaveBeenCalledTimes(2) - expect(harness.listenerCount()).toBe(0) - }) - - it('keeps targets independent: stopping one host leaves the other scanning', async () => { - const harness = createVisibilityHarness(true) - let nextA = 3000 - let nextB = 4000 - const a = createMux(() => [port(nextA++)]) - const b = createMux(() => [port(nextB++)]) - const scanner = new PortScanner(harness.visibility) - scanner.startScanning('host-a', a.mux, vi.fn()) - scanner.startScanning('host-b', b.mux, vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(a.request).toHaveBeenCalledTimes(1) - expect(b.request).toHaveBeenCalledTimes(1) - - scanner.stopScanning('host-a') - await vi.advanceTimersByTimeAsync(BASE) - expect(a.request).toHaveBeenCalledTimes(1) - expect(b.request).toHaveBeenCalledTimes(2) - - scanner.dispose() - await vi.advanceTimersByTimeAsync(10 * BASE) - expect(b.request).toHaveBeenCalledTimes(2) - expect(harness.listenerCount()).toBe(0) - }) -}) diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts deleted file mode 100644 index 2dbabff597a2..000000000000 --- a/src/main/ssh/ssh-port-scanner.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import type { DetectedPort } from '../../shared/ssh-types' - -// Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote -// CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the -// old 3s while keeping new-port detection well under the 30s workspace-scanner -// cadence users already accept. -export const SSH_PORT_SCAN_BASE_INTERVAL_MS = 12_000 -// Why: idle backoff cap — an unchanged port set doubles the interval up to -// this bound, so a quiet remote costs two scans per minute instead of twenty -// without exceeding the existing workspace-port scanner's visible cadence. -export const SSH_PORT_SCAN_MAX_INTERVAL_MS = 30_000 - -export type PortScannerWindowVisibility = { - isWindowVisible: () => boolean - // Fires when a hidden/minimized window is shown again; returns unsubscribe. - onWindowBecameVisible: (listener: () => void) => () => void -} - -type ScanHandle = { - timer: ReturnType | null - requestAbortController: AbortController | null - intervalMs: number - // Why: while the window is hidden the scan chain is parked outright — no - // timer wakeups, no remote requests — and the visibility listener resumes - // it with an immediate scan so ports opened while hidden surface at once. - parkedWhileHidden: boolean - unsubscribeVisibility: () => void - // Why: keyed by "host:port" (not just port) so that host-distinct listeners - // on the same port (e.g. 127.0.0.1:3000 + 0.0.0.0:3000) are tracked separately. - previousPorts: Map - // Why: ports detected on the first scan are pre-existing services (sshd, system - // daemons) that the user didn't just start. VS Code calls these "initialCandidates" - // and excludes them from auto-forward suggestions (Phase 3). - initialPorts: Set | null -} - -export class PortScanner { - private handles = new Map() - - constructor(private visibility: PortScannerWindowVisibility) {} - - startScanning( - targetId: string, - mux: SshChannelMultiplexer, - onChanged: (targetId: string, ports: DetectedPort[], platform: string) => void - ): void { - this.stopScanning(targetId) - - const handle: ScanHandle = { - timer: null, - requestAbortController: null, - intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS, - parkedWhileHidden: false, - unsubscribeVisibility: () => {}, - previousPorts: new Map(), - initialPorts: null - } - const isCurrent = (): boolean => this.handles.get(targetId) === handle - - // Why: guard against overlapping scans. The timer chain only reschedules - // after a poll completes, but the visibility-resume path can race a poll - // still in flight on a slow remote. - let polling = false - const poll = async (): Promise => { - if (polling) { - return - } - polling = true - const requestAbortController = new AbortController() - handle.requestAbortController = requestAbortController - try { - const result = (await mux.request('ports.detect', undefined, { - signal: requestAbortController.signal - })) as { - ports: DetectedPort[] - platform: string - } - - if (!isCurrent()) { - return - } - - const currentPorts = new Map() - for (const p of result.ports) { - currentPorts.set(`${p.host}:${p.port}`, p) - } - - if (handle.initialPorts === null) { - handle.initialPorts = new Set(currentPorts.keys()) - } - - if (!portsEqual(handle.previousPorts, currentPorts)) { - handle.previousPorts = currentPorts - handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS - onChanged(targetId, result.ports, result.platform) - } else { - handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS) - } - } catch { - // Relay disconnected or request timed out — retry on next interval - } finally { - if (handle.requestAbortController === requestAbortController) { - handle.requestAbortController = null - } - polling = false - } - } - - const tick = async (): Promise => { - handle.timer = null - if (!isCurrent()) { - return - } - if (!this.visibility.isWindowVisible()) { - handle.parkedWhileHidden = true - return - } - await poll() - if (!isCurrent()) { - return - } - handle.timer = setTimeout(() => void tick(), handle.intervalMs) - } - - handle.unsubscribeVisibility = this.visibility.onWindowBecameVisible(() => { - if (!isCurrent() || !handle.parkedWhileHidden) { - return - } - handle.parkedWhileHidden = false - void tick() - }) - - this.handles.set(targetId, handle) - void tick() - } - - getDetectedPorts(targetId: string): DetectedPort[] { - const handle = this.handles.get(targetId) - if (!handle) { - return [] - } - return Array.from(handle.previousPorts.values()) - } - - stopScanning(targetId: string): void { - const handle = this.handles.get(targetId) - if (!handle) { - return - } - if (handle.timer) { - clearTimeout(handle.timer) - } - handle.requestAbortController?.abort() - handle.requestAbortController = null - handle.unsubscribeVisibility() - this.handles.delete(targetId) - } - - dispose(): void { - for (const [targetId] of this.handles) { - this.stopScanning(targetId) - } - } -} - -function portsEqual(a: Map, b: Map): boolean { - if (a.size !== b.size) { - return false - } - for (const [key, entryA] of a) { - const entryB = b.get(key) - if (!entryB) { - return false - } - if (entryA.pid !== entryB.pid || entryA.processName !== entryB.processName) { - return false - } - } - return true -} diff --git a/src/main/ssh/ssh-pty-consumer-recovery.test.ts b/src/main/ssh/ssh-pty-consumer-recovery.test.ts deleted file mode 100644 index d77e8c3c8dc6..000000000000 --- a/src/main/ssh/ssh-pty-consumer-recovery.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session' -import { - claimSshPtyConsumerRecovery, - detachSshPtyConsumerRecovery, - rememberSshPtyConsumerRecovery -} from './ssh-pty-consumer-recovery' - -describe('SSH PTY consumer recovery', () => { - it('says out loud when it mints a new identity, because the sweep goes quiet after', () => { - // The id the host attests on every PTY it holds for us. Minting a new one is fail-safe — it - // can only under-sweep — but it makes the reaper silently stop working, so it must be visible. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const store = { - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn() - } as unknown as Store - - claimSshPtyConsumerRecovery('mint-logs-the-loss', store) - - expect(warn).toHaveBeenCalledWith(expect.stringContaining('minting a new consumer identity')) - warn.mockRestore() - }) - - it('keeps a detached identity when a concurrent open finishes late', async () => { - const targetId = 'remember-detach-race' - const store = { - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn() - } as unknown as Store - const claimed = claimSshPtyConsumerRecovery(targetId, store) - let finishOpen!: (owner: SshPtyConsumerOwnerState) => void - const opened = new Promise((resolve) => { - finishOpen = resolve - }) - const remembering = opened.then((owner) => - rememberSshPtyConsumerRecovery({ - targetId, - clientInstanceId: claimed.clientInstanceId, - serverBuildId: 'relay-build', - owner, - store - }) - ) - - detachSshPtyConsumerRecovery(targetId, claimed.clientInstanceId) - finishOpen({ - mode: 'negotiated', - clientInstanceId: claimed.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'late-owner' - }) - await remembering - - expect(store.upsertSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(claimSshPtyConsumerRecovery(targetId, store)).toBe(claimed) - }) -}) diff --git a/src/main/ssh/ssh-pty-consumer-recovery.ts b/src/main/ssh/ssh-pty-consumer-recovery.ts deleted file mode 100644 index 727f293b5728..000000000000 --- a/src/main/ssh/ssh-pty-consumer-recovery.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { SshPtyConsumerRecovery as PersistedRecovery } from '../../shared/ssh-types' -import type { SshPtyAcceptedSourceCheckpoint } from '../ipc/ssh-pty-output-source-obligations' -import type { SshPtyOutputMigrationResult } from '../ipc/ssh-pty-output-model-migration' -import type { Store } from '../persistence' -import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session' - -export type SshPtyConsumerRecoveryState = { - clientInstanceId: string - detached: boolean - serverBuildId?: string - owner?: SshPtyConsumerOwnerState - checkpointsByAppPtyId: Map - modelMigrationsByAppPtyId: Map> -} - -const recoveryByTarget = new Map() - -function ownerFromPersisted(record: PersistedRecovery): SshPtyConsumerOwnerState { - return { - mode: 'negotiated', - clientInstanceId: record.clientInstanceId, - clientGeneration: record.clientGeneration, - ownerGeneration: record.ownerGeneration, - ownerLease: record.ownerLease, - ...(record.outputFlowControl ? { outputFlowControl: record.outputFlowControl } : {}) - } -} - -export function claimSshPtyConsumerRecovery( - targetId: string, - store: Store -): SshPtyConsumerRecoveryState { - const current = recoveryByTarget.get(targetId) - if (current?.detached) { - current.detached = false - return current - } - const persisted = current ? null : store.getSshPtyConsumerRecovery(targetId) - if (!persisted) { - // Deliberately not stabilized: this id is also the consumer session's ownership identity, and - // reusing it without the generations the same dropped record carried would replay a stale - // owner generation at the relay. The cost is visible instead of silent — every relay PTY the - // host still attributes to the previous identity becomes permanently unsweepable (#9819). - console.warn( - `[ssh-pty-consumer] no recovery record for ${targetId}; minting a new consumer identity. Relay PTYs the host attributes to this client's previous identity can no longer be swept.` - ) - } - const created: SshPtyConsumerRecoveryState = { - clientInstanceId: persisted?.clientInstanceId ?? randomUUID(), - detached: false, - ...(persisted - ? { serverBuildId: persisted.serverBuildId, owner: ownerFromPersisted(persisted) } - : {}), - checkpointsByAppPtyId: new Map(), - modelMigrationsByAppPtyId: new Map() - } - recoveryByTarget.set(targetId, created) - return created -} - -export function getSshPtyConsumerRecovery( - targetId: string -): SshPtyConsumerRecoveryState | undefined { - return recoveryByTarget.get(targetId) -} - -// Why async: the in-memory update lands synchronously (before the first await) so no caller can -// observe a torn state; only the awaited durability barrier is deferred. -export async function rememberSshPtyConsumerRecovery(args: { - targetId: string - clientInstanceId: string - serverBuildId: string - owner: SshPtyConsumerOwnerState - store: Store -}): Promise { - const current = recoveryByTarget.get(args.targetId) - if (current?.clientInstanceId !== args.clientInstanceId || current.detached) { - return - } - current.serverBuildId = args.serverBuildId - current.owner = args.owner - await args.store.upsertSshPtyConsumerRecovery({ - targetId: args.targetId, - clientInstanceId: args.clientInstanceId, - serverBuildId: args.serverBuildId, - clientGeneration: args.owner.clientGeneration, - ownerGeneration: args.owner.ownerGeneration, - ownerLease: args.owner.ownerLease, - ...(args.owner.outputFlowControl ? { outputFlowControl: args.owner.outputFlowControl } : {}) - }) -} - -export async function removeSshPtyConsumerOwnerRecovery( - targetId: string, - clientInstanceId: string, - store: Store -): Promise { - await store.removeSshPtyConsumerRecovery(targetId, clientInstanceId) -} - -export function detachSshPtyConsumerRecovery(targetId: string, clientInstanceId: string): void { - const current = recoveryByTarget.get(targetId) - if (current?.clientInstanceId === clientInstanceId) { - current.detached = true - } -} - -export async function forgetSshPtyConsumerRecovery( - targetId: string, - clientInstanceId: string, - store: Store -): Promise { - const current = recoveryByTarget.get(targetId) - if (current?.clientInstanceId === clientInstanceId) { - recoveryByTarget.delete(targetId) - } - await removeSshPtyConsumerOwnerRecovery(targetId, clientInstanceId, store) -} diff --git a/src/main/ssh/ssh-pty-frame-rejection.ts b/src/main/ssh/ssh-pty-frame-rejection.ts deleted file mode 100644 index 07dcf3827cdb..000000000000 --- a/src/main/ssh/ssh-pty-frame-rejection.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' -import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session' - -type SshPtyDataPayload = Parameters[0] - -const SSH_PTY_FRAME_REJECTION_LOG_KEY_LIMIT = 12 - -export type SshPtyFrameRejectionReason = - | 'source-missing' - | 'source-malformed' - | 'client-generation-stale' - | 'owner-generation-stale' - | 'client-and-owner-generation-stale' - | 'source-range-invalid' - -export type SshPtyFrameRejection = Readonly<{ - reason: SshPtyFrameRejectionReason - action: 'quarantine-legacy-frame' | 'retire-and-reattach-delivery' -}> - -// Why a source-less frame is quarantined rather than reattached: it is the signature of legacy -// output already in flight when the flow-control grant landed, not of a broken delivery. The relay's -// own admission stops publishing those once the grant is live, and source recovery replays from -// sourceStartSu, so dropping them loses nothing. Reattaching instead would restart the PTY on every -// openClient handshake. -export function classifySshPtyFrameRejection( - payload: SshPtyDataPayload, - owner: SshPtyConsumerOwnerState | null -): SshPtyFrameRejection | null { - if (payload.sourceMalformed) { - return Object.freeze({ - reason: 'source-malformed', - action: 'retire-and-reattach-delivery' - }) - } - const source = payload.source - if (!source) { - return owner?.outputFlowControl - ? Object.freeze({ - reason: 'source-missing', - action: 'quarantine-legacy-frame' - }) - : null - } - if (!owner?.outputFlowControl) { - return payload.sourceRejected - ? Object.freeze({ - reason: 'source-range-invalid', - action: 'retire-and-reattach-delivery' - }) - : null - } - const staleClient = source.clientGeneration !== owner.clientGeneration - const staleOwner = source.ownerGeneration !== owner.ownerGeneration - if (staleClient || staleOwner) { - return Object.freeze({ - reason: - staleClient && staleOwner - ? 'client-and-owner-generation-stale' - : staleClient - ? 'client-generation-stale' - : 'owner-generation-stale', - action: 'retire-and-reattach-delivery' - }) - } - if (payload.sourceRejected) { - return Object.freeze({ - reason: 'source-range-invalid', - action: 'retire-and-reattach-delivery' - }) - } - return null -} - -/** - * Why bounded: a quarantined frame carries no delivery token to retire, so nothing dedupes it — a - * relay that keeps publishing legacy output to a flow-control owner would log once per frame and - * bury every other diagnostic. One line per PTY and reason per generation names the fault; the - * generation is itself the counter for how often it recurs. - */ -export class SshPtyFrameRejectionLog { - private generation: number | null = null - private readonly loggedKeys = new Set() - - record( - payload: SshPtyDataPayload, - owner: SshPtyConsumerOwnerState | null, - rejection: SshPtyFrameRejection - ): void { - if (this.generation !== payload.providerGeneration) { - this.generation = payload.providerGeneration - this.loggedKeys.clear() - } - const key = `${payload.id}\0${rejection.reason}` - if (this.loggedKeys.has(key) || this.loggedKeys.size >= SSH_PTY_FRAME_REJECTION_LOG_KEY_LIMIT) { - return - } - this.loggedKeys.add(key) - console.warn('[ssh-relay-session] Rejected PTY delivery', { - ptyId: payload.id, - providerGeneration: payload.providerGeneration, - expectedClientGeneration: owner?.clientGeneration ?? null, - expectedOwnerGeneration: owner?.ownerGeneration ?? null, - offeredClientGeneration: payload.source?.clientGeneration ?? null, - offeredOwnerGeneration: payload.source?.ownerGeneration ?? null, - sourceState: rejection.reason, - recoveryAction: rejection.action - }) - } - - clear(): void { - this.generation = null - this.loggedKeys.clear() - } -} diff --git a/src/main/ssh/ssh-pty-recovery-retention-budget.test.ts b/src/main/ssh/ssh-pty-recovery-retention-budget.test.ts deleted file mode 100644 index 47219e617800..000000000000 --- a/src/main/ssh/ssh-pty-recovery-retention-budget.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SshPtyRecoveryRetentionBudget } from './ssh-pty-recovery-retention-budget' - -describe('SshPtyRecoveryRetentionBudget', () => { - it('bounds fragmented recovery by per-PTY and session source, charged bytes, and frames', () => { - const budget = new SshPtyRecoveryRetentionBudget({ - perPtySourceSu: 4, - perPtyBytes: 300, - perPtyFrames: 2, - sessionSourceSu: 6, - sessionBytes: 500, - sessionFrames: 3 - }) - - expect(budget.tryRetain('pty-1', 'aa', 2)).toBe(true) - expect(budget.tryRetain('pty-1', 'bb', 2)).toBe(true) - expect(budget.tryRetain('pty-1', 'c', 0)).toBe(false) - expect(budget.tryRetain('pty-2', 'cc', 2)).toBe(true) - expect(budget.snapshot()).toEqual({ sourceSu: 6, bytes: 396, frames: 3, ptys: 2 }) - expect(budget.tryRetain('pty-2', 'd', 1)).toBe(false) - - budget.release('pty-1') - - expect(budget.snapshot()).toEqual({ sourceSu: 2, bytes: 132, frames: 1, ptys: 1 }) - expect(budget.tryRetain('pty-2', 'dd', 2)).toBe(true) - }) - - it('charges UTF-16 storage plus one record before aggregate admission', () => { - const budget = new SshPtyRecoveryRetentionBudget({ - perPtySourceSu: 100, - perPtyBytes: 1_000, - perPtyFrames: 10, - sessionSourceSu: 100, - sessionBytes: 500, - sessionFrames: 10 - }) - - expect(budget.tryRetain('pty-1', '\u0000'.repeat(64), 1)).toBe(true) - expect(budget.snapshot().bytes).toBe(256) - expect(budget.tryRetain('pty-2', '\u0000'.repeat(64), 1)).toBe(false) - expect(budget.snapshot()).toEqual({ sourceSu: 1, bytes: 256, frames: 1, ptys: 1 }) - }) -}) diff --git a/src/main/ssh/ssh-pty-recovery-retention-budget.ts b/src/main/ssh/ssh-pty-recovery-retention-budget.ts deleted file mode 100644 index 873b56a87784..000000000000 --- a/src/main/ssh/ssh-pty-recovery-retention-budget.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' -import { chargedPtyRetainedStringBytes } from '../../shared/pty-retained-string-memory' - -export const SSH_PTY_RECOVERY_PER_PTY_MAX_SOURCE_SU = DEFAULT_PTY_SOURCE_WINDOW_SU -export const SSH_PTY_RECOVERY_PER_PTY_MAX_BYTES = 2 * 1024 * 1024 -export const SSH_PTY_RECOVERY_PER_PTY_MAX_FRAMES = 1_024 -export const SSH_PTY_RECOVERY_SESSION_MAX_SOURCE_SU = 50 * DEFAULT_PTY_SOURCE_WINDOW_SU -export const SSH_PTY_RECOVERY_SESSION_MAX_BYTES = 64 * 1024 * 1024 -export const SSH_PTY_RECOVERY_SESSION_MAX_FRAMES = 64 * 1_024 - -export type SshPtyRecoveryRetentionLimits = Readonly<{ - perPtySourceSu: number - perPtyBytes: number - perPtyFrames: number - sessionSourceSu: number - sessionBytes: number - sessionFrames: number -}> - -type RetainedPty = { - sourceSu: number - bytes: number - frames: number -} - -const DEFAULT_LIMITS: SshPtyRecoveryRetentionLimits = Object.freeze({ - perPtySourceSu: SSH_PTY_RECOVERY_PER_PTY_MAX_SOURCE_SU, - perPtyBytes: SSH_PTY_RECOVERY_PER_PTY_MAX_BYTES, - perPtyFrames: SSH_PTY_RECOVERY_PER_PTY_MAX_FRAMES, - sessionSourceSu: SSH_PTY_RECOVERY_SESSION_MAX_SOURCE_SU, - sessionBytes: SSH_PTY_RECOVERY_SESSION_MAX_BYTES, - sessionFrames: SSH_PTY_RECOVERY_SESSION_MAX_FRAMES -}) - -export class SshPtyRecoveryRetentionBudget { - private readonly retainedByPty = new Map() - private sourceSu = 0 - private bytes = 0 - private frames = 0 - - constructor(private readonly limits: SshPtyRecoveryRetentionLimits = DEFAULT_LIMITS) {} - - tryRetain(ptyId: string, data: string, sourceSu: number): boolean { - if (!Number.isSafeInteger(sourceSu) || sourceSu < 0) { - return false - } - const retained = this.retainedByPty.get(ptyId) ?? { sourceSu: 0, bytes: 0, frames: 0 } - const chargedBytes = chargedPtyRetainedStringBytes(data) - if ( - retained.sourceSu + sourceSu > this.limits.perPtySourceSu || - retained.bytes + chargedBytes > this.limits.perPtyBytes || - retained.frames + 1 > this.limits.perPtyFrames || - this.sourceSu + sourceSu > this.limits.sessionSourceSu || - this.bytes + chargedBytes > this.limits.sessionBytes || - this.frames + 1 > this.limits.sessionFrames - ) { - return false - } - retained.sourceSu += sourceSu - retained.bytes += chargedBytes - retained.frames++ - this.retainedByPty.set(ptyId, retained) - this.sourceSu += sourceSu - this.bytes += chargedBytes - this.frames++ - return true - } - - release(ptyId: string): void { - const retained = this.retainedByPty.get(ptyId) - if (!retained) { - return - } - this.retainedByPty.delete(ptyId) - this.sourceSu -= retained.sourceSu - this.bytes -= retained.bytes - this.frames -= retained.frames - } - - clear(): void { - this.retainedByPty.clear() - this.sourceSu = 0 - this.bytes = 0 - this.frames = 0 - } - - snapshot(): Readonly<{ sourceSu: number; bytes: number; frames: number; ptys: number }> { - return Object.freeze({ - sourceSu: this.sourceSu, - bytes: this.bytes, - frames: this.frames, - ptys: this.retainedByPty.size - }) - } -} diff --git a/src/main/ssh/ssh-pty-retired-source-deliveries.test.ts b/src/main/ssh/ssh-pty-retired-source-deliveries.test.ts deleted file mode 100644 index 377d545592b6..000000000000 --- a/src/main/ssh/ssh-pty-retired-source-deliveries.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SshPtyRetiredSourceDeliveries } from './ssh-pty-retired-source-deliveries' - -const source = (deliveryToken: string, relayPtyId = 'pty-1') => ({ - relayPtyId, - deliveryToken, - clientGeneration: 2, - ownerGeneration: 3 -}) - -describe('SshPtyRetiredSourceDeliveries', () => { - it('retains only the latest canceled token for each ordered PTY stream', () => { - const retired = new SshPtyRetiredSourceDeliveries() - - for (let token = 0; token < 10_000; token++) { - retired.retire(1, source(`token-${token}`)) - } - - expect(retired.size).toBe(1) - expect(retired.has(1, source('token-9999'))).toBe(true) - expect(retired.has(1, source('token-9998'))).toBe(false) - }) - - it('retires state at the next activation or PTY exit boundary', () => { - const retired = new SshPtyRetiredSourceDeliveries() - retired.retire(1, source('old-token')) - retired.retire(1, source('other-token', 'pty-2')) - - retired.activate('pty-1') - - expect(retired.has(1, source('old-token'))).toBe(false) - expect(retired.has(1, source('other-token', 'pty-2'))).toBe(true) - expect(retired.size).toBe(1) - }) -}) diff --git a/src/main/ssh/ssh-pty-retired-source-deliveries.ts b/src/main/ssh/ssh-pty-retired-source-deliveries.ts deleted file mode 100644 index 8caca4f889fa..000000000000 --- a/src/main/ssh/ssh-pty-retired-source-deliveries.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { SshPtySourceFrame } from '../providers/ssh-pty-source-frame' - -type SourceDeliveryIdentity = Pick< - SshPtySourceFrame, - 'relayPtyId' | 'deliveryToken' | 'clientGeneration' | 'ownerGeneration' -> - -export class SshPtyRetiredSourceDeliveries { - private readonly keyByRelayPtyId = new Map() - - has(providerGeneration: number, source: SourceDeliveryIdentity): boolean { - return ( - this.keyByRelayPtyId.get(source.relayPtyId) === sourceDeliveryKey(providerGeneration, source) - ) - } - - retire(providerGeneration: number, source: SourceDeliveryIdentity): void { - this.keyByRelayPtyId.set(source.relayPtyId, sourceDeliveryKey(providerGeneration, source)) - } - - activate(relayPtyId: string): void { - this.keyByRelayPtyId.delete(relayPtyId) - } - - clear(): void { - this.keyByRelayPtyId.clear() - } - - get size(): number { - return this.keyByRelayPtyId.size - } -} - -function sourceDeliveryKey(providerGeneration: number, source: SourceDeliveryIdentity): string { - return `${providerGeneration}\0${source.clientGeneration}\0${source.ownerGeneration}\0${source.deliveryToken}` -} diff --git a/src/main/ssh/ssh-pty-targeted-reattach-queue.ts b/src/main/ssh/ssh-pty-targeted-reattach-queue.ts deleted file mode 100644 index 23329abf5875..000000000000 --- a/src/main/ssh/ssh-pty-targeted-reattach-queue.ts +++ /dev/null @@ -1,57 +0,0 @@ -type TargetedReattach = Readonly<{ start: () => void }> - -// Why bounded: every rejected frame asks for its own attach round trip, so a relay that starts -// rejecting across many PTYs at once fans out one concurrent reattach per PTY. The bulk reconnect -// path caps the identical work, and each attach also costs a lease read and a lease write. -export class SshPtyTargetedReattachQueue { - private readonly running = new Map() - private readonly waiting: TargetedReattach[] = [] - private active = 0 - - constructor(private readonly maxConcurrency: number) {} - - has(key: string): boolean { - return this.running.has(key) - } - - run(key: string, task: () => Promise): Promise { - return new Promise((resolve, reject) => { - const entry: TargetedReattach = { - start: () => { - this.active++ - task().then( - (recovered) => { - this.settle(key, entry) - resolve(recovered) - }, - (error: unknown) => { - this.settle(key, entry) - reject(error instanceof Error ? error : new Error(String(error))) - } - ) - } - } - this.running.set(key, entry) - if (this.active < this.maxConcurrency) { - entry.start() - return - } - this.waiting.push(entry) - }) - } - - // Why queued entries are dropped rather than started: each one is keyed to the provider generation - // the teardown just ended, so running it would attach onto a mux that is already gone. - clear(): void { - this.waiting.length = 0 - this.running.clear() - } - - private settle(key: string, entry: TargetedReattach): void { - if (this.running.get(key) === entry) { - this.running.delete(key) - } - this.active-- - this.waiting.shift()?.start() - } -} diff --git a/src/main/ssh/ssh-relay-build-toolchain.test.ts b/src/main/ssh/ssh-relay-build-toolchain.test.ts deleted file mode 100644 index 6fcc4f5321d3..000000000000 --- a/src/main/ssh/ssh-relay-build-toolchain.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildToolchainProbeCommand, - parseBuildToolchainProbe, - formatMissingToolchainError, - formatNodeHeadersDownloadError, - formatSkippedNodePtyWarning, - isNodeHeadersDownloadFailure, - shouldProbeBuildToolchainAfterNativeDepsFailure -} from './ssh-relay-build-toolchain' - -describe('buildToolchainProbeCommand', () => { - it('probes make and a C++ compiler and detects the package manager', () => { - const cmd = buildToolchainProbeCommand() - expect(cmd).toContain('command -v "$t"') - expect(cmd).toMatch(/\bmake\b/) - expect(cmd).toMatch(/g\+\+/) - expect(cmd).toContain('apt-get') - // Single POSIX-sh line (runs under `/bin/sh -c`), no embedded single quotes - // that shellEscape would have to wrap. - expect(cmd).not.toContain('\n') - expect(cmd).not.toContain("'") - }) -}) - -describe('parseBuildToolchainProbe', () => { - it('flags a missing toolchain when make and the C++ compiler are absent', () => { - const status = parseBuildToolchainProbe('PKG apt-get\n') - expect(status.toolchainMissing).toBe(true) - expect(status.present).toEqual([]) - expect(status.packageManager).toBe('apt-get') - }) - - it('treats a full toolchain as present', () => { - const status = parseBuildToolchainProbe('HAVE make\nHAVE gcc\nHAVE g++\nHAVE python3\nPKG dnf') - expect(status.toolchainMissing).toBe(false) - expect(status.present).toContain('make') - expect(status.present).toContain('g++') - expect(status.packageManager).toBe('dnf') - }) - - it('still flags missing when make is present but no C++ compiler is', () => { - const status = parseBuildToolchainProbe('HAVE make\nHAVE gcc\nHAVE python3') - expect(status.toolchainMissing).toBe(true) - expect(status.packageManager).toBeNull() - }) - - it('accepts clang++ as the C++ compiler', () => { - const status = parseBuildToolchainProbe('HAVE make\nHAVE clang\nHAVE clang++\nHAVE python3') - expect(status.toolchainMissing).toBe(false) - }) - - it('ignores shell noise around the markers', () => { - const status = parseBuildToolchainProbe( - 'Welcome to Acme\nHAVE make\nHAVE g++\nHAVE python3\nMOTD line\nPKG apk' - ) - expect(status.toolchainMissing).toBe(false) - expect(status.packageManager).toBe('apk') - }) -}) - -describe('shouldProbeBuildToolchainAfterNativeDepsFailure', () => { - it('matches node-gyp missing build-tool output', () => { - expect( - shouldProbeBuildToolchainAfterNativeDepsFailure('gyp ERR! stack Error: not found: make') - ).toBe(true) - expect( - shouldProbeBuildToolchainAfterNativeDepsFailure( - 'node-gyp ERR! Could not find any Python installation' - ) - ).toBe(true) - }) - - it('does not match unrelated npm failures', () => { - expect(shouldProbeBuildToolchainAfterNativeDepsFailure('npm ERR! network ETIMEDOUT')).toBe( - false - ) - expect( - shouldProbeBuildToolchainAfterNativeDepsFailure('npm ERR! E404 Not Found node-pty') - ).toBe(false) - }) -}) - -describe('formatMissingToolchainError', () => { - it('lists the missing tools and a package-manager-specific install command', () => { - const status = parseBuildToolchainProbe('PKG apt-get') - const msg = formatMissingToolchainError(status, 'gyp ERR! not found: make') - expect(msg).toContain('make') - expect(msg).toContain('a C++ compiler (g++ or clang++)') - expect(msg).toContain('python3') - expect(msg).toContain('sudo apt-get install -y build-essential python3') - // Tailored hint replaces the generic distro list. - expect(msg).not.toContain('Fedora/RHEL:') - // Original error retained for triage. - expect(msg).toContain('gyp ERR! not found: make') - }) - - it('falls back to a multi-distro list when no package manager was detected', () => { - const status = parseBuildToolchainProbe('') - const msg = formatMissingToolchainError(status, 'exit 1') - expect(msg).toContain('Debian/Ubuntu:') - expect(msg).toContain('Fedora/RHEL:') - expect(msg).toContain('Arch:') - expect(msg).toContain('Alpine:') - }) - - it('only lists the genuinely missing pieces', () => { - const status = parseBuildToolchainProbe('HAVE make\nHAVE python3\nPKG pacman') - const msg = formatMissingToolchainError(status, 'err') - expect(msg).toContain('a C++ compiler (g++ or clang++)') - expect(msg).not.toMatch(/\(make,/) - expect(msg).toContain('sudo pacman -S --needed base-devel python') - }) -}) - -describe('formatSkippedNodePtyWarning', () => { - it('quotes the tailored install command when a package manager was detected', () => { - const warning = formatSkippedNodePtyWarning(parseBuildToolchainProbe('PKG dnf')) - expect(warning).toContain('skipping node-pty') - expect(warning).toContain('sudo dnf install -y make gcc gcc-c++ python3') - }) - - it('stays distro-neutral when no package manager was detected', () => { - // The hint list is the cross-distro menu here; quoting its first line would name Debian on any host. - const warning = formatSkippedNodePtyWarning(parseBuildToolchainProbe('')) - expect(warning).not.toContain('apt-get') - expect(warning).toContain('install a C/C++ toolchain') - }) -}) - -// Verbatim shape of the STA-6674 failure: node-gyp on a host whose nodejs.org is refused. -const HEADERS_REFUSED = - 'npm error gyp http GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz\n' + - 'npm error gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNREFUSED\n' + - 'npm error gyp ERR! configure error\n' + - 'npm error gyp ERR! stack FetchError: request to https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz failed, reason: connect ECONNREFUSED 127.0.0.1:443' - -describe('isNodeHeadersDownloadFailure', () => { - it('matches node-gyp failing to fetch the Node headers tarball', () => { - expect(isNodeHeadersDownloadFailure(HEADERS_REFUSED)).toBe(true) - expect( - isNodeHeadersDownloadFailure( - 'gyp http fetch GET https://nodejs.org/download/release/v20.19.0/node-v20.19.0-headers.tar.gz attempt 1 failed with ENOTFOUND\ngyp ERR! configure error' - ) - ).toBe(true) - }) - - it('is not the toolchain diagnosis, and does not fire on other network failures', () => { - expect(shouldProbeBuildToolchainAfterNativeDepsFailure(HEADERS_REFUSED)).toBe(false) - // The registry, not nodejs.org: a different remedy. - expect( - isNodeHeadersDownloadFailure( - 'npm error network request to https://registry.npmjs.org/node-pty failed, reason: connect ECONNREFUSED' - ) - ).toBe(false) - // Headers named but the build failed for another reason. - expect( - isNodeHeadersDownloadFailure( - 'gyp info using node-v24.12.0-headers.tar.gz\ngyp ERR! build error make failed with exit code: 2' - ) - ).toBe(false) - // A retried attempt that recovered, then a compile failure: not a download failure. - expect( - isNodeHeadersDownloadFailure( - 'gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNRESET\n' + - 'gyp http 200 https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz\n' + - 'gyp ERR! build error\ngyp ERR! stack Error: `make` failed with exit code: 2' - ) - ).toBe(false) - // A mirror answering non-2xx is a FetchError without a network code: a different remedy. - expect( - isNodeHeadersDownloadFailure( - 'gyp ERR! configure error\ngyp ERR! stack FetchError: 404 Not Found https://mirror/dist/v24.12.0/node-v24.12.0-headers.tar.gz' - ) - ).toBe(false) - }) -}) - -describe('formatNodeHeadersDownloadError', () => { - it('names both host remedies when the host ships no headers', () => { - const msg = formatNodeHeadersDownloadError(HEADERS_REFUSED, null) - expect(msg).toContain('no local headers matching its own version') - expect(msg).toContain('/include/node') - expect(msg).toContain('nvm, fnm, volta, n') - expect(msg).toContain('disturl') - expect(msg).toContain('ECONNREFUSED') - }) - - it('reports an Orca defect, not a host problem, when headers were exported and ignored', () => { - const msg = formatNodeHeadersDownloadError(HEADERS_REFUSED, '/usr/local') - expect(msg).toContain('/usr/local/include/node') - expect(msg).toContain('Orca defect') - expect(msg).not.toContain('no local headers matching its own version') - expect(msg).not.toContain('nvm, fnm, volta, n') - expect(msg).toContain('ECONNREFUSED') - }) -}) diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts deleted file mode 100644 index db7b6353697e..000000000000 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { execCommand } from './ssh-relay-deploy-helpers' -import type { SshConnection } from './ssh-connection' -import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { - buildToolchainProbeCommand, - parseBuildToolchainProbe, - type BuildToolchainStatus -} from './build-toolchain-diagnosis' - -// Why re-exported rather than moved outright: `ssh-relay-deploy.ts` and the relay tests -// import the whole diagnosis surface from here, and the split exists for bundle reasons, -// not to redraw the relay's own API. -export { - buildToolchainProbeCommand, - parseBuildToolchainProbe, - shouldProbeBuildToolchainAfterNativeDepsFailure, - toolchainInstallHintLines, - formatSkippedNodePtyWarning, - formatMissingToolchainError, - formatNodeHeadersDownloadError, - isNodeHeadersDownloadFailure -} from './build-toolchain-diagnosis' -export type { BuildToolchainStatus } from './build-toolchain-diagnosis' - -// Best-effort: returns null on Windows hosts (node-pty ships win32 prebuilds, so -// a missing toolchain isn't the failure there) or if the probe itself errors — -// callers fall back to the original install error in those cases. -export async function probeBuildToolchain( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - if (isWindowsRemoteHost(hostPlatform)) { - return null - } - try { - const output = await execCommand(conn, buildToolchainProbeCommand(), { - wrapCommand: true, - signal - }) - return parseBuildToolchainProbe(output) - } catch { - signal?.throwIfAborted() - return null - } -} diff --git a/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts b/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts deleted file mode 100644 index b94d2102865d..000000000000 --- a/src/main/ssh/ssh-relay-bundled-ripgrep.docker.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { execFileSync, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { appendFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshConnection } from './ssh-connection' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { bundledRipgrepContentKey } from '../ripgrep/bundled-ripgrep-path' -import type { SshTarget } from '../../shared/ssh-types' - -// Why opt-in: needs Docker plus an sshd+node+git image WITHOUT rg (ORCA_REVIEW_SSH_NORG_IMAGE). -const RUN = process.env.ORCA_REVIEW_SSH_BUNDLED_RG === '1' -const REMOTE_REPO = '/tmp/orca-bundled-rg-repo' -const TARGET = 'ios/Notion Web Clipper/AppDelegate.swift' -const REPORT = process.env.ORCA_REVIEW_REPORT_FILE - -type Fixture = { containerName: string; identityFile: string; port: number; tempDir: string } - -function run(command: string, args: string[], timeout = 60_000): string { - return execFileSync(command, args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout - }) - .toString() - .trim() -} - -function dockerExec(fixture: Fixture, command: string): string { - return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 120_000) -} - -function report(line: string): void { - if (REPORT) { - appendFileSync(REPORT, `${line}\n`) - } -} - -function startTarget(): Fixture { - const image = process.env.ORCA_REVIEW_SSH_NORG_IMAGE ?? 'orca-ssh-norg:latest' - const tempDir = mkdtempSync(join(tmpdir(), 'orca-bundled-rg-ssh-')) - const identityFile = join(tempDir, 'id_ed25519') - run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) - const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() - const containerName = `orca-bundled-rg-${randomUUID().slice(0, 12)}` - run('docker', [ - 'run', - '-d', - '--name', - containerName, - '-p', - '127.0.0.1::22', - '-e', - `AUTHORIZED_KEY=${publicKey}`, - image, - 'bash', - '-lc', - 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' - ]) - const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) - const fixture = { containerName, identityFile, port, tempDir } - // Why 25k files: past the 20,001-path listing cap, so only a real rg search finds the target. - dockerExec( - fixture, - [ - `mkdir -p '${REMOTE_REPO}' && cd '${REMOTE_REPO}' && git init -q`, - `for d in $(seq 0 249); do mkdir -p a$d && (cd a$d && for i in $(seq 0 99); do : > f$i.txt; done); done`, - `mkdir -p 'ios/Notion Web Clipper' && echo 'class AppDelegate {}' > '${TARGET}'`, - 'git add -A >/dev/null' - ].join(' && ') - ) - return fixture -} - -function stopTarget(fixture: Fixture | null): void { - if (!fixture) { - return - } - spawnSync('docker', ['rm', '-f', fixture.containerName], { stdio: 'ignore', timeout: 30_000 }) - rmSync(fixture.tempDir, { recursive: true, force: true }) -} - -function createConnection(fixture: Fixture): SshConnection { - const target: SshTarget = { - id: `bundled-rg-${randomUUID()}`, - label: 'Bundled ripgrep Docker SSH target', - source: 'manual', - host: '127.0.0.1', - port: fixture.port, - username: 'root', - identityFile: fixture.identityFile, - identitiesOnly: true - } - return new SshConnection(target, { onStateChange: vi.fn() }) -} - -async function waitFor(check: () => boolean, timeoutMs: number): Promise { - const startedAt = Date.now() - while (Date.now() - startedAt < timeoutMs) { - if (check()) { - return Date.now() - startedAt - } - await new Promise((resolve) => setTimeout(resolve, 250)) - } - throw new Error(`condition not met within ${timeoutMs}ms`) -} - -describe.skipIf(!RUN)('SSH relay bundled ripgrep', () => { - let fixture: Fixture | null = null - - beforeAll(() => { - fixture = startTarget() - }, 300_000) - - afterAll(() => { - stopTarget(fixture) - }) - - it('installs Orca ripgrep on a host without rg and serves Quick Open search with it', async () => { - const active = fixture! - expect(dockerExec(active, 'command -v rg || echo NO_RG')).toBe('NO_RG') - const platform = /^(aarch64|arm64)$/.test(dockerExec(active, 'uname -m')) - ? 'linux-arm64' - : 'linux-x64' - const entry = `${bundledRipgrepContentKey(platform)}-${platform}` - const remoteBinary = `/root/.orca-remote/ripgrep/${entry}/rg` - const connection = createConnection(active) - await connection.connect() - try { - const deployStartedAt = Date.now() - const deployed = await deployAndLaunchRelay(connection, undefined, 60) - const deployMs = Date.now() - deployStartedAt - const installMs = await waitFor( - () => dockerExec(active, `test -x '${remoteBinary}' && echo yes || echo no`) === 'yes', - 60_000 - ) - report( - `SSH deploy_ms=${deployMs} rg_installed_after_launch_ms=${installMs} platform=${platform}` - ) - expect(dockerExec(active, `'${remoteBinary}' --version | head -1`)).toContain('ripgrep') - expect(dockerExec(active, "ps -eo args | grep '[r]elay.js --detached'")).toContain( - `--ripgrep-path ${remoteBinary}` - ) - expect(dockerExec(active, `ls /root/.orca-remote/ripgrep/${entry}`)).toBe('rg') - - const mux = new SshChannelMultiplexer(deployed.transport) - try { - await mux.request('session.registerRoot', { rootPath: REMOTE_REPO }) - const searchStartedAt = Date.now() - const files = await mux.request('fs.listFiles', { - rootPath: REMOTE_REPO, - searchQuery: 'AppDelegate', - maxResults: 33 - }) - report( - `SSH quick_open_search_ms=${Date.now() - searchStartedAt} result=${JSON.stringify(files)}` - ) - expect(files).toEqual([TARGET]) - - const textStartedAt = Date.now() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: relay fs.search resolves a SearchResult. - const text = (await mux.request('fs.search', { - query: 'class AppDelegate', - rootPath: REMOTE_REPO - })) as { files: { relativePath: string }[] } - report(`SSH text_search_ms=${Date.now() - textStartedAt} files=${text.files.length}`) - expect(text.files.map((file) => file.relativePath)).toEqual([TARGET]) - } finally { - mux.dispose() - } - } finally { - await connection.disconnect() - } - }, 300_000) - - it('reuses the installed binary on the next deploy without re-uploading', async () => { - const active = fixture! - const before = dockerExec(active, 'stat -c %Y /root/.orca-remote/ripgrep/*/rg') - const connection = createConnection(active) - await connection.connect() - try { - await deployAndLaunchRelay(connection, undefined, 60) - await new Promise((resolve) => setTimeout(resolve, 3_000)) - expect(dockerExec(active, 'stat -c %Y /root/.orca-remote/ripgrep/*/rg')).toBe(before) - expect(dockerExec(active, 'ls -A /root/.orca-remote/ripgrep | grep -c upload || true')).toBe( - '0' - ) - } finally { - await connection.disconnect() - } - }, 300_000) -}) diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts deleted file mode 100644 index 18624eb170dc..000000000000 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -// Cross-version isolation guard. -// -// Why: this test is the executable form of the "Pattern Note" in -// docs/ssh-relay-versioned-install-dirs.md — it asserts that a v2 deploy -// targeting a remote where a v1 daemon is already running NEVER touches -// v1's install dir or socket. Without this test a future refactor that -// collapses to a shared dir passes every other unit test and re-introduces -// the original "stale daemon serves new client" bug. - -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+222222222222') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('not-needed') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' -import type { SshConnection } from './ssh-connection' -import { REMOTE_INSTALL_ORDER_OK } from './remote-install-previous-version' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - // Why: production attaches and removes real SFTP/write-stream listeners, so the fake must be an emitter. - sftp: vi.fn().mockImplementation(() => { - const sftp = new EventEmitter() - return Promise.resolve( - Object.assign(sftp, { - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - // Shell home and SFTP start directory agree here, so no namespace redirect applies. - realpath: vi.fn((_p: string, cb: (err: Error | null, resolved: string) => void) => - cb(null, '/home/u') - ), - lstat: vi.fn((_p: string, cb: (err: Error | null) => void) => - cb(Object.assign(new Error('No such file'), { code: 2 })) - ), - createWriteStream: vi.fn().mockImplementation(() => { - const ws = new EventEmitter() - return Object.assign(ws, { - end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)) - }) - }), - end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) - }) - ) - }) - } as unknown as SshConnection -} - -describe('cross-version isolation', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - }) - - it('a v2 deploy never references the v1 install dir or v1 socket path', async () => { - const conn = makeMockConnection() - const mockExec = vi.mocked(execCommand) - - // Simulated remote where: - // v1 dir = ~/.orca-remote/relay-0.1.0+111111111111/ (live daemon, listening) - // v2 dir = ~/.orca-remote/relay-0.1.0+222222222222/ (does not yet exist) - // The v2 client has fullVersion='0.1.0+222222222222' (from the fs mock above). - // - mockExec.mockImplementation((_conn, command) => { - if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__')) { - return Promise.resolve( - '__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0' - ) - } - if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__')) { - return Promise.resolve( - '__ORCA_UPLOAD_STAGE_PROMOTION__.sftp-namespace-00000000000000000000000000000000:PROMOTED' - ) - } - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/u') - } - if (command.includes("-name 'relay-0.1.0+222222222222.upload-*'")) { - return Promise.resolve('') - } - if (command.includes('relay-watcher.js') && command.includes('.install-complete')) { - return Promise.resolve('MISSING') - } - if (command.includes('.gc-claim') && command.includes('echo LOCKED || echo OPEN')) { - return Promise.resolve('OPEN') - } - if (command.startsWith('if mkdir ') && command.includes('.install-lock')) { - return Promise.resolve('OK') - } - if (command.includes('ORCA-NPTY-PROBE-OK')) { - return Promise.resolve('ORCA-NPTY-PROBE-OK\n') - } - if (command.includes('process.stdout.write("READY")')) { - return Promise.resolve('READY') - } - if (command.includes('test -S') && command.includes('echo ALIVE || echo DEAD')) { - return Promise.resolve('DEAD') - } - if (command.includes(REMOTE_INSTALL_ORDER_OK)) { - // A newer v0 is the previous build, so v1 is kept only by its live socket. - return Promise.resolve( - `relay-0.1.0+222222222222\nrelay-0.1.0+000000000000\nrelay-0.1.0+111111111111\n${REMOTE_INSTALL_ORDER_OK}` - ) - } - if (command.includes('__ORCA_RELAY_GC_FIND_STATUS__')) { - return Promise.resolve('relay-0.1.0+111111111111\nrelay-0.1.0+222222222222\n') - } - if (command.includes('relay-0.1.0+111111111111/.install-lock')) { - return Promise.resolve('OPEN') - } - if (command.includes('relay-0.1.0+111111111111/.install-complete')) { - return Promise.resolve('COMPLETE') - } - if (command.includes('relay-0.1.0+111111111111') && command.includes('relay-*.sock')) { - return Promise.resolve('ALIVE') - } - return Promise.resolve('') - }) - - await deployAndLaunchRelay(conn) - await vi.waitFor(() => - expect(mockExec.mock.calls.some(([, command]) => command.includes('relay-*.sock'))).toBe(true) - ) - - const allCmds = [ - ...mockExec.mock.calls.map(([, c]) => c), - ...vi.mocked(conn.exec).mock.calls.map(([c]) => c as string) - ] - - // (a) the v2 deploy creates dirs/files under its content-hashed directory - expect(allCmds.some((c) => c.includes('relay-0.1.0+222222222222'))).toBe(true) - - // (b) the v2 launch and connect socket paths are rooted in v2 dir, never v1 - const launchAndConnectCmds = vi - .mocked(conn.exec) - .mock.calls.map(([c]) => c as string) - .filter((c) => c.includes('--sock-path')) - expect(launchAndConnectCmds.length).toBeGreaterThan(0) - for (const cmd of launchAndConnectCmds) { - expect(cmd).toContain('relay-0.1.0+222222222222') - expect(cmd).not.toContain('relay-0.1.0+111111111111') - } - - // (c) GC observes v1 has a live socket and never issues an rm -rf for it - const v1RemoveCmds = allCmds.filter( - (c) => c.includes('rm -rf') && c.includes('relay-0.1.0+111111111111') - ) - expect(v1RemoveCmds).toHaveLength(0) - - // (d) blanket isolation: every command that mentions v1hash MUST be a - // GC liveness probe (`ls`, `test -d`, `test -f`, or `for f in .../*.sock`) - // — never a write, mkdir, chmod, touch, rm, node launch, or socket poll. - // This prevents a future refactor that accidentally writes to the v1 dir - // (e.g. shared install-complete, upload over symlink) from passing. - const v1Refs = allCmds.filter((c) => c.includes('relay-0.1.0+111111111111')) - for (const cmd of v1Refs) { - const isReadOnlyProbe = - /^\s*ls\b/.test(cmd) || - /\btest -d\b/.test(cmd) || - /\btest -e\b/.test(cmd) || - /\btest -f\b/.test(cmd) || - /\btest -S\b/.test(cmd) || - /\bfor f in .*\.sock\b/.test(cmd) - expect(isReadOnlyProbe, `unexpected v1 reference: ${cmd}`).toBe(true) - } - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 6c47a08e1aeb..41a8022f0be1 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -2,7 +2,6 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import type { ClientChannel } from 'ssh2' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { shouldProbeBuildToolchainAfterNativeDepsFailure } from './ssh-relay-build-toolchain' import { RELAY_SENTINEL } from './relay-protocol' import { RelayVersionMismatchError, @@ -400,30 +399,6 @@ describe('execCommand', () => { expect(error.message.length).toBeLessThan(1024 * 1024 + 200) }) - it('keeps the merged error message greppable by the build-toolchain probe', async () => { - const channel = createMockChannel() - const conn = { - exec: vi.fn().mockResolvedValue(channel) - } - const commandPromise = execCommand(conn as never, 'npm install 2>&1') - - await Promise.resolve() - // Why: the OpenSSH banner alone never matches the probe, so the old - // `stderr || stdout` masking suppressed the actionable "install build - // tools" hint. The merged message keeps the node-gyp failure visible. - channel.stderr.emit('data', Buffer.from("Warning: Permanently added 'host' (ED25519)\n")) - channel.emit( - 'data', - Buffer.from( - 'npm error gyp ERR! configure error\nnpm error gyp ERR! stack Error: not found: make\n' - ) - ) - channel.emit('close', 1) - - const error = await execCommandRejection(commandPromise) - expect(shouldProbeBuildToolchainAfterNativeDepsFailure(error.message)).toBe(true) - }) - it('cleans command channel listeners when a command times out', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts deleted file mode 100644 index 7c5676f572da..000000000000 --- a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn(() => 'linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn(), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { name: 'AbortError' }) -})) - -vi.mock('./ssh-relay-superseded-endpoints', () => ({ - sweepSupersededRelayEndpoints: vi.fn().mockResolvedValue([]) -})) -import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints' -import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' -import { gcOldRelayVersions } from './ssh-relay-versioned-install' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { RelayCredentialMismatchError } from './ssh-relay-credential-mismatch-error' -import { - RelayProbeCleanupUnconfirmedError, - isRelayEndpointHeldError, - isRelayEndpointUnresponsiveError -} from './ssh-relay-endpoint-incumbent' -import type { SshConnection } from './ssh-connection' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection -} - -// The daemon is present and its listener accepts (a SIGSTOPped relay still does — the kernel -// backlog answers), but nothing on the host can enumerate who holds the socket. -const LIVE_UNENUMERABLE_PROBE = [ - 'ORCA-INCUMBENT-BEGIN', - 'PRESENT=yes', - 'LISTEN=accepted', - 'HOLDERS_SOURCE=unavailable', - 'ORCA-INCUMBENT-END' -].join('\n') - -function queueAliveSocketThenProbe(output = LIVE_UNENUMERABLE_PROBE): void { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('ALIVE') - .mockResolvedValueOnce(output) -} - -function launchedDaemon(conn: SshConnection): boolean { - return vi.mocked(conn.exec).mock.calls.some(([command]) => String(command).includes('--detached')) -} - -/** - * The `--connect` probe's catch block predates the incumbent probe and used to swallow every - * error as "socket probe failed, launch fresh". With a live incumbent that is the collision the - * probe exists to prevent: the fresh daemon loses the bind by luck, not by design. - */ -describe('deployAndLaunchRelay honours the incumbent verdict', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(sweepSupersededRelayEndpoints).mockReset().mockResolvedValue([]) - vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(waitForSentinel).mockReset() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - it('does not launch over a live relay that never answered; the error is retryable', async () => { - const conn = makeMockConnection() - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('Relay failed to start within 10s')) - queueAliveSocketThenProbe() - - await expect(deployAndLaunchRelay(conn)).rejects.toSatisfy(isRelayEndpointUnresponsiveError) - expect(launchedDaemon(conn)).toBe(false) - }) - - it('does not launch over a live relay that refused the credential; the error is terminal', async () => { - const conn = makeMockConnection() - vi.mocked(waitForSentinel).mockRejectedValueOnce(new RelayCredentialMismatchError('')) - queueAliveSocketThenProbe() - - await expect(deployAndLaunchRelay(conn)).rejects.toSatisfy(isRelayEndpointHeldError) - expect(launchedDaemon(conn)).toBe(false) - }) - - it('does not launch while the incumbent probe cleanup is unconfirmed', async () => { - const conn = makeMockConnection() - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('Relay handshake timed out')) - queueAliveSocketThenProbe( - LIVE_UNENUMERABLE_PROBE.replace( - 'HOLDERS_SOURCE=unavailable', - 'HOLDERS_SOURCE=unavailable\nPROBE_CLEANUP=unconfirmed' - ) - ) - await expect(deployAndLaunchRelay(conn)).rejects.toBeInstanceOf( - RelayProbeCleanupUnconfirmedError - ) - expect(launchedDaemon(conn)).toBe(false) - }) - - it('still launches fresh when the socket probe itself fails', async () => { - const conn = makeMockConnection() - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockRejectedValueOnce(new Error('test -S: transport hiccup')) - .mockResolvedValueOnce('READY') - vi.mocked(waitForSentinel).mockResolvedValueOnce({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - - await deployAndLaunchRelay(conn) - expect(launchedDaemon(conn)).toBe(true) - }) - it.each([ - { error: new Error('completed sweep read failure'), expectedGcCalls: 1 }, - { error: new RelayProbeCleanupUnconfirmedError(), expectedGcCalls: 0 } - ])( - 'runs background GC only after probe cleanup is settled: $error.name', - async ({ error, expectedGcCalls }) => { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') - vi.mocked(waitForSentinel).mockResolvedValueOnce({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - vi.mocked(sweepSupersededRelayEndpoints).mockRejectedValueOnce(error) - vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('not-needed') - const result = await deployAndLaunchRelay(makeMockConnection()) - await vi.waitFor(() => expect(sweepSupersededRelayEndpoints).toHaveBeenCalledOnce()) - await new Promise((resolve) => setImmediate(resolve)) - expect(gcOldRelayVersions).toHaveBeenCalledTimes(expectedGcCalls) - const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 120_000) - try { - await result.prepareOpenCodeRuntime?.(new AbortController().signal) - expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(expectedGcCalls + 1) - } finally { - now.mockRestore() - } - } - ) -}) diff --git a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts deleted file mode 100644 index caaf3919a033..000000000000 --- a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts +++ /dev/null @@ -1,907 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - isSshRelayOnHostNodeRuntime, - recordSshRelayRuntimeStep -} from './ssh-host-node-runtime-mode' -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' -import type * as OrcadRemoteNodeRuntimeModule from './orcad-remote-node-runtime' -import type * as PinnedNodeInstallModule from './ssh-relay-pinned-node-install' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront -// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report -// the local relay package as existing AND return a content-hashed version -// string so readLocalFullVersion succeeds. -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { - const normalizedOs = os.toLowerCase() - const normalizedArch = arch.toLowerCase() - const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' - if (normalizedOs === 'windows' || normalizedOs === 'win32') { - return `win32-${relayArch}` - } - if (normalizedOs === 'darwin') { - return `darwin-${relayArch}` - } - if (normalizedOs === 'linux') { - return `linux-${relayArch}` - } - return null - }), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - 'sshChannelCloseConfirmed' in error && - error.sshChannelCloseConfirmed === false, - execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') -})) - -vi.mock('./ssh-remote-node-resolution', async (importOriginal) => ({ - ...(await importOriginal>()), - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -// Why: this file mocks fs, so the real content hash cannot read a binary. -vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ - resolveBundledRipgrepPath: () => null, - bundledRipgrepContentKey: () => 'c0ffee0123456789' -})) - -// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -// Why: the versioned-install modules shell out for install state, locking, -// and GC. Stub them so deploy tests need no real SSH connection. -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -vi.mock('./ssh-relay-pinned-node', async (importOriginal) => ({ - ...(await importOriginal>()), - planPinnedNodeRelay: vi.fn(), - resolvePinnedRelayTargetFacts: vi.fn() -})) - -vi.mock('./ssh-relay-pinned-node-install', async (importOriginal) => ({ - ...(await importOriginal>()), - ensurePinnedRelayRuntime: vi.fn().mockResolvedValue(undefined), - verifyPinnedRelayInstall: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./orcad-remote-node-runtime', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - ensureRemoteOrcadNodeRuntime: vi.fn(actual.ensureRemoteOrcadNodeRuntime) - } -}) - -vi.mock('./ssh-relay-host-node-addons', async (importOriginal) => ({ - ...(await importOriginal>()), - planHostNodeAddonRelay: vi.fn() -})) - -vi.mock('./ssh-target-registry', () => ({ getSshTargetRegistryStore: vi.fn(() => null) })) -vi.mock('../telemetry/client', () => ({ track: vi.fn() })) - -vi.mock('./remote-node-runtime-store-gc', () => ({ - gcRemoteNodeRuntimeStore: vi.fn().mockResolvedValue({ state: 'skipped', reason: 'test' }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import { execCommand } from './ssh-relay-deploy-helpers' -import { RemoteNodeNotFoundError, resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { - finalizeInstall, - gcOldRelayVersions, - isRelayAlreadyInstalled -} from './ssh-relay-versioned-install' -import { - PinnedRelayFallbackError, - planPinnedNodeRelay, - resolvePinnedRelayTargetFacts, - type PinnedRelayPlan -} from './ssh-relay-pinned-node' -import { - MUTABLE_PINNED_REFUSAL_REPLAY_MS, - resetPinnedRuntimeRefusalsForTests -} from './ssh-relay-pinned-refusal-cache' -import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' -import { planHostNodeAddonRelay, type HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' -import { - ensureRemoteOrcadNodeRuntime, - RemoteNodeRuntimeSelfTestError -} from './orcad-remote-node-runtime' -import { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' -import { resetSshRemoteRuntimeTelemetryForTests } from './ssh-remote-runtime-telemetry' -import { getSshTargetRegistryStore } from './ssh-target-registry' -import { track } from '../telemetry/client' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionStore } from './ssh-connection-store' -import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS } from '../../shared/node-runtime-pin' -import type { SshRemoteRuntime, SshTarget } from '../../shared/ssh-types' -import { terminalUnavailableCauseFromError } from '../../shared/terminal-unavailable-cause' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' - -const PINNED_VERSION = '0.1.0+feedfacecafe' -const RUNTIME_SHA = NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256 -const PINNED_NODE = `/home/user/.orca-remote/runtimes/node-${RUNTIME_SHA}/bin/node` -const COMPAT_SHA = NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256 -const COMPAT_NODE = `/home/user/.orca-remote/runtimes/node-${COMPAT_SHA}/bin/node` - -function pinnedPlan(): PinnedRelayPlan { - return { - kind: 'pinned-node', - target: 'linux-x64-glibc', - glibc: { major: 2, minor: 31 }, - fullVersion: PINNED_VERSION, - addons: { dir: '/tmp/addons', digest: 'd', dispose: vi.fn().mockResolvedValue(undefined) }, - runtimeArchive: vi.fn() - } -} - -const HOST_NODE_VERSION = '0.1.0+0123456789ab' - -function hostNodePlan(): HostNodeAddonRelayPlan { - return { - kind: 'host-node-addons', - target: 'linux-x64-glibc', - glibc: { major: 2, minor: 31 }, - fullVersion: HOST_NODE_VERSION, - addons: { dir: '/tmp/host-addons', digest: 'd', dispose: vi.fn().mockResolvedValue(undefined) }, - nodePath: '/opt/node18/bin/node', - hostNode: { version: { major: 18, minor: 20 }, napi: 9 } - } -} - -function makeConnection(remoteRuntime?: SshRemoteRuntime): SshConnection { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deploy path under test touches only these members of the connection. - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - getTarget: () => ({ id: 'target-1', ...(remoteRuntime ? { remoteRuntime } : {}) }), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }) - } as unknown as SshConnection -} - -function detachedLaunchCommand(conn: SshConnection): string | undefined { - return vi - .mocked(conn.exec) - .mock.calls.map(([cmd]) => String(cmd)) - .find((cmd) => cmd.includes('--detached')) -} - -function queueInstalledPinnedLaunch(): void { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') -} - -function queueInstalledLegacyLaunch(): void { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('READY') -} - -/** A Linux host whose host-Node relay is installed and launches, answered by command text. */ -function answerHostNodeLaunchByCommand(): void { - const answers: [string, string][] = [ - ['uname', '__ORCA_REMOTE_PLATFORM__ Linux x86_64'], - ['process.stdout.write("READY")', 'READY'], - ['ORCA-NATIVE-DEPS-OK', 'ORCA-NATIVE-DEPS-OK'], - ['test -S', 'DEAD'] - ] - vi.mocked(execCommand).mockImplementation(async (_conn, command) => - command === 'echo $HOME' - ? '/home/user' - : (answers.find(([needle]) => command.includes(needle))?.[1] ?? '') - ) -} - -describe('deployAndLaunchRelay on the pinned Node runtime', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(planPinnedNodeRelay).mockReset().mockResolvedValue(pinnedPlan()) - vi.mocked(ensurePinnedRelayRuntime).mockReset().mockResolvedValue(undefined) - vi.mocked(resolvePinnedRelayTargetFacts) - .mockReset() - .mockResolvedValue({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 31 } }) - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockRejectedValue(new PinnedRelayFallbackError('libc_floor', 'addons refused')) - vi.mocked(getSshTargetRegistryStore).mockReset().mockReturnValue(null) - resetSshRemoteRuntimeTelemetryForTests() - resetPinnedRuntimeRefusalsForTests() - }) - - it('runs the pinned ladder when the host has no runtime setting and nothing recorded', async () => { - const conn = makeConnection() - queueInstalledPinnedLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(result.nodePath).toBe(PINNED_NODE) - expect(result.serverBuildId).toBe(PINNED_VERSION) - expect( - vi.mocked(execCommand).mock.calls.some(([, cmd]) => String(cmd).includes('NATIVE-DEPS')) - ).toBe(false) - }) - - it('keeps the host-npm path for a host that opts into Host Node', async () => { - const conn = makeConnection('legacy') - queueInstalledLegacyLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(planPinnedNodeRelay).not.toHaveBeenCalled() - expect(result.nodePath).toBe('/usr/bin/node') - expect(result.serverBuildId).toBe('0.1.0+abcdef012345') - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - }) - - it('clears the Host Node marker once a later connect launches on the ladder', async () => { - recordSshRelayRuntimeStep('target-1', true) - queueInstalledPinnedLaunch() - - await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') - - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(false) - }) - - it('launches from the runtime-folded version dir with the pinned Node and no host Node probe', async () => { - const conn = makeConnection('pinned-node') - queueInstalledPinnedLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(result.serverBuildId).toBe(PINNED_VERSION) - expect(result.remoteRelayDir).toBe(`/home/user/.orca-remote/relay-${PINNED_VERSION}`) - expect(result.nodePath).toBe(PINNED_NODE) - expect(detachedLaunchCommand(conn)).toContain(`'${PINNED_NODE}' relay.js --detached`) - expect( - vi.mocked(execCommand).mock.calls.some(([, cmd]) => String(cmd).includes('NATIVE-DEPS')) - ).toBe(false) - }) - - it('collects the runtime store after a launch, keeping the pin it runs', async () => { - const conn = makeConnection('pinned-node') - queueInstalledPinnedLaunch() - - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - await vi.waitFor(() => expect(gcRemoteNodeRuntimeStore).toHaveBeenCalledOnce()) - expect(gcRemoteNodeRuntimeStore).toHaveBeenCalledWith( - conn, - expect.objectContaining({ os: 'linux' }), - '/home/user', - // The compat runtime stays pinned too, so a rung A connect never collects rung B's. - { currentPins: [RUNTIME_SHA, COMPAT_SHA] } - ) - // Why after: the version pass is what drops the refs that held superseded runtimes. - expect(vi.mocked(gcOldRelayVersions).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(gcRemoteNodeRuntimeStore).mock.invocationCallOrder[0] - ) - }) - - it('leaves the runtime store alone on the legacy host-Node path', async () => { - const conn = makeConnection('legacy') - queueInstalledLegacyLaunch() - - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalled()) - await new Promise((resolve) => setImmediate(resolve)) - expect(gcRemoteNodeRuntimeStore).not.toHaveBeenCalled() - }) - - it('falls back to the host-Node relay, as before the ladder, once A, B and C all refuse', async () => { - const conn = makeConnection() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( - new PinnedRelayFallbackError('missing_lib', 'libstdc++.so.6: cannot open') - ) - vi.mocked(planPinnedNodeRelay) - .mockResolvedValueOnce(pinnedPlan()) - .mockRejectedValueOnce(new PinnedRelayFallbackError('missing_lib', 'refused')) - answerHostNodeLaunchByCommand() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(result.nodePath).toBe('/usr/bin/node') - // The fallback's strict probe found Node once; the launch reuses it. - expect(resolveRemoteNodePath).toHaveBeenCalledOnce() - expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung A unavailable (missing_lib)')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung B unavailable (missing_lib)')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung C unavailable (libc_floor)')) - warn.mockRestore() - }) - - it('keeps the host-Node relay, never rung D or a persisted decision, when this build lacks the orcad template', async () => { - const registry = { - getTarget: vi.fn(() => ({ id: 'target-1' })), - updateTarget: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. - vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const missing = 'The packaged orcad deployment template is missing' - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('artifacts_unavailable', 'refused') - ) - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockRejectedValueOnce(new PinnedRelayFallbackError('artifacts_unavailable', missing)) - const conn = makeConnection() - queueInstalledLegacyLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(result.nodePath).toBe('/usr/bin/node') - expect(detachedLaunchCommand(conn)).toContain("'/usr/bin/node' relay.js --detached") - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - expect(registry.updateTarget).not.toHaveBeenCalled() - expect(track).toHaveBeenCalledWith( - 'ssh_remote_runtime_resolved', - expect.objectContaining({ rung: 'legacy', first_refusal: 'artifacts_unavailable' }) - ) - }) - - it('settles rung D after a client gap only once the host-Node fallback proves no Node', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('artifacts_unavailable', 'refused') - ) - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockRejectedValueOnce(new PinnedRelayFallbackError('host_node_missing', 'no Node 18+')) - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - - const failure = await deployAndLaunchRelay( - makeConnection(), - undefined, - undefined, - 'target-1' - ).catch((error: unknown) => error) - - expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) - expect(String(failure)).toContain('could not prepare its bundled Node.js') - expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ reason: 'no_runtime' }) - expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) - }) - - function queueWindowsPlatformProbe(): SshConnection { - const conn = makeConnection() - Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) - vi.mocked(execCommand) - .mockRejectedValueOnce(new Error('uname not found')) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - return conn - } - - it('settles rung D with Windows wording when a Windows host has no Node to fall back to', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('security_software', 'refused') - ) - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - - const failure = await deployAndLaunchRelay( - queueWindowsPlatformProbe(), - undefined, - 300, - 'target-1' - ).catch((error: unknown) => error) - - expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) - expect(String(failure)).toContain('Windows host') - expect(String(failure)).toContain('install Node.js 18+') - expect(String(failure)).not.toContain('mounted noexec') - expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ - reason: 'no_runtime', - host: { platform: 'win32' } - }) - }) - - it('keeps an unanswered host-Node probe a retryable failure, not rung D', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('security_software', 'refused') - ) - const lost = new Error('channel closed') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(lost) - - await expect( - deployAndLaunchRelay(queueWindowsPlatformProbe(), undefined, 300, 'target-1') - ).rejects.toBe(lost) - }) - - it('replays a Windows security-software refusal for a day, then re-proves rung A', async () => { - const winSha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 - const stored: Partial = {} - const registry = { - getTarget: vi.fn(() => ({ id: 'target-1', ...stored })), - updateTarget: vi.fn((_id: string, updates: Partial) => { - Object.assign(stored, updates) - return null - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. - vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) - vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ target: 'win32-x64', glibc: null }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('security_software', 'refused') - ) - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - - await deployAndLaunchRelay(queueWindowsPlatformProbe(), undefined, 300, 'target-1').catch( - () => undefined - ) - - // Persisted with its proof time, so a restart doesn't re-upload to a host that still blocks it. - expect(stored.remoteRuntimeResolution).toMatchObject({ - pinnedRefusal: 'security_software', - runtimeSha256: winSha, - refusedAt: expect.any(Number) - }) - const persisted = vi.mocked(planPinnedNodeRelay).mock.calls[0]![0].persistedRefusal - const winFacts = { target: 'win32-x64' as const, glibc: null } - expect(persisted?.(winFacts)).toBe('security_software') - stored.remoteRuntimeResolution = { - ...stored.remoteRuntimeResolution!, - refusedAt: Date.now() - MUTABLE_PINNED_REFUSAL_REPLAY_MS - } - expect(persisted?.(winFacts)).toBeNull() - }) - - it.each(['missing_lib', 'security_software', 'noexec', 'artifacts_unavailable'] as const)( - 'keeps a Windows host on the host-Node relay after rung A refuses for %s', - async (reason) => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const conn = makeConnection() - Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError(reason, 'refused') - ) - vi.mocked(execCommand) - .mockRejectedValueOnce(new Error('uname not found')) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe - .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch - .mockResolvedValueOnce('READY') // named pipe poll - .mockResolvedValueOnce('') // persist active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-1') - - expect(resolveRemoteNodePath).toHaveBeenCalled() - expect(result.serverBuildId).toBe('0.1.0+abcdef012345') - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - } - ) - - it('runs rung B on the glibc 2.17 compat runtime when rung A is below its glibc floor', async () => { - const conn = makeConnection('pinned-node') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const oldGlibc = { major: 2, minor: 17 } - vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ - target: 'linux-x64-glibc', - glibc: oldGlibc - }) - vi.mocked(planPinnedNodeRelay) - .mockRejectedValueOnce(new PinnedRelayFallbackError('libc_floor', 'refused')) - .mockResolvedValueOnce({ ...pinnedPlan(), target: 'linux-x64-glibc217', glibc: oldGlibc }) - queueInstalledPinnedLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(vi.mocked(planPinnedNodeRelay).mock.calls[1]?.[0]).toMatchObject({ - compat: { target: 'linux-x64-glibc217', glibcFloor: oldGlibc } - }) - expect(result.nodePath).toBe(COMPAT_NODE) - expect(detachedLaunchCommand(conn)).toContain(`'${COMPAT_NODE}' relay.js --detached`) - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - await vi.waitFor(() => expect(gcRemoteNodeRuntimeStore).toHaveBeenCalledOnce()) - expect(vi.mocked(gcRemoteNodeRuntimeStore).mock.calls[0]?.[3]).toEqual({ - currentPins: [RUNTIME_SHA, COMPAT_SHA] - }) - warn.mockRestore() - }) - - it('skips rung B on a current glibc when rung A refused for a reason B cannot answer', async () => { - const conn = makeConnection('pinned-node') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('illegal_instruction', 'refused') - ) - vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - - await expect( - deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - ).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) - - expect(planPinnedNodeRelay).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('rung B unavailable (runtime_unavailable)') - ) - warn.mockRestore() - }) - - it('does not step down when the runtime check is unverifiable', async () => { - const conn = makeConnection('pinned-node') - const unverifiable = new Error('The pinned Node self-test is unverifiable') - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(unverifiable) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - - await expect(deployAndLaunchRelay(conn, undefined, undefined, 'target-1')).rejects.toBe( - unverifiable - ) - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(detachedLaunchCommand(conn)).toBeUndefined() - // No self-test ran, so there is no unverifiable runtime outcome to report. - expect(track).not.toHaveBeenCalled() - }) - - it('releases the staged addons after the attempt', async () => { - const conn = makeConnection('pinned-node') - const plan = pinnedPlan() - vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce(plan) - queueInstalledPinnedLaunch() - - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(plan.addons.dispose).toHaveBeenCalledOnce() - }) - - it('uploads the relay and its addons, then self-tests instead of installing native deps', async () => { - const conn = makeConnection('pinned-node') - const uploadDirectory = vi.fn().mockResolvedValue(undefined) - Object.assign(conn, { uploadDirectory, writeFile: vi.fn().mockResolvedValue(undefined) }) - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0] - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) { - return Promise.resolve(`__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0`) - } - if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__') && marker) { - return Promise.resolve(`__ORCA_UPLOAD_STAGE_PROMOTION__${marker}:PROMOTED`) - } - if (command.includes('process.stdout.write("READY")')) { - return Promise.resolve('READY') - } - return Promise.resolve(command.includes('test -S') ? 'DEAD' : '') - }) - - await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(uploadDirectory.mock.calls.map(([local]) => local)).toContain('/tmp/addons') - expect(ensurePinnedRelayRuntime).toHaveBeenCalledWith( - expect.objectContaining({ - remoteRelayDir: `/home/user/.orca-remote/relay-${PINNED_VERSION}` - }), - false - ) - expect(verifyPinnedRelayInstall).toHaveBeenCalledOnce() - expect(vi.mocked(verifyPinnedRelayInstall).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(finalizeInstall).mock.invocationCallOrder[0] - ) - expect( - vi.mocked(execCommand).mock.calls.some(([, cmd]) => /npm (?:install|ci)/.test(String(cmd))) - ).toBe(false) - expect(detachedLaunchCommand(conn)).toContain(`'${PINNED_NODE}' relay.js --detached`) - }) - - it('runs rung C on the host Node with the prebuilt addons and no npm', async () => { - const conn = makeConnection('pinned-node') - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(planPinnedNodeRelay).mockRejectedValueOnce( - new PinnedRelayFallbackError('artifacts_unavailable', 'refused') - ) - const plan = hostNodePlan() - vi.mocked(planHostNodeAddonRelay).mockReset().mockResolvedValueOnce(plan) - queueInstalledPinnedLaunch() - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(ensurePinnedRelayRuntime).not.toHaveBeenCalled() - expect(result.serverBuildId).toBe(HOST_NODE_VERSION) - expect(result.nodePath).toBe('/opt/node18/bin/node') - expect(detachedLaunchCommand(conn)).toContain("'/opt/node18/bin/node' relay.js --detached") - expect(plan.addons.dispose).toHaveBeenCalledOnce() - expect(track).toHaveBeenCalledWith( - 'ssh_remote_runtime_resolved', - expect.objectContaining({ - rung: 'c', - first_refusal: 'artifacts_unavailable', - host_node_major: '18', - host_libc: 'glibc', - glibc_minor: '31' - }) - ) - }) - - it("steps past rung A to the host's Node when NixOS's stub loader refuses the pinned Node", async () => { - const conn = makeConnection() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const actualInstall = await vi.importActual( - './ssh-relay-pinned-node-install' - ) - vi.mocked(ensurePinnedRelayRuntime).mockImplementationOnce( - actualInstall.ensurePinnedRelayRuntime - ) - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSelfTestError( - 127, - `Could not start dynamically linked executable: ${PINNED_NODE}\n` + - 'NixOS cannot run dynamically linked executables intended for generic\n' + - 'linux environments out of the box. For more information, see:\n' + - 'https://nix.dev/permalink/stub-ld' - ) - ) - const nixNode = '/run/current-system/sw/bin/node' - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockResolvedValueOnce({ ...hostNodePlan(), nodePath: nixNode }) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('uname')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('process.stdout.write("READY")')) { - return 'READY' - } - return command.includes('test -S') ? 'DEAD' : '' - }) - - const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') - - expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce() - expect(result.nodePath).toBe(nixNode) - expect(detachedLaunchCommand(conn)).toContain(`'${nixNode}' relay.js --detached`) - expect( - vi.mocked(execCommand).mock.calls.some(([, cmd]) => /NATIVE-DEPS|npm /.test(String(cmd))) - ).toBe(false) - expect(track).toHaveBeenCalledWith( - 'ssh_remote_runtime_resolved', - expect.objectContaining({ rung: 'c', first_refusal: 'wrong_libc' }) - ) - }) - - it('tries the host-Node fallback after a proved noexec, reaching D only on its answer', async () => { - const conn = makeConnection('pinned-node') - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( - new PinnedRelayFallbackError('noexec', 'exit 126: Permission denied') - ) - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - - const failure = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1').catch( - (error: unknown) => error - ) - - expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) - expect(String(failure)).toContain('mounted noexec') - expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ - status: 'blocked', - reason: 'home_noexec', - repairable: false - }) - // B and C load addons from the same tree, so only the fallback can disprove the noexec. - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - expect(vi.mocked(resolveRemoteNodePath).mock.calls[0]?.[2]).toMatchObject({ strict: true }) - expect(detachedLaunchCommand(conn)).toBeUndefined() - expect(track).toHaveBeenCalledWith( - 'ssh_remote_runtime_resolved', - expect.objectContaining({ rung: 'd', first_refusal: 'noexec' }) - ) - }) - - it('steps down on an answered but unclassified install failure (ENOSPC), not abort', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const enospc = Object.assign( - new Error('Command "tar -xzf" failed (exit 2): tar: write error: No space left on device'), - { exitCode: 2, stdout: '' } - ) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(enospc) - answerHostNodeLaunchByCommand() - vi.mocked(planHostNodeAddonRelay) - .mockReset() - .mockRejectedValueOnce(new PinnedRelayFallbackError('host_node_missing', 'no Node 18+')) - - const result = await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') - - expect(result.nodePath).toBe('/usr/bin/node') - expect(isSshRelayOnHostNodeRuntime('target-1')).toBe(true) - }) - - it('keeps an unanswered install failure retryable rather than stepping down', async () => { - const lost = Object.assign(new Error('channel lost'), { sshChannelCloseConfirmed: false }) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(lost) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - - await expect( - deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') - ).rejects.toBe(lost) - expect(planHostNodeAddonRelay).not.toHaveBeenCalled() - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - }) - - it('persists the rung A refusal under the host key and skips A on the next connect', async () => { - const stored: Partial = {} - const registry = { - getTarget: vi.fn(() => ({ id: 'target-1', ...stored })), - updateTarget: vi.fn((_id: string, updates: Partial) => { - Object.assign(stored, updates) - return null - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. - vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( - new PinnedRelayFallbackError('illegal_instruction', 'SIGILL') - ) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce( - new RemoteNodeNotFoundError('Node.js not found on remote host.') - ) - - await expect( - deployAndLaunchRelay(makeConnection('pinned-node'), undefined, undefined, 'target-1') - ).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) - - expect(stored.remoteRuntimeResolution).toMatchObject({ - rung: 'D', - pinnedRefusal: 'illegal_instruction', - glibc: '2.31', - runtimeSha256: RUNTIME_SHA - }) - const persisted = vi.mocked(planPinnedNodeRelay).mock.calls[0]![0].persistedRefusal - expect(persisted?.({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 31 } })).toBe( - 'illegal_instruction' - ) - // A different glibc is a different key: the cached refusal no longer applies. - expect(persisted?.({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 35 } })).toBeNull() - expect(track).toHaveBeenCalledOnce() - }) - - it('launches a Windows relay on node.exe from the runtimes store, with no host Node probe', async () => { - const conn = makeConnection('pinned-node') - Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) - const sha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 - const nodeExe = `C:/Users/me/.orca-remote/runtimes/node-${sha}/node.exe` - vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ - ...pinnedPlan(), - target: 'win32-x64', - glibc: null - }) - vi.mocked(execCommand) - .mockRejectedValueOnce(new Error('uname not found')) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me') - .mockResolvedValueOnce('') // no persisted active pipe - .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch - .mockResolvedValueOnce('READY') // named pipe poll - .mockResolvedValueOnce('') // persist active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-1') - - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - expect(result.nodePath).toBe(nodeExe) - expect(result.remoteRelayDir).toBe(`C:/Users/me/.orca-remote/relay-${PINNED_VERSION}`) - expect(ensurePinnedRelayRuntime).toHaveBeenCalledWith( - expect.objectContaining({ host: expect.objectContaining({ os: 'win32' }) }), - true - ) - const launchScript = vi - .mocked(execCommand) - .mock.calls.map(([, command]) => decodeRemotePowerShellScript(String(command))) - .find((script) => script.includes('Invoke-CimMethod')) - expect(launchScript).toContain(nodeExe) - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts deleted file mode 100644 index 063414ea8b42..000000000000 --- a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts +++ /dev/null @@ -1,687 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { - const normalizedOs = os.toLowerCase() - const normalizedArch = arch.toLowerCase() - const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' - if (normalizedOs === 'windows' || normalizedOs === 'win32') { - return `win32-${relayArch}` - } - if (normalizedOs === 'darwin') { - return `darwin-${relayArch}` - } - if (normalizedOs === 'linux') { - return `linux-${relayArch}` - } - return null - }), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -// Why: the post-launch ripgrep install is fire-and-forget and would drain the queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { ensureRemoteBundledRipgrep } from './ssh-relay-ripgrep-install' -import { RELAY_UPLOAD_STAGE_POOL_NAME } from './ssh-relay-upload-stage-contract' -import { - RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, - RELAY_DEPLOY_TIMEOUT_MS -} from './ssh-relay-deploy-timing' -import type { SshConnection } from './ssh-connection' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() - }) - } as unknown as SshConnection -} - -function stageCommandResponse(command: string): string | undefined { - const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0] - if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) { - return `__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0` - } - if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__') && marker) { - return `__ORCA_UPLOAD_STAGE_PROMOTION__${marker}:PROMOTED` - } - return command.includes('.upload-stages') ? '' : undefined -} - -describe('deployAndLaunchRelay staged uploads', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined) - }) - - it('aborts an in-progress relay upload at the overall deploy timeout', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - return Promise.resolve(stageCommandResponse(command) ?? '') - }) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(false) - let uploadSignal: AbortSignal | undefined - conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => { - uploadSignal = options?.signal - return new Promise((_resolve, reject) => { - uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { - once: true - }) - }) - }) - - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.advanceTimersByTimeAsync(0) - expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(900_000) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 900s') - expect(uploadSignal?.aborted).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('joins the bounded teardown window when an aborted transfer never settles', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - return Promise.resolve(stageCommandResponse(command) ?? '') - }) - let uploadSignal: AbortSignal | undefined - conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => { - uploadSignal = options?.signal - return new Promise(() => {}) - }) - - const deployment = deployAndLaunchRelay(conn).catch((error: Error) => error) - await vi.advanceTimersByTimeAsync(0) - expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - expect(uploadSignal?.aborted).toBe(true) - expect( - await Promise.race([deployment.then(() => 'settled'), Promise.resolve('pending')]) - ).toBe('pending') - - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) - await expect(deployment).resolves.toMatchObject({ - message: 'Relay deployment timed out after 900s', - sshChannelCloseConfirmed: false, - sshTransferTeardownConfirmed: false - }) - } finally { - vi.useRealTimers() - } - }) - - it('waits for a deferred SFTP upload before acquiring the install lock', async () => { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - let finishUpload: () => void = () => {} - conn.uploadDirectory = vi.fn( - () => - new Promise((resolve) => { - finishUpload = resolve - }) - ) - - const deploy = deployAndLaunchRelay(conn).catch(() => undefined) - await vi.waitFor(() => expect(conn.uploadDirectory).toHaveBeenCalledTimes(1)) - expect(acquireInstallLock).not.toHaveBeenCalled() - finishUpload() - await vi.waitFor(() => expect(acquireInstallLock).toHaveBeenCalledTimes(1)) - await deploy - }) - - it('drops only its stage when a sibling finishes before the locked re-probe', async () => { - const conn = makeMockConnection() - const events: string[] = [] - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockImplementationOnce(async () => { - events.push('initial-probe') - return false - }) - .mockImplementationOnce(async () => { - events.push('locked-re-probe') - return true - }) - vi.mocked(acquireInstallLock).mockImplementationOnce(async () => { - events.push('lock') - }) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockImplementation(async () => { - events.push('upload') - }) - - await deployAndLaunchRelay(conn) - - expect(events).toEqual(['initial-probe', 'upload', 'lock', 'locked-re-probe']) - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('cp -a'))).toBe(false) - const uploadStageRemovals = commands.filter( - (command) => /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') - ) - expect(uploadStageRemovals).toHaveLength(1) - expect(uploadStageRemovals[0]).toContain('/.orca-remote/.upload-stages/claim-0') - }) - - it.each(['lock acquisition', 'locked recheck'])( - 'preserves the stage after uncertain %s termination', - async (phase) => { - const conn = makeMockConnection() - const error = Object.assign(new Error('install command still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false) - if (phase === 'lock acquisition') { - vi.mocked(acquireInstallLock).mockRejectedValueOnce(error) - } else { - vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(error) - } - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('uname')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - return stageCommandResponse(command) ?? '' - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) - expect( - vi - .mocked(execCommand) - .mock.calls.some( - ([, command]) => - /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') - ) - ).toBe(false) - expect(conn.exec).not.toHaveBeenCalled() - } - ) - - it('stops before launch when owned-stage cleanup cannot confirm termination', async () => { - const conn = makeMockConnection() - const error = Object.assign(new Error('stage removal still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(true) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (/\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf')) { - throw error - } - if (command.includes('uname')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - return stageCommandResponse(command) ?? '' - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) - expect(conn.exec).not.toHaveBeenCalled() - expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() - }) - - it('runs bounded fixed-path recovery before a fresh upload', async () => { - const conn = makeMockConnection() - const events: string[] = [] - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('deleting_old=')) { - events.push('recover') - } - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockImplementation(async () => { - events.push('upload') - }) - - await deployAndLaunchRelay(conn) - - expect(events.indexOf('recover')).toBeLessThan(events.indexOf('upload')) - }) - - it('launches before one bounded installed-path recovery exec', async () => { - const conn = makeMockConnection() - const events: string[] = [] - let socketProbe = 0 - vi.mocked(waitForSentinel).mockImplementation(async () => { - events.push('launch-ready') - return { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - } - }) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('deleting_old=')) { - events.push('recover') - } - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ % 2 === 0 ? 'DEAD' : 'READY') - } - return Promise.resolve('') - }) - - for (let deployment = 0; deployment < 12; deployment += 1) { - await deployAndLaunchRelay(conn) - } - - await vi.waitFor(() => expect(events).toHaveLength(24)) - expect(events).toEqual(Array.from({ length: 12 }, () => ['launch-ready', 'recover']).flat()) - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const recoveryCommands = commands.filter((command) => command.includes('deleting_old=')) - expect(recoveryCommands).toHaveLength(12) - expect(recoveryCommands.every((command) => command.includes('.upload-stages'))).toBe(true) - expect(recoveryCommands.every((command) => !command.includes('find "$pool"'))).toBe(true) - }) - - it('never enumerates arbitrary stage paths during installation', async () => { - const conn = makeMockConnection() - conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - - await deployAndLaunchRelay(conn) - - expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) - const stageCommands = vi - .mocked(execCommand) - .mock.calls.map(([, command]) => command) - .filter((command) => command.includes('.upload-stages')) - expect(stageCommands.length).toBeGreaterThan(0) - expect(stageCommands.every((command) => !command.includes('-mindepth'))).toBe(true) - }) - - it('keeps the staging tree after an unconfirmed system SSH upload termination', async () => { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - return Promise.resolve( - command.includes('uname') ? '__ORCA_REMOTE_PLATFORM__ Linux x86_64' : '/home/user' - ) - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - const termination = Object.assign(new Error('upload teardown unconfirmed'), { - sshChannelCloseConfirmed: false - }) - conn.uploadDirectory = vi.fn().mockRejectedValue(termination) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination) - expect(acquireInstallLock).not.toHaveBeenCalled() - expect( - vi - .mocked(execCommand) - .mock.calls.some( - ([, command]) => - /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') - ) - ).toBe(false) - }) - - it('retries immediately after an unconfirmed upload termination instead of waiting on a fresh install lock', async () => { - const conn = makeMockConnection() - const termination = Object.assign(new Error('upload teardown unconfirmed'), { - sshChannelCloseConfirmed: false - }) - let lockHeld = false - vi.mocked(acquireInstallLock).mockImplementation((_conn, _dir, _host, options) => { - if (!lockHeld) { - lockHeld = true - return Promise.resolve() - } - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { - once: true - }) - }) - }) - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockRejectedValueOnce(termination).mockResolvedValue(undefined) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination) - await deployAndLaunchRelay(conn) - - expect(conn.uploadDirectory).toHaveBeenCalledTimes(2) - expect(acquireInstallLock).toHaveBeenCalledTimes(1) - }) - - it('cleans a confirmed-abort staging tree so the next deployment retries immediately', async () => { - const conn = makeMockConnection() - const termination = Object.assign(new Error('upload aborted'), { - sshChannelCloseConfirmed: true - }) - vi.mocked(isRelayAlreadyInstalled) - .mockReset() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const stageResponse = stageCommandResponse(command) - if (stageResponse !== undefined) { - return Promise.resolve(stageResponse) - } - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - return Promise.resolve('') - }) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - conn.uploadDirectory = vi.fn().mockRejectedValueOnce(termination).mockResolvedValue(undefined) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination) - expect( - vi - .mocked(execCommand) - .mock.calls.some( - ([, command]) => - /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') - ) - ).toBe(true) - - await deployAndLaunchRelay(conn) - expect(conn.uploadDirectory).toHaveBeenCalledTimes(2) - }) - it('returns the relay before ripgrep finishes but defers stale-stage cleanup', async () => { - const conn = makeMockConnection() - let finishUpload = (): void => {} - vi.mocked(ensureRemoteBundledRipgrep).mockImplementationOnce( - () => - new Promise((resolve) => { - finishUpload = () => resolve('present') - }) - ) - let socketProbe = 0 - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('test -S')) { - return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY') - } - return Promise.resolve('') - }) - - const deployed = await deployAndLaunchRelay(conn) - expect(deployed.transport).toBeDefined() - await new Promise((resolve) => setImmediate(resolve)) - expect( - vi - .mocked(execCommand) - .mock.calls.some(([, command]) => command.includes(RELAY_UPLOAD_STAGE_POOL_NAME)) - ).toBe(false) - finishUpload() - - await vi.waitFor(() => { - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes(RELAY_UPLOAD_STAGE_POOL_NAME))).toBe(true) - }) - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts deleted file mode 100644 index 1036385fac11..000000000000 --- a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -// Windows named-pipe endpoint deploys, split out of ssh-relay-deploy.test.ts: that file sits at -// the max-lines cap, and these four share only the deploy harness with the rest of it. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront -// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report -// the local relay package as existing AND return a content-hashed version -// string so readLocalFullVersion succeeds. -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { - const normalizedOs = os.toLowerCase() - const normalizedArch = arch.toLowerCase() - const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' - if (normalizedOs === 'windows' || normalizedOs === 'win32') { - return `win32-${relayArch}` - } - if (normalizedOs === 'darwin') { - return `darwin-${relayArch}` - } - if (normalizedOs === 'linux') { - return `linux-${relayArch}` - } - return null - }), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -// Why: this file mocks fs, so the real content hash cannot read a binary. -vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ - resolveBundledRipgrepPath: () => null, - bundledRipgrepContentKey: () => 'c0ffee0123456789' -})) - -// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -// Why: the versioned-install modules shell out for install state, locking, -// and GC. Stub them so deploy tests need no real SSH connection. -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import type { SshConnection } from './ssh-connection' - -function decodePowerShellCommand(command: string): string | null { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null -} - -const extractWindowsSockPath = (script: string): string => - /--sock-path\s+'([^']+)'/.exec(script)?.[1] ?? '' - -const extractWindowsMarkerPath = (script: string): string => - /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? '' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - writeFile: vi.fn().mockResolvedValue(undefined), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() - }) - } as unknown as SshConnection -} - -describe('deployAndLaunchRelay on Windows remotes', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined) - }) - - it('launches Windows remotes via a named pipe endpoint', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce('') // no persisted active pipe - .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch - .mockResolvedValueOnce('READY') // named pipe poll - .mockResolvedValueOnce('') // persist active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - expect(result.platform).toBe('win32-x64') - expect(result.remoteHome).toBe('C:/Users/me user') - expect(result.sockPath).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(1) - expect(execCommands[0]).toContain('powershell.exe') - const decodedScripts = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => script !== null) - const launchScript = decodedScripts.find((script) => script.includes('Invoke-CimMethod')) ?? '' - expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js"' - ) - expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-' - ) - expect(launchScript).toContain('--endpoint-dir') - expect(launchScript).toContain( - '--ripgrep-path "C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe"' - ) - expect(launchScript).not.toContain('--pty-source-credit-v1') - expect(launchScript).not.toContain('.pty-source-credit-policy') - expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') - const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' - expect(waitScript).toContain('setTimeout(attempt,intervalMs)') - const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { - const script = decodePowerShellCommand(command) - return ( - script?.includes('.windows-active-pipe') || - script?.includes('Invoke-CimMethod') || - script?.includes('deadline=Date.now()') - ) - }) - expect(windowsLaunchCalls.length).toBeGreaterThan(0) - expect( - windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) - ).toBe(true) - expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) - expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) - }) - - it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - vi.mocked(waitForSentinel) - .mockRejectedValueOnce(new Error('stale daemon handshake failed')) - .mockResolvedValueOnce({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce('') // no persisted active pipe yet - .mockResolvedValueOnce('READY') // existing named pipe probe - .mockResolvedValueOnce('WAITING') // deterministic fallback pipe is not already running - .mockResolvedValueOnce('') // WMI relay launch on fallback pipe - .mockResolvedValueOnce('READY') // fallback pipe poll - .mockResolvedValueOnce('') // persist fallback active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(2) - const firstConnectScript = decodePowerShellCommand(execCommands[0]) ?? '' - const secondConnectScript = decodePowerShellCommand(execCommands[1]) ?? '' - const primaryPipe = extractWindowsSockPath(firstConnectScript) - const fallbackPipe = extractWindowsSockPath(secondConnectScript) - expect(primaryPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - expect(fallbackPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - expect(fallbackPipe).not.toBe(primaryPipe) - expect(result.sockPath).toBe(fallbackPipe) - - const launchScript = - mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .find((script) => script?.includes('Invoke-CimMethod')) ?? '' - expect(launchScript).toContain(fallbackPipe) - expect(launchScript).not.toContain(primaryPipe) - - const markerWriteScript = - mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .find( - (script) => script?.includes('Set-Content') && script.includes('.windows-active-pipe') - ) ?? '' - expect(markerWriteScript).toContain(fallbackPipe) - expect(markerWriteScript).not.toContain(primaryPipe) - }) - - it('prefers a persisted Windows fallback pipe on later reconnects', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker - .mockResolvedValueOnce('READY') // persisted named pipe probe - .mockResolvedValueOnce('') // refresh active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(1) - const connectScript = decodePowerShellCommand(execCommands[0]) ?? '' - expect(extractWindowsSockPath(connectScript)).toBe(persistedPipe) - expect(result.sockPath).toBe(persistedPipe) - - const decodedExecScripts = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => script !== null) - expect(decodedExecScripts.some((script) => script.includes('Invoke-CimMethod'))).toBe(false) - }) - - it('scopes persisted Windows active pipe markers by relay target', async () => { - const connA = makeMockConnection() - const connB = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe A - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('READY') - .mockResolvedValueOnce('') // persist active pipe A - .mockResolvedValueOnce('') // deferred stale-stage cleanup A - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe B - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('READY') - .mockResolvedValueOnce('') // persist active pipe B - - await deployAndLaunchRelay(connA, undefined, 300, 'target-a') - await new Promise((resolve) => setImmediate(resolve)) - await deployAndLaunchRelay(connB, undefined, 300, 'target-b') - - const markerPaths = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => Boolean(script?.includes('Get-Content'))) - .map(extractWindowsMarkerPath) - - expect(markerPaths).toHaveLength(2) - expect(markerPaths[0]).toContain('.windows-active-pipe-relay-') - expect(markerPaths[1]).toContain('.windows-active-pipe-relay-') - expect(markerPaths[0]).not.toBe(markerPaths[1]) - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts deleted file mode 100644 index db38b1951450..000000000000 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ /dev/null @@ -1,915 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront -// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report -// the local relay package as existing AND return a content-hashed version -// string so readLocalFullVersion succeeds. -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { - const normalizedOs = os.toLowerCase() - const normalizedArch = arch.toLowerCase() - const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' - if (normalizedOs === 'windows' || normalizedOs === 'win32') { - return `win32-${relayArch}` - } - if (normalizedOs === 'darwin') { - return `darwin-${relayArch}` - } - if (normalizedOs === 'linux') { - return `linux-${relayArch}` - } - return null - }), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -// Why: this file mocks fs, so the real content hash cannot read a binary. -vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ - resolveBundledRipgrepPath: () => null, - bundledRipgrepContentKey: () => 'c0ffee0123456789' -})) - -// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -// Why: the versioned-install modules shell out for install state, locking, -// and GC. Stub them so deploy tests need no real SSH connection. -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { - ensureRemoteBundledRipgrep, - recordRemoteRipgrepReference -} from './ssh-relay-ripgrep-install' -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' -import * as DeployTiming from './ssh-relay-deploy-timing' -import type { SshConnection } from './ssh-connection' -import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS -} from '../../shared/ssh-types' - -function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - writeFile: vi.fn().mockResolvedValue(undefined), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() - }) - } as unknown as SshConnection -} - -function queueLaunchNamespaceAndDeadSocketProbe(): void { - vi.mocked(execCommand).mockResolvedValueOnce('').mockResolvedValueOnce('DEAD') -} - -function queueFreshLinuxDeploy(): void { - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - queueLaunchNamespaceAndDeadSocketProbe() - vi.mocked(execCommand).mockResolvedValueOnce('READY') -} - -function detachedLaunchCommand(conn: SshConnection): string | undefined { - return vi - .mocked(conn.exec) - .mock.calls.map(([cmd]) => cmd as string) - .find((cmd) => cmd.includes('--detached')) -} - -describe('deployAndLaunchRelay', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(ensureRemoteOpenCodeRuntime).mockReset().mockResolvedValue('ready') - vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined) - }) - - it('calls exec to detect remote platform', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - - await deployAndLaunchRelay(conn) - - expect(mockExecCommand).toHaveBeenCalledWith( - conn, - "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm", - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ) - }) - - it('reports progress via callback', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - mockExecCommand.mockResolvedValueOnce('/home/user') - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - - const progress: string[] = [] - await deployAndLaunchRelay(conn, (status) => progress.push(status)) - - expect(progress).toContain('Detecting remote platform...') - expect(progress).toContain('Starting relay...') - }) - - it('does not launch fresh after an unconfirmed endpoint-incumbent probe', async () => { - const conn = makeMockConnection() - const unconfirmedCleanup = Object.assign(new Error('endpoint probe still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('stale relay reconnect failed')) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('ALIVE') - .mockRejectedValueOnce(unconfirmedCleanup) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(unconfirmedCleanup) - - const commands = vi.mocked(conn.exec).mock.calls.map(([command]) => command) - expect(commands).toHaveLength(1) - expect( - commands.filter((command) => /--detached|\brm -f\b|\bkill\s/.test(command)) - ).toHaveLength(0) - }) - - it('resolves the remote node path once per deploy', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn) - - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) - - it('resolves node concurrently with remote home, not after the install-state chain', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - - let markNodeResolutionStarted: () => void = () => {} - const nodeResolutionStarted = new Promise((resolve) => { - markNodeResolutionStarted = resolve - }) - vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { - markNodeResolutionStarted() - return Promise.resolve('/usr/bin/node') - }) - - // Hold the first install-state step open. The optimization starts the node - // branch before the remote-home -> install-check chain finishes. - let releaseRemoteHome: (home: string) => void = () => {} - mockExecCommand.mockReturnValueOnce( - new Promise((resolve) => { - releaseRemoteHome = resolve - }) - ) - - const deployPromise = deployAndLaunchRelay(conn) - let assertionError: unknown - let deployError: unknown - try { - await nodeResolutionStarted - - expect(isRelayAlreadyInstalled).not.toHaveBeenCalled() - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - } catch (err) { - assertionError = err - } finally { - // Drain the rest of the happy path so a failed assertion does not leave - // the deploy promise pending until the overall deploy timeout. - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - releaseRemoteHome('/home/user') - deployError = await deployPromise.then( - () => undefined, - (err: unknown) => err - ) - } - if (assertionError) { - throw assertionError - } - if (deployError) { - throw deployError - } - }) - - it('keeps bootstrap sequential when the connection cannot run concurrent exec commands', async () => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - let releaseRemoteHome: (home: string) => void = () => {} - let remoteHomeProbeStarted: () => void = () => {} - const remoteHomeProbeStartedPromise = new Promise((resolve) => { - remoteHomeProbeStarted = resolve - }) - mockExecCommand.mockReturnValueOnce( - new Promise((resolve) => { - remoteHomeProbeStarted() - releaseRemoteHome = resolve - }) - ) - - const deployPromise = deployAndLaunchRelay(conn) - await remoteHomeProbeStartedPromise - expect(resolveRemoteNodePath).not.toHaveBeenCalled() - - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - releaseRemoteHome('/home/user') - await deployPromise - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) - - it('falls back to sequential bootstrap when concurrent SSH sessions are refused', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - const { resolveRemoteNodePath: resolveRemoteNodePathActual } = await vi.importActual< - typeof SshRemoteNodeResolution - >('./ssh-remote-node-resolution') - let fallbackInstallStateCompleted = false - vi.mocked(resolveRemoteNodePath) - .mockImplementationOnce(resolveRemoteNodePathActual) - .mockImplementationOnce(() => { - if (!fallbackInstallStateCompleted) { - throw new Error('Sequential fallback resolved node before install state finished') - } - return Promise.resolve('/usr/bin/node') - }) - vi.mocked(isRelayAlreadyInstalled) - .mockImplementationOnce(async (_conn, _dir, _host, options) => { - expect(options?.rethrowSessionLimitErrors).toBe(true) - return true - }) - .mockImplementationOnce(async (_conn, _dir, _host, options) => { - expect(options?.rethrowSessionLimitErrors).toBeUndefined() - fallbackInstallStateCompleted = true - return true - }) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - mockExecCommand.mockRejectedValueOnce(sessionLimitError) // concurrent node path probe - mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - - await deployAndLaunchRelay(conn) - - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ - rethrowSessionLimitErrors: true - }) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) - }) - - it('falls back to sequential bootstrap when the install-state probe hits a session limit', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - vi.mocked(isRelayAlreadyInstalled) - .mockImplementationOnce(async (_conn, _dir, _host, options) => { - if (!options?.rethrowSessionLimitErrors) { - return true - } - throw sessionLimitError - }) - .mockResolvedValueOnce(true) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - - await deployAndLaunchRelay(conn) - - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({ - rethrowSessionLimitErrors: true - }) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toMatchObject({ - rethrowSessionLimitErrors: undefined, - signal: expect.any(AbortSignal) - }) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ - rethrowSessionLimitErrors: true - }) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) - }) - - it('does not retry bootstrap for non-session failures', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const nodeError = new Error('Node.js not found on remote host') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(nodeError) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(nodeError) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) - - it('aborts a pending sibling probe and preserves a non-session install-state failure', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - let nodeProbeAborted = false - vi.mocked(resolveRemoteNodePath).mockImplementationOnce((_conn, _host, options) => { - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => { - nodeProbeAborted = true - const abortError = new Error('aborted') - abortError.name = 'AbortError' - reject(abortError) - }) - }) - }) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('relative-home') // invalid install-state $HOME - - const timedDeploy = Promise.race([ - deployAndLaunchRelay(conn), - new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error('deploy did not fail promptly')), 100) - }) - ]) - - await expect(timedDeploy).rejects.toThrow(/Remote home is not a valid path/) - expect(nodeProbeAborted).toBe(true) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) - - it.each([false, true])( - 'does not retry after a sibling failure (unconfirmed abort: %s)', - async (unconfirmed) => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign( - new Error('(SSH) Channel open failure: open failed'), - { - reason: 4 - } - ) - const installError = unconfirmed - ? Object.assign(new Error('probe still running'), { - name: 'AbortError', - sshChannelCloseConfirmed: false - }) - : new Error('permission denied while checking relay install') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) - vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - } - ) - - it('lets the surviving probe finish before retrying a refused SSH session', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - let releaseRemoteHome: (home: string) => void = () => {} - let remoteHomeSettled = false - const cancelledProbe = Object.assign(new Error('system SSH probe cancellation unconfirmed'), { - name: 'AbortError', - sshChannelCloseConfirmed: false - }) - mockExecCommand.mockImplementationOnce( - (_conn, _command, options) => - new Promise((resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(cancelledProbe), { once: true }) - releaseRemoteHome = (home: string) => { - remoteHomeSettled = true - resolve(home) - } - }) - ) - vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => Promise.reject(sessionLimitError)) - vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { - if (!remoteHomeSettled) { - throw new Error('Sequential fallback started before first install-state probe settled') - } - return Promise.resolve('/usr/bin/node') - }) - - const deployPromise = deployAndLaunchRelay(conn).catch((error: unknown) => error) - await vi.waitFor(() => expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1)) - await new Promise((resolve) => setImmediate(resolve)) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - - mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME - mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - queueLaunchNamespaceAndDeadSocketProbe() - mockExecCommand.mockResolvedValueOnce('READY') // socket poll - releaseRemoteHome('/home/user') - await expect(deployPromise).resolves.toHaveProperty('transport') - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) - }) - - it('defaults fresh relays to keep-alive-until-reset without rollout artifacts', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn) - - const launchCommand = detachedLaunchCommand(conn) - - expect(launchCommand).toContain(`--grace-time ${DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS}`) - expect(launchCommand).toContain( - "--ripgrep-path '/home/user/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'" - ) - await vi.waitFor(() => - expect(ensureRemoteBundledRipgrep).toHaveBeenCalledWith( - conn, - expect.anything(), - '/home/user', - expect.objectContaining({ signal: expect.anything() }) - ) - ) - expect(launchCommand).not.toContain('--pty-source-credit-v1') - expect(launchCommand).not.toContain('.pty-source-credit-policy') - }) - - it.each([ - [false, false], - [false, true], - [true, false], - [true, true] - ])( - 'waits for ripgrep before cleanup (concurrent exec: %s, upload failure: %s)', - async (concurrent, fails) => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) - queueFreshLinuxDeploy() - let finishUpload = (): void => {} - vi.mocked(ensureRemoteBundledRipgrep).mockImplementationOnce( - () => - new Promise((resolve, reject) => { - finishUpload = () => (fails ? reject(new Error('upload failed')) : resolve('present')) - }) - ) - await deployAndLaunchRelay(conn) - expect(ensureRemoteBundledRipgrep).toHaveBeenCalledOnce() - const execCount = vi.mocked(execCommand).mock.calls.length - await new Promise((resolve) => setImmediate(resolve)) - expect(execCommand).toHaveBeenCalledTimes(execCount) - expect(gcOldRelayVersions).not.toHaveBeenCalled() - expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(concurrent ? 1 : 0) - finishUpload() - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) - } - ) - - it.each([false, true])( - 'waits for SQLite setup before cleanup (concurrent exec: %s)', - async (concurrent) => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) - queueFreshLinuxDeploy() - let finishSetup!: () => void - vi.mocked(ensureRemoteOpenCodeRuntime).mockImplementationOnce( - () => - new Promise((resolve) => { - finishSetup = () => resolve('failed') - }) - ) - await deployAndLaunchRelay(conn) - await vi.waitFor(() => expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledOnce()) - const execCount = vi.mocked(execCommand).mock.calls.length - await new Promise((resolve) => setImmediate(resolve)) - expect(execCommand).toHaveBeenCalledTimes(execCount) - expect(gcOldRelayVersions).not.toHaveBeenCalled() - finishSetup() - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) - } - ) - - it('does not launch or upload an unprotected binary when recording its reference fails', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - vi.mocked(recordRemoteRipgrepReference).mockResolvedValueOnce(false) - await deployAndLaunchRelay(conn) - expect(detachedLaunchCommand(conn)).not.toContain('--ripgrep-path') - expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() - }) - - it.each([false, true])( - 'skips cleanup after unconfirmed SQLite teardown (concurrent exec: %s)', - async (concurrent) => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) - queueFreshLinuxDeploy() - vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('teardown-unconfirmed') - await deployAndLaunchRelay(conn) - await vi.waitFor(() => expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledOnce()) - const execCount = vi.mocked(execCommand).mock.calls.length - await new Promise((resolve) => setImmediate(resolve)) - expect(execCommand).toHaveBeenCalledTimes(execCount) - expect(gcOldRelayVersions).not.toHaveBeenCalled() - expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() - } - ) - - it('does not launch after an unconfirmed ripgrep reference write', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - const error = Object.assign(new Error('reference write still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(recordRemoteRipgrepReference).mockRejectedValueOnce(error) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) - expect(detachedLaunchCommand(conn)).toBeUndefined() - expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() - }) - - it.each([false, true])( - 'blocks cleanup and runtime retry after uncertain ripgrep teardown (concurrent exec: %s)', - async (concurrent) => { - const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) - queueFreshLinuxDeploy() - vi.mocked(ensureRemoteBundledRipgrep).mockRejectedValueOnce( - Object.assign(new Error('upload still running'), { sshChannelCloseConfirmed: false }) - ) - vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValue('failed') - const deployed = await deployAndLaunchRelay(conn) - await new Promise((resolve) => setImmediate(resolve)) - expect(gcOldRelayVersions).not.toHaveBeenCalled() - expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() - const execCount = vi.mocked(execCommand).mock.calls.length - const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_001) - try { - await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) - expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(concurrent ? 1 : 0) - expect(execCommand).toHaveBeenCalledTimes(execCount) - } finally { - clock.mockRestore() - } - } - ) - - it.each([false, true])( - 'gates later cache cleanup and runtime retry on GC termination (confirmed: %s)', - async (confirmed) => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('failed') - vi.mocked(gcOldRelayVersions).mockRejectedValueOnce( - Object.assign(new Error('GC interrupted'), { sshChannelCloseConfirmed: confirmed }) - ) - const deployed = await deployAndLaunchRelay(conn) - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) - expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() - const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_001) - try { - await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) - expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(confirmed ? 2 : 1) - } finally { - clock.mockRestore() - } - } - ) - - it('allows an unlimited SSH disconnect grace window', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn, undefined, 0, 'target-a') - - const launchCommand = detachedLaunchCommand(conn) - - expect(launchCommand).toContain('--grace-time 0') - expect(launchCommand).not.toContain('--pty-source-credit-v1') - expect(launchCommand).not.toContain('.pty-source-credit-policy') - }) - - it('clamps configured SSH disconnect grace to the seven-day maximum', async () => { - const conn = makeMockConnection() - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn, undefined, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + 1, 'target-a') - - const launchCommand = detachedLaunchCommand(conn) - - expect(launchCommand).toContain(`--grace-time ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}`) - }) - - it('uses a content-hashed versioned remote install directory', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - queueFreshLinuxDeploy() - - await deployAndLaunchRelay(conn) - - // The launch + connect commands include the versioned dir path. - const execArgs = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - const allCmds = [...execArgs, ...mockExecCommand.mock.calls.map(([, cmd]) => cmd)] - const sawVersionedDir = allCmds.some((cmd) => - cmd.includes('/.orca-remote/relay-0.1.0+abcdef012345') - ) - expect(sawVersionedDir).toBe(true) - const sawLegacyDir = allCmds.some((cmd) => cmd.includes('relay-v0.1.0')) - expect(sawLegacyDir).toBe(false) - }) - - it('bounds the overall deploy so install + rebuild both fit under the timeout', async () => { - // Why: the outer bound must exceed the worst-case sequential native-deps - // work — a first install (240s) AND a follow-up rebuild (240s) — so a - // legitimate install-then-rebuild is not falsely timed out mid-repair. - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - - // Make the first exec never resolve - mockExecCommand.mockReturnValueOnce(new Promise(() => {})) - - vi.useFakeTimers() - - // Catch the rejection immediately to avoid unhandled rejection warning - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - - // Not timed out yet at the old 300s bound (install + rebuild need more). - await vi.advanceTimersByTimeAsync(301_000) - expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending') - - await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TIMEOUT_MS - 301_000) - expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending') - - await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 900s') - - vi.useRealTimers() - }) - - it('aborts a contended install-lock wait at the overall deploy timeout', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) - conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) - conn.writeFile = vi.fn().mockResolvedValue(undefined) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - if (command.includes('uname')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0] - if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) { - return Promise.resolve(`__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0`) - } - return Promise.resolve('') - }) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false) - let lockSignal: AbortSignal | undefined - vi.mocked(acquireInstallLock).mockImplementationOnce((_conn, _dir, _host, options) => { - lockSignal = options?.signal - return new Promise((_resolve, reject) => { - lockSignal?.addEventListener('abort', () => reject(lockSignal?.reason), { once: true }) - }) - }) - - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.advanceTimersByTimeAsync(0) - expect(acquireInstallLock).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TIMEOUT_MS) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 900s') - expect(lockSignal?.aborted).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('aborts a launch started near the deploy deadline and closes its channel once', async () => { - vi.useFakeTimers() - try { - const launchChannel = { - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - } - const conn = makeMockConnection() - vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) - vi.mocked(conn.exec).mockResolvedValue(launchChannel as never) - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/user') - .mockImplementationOnce( - () => - new Promise((resolve) => - setTimeout(() => resolve('ORCA-NATIVE-DEPS-OK'), 899_900) - ) - ) - .mockResolvedValueOnce('') // launch namespace marker - .mockResolvedValueOnce('DEAD') - .mockImplementationOnce((_conn, _command, options) => { - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - 'abort', - () => { - const error = new Error('SSH operation was cancelled') - error.name = 'AbortError' - reject(error) - }, - { once: true } - ) - }) - }) - - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.advanceTimersByTimeAsync(899_900) - expect(conn.exec).toHaveBeenCalledTimes(1) - expect(launchChannel.close).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(100) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 900s') - expect(launchChannel.close).toHaveBeenCalledTimes(1) - expect(mockExecCommand).toHaveBeenCalledTimes(6) - await vi.advanceTimersByTimeAsync(10_000) - expect(mockExecCommand).toHaveBeenCalledTimes(6) - } finally { - vi.useRealTimers() - } - }) - - it('uses distinct target-specific relay socket paths', async () => { - const connA = makeMockConnection() - const connB = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockImplementation((_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - } - if (command === 'echo $HOME') { - return Promise.resolve('/home/user') - } - if (command.includes('ORCA-NATIVE')) { - return Promise.resolve('ORCA-NATIVE-DEPS-OK') - } - if (command.includes('process.stdout.write("READY")')) { - return Promise.resolve('READY') - } - if (command.includes('test -S')) { - return Promise.resolve('DEAD') - } - return Promise.resolve('') - }) - - await deployAndLaunchRelay(connA, undefined, 300, 'target-a') - await deployAndLaunchRelay(connB, undefined, 300, 'target-b') - - const probeCommands = mockExecCommand.mock.calls - .map(([, command]) => command) - .filter( - (command) => - command.includes('test -S') && command.includes('relay-') && command.includes('ALIVE') - ) - expect(probeCommands).toHaveLength(2) - expect(probeCommands[0]).toContain('relay-') - expect(probeCommands[0]).not.toContain('relay.sock') - expect(probeCommands[1]).toContain('relay-') - expect(probeCommands[1]).not.toContain('relay.sock') - expect(probeCommands[0]).not.toEqual(probeCommands[1]) - - const launchA = vi.mocked(connA.exec).mock.calls.at(-1)?.[0] ?? '' - const launchB = vi.mocked(connB.exec).mock.calls.at(-1)?.[0] ?? '' - expect(launchA).toContain('--sock-path') - expect(launchB).toContain('--sock-path') - expect(launchA).not.toEqual(launchB) - }) -}) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts deleted file mode 100644 index b2177a7665c3..000000000000 --- a/src/main/ssh/ssh-relay-deploy.ts +++ /dev/null @@ -1,2641 +0,0 @@ -/* eslint-disable max-lines -- Why: one cohesive contract (version detect, install-locked deploy, native-deps probe, launch, GC); splitting risks install/GC drift. */ -import { existsSync } from 'node:fs' -import { app } from 'electron' -import { relayBundleCandidates } from './relay-bundle-paths' -import { PinnedRelayFallbackError, resolveSshRemoteRuntime } from './ssh-relay-pinned-node' -import { recordSshRelayRuntimeStep } from './ssh-host-node-runtime-mode' -import { isAnsweredHostFailure } from './ssh-relay-host-answered-failure' -import { - ensurePinnedRelayRuntime, - prebuiltRelayNodePath, - verifyPinnedRelayInstall -} from './ssh-relay-pinned-node-install' -import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' -import { - relayRuntimeStepAfterRefusal, - relayRuntimeLadder, - relayRuntimeStorePins, - type RelayRuntimeStep -} from './ssh-relay-runtime-ladder' -import { - RelayRuntimeLadderRun, - RemoteRuntimeUnavailableError, - sshTargetRelayRuntimeDecisionStore -} from './ssh-relay-runtime-resolution' -import { planRelayRuntimeStep } from './ssh-relay-runtime-step-plan' -import { getSshTargetRegistryStore } from './ssh-target-registry' -import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import type { SshConnection } from './ssh-connection' -import { RELAY_REMOTE_DIR, type RelayPlatform } from './relay-protocol' -import type { MultiplexerTransport } from './ssh-channel-multiplexer' -import { - waitForSentinel, - execCommand, - isUnconfirmedSshCommandTermination -} from './ssh-relay-deploy-helpers' -import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' -import { - createRelayInstallMarkerCommand, - createRelayInstallNamespace, - createRelayUploadStageNamespace, - makeRelayInstallDirectoryCommand, - relayHomeRelativeDir, - relaySftpNamespaceMapping, - relayUploadStageSftpNamespaceMapping, - type RelayInstallNamespace, - type RelayUploadStageNamespace -} from './ssh-relay-install-namespace' -import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { - ensureRemoteBundledRipgrep, - remoteRipgrepLayout, - recordRemoteRipgrepReference -} from './ssh-relay-ripgrep-install' -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' -import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' -import { - createRemoteOpenCodeRuntimeRetry, - type RemoteOpenCodeRuntimePreparation -} from './ssh-relay-opencode-runtime-retry' -import { - readLocalFullVersion, - computeRemoteRelayDir, - isRelayAlreadyInstalled, - finalizeInstall, - abandonInstall, - gcOldRelayVersions -} from './ssh-relay-versioned-install' -import { - attachRelayNativeDepsCache, - promoteRelayNativeDepsCache, - resolveRelayNativeDepsCacheKey, - type RelayNativeDepsCacheContext -} from './ssh-relay-native-deps-cache-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { - releaseRelayGcClaimWithRetry, - tryAcquireRelayGcClaim, - waitForRelayGcClaimRelease -} from './ssh-relay-gc-claim' -import { - NATIVE_DEPS_COMMAND_TIMEOUT_MS, - RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, - RELAY_DEPLOY_TIMEOUT_MS -} from './ssh-relay-deploy-timing' -import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' -import { isWindowsRelayPlatform } from '../../shared/relay-artifacts' -import { exportLocalNodeHeadersPrefix, localNodeHeadersFromOutput } from './ssh-relay-node-headers' -import { - probeBuildToolchain, - formatMissingToolchainError, - formatSkippedNodePtyWarning, - shouldProbeBuildToolchainAfterNativeDepsFailure, - formatNodeHeadersDownloadError, - isNodeHeadersDownloadFailure -} from './ssh-relay-build-toolchain' -import { - commandWithNodePath, - makeRemoteExecutableCommand, - readRemoteHomeCommand, - removeRemoteFileCommand -} from './ssh-remote-commands' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - promoteOwnedRelayUploadStageCommand, - recoverOneStaleRelayUploadStageCommand, - relayUploadStagePromotionConfirmed, - RELAY_UPLOAD_STAGE_POOL_NAME, - reserveRelayUploadStageCommand, - type WindowsUploadStageIdentity -} from './ssh-relay-upload-stage-commands' -import { - isWindowsRemoteHost, - joinRemotePath, - normalizeRemoteHome, - validateRemoteHome, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' -import { execHostCommand } from './ssh-relay-host-exec' -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' -import { - classifyWindowsRelayLaunchError, - WINDOWS_RELAY_LAUNCH_LOG_PREFIX, - windowsRelayConnectCommand, - windowsRelayLaunchCommand -} from './ssh-relay-windows-launch-command' -import { - parseWindowsBreakawayLaunchReport, - RELAY_WINDOWS_BREAKAWAY_CONTRACT -} from '../../shared/windows-breakaway-launch' -import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' -import { resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover' -import { - RelayProbeCleanupUnconfirmedError, - isRelayEndpointHeldError, - isRelayEndpointUnresponsiveError -} from './ssh-relay-endpoint-incumbent' -import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints' -import { - parseShortRelaySocketDir, - remoteSocketPathFitsLimit, - resolveShortRelaySocketDirCommand, - shortRelaySocketPath, - shortRelayVersionSegment, - SHORT_RELAY_SOCKET_DIR_PREFIX -} from './relay-socket-path-limit' -import { isSshSessionLimitError } from './ssh-session-limit-error' -import { - isWindowsRelayPipePath, - relayEndpointForHost, - relayHookEndpointDirForHost, - windowsActivePipeMarkerPath, - windowsRelayFallbackSocketName -} from './ssh-relay-endpoints' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS -} from '../../shared/ssh-types' - -export type RelayDeployResult = { - transport: MultiplexerTransport - serverBuildId?: string - platform: RelayPlatform - hostPlatform?: RemoteHostPlatform - remoteHome?: string - remoteRelayDir?: string - nodePath?: string - sockPath?: string - credentialFile?: string - prepareOpenCodeRuntime?: RemoteOpenCodeRuntimePreparation -} - -class RelayDirectoryGcConflictError extends Error { - constructor( - readonly remoteRelayDir: string, - readonly hostPlatform: RemoteHostPlatform - ) { - super(`Relay directory GC is in progress at ${remoteRelayDir}`) - } -} - -/** - * Deploy the relay to the remote host and launch it, returning the transport (relay's stdin/stdout) for multiplexer use. - */ -export async function deployAndLaunchRelay( - conn: SshConnection, - onProgress?: (status: string) => void, - graceTimeSeconds?: number, - relayInstanceId?: string -): Promise { - let timeoutHandle: ReturnType - const deployAbortController = new AbortController() - const timedOut = Symbol('relay-deploy-timeout') - const deployment = deployAndLaunchRelayInner( - conn, - onProgress, - graceTimeSeconds, - relayInstanceId, - deployAbortController.signal - ).then( - (result) => ({ status: 'fulfilled' as const, result }), - (error: unknown) => ({ status: 'rejected' as const, error }) - ) - const timeoutPromise = new Promise((resolve) => { - timeoutHandle = setTimeout(() => { - deployAbortController.abort() - resolve(timedOut) - }, RELAY_DEPLOY_TIMEOUT_MS) - }) - - try { - const outcome = await Promise.race([deployment, timeoutPromise]) - if (outcome !== timedOut) { - if (outcome.status === 'fulfilled') { - return outcome.result - } - throw outcome.error - } - - const teardownExpired = Symbol('relay-deploy-teardown-timeout') - let teardownTimeoutHandle: ReturnType - const teardown = await Promise.race([ - deployment, - new Promise((resolve) => { - teardownTimeoutHandle = setTimeout( - () => resolve(teardownExpired), - RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS - ) - }) - ]).finally(() => clearTimeout(teardownTimeoutHandle!)) - const timeoutError = Object.assign( - new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`), - teardownConfirmation(teardown === teardownExpired ? undefined : teardown) - ) - throw timeoutError - } finally { - clearTimeout(timeoutHandle!) - } -} - -function teardownConfirmation( - outcome: - | { status: 'fulfilled'; result: RelayDeployResult } - | { status: 'rejected'; error: unknown } - | undefined -): { sshChannelCloseConfirmed: boolean; sshTransferTeardownConfirmed: boolean } { - if (!outcome) { - return { sshChannelCloseConfirmed: false, sshTransferTeardownConfirmed: false } - } - if (outcome.status === 'fulfilled') { - return { sshChannelCloseConfirmed: true, sshTransferTeardownConfirmed: true } - } - const error = outcome.error as { - sshChannelCloseConfirmed?: unknown - sshTransferTeardownConfirmed?: unknown - } - return { - sshChannelCloseConfirmed: error?.sshChannelCloseConfirmed === true, - sshTransferTeardownConfirmed: error?.sshTransferTeardownConfirmed === true - } -} - -/** - * Resolve the remote home, derive the versioned relay dir, and check whether the relay is installed there. - * - * Why: extracted to run concurrently with node-path resolution; home and install-check stay sequential because the check needs the resolved dir. - */ -async function resolveRemoteInstallState( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - fullVersion: string, - options?: { rethrowSessionLimitErrors?: boolean; signal?: AbortSignal } -): Promise<{ remoteHome: string; remoteRelayDir: string; alreadyInstalled: boolean }> { - // Why: SFTP doesn't expand `~`, so resolve the remote home explicitly via the host's native shell and normalize it. - const remoteHome = normalizeRemoteHome( - await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform), { - signal: options?.signal - }), - hostPlatform - ) - // Why: $HOME is only used inside single-quoted shell strings, so validation only rejects control chars — spaces and non-ASCII stay valid. - if (!validateRemoteHome(remoteHome, hostPlatform)) { - throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) - } - const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor) - const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { - rethrowSessionLimitErrors: options?.rethrowSessionLimitErrors, - signal: options?.signal - }) - return { remoteHome, remoteRelayDir, alreadyInstalled } -} - -type RelayBootstrapState = { - remoteHome: string - remoteRelayDir: string - alreadyInstalled: boolean - nodePath: string -} - -async function resolveRelayBootstrapStateSequentially( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - fullVersion: string, - signal?: AbortSignal -): Promise { - const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal }) - const nodePath = await resolveRemoteNodePath(conn, hostPlatform, { signal }) - return { ...installState, nodePath } -} - -async function resolveRelayBootstrapState( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - fullVersion: string, - signal?: AbortSignal -): Promise { - if (!conn.canRunConcurrentExecCommands()) { - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) - } - const abortController = new AbortController() - const abortForDeploy = (): void => abortController.abort() - signal?.addEventListener('abort', abortForDeploy, { once: true }) - if (signal?.aborted) { - abortForDeploy() - } - const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, { - rethrowSessionLimitErrors: true, - signal: abortController.signal - }) - const nodePathPromise = resolveRemoteNodePath(conn, hostPlatform, { - rethrowSessionLimitErrors: true, - signal: abortController.signal - }) - try { - const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise]) - signal?.throwIfAborted() - return { ...installState, nodePath } - } catch (err) { - // Let an admitted probe finish before retrying a refused parallel session. - if (!isSshSessionLimitError(err)) { - abortController.abort() - } - const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) - const unconfirmed = settled.find( - (result) => result.status === 'rejected' && isUnconfirmedSshCommandTermination(result.reason) - ) - if (unconfirmed?.status === 'rejected') { - throw unconfirmed.reason - } - signal?.throwIfAborted() - if (!isSshSessionLimitError(err)) { - throw err - } - const nonSessionFailure = settled.find( - (result) => - result.status === 'rejected' && - !isSshSessionLimitError(result.reason) && - !isAbortError(result.reason) - ) - if (nonSessionFailure?.status === 'rejected') { - throw nonSessionFailure.reason - } - console.warn( - '[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.' - ) - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) - } finally { - signal?.removeEventListener('abort', abortForDeploy) - } -} - -/** A prebuilt plan already knows its Node, so only home and install state are probed. */ -async function resolvePrebuiltRelayBootstrapState( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - fullVersion: string, - plan: PrebuiltRelayPlan, - signal?: AbortSignal -): Promise { - const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal }) - return { - ...installState, - nodePath: prebuiltRelayNodePath({ - host: hostPlatform, - remoteRelayDir: installState.remoteRelayDir, - plan - }) - } -} - -function isAbortError(err: unknown): boolean { - return err instanceof Error && err.name === 'AbortError' -} - -/** - * Detect platform, resolve install state + node path, install if absent, launch, and return the transport. - * Inner implementation wrapped by `deployAndLaunchRelay` with an overall timeout. - */ -async function deployAndLaunchRelayInner( - conn: SshConnection, - onProgress?: (status: string) => void, - graceTimeSeconds?: number, - relayInstanceId?: string, - deploySignal?: AbortSignal -): Promise { - const target = typeof conn.getTarget === 'function' ? conn.getTarget() : undefined - const registry = getSshTargetRegistryStore() - const ladder = relayRuntimeLadder(resolveSshRemoteRuntime(target)) - const run = new RelayRuntimeLadderRun( - target?.id ?? relayInstanceId ?? '', - target && registry ? sshTargetRelayRuntimeDecisionStore(registry) : null, - ladder.length > 1 - ) - let step = ladder[0] ?? 'legacy' - while (true) { - deploySignal?.throwIfAborted() - try { - if (step === 'D') { - if (target) { - recordSshRelayRuntimeStep(target.id, false) - } - run.settle('D') - throw new RemoteRuntimeUnavailableError(run) - } - run.launchStarted = false - const result = await deployAndLaunchRelayAttempt( - conn, - { step, run }, - onProgress, - graceTimeSeconds, - relayInstanceId, - deploySignal - ) - // Why only a laddered pass: a plain host-npm connect has no rung decision to record. - if (run.laddered) { - run.settle(step) - } - if (target) { - recordSshRelayRuntimeStep(target.id, step === 'legacy') - } - return result - } catch (err) { - const refusal = ladderRefusal(err, step, run) - if (refusal) { - console.warn( - `[ssh-relay] Relay runtime rung ${step} unavailable (${refusal.reason}): ${refusal.detail}` - ) - run.refused(step, refusal.reason, refusal.remembered) - step = relayRuntimeStepAfterRefusal(ladder, step, refusal.reason, refusal.remembered) - run.enter(step) - continue - } - if (!(err instanceof RelayDirectoryGcConflictError)) { - if (run.laddered && !deploySignal?.aborted) { - run.unresolved(step) - } - throw err - } - // Why: GC atomically moves the old install aside; wait for its sibling claim to clear, then recompute install state. - await waitForRelayGcClaimRelease(conn, err.remoteRelayDir, err.hostPlatform, deploySignal) - } - } -} - -/** - * The refusal a failed ladder step steps past, or null when it must propagate. Any failure the - * host answered steps down, classified or not; opt-in legacy is the user's choice, with no D below. - */ -function ladderRefusal( - err: unknown, - step: RelayRuntimeStep, - run: RelayRuntimeLadderRun -): PinnedRelayFallbackError | null { - if (step === 'D' || (step === 'legacy' && !run.laddered)) { - return null - } - if (err instanceof PinnedRelayFallbackError) { - return err - } - // Why not after launch: a relay may be running, and a second rung would launch beside it. - if ( - run.launchStarted || - err instanceof RelayDirectoryGcConflictError || - !isAnsweredHostFailure(err) - ) { - return null - } - return new PinnedRelayFallbackError('install_failed', err instanceof Error ? err.message : '') -} - -type RelayRuntimeRequest = { step: RelayRuntimeStep; run: RelayRuntimeLadderRun } - -async function deployAndLaunchRelayAttempt( - conn: SshConnection, - runtimeRequest: RelayRuntimeRequest, - onProgress?: (status: string) => void, - graceTimeSeconds?: number, - relayInstanceId?: string, - deploySignal?: AbortSignal -): Promise { - onProgress?.('Detecting remote platform...') - console.log('[ssh-relay] Detecting remote platform...') - // Why reuse: each ladder step would otherwise re-probe a platform that cannot have changed. - const hostPlatform = - runtimeRequest.run.host ?? (await detectRemoteHostPlatform(conn, { signal: deploySignal })) - if (!hostPlatform) { - throw new Error( - 'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64.' - ) - } - const platform = hostPlatform.relayPlatform - console.log(`[ssh-relay] Platform: ${platform}`) - - const localRelayDir = getLocalRelayPath(platform) - if (!localRelayDir) { - throw new Error( - `Relay package for ${platform} not found locally. ` + - `This may be a packaging issue — try reinstalling Orca.` - ) - } - // Why: content-hashed version doubles as remote dir name and wire-handshake version; throws on missing rather than falling back (see docs/ssh-relay-versioned-install-dirs.md). - const baseVersion = readLocalFullVersion(localRelayDir) - const prebuilt = await planRelayRuntimeStep({ - conn, - host: hostPlatform, - baseVersion, - step: runtimeRequest.step, - run: runtimeRequest.run, - signal: deploySignal - }) - try { - return await deployAndLaunchRelayOnRuntime({ - conn, - onProgress, - graceTimeSeconds, - relayInstanceId, - deploySignal, - hostPlatform, - platform, - localRelayDir, - fullVersion: prebuilt?.fullVersion ?? baseVersion, - prebuilt, - run: runtimeRequest.run - }) - } finally { - await prebuilt?.addons.dispose().catch(() => {}) - } -} - -async function deployAndLaunchRelayOnRuntime({ - conn, - onProgress, - graceTimeSeconds, - relayInstanceId, - deploySignal, - hostPlatform, - platform, - localRelayDir, - fullVersion, - prebuilt, - run -}: { - conn: SshConnection - onProgress?: (status: string) => void - graceTimeSeconds?: number - relayInstanceId?: string - deploySignal?: AbortSignal - hostPlatform: RemoteHostPlatform - platform: RelayPlatform - localRelayDir: string - /** Already folded with the runtime digest when `prebuilt` is set (design D8.1). */ - fullVersion: string - prebuilt?: PrebuiltRelayPlan - run: RelayRuntimeLadderRun -}): Promise { - onProgress?.('Checking existing relay...') - // Why: install-check and node resolution are independent; run concurrently to save a round trip, with sequential fallback for restrictive SSH servers. - const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = prebuilt - ? await resolvePrebuiltRelayBootstrapState( - conn, - hostPlatform, - fullVersion, - prebuilt, - deploySignal - ) - : run.hostNodePath - ? // Why: the fallback's strict probe already found this Node; don't resolve it twice. - { - ...(await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { - signal: deploySignal - })), - nodePath: run.hostNodePath - } - : await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) - console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) - console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) - const pinnedContext = prebuilt - ? { - conn, - host: hostPlatform, - remoteRelayDir, - plan: prebuilt, - targetId: run.targetId, - signal: deploySignal, - run - } - : undefined - let uploadStageIdentity: WindowsUploadStageIdentity | undefined - if (pinnedContext?.plan.kind === 'pinned-node') { - onProgress?.('Checking Orca Node runtime...') - await ensurePinnedRelayRuntime({ ...pinnedContext, plan: pinnedContext.plan }, alreadyInstalled) - // Why: once node.exe is verified, stage fencing reads file IDs through it, not Add-Type (D5). - uploadStageIdentity = { node: prebuiltRelayNodePath(pinnedContext) } - } - - // Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace. - const homeRelativeRelayDir = relayHomeRelativeDir(fullVersion) - const uploadStagePoolDir = joinRemotePath( - hostPlatform, - remoteHome, - RELAY_REMOTE_DIR, - RELAY_UPLOAD_STAGE_POOL_NAME - ) - const homeRelativeUploadStagePoolDir = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}` - - let ownsInstallLock = false - let launchGcClaimToken: string | undefined - let launchNamespace: RelayInstallNamespace | undefined - if (alreadyInstalled) { - const launchFence = prebuilt - ? await fenceInstalledPinnedRelay( - conn, - remoteRelayDir, - hostPlatform, - homeRelativeRelayDir, - deploySignal - ) - : await repairInstalledNativeDeps( - conn, - remoteRelayDir, - platform, - hostPlatform, - nodePath, - homeRelativeRelayDir, - deploySignal - ) - ownsInstallLock = launchFence.ownsInstallLock - launchGcClaimToken = launchFence.gcClaimToken - launchNamespace = launchFence.sftpNamespace - deploySignal?.throwIfAborted() - } else { - await execHostCommand( - conn, - hostPlatform, - recoverOneStaleRelayUploadStageCommand( - hostPlatform, - uploadStagePoolDir, - undefined, - uploadStageIdentity - ), - { signal: deploySignal } - ) - const uploadStageOwner = createRelayInstallMarkerFileName() - const reservation = await execHostCommand( - conn, - hostPlatform, - reserveRelayUploadStageCommand( - hostPlatform, - uploadStagePoolDir, - uploadStageOwner, - uploadStageIdentity - ), - { signal: deploySignal } - ) - const uploadStage = parseReservedRelayUploadStage( - hostPlatform, - uploadStagePoolDir, - uploadStageOwner, - reservation - ) - const uploadStagePayloadDir = joinRemotePath(hostPlatform, uploadStage.slotDir, 'payload') - const uploadStageNamespace = createRelayUploadStageNamespace( - `${homeRelativeUploadStagePoolDir}/${uploadStage.slotName}`, - uploadStageOwner - ) - const uploadStageSftpNamespace = uploadStageNamespaceIfSupported( - conn, - hostPlatform, - uploadStageNamespace - ) - let uploadStageCleanupAllowed = true - onProgress?.('Uploading relay...') - console.log('[ssh-relay] Uploading relay...') - try { - await uploadRelay( - conn, - platform, - uploadStagePayloadDir, - fullVersion, - hostPlatform, - deploySignal, - { rootDir: uploadStage.slotDir, namespace: uploadStageSftpNamespace }, - prebuilt?.addons.dir - ) - - await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) - ownsInstallLock = true - try { - // Re-probe after acquiring the lock — a sibling installer may have finished while we waited. - if ( - !(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { - signal: deploySignal - })) - ) { - launchNamespace = await createRelayLaunchNamespace( - conn, - hostPlatform, - remoteRelayDir, - homeRelativeRelayDir, - deploySignal - ) - const promotion = await execHostCommand( - conn, - hostPlatform, - promoteOwnedRelayUploadStageCommand( - hostPlatform, - uploadStage, - uploadStageOwner, - remoteRelayDir, - uploadStageIdentity - ), - { signal: deploySignal } - ) - if (!relayUploadStagePromotionConfirmed(uploadStageOwner, promotion)) { - throw new Error('Relay upload stage ownership was lost before promotion') - } - console.log('[ssh-relay] Upload complete') - - if (pinnedContext) { - onProgress?.('Verifying relay runtime...') - await verifyPinnedRelayInstall(pinnedContext) - } else { - onProgress?.('Installing native dependencies...') - console.log('[ssh-relay] Installing native dependencies...') - await installNativeDeps( - conn, - remoteRelayDir, - platform, - hostPlatform, - nodePath, - deploySignal, - [], - launchNamespace, - remoteHome - ) - console.log('[ssh-relay] Native deps installed') - } - - // Why: mark complete but retain the lock until launch makes daemon liveness observable to cross-version GC. - await finalizeInstall(conn, remoteRelayDir, hostPlatform, { - signal: deploySignal, - releaseLock: false - }) - } - } catch (err) { - if (!isUnconfirmedSshCommandTermination(err)) { - await abandonInstall(conn, remoteRelayDir, hostPlatform) - ownsInstallLock = false - } - throw err - } - } catch (error) { - uploadStageCleanupAllowed = !isUnconfirmedSshCommandTermination(error) - throw error - } finally { - if (uploadStageCleanupAllowed) { - await execHostCommand( - conn, - hostPlatform, - cleanupOwnedRelayUploadStageCommand( - hostPlatform, - uploadStage, - uploadStageOwner, - uploadStageIdentity - ) - ).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - }) - } - } - } - - const ripgrepLayout = remoteRipgrepLayout(hostPlatform, remoteHome) - const ripgrepReferenced = - ripgrepLayout && - (await recordRemoteRipgrepReference( - conn, - hostPlatform, - remoteRelayDir, - ripgrepLayout.entryName - )) - deploySignal?.throwIfAborted() - onProgress?.('Starting relay...') - console.log('[ssh-relay] Launching relay...') - // A failed launch retains its fences until stale recovery can establish liveness. - run.launchStarted = true - const launched = await launchRelay( - conn, - remoteRelayDir, - hostPlatform, - nodePath, - graceTimeSeconds, - relayInstanceId, - deploySignal, - ripgrepReferenced ? ripgrepLayout.binaryPath : undefined - ) - let launchCleanupSettled = true - try { - if (ownsInstallLock) { - await abandonInstall(conn, remoteRelayDir, hostPlatform) - } - if (launchGcClaimToken) { - await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform) - } - } catch (error) { - // Keep the connected transport, but stop optional commands after uncertain fence release. - launchCleanupSettled = !isUnconfirmedSshCommandTermination(error) - console.warn('[ssh-relay] Launch fence release failed:', error) - } - console.log('[ssh-relay] Relay started successfully') - - // Keep background commands serial for SSH transports that allow only one exec at a time. - const ripgrepEntry = ripgrepLayout?.entryName - const ripgrepInstall = ( - ripgrepReferenced && launchCleanupSettled - ? ensureRemoteBundledRipgrep(conn, hostPlatform, remoteHome, { signal: deploySignal }) - : Promise.resolve() - ).then( - () => launchCleanupSettled, - (error) => !isUnconfirmedSshCommandTermination(error) - ) - const runtimeInstall = ( - conn.canRunConcurrentExecCommands() ? Promise.resolve(launchCleanupSettled) : ripgrepInstall - ) - .then((ripgrepSettled) => - ripgrepSettled - ? ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, { - nodePath: launched.nodePath, - verifiedNodePath: uploadStageIdentity?.node, - relayDir: remoteRelayDir, - signal: deploySignal - }) - : ('teardown-unconfirmed' as const) - ) - .catch(() => 'teardown-unconfirmed' as const) - - const cleanupReady = Promise.all([ripgrepInstall, runtimeInstall]).then( - ([ripgrepSettled, runtimeOutcome]) => - ripgrepSettled && runtimeOutcome !== 'teardown-unconfirmed' - ) - const backgroundCleanup = cleanupReady.then((ready) => { - if (!ready) { - return false - } - return ( - execHostCommand( - conn, - hostPlatform, - recoverOneStaleRelayUploadStageCommand( - hostPlatform, - uploadStagePoolDir, - undefined, - uploadStageIdentity - ) - ) - .catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - }) - // Why before GC: a superseded relay pins its version dir via the live-socket probe, so the - // sweep has to settle first or GC keeps every orphan's tree forever. - .then(() => - sweepSupersededRelayEndpoints(conn, hostPlatform, { - remoteHome, - currentRelayDir: remoteRelayDir, - sockName: relaySocketNameForInstanceId(relayInstanceId), - // Set only when this launch relocated past sun_path; the sweep must not reap - // the socket the transport it just handed back is talking to. - ...(launched.sockPath.startsWith(SHORT_RELAY_SOCKET_DIR_PREFIX) - ? { - currentShortSocketDir: launched.sockPath.slice( - 0, - launched.sockPath.lastIndexOf('/') - ) - } - : {}), - nodePath: launched.nodePath - }) - ) - .catch((error) => { - if ( - error instanceof RelayProbeCleanupUnconfirmedError || - isUnconfirmedSshCommandTermination(error) - ) { - throw error - } - }) - .then(() => - gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { - windowsNodePath: launched.nodePath, - windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)], - nodePath: launched.nodePath, - // Why pin rather than rely on the symlink alone: a deploy that fell back to a - // per-directory install has no reference to show, and its key must still survive. - nativeDepsCacheKeys: [ - resolveRelayNativeDepsCacheKey({ - platform, - localRelayDir, - deps: RELAY_NATIVE_DEPS - }) - ].filter((key): key is string => key !== null) - }) - ) - // Why after the version GC and not beside it: that pass is what removes the relay directories - // holding the references, so running second is what lets a superseded build become collectable - // in the same connect rather than the next one. - .then(() => - gcRemoteRipgrepCache(conn, hostPlatform, remoteHome, { pinnedEntry: ripgrepEntry }) - ) - // Same ordering reason as ripgrep: the version pass drops the refs that held old runtimes. - .then(() => - // Why every compat pin too: a rung A connect must not collect the rung B runtime. - prebuilt?.kind === 'pinned-node' - ? gcRemoteNodeRuntimeStore(conn, hostPlatform, remoteHome, { - currentPins: relayRuntimeStorePins(prebuilt.target) - }) - : undefined - ) - .then(() => true) - .catch( - (error) => - !(error instanceof RelayProbeCleanupUnconfirmedError) && - !isUnconfirmedSshCommandTermination(error) - ) - ) - }) - - return { - transport: launched.transport, - serverBuildId: fullVersion, - platform, - hostPlatform, - remoteHome, - remoteRelayDir, - nodePath: launched.nodePath, - sockPath: launched.sockPath, - credentialFile: launched.credentialFile, - prepareOpenCodeRuntime: createRemoteOpenCodeRuntimeRetry( - runtimeInstall, - backgroundCleanup, - (signal) => - ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, { - nodePath: launched.nodePath, - verifiedNodePath: uploadStageIdentity?.node, - relayDir: remoteRelayDir, - signal - }) - ) - } -} - -async function uploadRelay( - conn: SshConnection, - platform: RelayPlatform, - remoteDir: string, - fullVersion: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal, - stage?: { rootDir: string; namespace?: RelayUploadStageNamespace }, - /** Pinned-Node addons laid out relative to the relay dir. */ - localAddonsDir?: string -): Promise { - const localRelayDir = getLocalRelayPath(platform) - if (!localRelayDir || !existsSync(localRelayDir)) { - throw new Error( - `Relay package for ${platform} not found. Searched: ${getLocalRelayCandidates(platform).join(', ')}. ` + - `This may be a packaging issue — try reinstalling Orca.` - ) - } - - if (!stage) { - await execHostCommand( - conn, - hostPlatform, - makeRelayInstallDirectoryCommand(hostPlatform, remoteDir), - { signal } - ) - } - - for (const localDir of localAddonsDir ? [localRelayDir, localAddonsDir] : [localRelayDir]) { - await uploadRelayDirectory(conn, localDir, remoteDir, hostPlatform, { - signal, - sftpNamespace: stage?.namespace - ? relayUploadStageSftpNamespaceMapping(stage.namespace, hostPlatform, stage.rootDir) - : undefined - }) - } - - if (!isWindowsRemoteHost(hostPlatform)) { - await execHostCommand( - conn, - hostPlatform, - makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')), - { signal } - ) - } - - // Why: write .version via SFTP not shell to avoid quoting content-hashed versions; the daemon reads it to validate the wire handshake. - await writeRelayFile( - conn, - hostPlatform, - joinRemotePath(hostPlatform, remoteDir, '.version'), - fullVersion, - { - signal, - sftpNamespace: stage?.namespace - ? relayUploadStageSftpNamespaceMapping( - stage.namespace, - hostPlatform, - stage.rootDir, - '.version' - ) - : undefined - } - ) -} - -/** - * A marker is only meaningful where a split namespace can occur and where Orca - * owns the SFTP session: POSIX hosts reached over the bundled ssh2 transport. - */ -function createInstallNamespaceIfSupported( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - homeRelativeRelayDir: string -): RelayInstallNamespace | undefined { - if (isWindowsRemoteHost(hostPlatform)) { - return undefined - } - // A connection double without the transport accessor is an ssh2 connection. - const usesSystemSsh = - typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false - return usesSystemSsh ? undefined : createRelayInstallNamespace(homeRelativeRelayDir) -} - -function uploadStageNamespaceIfSupported( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - namespace: RelayUploadStageNamespace -): RelayUploadStageNamespace | undefined { - if (isWindowsRemoteHost(hostPlatform)) { - return undefined - } - const usesSystemSsh = - typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false - return usesSystemSsh ? undefined : namespace -} - -const NODE_PTY_VERSION = '1.1.0' -const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' -const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs' -const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' -const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' -/** - * Whether the tree the patch left behind still leaks a pty fd -- the master into every later child - * on Linux, a throwaway /dev/ptmx per spawn on macOS. `fixed` is the only outcome a shared cache - * entry may be published from. - */ -type NodePtyMasterCloexecOutcome = 'fixed' | 'unfixed' -/** - * The statuses that leave a non-leaking tree. Deliberately an allowlist, not a `failed:` denylist: - * the script's `skipped:` family is mixed. `skipped:unsupported-platform` is a platform that never - * leaks, but `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:no-prebuild`, - * `skipped:unexpected-source` and the two `skipped:` forms all mean the patch was refused - * and the leaky build is still on disk -- indistinguishable from `failed:` as far as what gets - * published. - */ -const NODE_PTY_CLOEXEC_FIXED_STATUSES: ReadonlySet = new Set([ - 'patched', - // The rebuild ran from patched source; only the leak check could not observe the result. An - // unobservable check is not a failed patch, and treating it as one would disable the shared - // cache on every host without `/proc` or `lsof`. - 'patched-unverified', - 'already-patched', - // Unreachable while the platform gate below short-circuits Windows first, but it is the one - // `skipped:` that means "nothing to fix" rather than "would not fix it". - 'skipped:unsupported-platform' -]) -// Exported for the relay-native-dependency-coverage test, which asserts every -// native addon the relay bundle imports is either installed here or explicitly -// declared as degrading without it. -export const RELAY_NATIVE_DEPS = { - 'node-pty': NODE_PTY_VERSION, - '@parcel/watcher': '2.5.6' -} as const - -type RelayNativeDepName = keyof typeof RELAY_NATIVE_DEPS -const RELAY_NATIVE_DEP_NAMES = Object.keys(RELAY_NATIVE_DEPS) as RelayNativeDepName[] -const NATIVE_DEPS_MISSING_PREFIX = 'ORCA-NATIVE-DEPS-MISSING:' - -// Why: npm 12 blocks dependency lifecycle scripts unless each exact package version is approved, even with ignore-scripts disabled. -const RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST = Object.fromEntries( - Object.entries(RELAY_NATIVE_DEPS).map(([name, version]) => [`${name}@${version}`, true]) -) - -function nativeDepsProbeJs(successToken: string): string { - // Why: node-pty's Windows wrapper defers conpty.node until first spawn, so require("node-pty") alone can't prove the binding is healthy. - const loadNodePty = - 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty");' + - `if(process.platform==="win32"){require("./${NODE_PTY_CONSOLE_LIST_PATCH_FILENAME}").assertPatchedNodePtyConsoleListAgent(process.cwd());` + - `require("./${NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME}").assertPatchedNodePtyWindowsTeardown(process.cwd())}` - return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` -} - -/** - * Which deps the probe *named* as unloadable, or `undefined` when the answer names none. - * - * Only the probe's own marker line is evidence about the deps. An answer without one (node never - * ran, was killed, exited before the script) says nothing, so it must not be read as "all of them" — - * that inference deleted both native modules on every reconnect of an affected host. - */ -function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] | undefined { - const marker = output - .split(/\r?\n/) - .find((line) => line.trim().startsWith(NATIVE_DEPS_MISSING_PREFIX)) - if (!marker) { - return undefined - } - const reported = marker.trim().slice(NATIVE_DEPS_MISSING_PREFIX.length).split(',') - const named = RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) - return named.length > 0 ? named : undefined -} - -/** - * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered with a marker - * naming deps that failed to load. `unverifiable` — the probe never answered, or answered nothing - * that names a dep; both are evidence about the probe, not about the deps. - * - * Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a - * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. An - * unparseable answer is the worse half of that — it is deterministic and per-host, so a node that - * cannot start (bad NODE_OPTIONS, OOM, exit 127) deleted both modules on every reconnect forever. - * Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and - * docs/reference/ssh-execution-boundary.md — loss of contact is not evidence. - */ -type RelayNativeDepsProbeStatus = 'ok' | 'blocked' | 'unverifiable' - -async function probeRequiredNativeDeps( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> { - const escapedNode = shellEscape(nodePath) - const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') - let probeStderr = '' - try { - const command = isWindowsRemoteHost(hostPlatform) - ? commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` - ) - : // Why: no `2>/dev/null` — it discarded the only line that says why node never reached the - // script. stderr stays its own stream so it can't be mistaken for the verdict, mirroring - // src/main/orcad/node-pty-precondition.ts. - commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `(${escapedNode} -e ${shellEscape(probeJs)} || echo MISSING)` - ) - const probe = await execHostCommand(conn, hostPlatform, command, { - signal, - onStderr: (text) => { - probeStderr = text - } - }) - if (probe.includes('ORCA-NATIVE-DEPS-OK')) { - return { status: 'ok', missing: [] } - } - const missing = missingNativeDepsFromProbe(probe) - if (!missing) { - console.warn( - `[ssh-relay][NATIVE-DEPS-PROBE-UNPARSEABLE] Probe at ${remoteDir} answered without naming a dep; launching as-is. stdout=${probe.trim().slice(-200)} stderr=${probeStderr.trim().slice(-500)}` - ) - return { status: 'unverifiable', missing: [] } - } - return { status: 'blocked', missing } - } catch (error) { - signal?.throwIfAborted() - // Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and - // recompiled healthy relays, turning one dropped exec channel into a multi-minute reconnect. - // Why: the wrongful rebuild was the only visible symptom, so without this line a dropped exec - // channel leaves no trace at all. - console.warn( - `[ssh-relay] Native deps probe unanswered at ${remoteDir}; treating as unverifiable: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return { status: 'unverifiable', missing: [] } - } -} - -async function repairInstalledNativeDeps( - conn: SshConnection, - remoteDir: string, - platform: RelayPlatform, - hostPlatform: RemoteHostPlatform, - nodePath: string, - homeRelativeRelayDir: string, - signal?: AbortSignal -): Promise { - const initialProbe = await probeRequiredNativeDeps( - conn, - remoteDir, - hostPlatform, - nodePath, - signal - ) - const { lockResult, gcClaimToken } = await acquireInstalledRelayFence( - conn, - remoteDir, - hostPlatform, - signal - ) - // Why: only a probe that answered may trigger repair; an unverifiable one launches as-is and the next reconnect re-probes. - if (initialProbe.status !== 'blocked') { - return launchFenceWithoutRepair( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - { lockResult, gcClaimToken }, - signal - ) - } - - // Why: an already-installed relay can launch degraded, so native-deps repair is best-effort — lock contention and failures must not abort the connection. - console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`) - if (lockResult === 'busy' || lockResult === 'error') { - console.warn( - `[ssh-relay] Native-deps repair lock is ${lockResult} at ${remoteDir}; launching degraded` - ) - return { ownsInstallLock: false, gcClaimToken } - } - try { - // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. - const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - let repairNamespace: RelayInstallNamespace | undefined - if (probe.status !== 'ok') { - // Why: the locked re-probe can only narrow the repair; when it can't answer, the initial probe's answered evidence still stands. - const resetDeps = probe.status === 'unverifiable' ? initialProbe.missing : probe.missing - // Why: only stamp ownership once the locked recheck proves this connection is the one about to write. - repairNamespace = await createRelayLaunchNamespace( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - signal - ) - await installNativeDeps( - conn, - remoteDir, - platform, - hostPlatform, - nodePath, - signal, - resetDeps, - repairNamespace - ) - await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) - } - return { ownsInstallLock: true, sftpNamespace: repairNamespace } - } catch (err) { - const terminationUnconfirmed = isUnconfirmedSshCommandTermination(err) - // Why: hold a confirmed-failure lock through degraded launch so GC can't move the relay before liveness is visible. - // Why: unconfirmed remote mutation keeps its stale-recoverable lock beyond this connection. - console.warn( - `[ssh-relay] Native deps repair failed at ${remoteDir}; launching degraded: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return { ownsInstallLock: !terminationUnconfirmed } - } -} - -type RelayLaunchFence = { - ownsInstallLock: boolean - gcClaimToken?: string - sftpNamespace?: RelayInstallNamespace -} - -type InstalledRelayFence = { - lockResult: 'acquired' | 'busy' | 'error' - gcClaimToken?: string -} - -/** Hold an installed relay dir against cross-version GC until launch makes liveness observable. */ -async function acquireInstalledRelayFence( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal }) - if (lockResult === 'gc') { - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - if (lockResult === 'acquired') { - let stillInstalled: boolean - try { - stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { - rethrowSessionLimitErrors: true, - signal - }) - } catch (err) { - if (!isUnconfirmedSshCommandTermination(err)) { - await abandonInstall(conn, remoteDir, hostPlatform) - } - throw err - } - if (!stillInstalled) { - // Why: GC may finish its rename before our lock recreates the path; never trust probes made before this locked recheck. - await abandonInstall(conn, remoteDir, hostPlatform) - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - } - const gcClaimToken = - lockResult === 'busy' || lockResult === 'error' - ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) - : undefined - return { lockResult, gcClaimToken } -} - -async function launchFenceWithoutRepair( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - remoteDir: string, - homeRelativeRelayDir: string, - { lockResult, gcClaimToken }: InstalledRelayFence, - signal?: AbortSignal -): Promise { - // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. - if (lockResult !== 'acquired') { - return { ownsInstallLock: false, gcClaimToken } - } - try { - return { - ownsInstallLock: true, - sftpNamespace: await createRelayLaunchNamespace( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - signal - ) - } - } catch (err) { - signal?.throwIfAborted() - console.warn( - `[ssh-relay] Launch namespace marker is unconfirmed at ${remoteDir}; deferring lock ownership to stale recovery` - ) - return { ownsInstallLock: !isUnconfirmedSshCommandTermination(err) } - } -} - -/** A pinned-Node relay dir has no host-installed native deps to repair; only the launch fence applies. */ -async function fenceInstalledPinnedRelay( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - homeRelativeRelayDir: string, - signal?: AbortSignal -): Promise { - const fence = await acquireInstalledRelayFence(conn, remoteDir, hostPlatform, signal) - return launchFenceWithoutRepair( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - fence, - signal - ) -} - -/** - * Stamp this connection as the launch writer while it owns the install lock. - * Confirmed marker failures fall back to shell-path credential generation. - */ -async function createRelayLaunchNamespace( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - remoteDir: string, - homeRelativeRelayDir: string, - signal?: AbortSignal -): Promise { - const namespace = createInstallNamespaceIfSupported(conn, hostPlatform, homeRelativeRelayDir) - if (!namespace) { - return undefined - } - try { - await execHostCommand( - conn, - hostPlatform, - createRelayInstallMarkerCommand(namespace, hostPlatform, remoteDir), - { signal } - ) - return namespace - } catch (err) { - // Why: an unconfirmed termination still owes the caller its lock semantics; only a confirmed failure degrades to shell paths. - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - console.warn( - `[ssh-relay] SFTP namespace marker unavailable at ${remoteDir}; retaining shell paths` - ) - return undefined - } -} - -async function acquireRelayLaunchGcFence( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - const token = await tryAcquireRelayGcClaim(conn, remoteDir, hostPlatform, signal) - if (!token) { - signal?.throwIfAborted() - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - try { - signal?.throwIfAborted() - const stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { - rethrowSessionLimitErrors: true, - signal - }) - if (!stillInstalled) { - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - // Why: a caller without the install lock still needs its own durable fence; never borrow another connection's lock through launch. - return token - } catch (err) { - if (!isUnconfirmedSshCommandTermination(err)) { - await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform) - } - throw err - } -} - -// Why: node-pty and @parcel/watcher are native addons esbuild can't bundle; install them on the remote against its Node/OS. -// TODO(#1693): ship per-platform tarballs with node-pty prebuilt from CI to skip remote npm install. -async function installNativeDeps( - conn: SshConnection, - remoteDir: string, - platform: RelayPlatform, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal, - resetDeps: RelayNativeDepName[] = [], - namespace?: RelayInstallNamespace, - remoteHome?: string -): Promise { - // Why a repair opts out: reset does `rm -rf node_modules/node-pty`, and through a shared - // symlink that is every relay on the host losing its addon. Repairs detach and install - // privately instead (the install command's own prefix drops the link). - const localRelayDir = resetDeps.length === 0 && remoteHome ? getLocalRelayPath(platform) : null - const cacheContext: RelayNativeDepsCacheContext | null = - remoteHome && localRelayDir - ? { - hostPlatform, - remoteHome, - relayDir: remoteDir, - platform, - localRelayDir, - deps: RELAY_NATIVE_DEPS, - signal - } - : null - const cache = cacheContext ? await attachRelayNativeDepsCache(conn, cacheContext) : null - - const writeRelayPackageJson = async (deps: Record): Promise => { - await writeRelayFile( - conn, - hostPlatform, - joinRemotePath(hostPlatform, remoteDir, 'package.json'), - `${JSON.stringify({ - name: 'orca-relay', - version: '1.0.0', - private: true, - type: 'commonjs', - dependencies: deps, - allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST - })}\n`, - { - signal, - sftpNamespace: namespace - ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, 'package.json') - : undefined - } - ) - } - - // Why: node-pty's prebuild spawns `node` as a child, so node must be in PATH (commandWithNodePath) or it fails exit 127. - // Why: npm init -y rejects '+' in content-hashed dir names, so write a fixed minimal package.json instead. - // Why: type:commonjs pins module resolution against Node default flips or a remote ~/.npmrc type=module. - await writeRelayPackageJson(RELAY_NATIVE_DEPS) - - if (cache?.mode === 'linked') { - await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) - const linkedProbe = await probeInstalledNativeDeps( - conn, - remoteDir, - hostPlatform, - nodePath, - signal - ) - if (linkedProbe.available) { - return - } - // Why fall through rather than repair the entry: it is shared, and something else on this - // host may be running out of it right now. This directory installs its own copy instead. - console.warn( - `[ssh-relay][NATIVE-CACHE-UNUSABLE] shared entry ${cache.key} did not load at ${remoteDir} (${platform}); installing per-directory. stderr=${linkedProbe.stderr.trim().slice(-500)}` - ) - } - - try { - const installArgs = Object.entries(RELAY_NATIVE_DEPS) - .map(([dep, version]) => shellEscape(`${dep}@${version}`)) - .join(' ') - // Why: npm reports a present package as up to date even if a native file was deleted; reset only deps the probe found broken. - const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps) - const resetPrefix = `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand ? `${resetCommand}; ` : ''}` - const command = isWindowsRemoteHost(hostPlatform) - ? commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${Object.entries( - RELAY_NATIVE_DEPS - ) - .map(([dep, version]) => powerShellLiteral(`${dep}@${version}`)) - .join( - ' ' - )}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ${windowsNodePtyPatchCommand(nodePath)}` - ) - : commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${exportLocalNodeHeadersPrefix(nodePath)}${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` - ) - await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, - signal - }) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - // Don't write .install-complete on hard fail so reconnect retries the partial install; greppable token aids bug reports. - const msg = (err as Error).message - console.warn( - `[ssh-relay][NATIVE-DEPS-INSTALL-FAIL] npm install native deps failed at ${remoteDir} (${platform}): ${msg}` - ) - // Why: on Linux node-pty compiles, so a missing C/C++ toolchain is the dominant first-connect failure (#1693); probe to give an actionable install hint. - if (platform.startsWith('linux') && shouldProbeBuildToolchainAfterNativeDepsFailure(msg)) { - const toolchain = await probeBuildToolchain(conn, hostPlatform, signal) - if (toolchain?.toolchainMissing) { - // Why: node-pty is the only dep that needs a compiler, and it only backs terminals. Retry - // without it so files/git/editor still connect instead of failing the host outright; a - // missing native dep is already non-fatal below. Rethrow the actionable error if even that - // fails, so a host broken for some other reason still reports the toolchain gap. - console.warn( - `[ssh-relay][NPTY-SKIP-NO-TOOLCHAIN] ${remoteDir} (${platform}): ${formatSkippedNodePtyWarning(toolchain)}` - ) - try { - await installNativeDepsWithoutNodePty( - conn, - remoteDir, - hostPlatform, - nodePath, - writeRelayPackageJson, - resetDeps, - signal - ) - } catch (retryErr) { - if (isUnconfirmedSshCommandTermination(retryErr)) { - throw retryErr - } - signal?.throwIfAborted() - // The thrown toolchain message is built from the original error, so log the retry's own - // cause (registry, ENOSPC, EACCES) rather than losing it. - console.warn( - `[ssh-relay][NPTY-SKIP-RETRY-FAIL] node-pty-less reinstall failed at ${remoteDir} (${platform}): ${(retryErr as Error).message}` - ) - throw new Error(formatMissingToolchainError(toolchain, msg), { cause: retryErr }) - } - // Why: this early return skips the probe below, so verify the dep that does have a prebuilt — - // a @parcel/watcher that installs but can't load would leave file watching silently dead. - await warnIfWatcherUnloadableWithoutNodePty( - conn, - remoteDir, - platform, - hostPlatform, - nodePath, - signal - ) - return - } - } - // Why: either the local-headers export found nothing (a host both header-less and offline) or - // it did and node-gyp downloaded anyway (the export is broken) -- name which, or the log reads - // as a broken relay either way. - if (platform.startsWith('linux') && isNodeHeadersDownloadFailure(msg)) { - throw new Error(formatNodeHeadersDownloadError(msg, localNodeHeadersFromOutput(msg)), { - cause: err - }) - } - throw err - } - - await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) - - let probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - if (!probe.available) { - // Why: npm treats an already-present package as up to date, so re-enabling lifecycle scripts on install can't repair a skipped binding. - console.warn(`[ssh-relay] Rebuilding unloadable native deps at ${remoteDir}`) - let rebuilt = false - try { - await rebuildNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - rebuilt = true - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - // Same diagnosis as the install catch: this fallback is non-fatal, so the log is the only - // place the offline-headers cause can reach anyone. - const rebuildMsg = (err as Error).message - console.warn( - `[ssh-relay][NATIVE-DEPS-REBUILD-FAIL] npm rebuild native deps failed at ${remoteDir} (${platform}): ${ - platform.startsWith('linux') && isNodeHeadersDownloadFailure(rebuildMsg) - ? formatNodeHeadersDownloadError(rebuildMsg, localNodeHeadersFromOutput(rebuildMsg)) - : rebuildMsg - }` - ) - } - signal?.throwIfAborted() - if (rebuilt) { - await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) - probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - } - } - - // Why this precedes promotion: the patch renames `node-pty/build/Release`, runs `npm rebuild` - // and rolls back inside `node_modules`, and promotion turns that directory into a symlink to a - // published -- and by contract immutable -- shared cache entry. Patching afterwards would write - // through the link, and `.deps-complete` would already have published an unpatched tree that - // every later host links and skips. - const cloexec = probe.available - ? await applyNodePtyMasterCloexecPatch( - conn, - remoteDir, - platform, - hostPlatform, - nodePath, - signal - ) - : 'unfixed' - - // Why promotion is gated on the probe and not on npm's exit code: an entry is shared, so the - // only evidence worth publishing is this host having loaded both addons out of that tree. - // Why it is gated on the patch too: a refused or rolled-back patch leaves the pre-patch leaky - // build in place, and the cache key hashes this patch's bytes -- so publishing it would hand - // every later host on the machine a tree that links, probes loadable, and skips patching. - if (probe.available && cacheContext && cache) { - if (cloexec === 'fixed') { - await promoteRelayNativeDepsCache(conn, cacheContext, cache.key) - } else { - console.warn( - `[ssh-relay][NPTY-CLOEXEC-UNSHARED] keeping the native deps at ${remoteDir} (${platform}) private; the tree still leaks the pty master, so it is not publishable as ${cache.key}` - ) - } - } - - // MISSING is non-fatal by design: the relay still serves fs/git/preflight; only native-backed ops fail on hosts that can't build the addons. - if (!probe.available) { - console.warn( - `[ssh-relay][NPTY-MISSING] native deps installed but require() failed at ${remoteDir} (${platform}). stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` - ) - } -} - -/** - * Re-apply the pty fd-leak patch the app gets from pnpm to the host's npm copy (#17915). - * - * Why it is safe to rebuild under a live relay: this only runs from installNativeDeps, so only on a - * freshly created directory or a locked repair, and a relay already serving PTYs has pty.node mapped - * -- replacing the file on disk does not touch the running process. It keeps the build it started - * with and picks up the patched one when it restarts. - * - * Why it is bounded: the remote script attempts the compile at most once per relay directory, and - * the directory is content-hashed over the relay manifest -- so at most one compile per bundle. - * - * Why a shared cache entry never reaches here: the caller returns as soon as a linked tree probes - * loadable, so this only ever rewrites a `node_modules` the relay directory still owns privately. - * - * Returns whether the tree that is left behind still leaks, which is what decides publishability. - * The script exits 0 on every outcome by design, so the status line is the only evidence there is. - */ -async function applyNodePtyMasterCloexecPatch( - conn: SshConnection, - remoteDir: string, - platform: RelayPlatform, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise { - // Both Unix relay platforms leak the pty master, by different bugs: Linux inherits it through - // forkpty()'s no-O_CLOEXEC path, macOS orphans one throwaway /dev/ptmx fd per spawn in - // pty_posix_spawn. Windows is short-circuited because it has no fds for a master to leak into -- - // and answering 'fixed' from a gate that ran nothing is exactly how a leaking darwin tree got - // published to the shared cache. - // - // What 'fixed' means here is exactly "this tree does not leak the pty MASTER", which is the only - // thing the shared native-deps cache keys on. It is NOT a statement that a Windows relay leaks - // nothing: it leaked one Windows File handle per terminal until the ConPTY teardown patch above, - // by a mechanism that has nothing to do with fds. Read this gate as scoped to its own question. - if (isWindowsRemoteHost(hostPlatform) || isWindowsRelayPlatform(platform)) { - return 'fixed' - } - try { - const command = commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${exportLocalNodeHeadersPrefix(nodePath)}${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1` - ) - const output = await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, - signal - }) - const status = - output - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.startsWith(NODE_PTY_CLOEXEC_STATUS_PREFIX)) - ?.slice(NODE_PTY_CLOEXEC_STATUS_PREFIX.length) ?? 'no-status' - if (!NODE_PTY_CLOEXEC_FIXED_STATUSES.has(status)) { - // Warn, not log: the script exits 0 on a refusal too, so this line is the only thing that - // says the relay directory will leak a master into every child for its whole life. - console.warn( - `[ssh-relay][NPTY-CLOEXEC-UNFIXED] pty master still leaks at ${remoteDir} (${platform}): ${status}` - ) - return 'unfixed' - } - console.log(`[ssh-relay][NPTY-CLOEXEC] ${remoteDir} (${platform}): ${status}`) - return 'fixed' - } catch (err) { - signal?.throwIfAborted() - // Never fatal: the script restores the working build itself, and a leaky relay beats none. An - // interrupted rebuild leaves node-pty unloadable, which the existing repair path reinstalls. - console.warn( - `[ssh-relay][NPTY-CLOEXEC-FAIL] pty master cloexec patch failed at ${remoteDir} (${platform}): ${(err as Error).message}` - ) - // An exec that never answered cannot say which build is on disk, and a tree nobody can vouch - // for is exactly the one not to share. - return 'unfixed' - } -} - -/** - * Drop a shared-cache symlink before anything writes into `node_modules`. - * - * Why it prefixes every install rather than living in its own exec: `rm -rf node_modules/node-pty` - * and `npm install` both follow the link, so a repair on one relay directory would otherwise - * rewrite the tree every other relay on the host is running out of. - */ -function detachSharedNativeDepsCommand(hostPlatform: RemoteHostPlatform): string { - if (isWindowsRemoteHost(hostPlatform)) { - return '' - } - return 'if [ -L node_modules ]; then rm -f node_modules; fi; ' -} - -function resetNativeDepsCommand( - hostPlatform: RemoteHostPlatform, - resetDeps: RelayNativeDepName[] -): string { - if (resetDeps.length === 0) { - return '' - } - const resetNodePty = resetDeps.includes('node-pty') - const resetWatcher = resetDeps.includes('@parcel/watcher') - if (isWindowsRemoteHost(hostPlatform)) { - const commands: string[] = [] - if (resetNodePty) { - commands.push( - `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/node-pty')} -Recurse -Force -ErrorAction SilentlyContinue` - ) - } - if (resetWatcher) { - commands.push( - `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/@parcel/watcher')} -Recurse -Force -ErrorAction SilentlyContinue`, - `$parcelScope = ${powerShellLiteral('node_modules/@parcel')}`, - `Get-ChildItem -LiteralPath $parcelScope -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith('watcher-', [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue` - ) - } - return commands.join('; ') - } - const commands: string[] = [] - if (resetNodePty) { - commands.push(`rm -rf ${shellEscape('node_modules/node-pty')}`) - } - if (resetWatcher) { - commands.push( - `rm -rf ${shellEscape('node_modules/@parcel/watcher')}`, - `find ${shellEscape('node_modules/@parcel')} -maxdepth 1 -name 'watcher-*' -exec rm -rf {} + 2>/dev/null || true` - ) - } - return commands.join('; ') -} - -/** - * Reinstall the relay's native deps with node-pty dropped, for hosts that cannot compile it. - * - * Why: npm reconciles every dependency in package.json, not just the ones named on the command - * line, so node-pty has to leave the manifest too — naming only @parcel/watcher still rebuilds it. - */ -async function installNativeDepsWithoutNodePty( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - writeRelayPackageJson: (deps: Record) => Promise, - resetDeps: RelayNativeDepName[], - signal?: AbortSignal -): Promise { - const deps = Object.fromEntries( - Object.entries(RELAY_NATIVE_DEPS).filter(([dep]) => dep !== 'node-pty') - ) - await writeRelayPackageJson(deps) - const installArgs = Object.entries(deps) - .map(([dep, version]) => shellEscape(`${dep}@${version}`)) - .join(' ') - // Why: the failed attempt leaves an unbuildable node-pty behind; clear it so npm prunes rather - // than rebuilds it. Keep the caller's resets too — a repair reconnect still needs them. - const resetCommand = resetNativeDepsCommand(hostPlatform, [ - ...new Set([...resetDeps, 'node-pty']) - ]) - // Why: POSIX-only command shape (`;` chaining, `2>&1`) — safe because the only caller is gated on a - // linux platform. Widen that gate and this needs the Windows branch installNativeDeps already has. - await execHostCommand( - conn, - hostPlatform, - commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand}; npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` - ), - { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal } - ) -} - -/** - * Report an unloadable @parcel/watcher after node-pty was skipped, without failing the connection. - * - * Why: node-pty is expected missing here, but a @parcel/watcher prebuilt that installs and still - * can't require() (glibc below the floor — docs/reference/linux-glibc-compatibility.md) means dead - * file watching. No rebuild: node-pty provably can't compile on this host, so it would only fail. - */ -async function warnIfWatcherUnloadableWithoutNodePty( - conn: SshConnection, - remoteDir: string, - platform: RelayPlatform, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise { - try { - const probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) - if (probe.missing.includes('@parcel/watcher')) { - console.warn( - `[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED] @parcel/watcher installed but require() failed at ${remoteDir} (${platform}); remote file watching is unavailable. stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` - ) - } - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - // Degraded-mode diagnostics must never cost the connection the retry just salvaged. - console.warn( - `[ssh-relay][WATCHER-PROBE-FAIL] native deps probe failed after skipping node-pty at ${remoteDir} (${platform}): ${(err as Error).message}` - ) - } -} - -async function rebuildNativeDeps( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise { - const depNames = Object.keys(RELAY_NATIVE_DEPS) - const command = isWindowsRemoteHost(hostPlatform) - ? commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `npm rebuild --ignore-scripts=false ${depNames.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ${windowsNodePtyPatchCommand(nodePath)}` - ) - : commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `${exportLocalNodeHeadersPrefix(nodePath)}npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1` - ) - await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, - signal - }) -} - -function windowsNodePtyPatchCommand(nodePath: string): string { - // Why: pnpm patches do not cross the SSH boundary; apply the version-checked fallbacks to the remote npm package. - return [ - `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`, - `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` - ].join('; ') -} - -async function makeNodePtySpawnHelperExecutable( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - if (isWindowsRemoteHost(hostPlatform)) { - return - } - // SFTP doesn't preserve execute bits; node-pty's spawn-helper must be +x for posix_spawnp. - await execHostCommand( - conn, - hostPlatform, - `find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`, - { signal } - ) -} - -async function probeInstalledNativeDeps( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - signal?: AbortSignal -): Promise<{ - available: boolean - missing: RelayNativeDepName[] - output: string - stderr: string -}> { - // require() catches unloadable installs (wrong arch, missing prebuild, skipped lifecycle script) that require.resolve() and test -d miss. - const PROBE_OK = 'ORCA-NPTY-PROBE-OK' - const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') - const escapedStderr = shellEscape(stderrFile) - const probeJs = nativeDepsProbeJs(PROBE_OK) - const probeCommand = isWindowsRemoteHost(hostPlatform) - ? commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` - ) - : commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `(${shellEscape(nodePath)} -e ${shellEscape(probeJs)} ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` - ) - const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand, { signal }) - const remoteStderr = - probeOutput.includes(PROBE_OK) || isWindowsRemoteHost(hostPlatform) - ? '' - : await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`, { - signal - }).catch(() => '') - signal?.throwIfAborted() - if (!isWindowsRemoteHost(hostPlatform)) { - // The POSIX probe redirects stderr to this file; the Windows probe does not. - await execHostCommand(conn, hostPlatform, removeRemoteFileCommand(hostPlatform, stderrFile), { - signal - }).catch(() => {}) - signal?.throwIfAborted() - } - return { - available: probeOutput.includes(PROBE_OK), - // A markerless answer names no dep, so it reports none; `available` already carries the failure. - missing: probeOutput.includes(PROBE_OK) ? [] : (missingNativeDepsFromProbe(probeOutput) ?? []), - output: probeOutput, - stderr: remoteStderr - } -} - -function getLocalRelayPath(platform: RelayPlatform): string | null { - for (const candidate of getLocalRelayCandidates(platform)) { - if (existsSync(candidate)) { - return candidate - } - } - return null -} - -export function getLocalRelayCandidates(platform: RelayPlatform): string[] { - return relayBundleCandidates(platform, app.getAppPath()) -} - -async function launchRelay( - conn: SshConnection, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - nodePath: string, - graceTimeSeconds?: number, - relayInstanceId?: string, - signal?: AbortSignal, - ripgrepPath?: string -): Promise<{ - transport: MultiplexerTransport - nodePath: string - sockPath: string - credentialFile: string -}> { - // Why: graceTimeSeconds comes from user-editable SshTarget config; floor+clamp to an integer prevents shell injection if the type ever loosened. - const requestedGraceTime = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) - const graceTime = - requestedGraceTime === 0 - ? 0 - : Math.max( - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, - Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requestedGraceTime) - ) - const escapedDir = shellEscape(remoteDir) - const escapedNode = shellEscape(nodePath) - // Why: remoteRelayDir is shared across Orca targets for one account; hashing the target ID into the socket name stops cross-target attach. - const sockName = relaySocketNameForInstanceId(relayInstanceId) - const defaultSockFile = relayEndpointForHost(hostPlatform, remoteDir, sockName) - const endpointDir = relayHookEndpointDirForHost(hostPlatform, remoteDir, defaultSockFile) - const credentialFile = joinRemotePath(hostPlatform, remoteDir, `${sockName}.credential`) - // Why: a long remote $HOME pushes the default endpoint past sun_path and bind fails with a bare `listen EINVAL` (#10726). - const sockFile = remoteSocketPathFitsLimit(hostPlatform, defaultSockFile) - ? defaultSockFile - : await resolveShortPosixRelaySocketPath(conn, remoteDir, sockName, defaultSockFile, signal) - - if (isWindowsRemoteHost(hostPlatform)) { - const activePipeMarkerPath = windowsActivePipeMarkerPath(hostPlatform, remoteDir, sockName) - const discoveredActiveEndpoint = await readWindowsActiveRelayEndpoint( - conn, - hostPlatform, - remoteDir, - activePipeMarkerPath, - signal - ) - const activeEndpoint = discoveredActiveEndpoint ?? { - sockPath: sockFile, - endpointDir - } - const fallbackEndpoint = buildWindowsRelayFallbackEndpoint(hostPlatform, remoteDir, sockName) - const launched = await launchWindowsRelay( - conn, - hostPlatform, - { - remoteDir, - nodePath, - sockPath: activeEndpoint.sockPath, - endpointDir: activeEndpoint.endpointDir, - graceTime, - activePipeMarkerPath, - reconnectFallback: fallbackEndpoint, - credentialFile, - ripgrepPath - }, - signal - ) - return { ...launched, credentialFile } - } - - // Why: after a restart the relay may still be alive in its grace period; --connect to its socket preserves PTY state and scrollback. - try { - const probeOutput = await execCommand( - conn, - `test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD`, - { signal } - ) - console.warn(`[ssh-relay] Socket probe result: "${probeOutput.trim()}"`) - if (probeOutput.trim() === 'ALIVE') { - console.log('[ssh-relay] Existing relay socket found, attempting reconnect...') - try { - const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)}`, - { signal } - ) - const transport = await waitForSentinel(channel, signal) - console.log('[ssh-relay] Reconnected to existing relay via socket') - return { transport, nodePath, sockPath: sockFile, credentialFile } - } catch (err) { - signal?.throwIfAborted() - console.warn( - '[ssh-relay] Socket reconnect failed, establishing what owns the endpoint:', - err instanceof Error ? err.message : String(err) - ) - // Why not `rm -f`: unlinking does not close the listener the incumbent already holds, - // so a refused --connect (version mismatch, rotated credential) used to leave a live - // relay running forever with its PTYs while a replacement bound the same path (#8585). - await resolveRelayEndpointBeforeRelaunch(conn, hostPlatform, nodePath, sockFile, err, { - signal - }) - signal?.throwIfAborted() - } - } - } catch (err) { - // Why rethrow the verdicts: this catch predates the incumbent probe and was meant for a failed - // `test -S`. Swallowing a Held/Unresponsive verdict launches a fresh daemon over a live one — - // the exact collision the probe exists to prevent (it lost the bind, but only by luck). - if ( - err instanceof RelayProbeCleanupUnconfirmedError || - isUnconfirmedSshCommandTermination(err) || - isRelayEndpointHeldError(err) || - isRelayEndpointUnresponsiveError(err) - ) { - throw err - } - signal?.throwIfAborted() - // Probe failed — fall through to fresh launch - } - - // Why: relay must outlive the SSH connection so PTY sessions survive app restarts — nohup + ${shellEscape(logFile)} 2>&1 {}) - launchChannel.on('error', () => {}) - launchChannel.stderr.on('data', () => {}) - launchChannel.stderr.on('error', () => {}) - // Why: the SSH channel stays open until all child fds close; close it after the poll or channels accumulate and hit the server's MaxSessions limit. - launchChannel.on('close', () => {}) - - // Why: poll rather than fixed sleep — remote host speed varies widely (CI vs. Raspberry Pi). - // Why: test -S only proves the inode exists, not that the relay is listening; a connect-and-close confirms it accepts connections. - const POLL_INTERVAL_MS = 200 - const POLL_TIMEOUT_MS = 10_000 - const pollStart = Date.now() - let socketReady = false - try { - while (Date.now() - pollStart < POLL_TIMEOUT_MS) { - try { - // Why: probe via node (guaranteed present) not python3/socat/perl; pass the socket path as argv[1] to dodge -e quoting issues. - const result = await execCommand( - conn, - `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`, - { signal } - ) - if (result.trim() === 'READY') { - socketReady = true - break - } - } catch { - signal?.throwIfAborted() - /* exec failed, retry */ - } - await waitForRelayPoll(POLL_INTERVAL_MS, signal) - } - } finally { - launchChannel.close() - } - - if (!socketReady) { - const logOutput = await execCommand( - conn, - `tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"`, - { signal } - ).catch(() => '(could not read log)') - signal?.throwIfAborted() - throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) - } - - // Why: backgrounded relay's stdout goes to a log file, not the exec channel; --connect bridges this channel to its Unix socket. - const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)}`, - { signal } - ) - return { - transport: await waitForSentinel(channel, signal), - nodePath, - sockPath: sockFile, - credentialFile - } -} - -/** - * Move the endpoint under a `$HOME`-independent base so its length is bounded. - * - * The hashed socket name is preserved in full: only the directory shrinks, so the - * short form stays deterministic per target and cannot collide with another target. - * The version directory's identity comes along as a hashed segment, so a later build - * still binds a path of its own rather than the one its predecessor is holding. - */ -async function resolveShortPosixRelaySocketPath( - conn: SshConnection, - remoteDir: string, - sockName: string, - defaultSockFile: string, - signal?: AbortSignal -): Promise { - const versionSegment = shortRelayVersionSegment(remoteDir.slice(remoteDir.lastIndexOf('/') + 1)) - const output = await execCommand(conn, resolveShortRelaySocketDirCommand(versionSegment), { - signal - }).catch((err: unknown) => { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - signal?.throwIfAborted() - return '' - }) - const shortDir = parseShortRelaySocketDir(output, versionSegment) - if (!shortDir) { - throw new Error( - `Relay socket path ${defaultSockFile} exceeds the remote Unix socket limit and no short socket directory could be created on the host.` - ) - } - const shortSockFile = shortRelaySocketPath(shortDir, sockName) - console.warn( - `[ssh-relay] Socket path too long for sun_path; using ${shortSockFile} instead of ${defaultSockFile}` - ) - return shortSockFile -} - -function waitForRelayPoll(delayMs: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const onAbort = (): void => { - clearTimeout(timeout) - signal?.removeEventListener('abort', onAbort) - reject(createSshOperationAbortError()) - } - const timeout = setTimeout(() => { - signal?.removeEventListener('abort', onAbort) - resolve() - }, delayMs) - signal?.addEventListener('abort', onAbort, { once: true }) - if (signal?.aborted) { - onAbort() - } - }) -} - -function buildWindowsRelayFallbackEndpoint( - hostPlatform: RemoteHostPlatform, - remoteDir: string, - sockName: string -): WindowsRelayEndpoint { - const fallbackSockName = windowsRelayFallbackSocketName(sockName) - const sockPath = relayEndpointForHost(hostPlatform, remoteDir, fallbackSockName) - return { - sockPath, - endpointDir: relayHookEndpointDirForHost(hostPlatform, remoteDir, sockPath) - } -} - -async function readWindowsActiveRelayEndpoint( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - remoteDir: string, - markerPath: string, - signal?: AbortSignal -): Promise { - const output = await execHostCommand( - conn, - hostPlatform, - powerShellCommand( - `if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }` - ), - { signal } - ).catch(() => { - signal?.throwIfAborted() - return '' - }) - const sockPath = output.trim() - if (!isWindowsRelayPipePath(sockPath)) { - return null - } - return { - sockPath, - endpointDir: relayHookEndpointDirForHost(hostPlatform, remoteDir, sockPath) - } -} - -async function rememberWindowsActiveRelayEndpoint( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - markerPath: string, - sockPath: string, - signal?: AbortSignal -): Promise { - await execHostCommand( - conn, - hostPlatform, - powerShellCommand( - `Set-Content -LiteralPath ${powerShellLiteral(markerPath)} -Value ${powerShellLiteral(sockPath)} -NoNewline` - ), - { signal } - ).catch((err) => { - signal?.throwIfAborted() - // Why: fallback pipe names are deterministic, so losing this marker won't orphan an undiscoverable relay. - console.warn( - `[ssh-relay] Failed to persist Windows active relay pipe at ${markerPath}: ${err instanceof Error ? err.message : String(err)}` - ) - }) -} - -type WindowsRelayEndpoint = { - sockPath: string - endpointDir: string -} - -type WindowsRelayLaunchOptions = { - remoteDir: string - nodePath: string - graceTime: number - activePipeMarkerPath: string - credentialFile: string - ripgrepPath?: string -} & WindowsRelayEndpoint & { - reconnectFallback?: WindowsRelayEndpoint - } - -async function launchWindowsRelay( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - opts: WindowsRelayLaunchOptions, - signal?: AbortSignal -): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { - let launchOpts = opts - if ((await probeWindowsRelayPipe(conn, hostPlatform, opts, signal)) === 'READY') { - try { - const transport = await connectWindowsRelay(conn, hostPlatform, opts, signal) - await rememberWindowsActiveRelayEndpoint( - conn, - hostPlatform, - opts.activePipeMarkerPath, - opts.sockPath, - signal - ) - return { - transport, - nodePath: opts.nodePath, - sockPath: opts.sockPath - } - } catch (err) { - signal?.throwIfAborted() - console.warn( - '[ssh-relay] Windows named pipe reconnect failed, launching fresh relay:', - err instanceof Error ? err.message : String(err) - ) - if (opts.reconnectFallback) { - // Why: a Windows named pipe can't be unlinked like a Unix socket; a deterministic fallback pipe keeps the next deploy recoverable. - // Why: spread keeps activePipeMarkerPath at the original target sock name — the marker records that target's active pipe, fallback or not. - launchOpts = { ...opts, ...opts.reconnectFallback } - } - } - } - - if ( - launchOpts !== opts && - (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts, signal)) === 'READY' - ) { - try { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) - await rememberWindowsActiveRelayEndpoint( - conn, - hostPlatform, - launchOpts.activePipeMarkerPath, - launchOpts.sockPath, - signal - ) - return { - transport, - nodePath: launchOpts.nodePath, - sockPath: launchOpts.sockPath - } - } catch (err) { - signal?.throwIfAborted() - console.warn( - '[ssh-relay] Windows fallback pipe reconnect failed, relaunching relay:', - err instanceof Error ? err.message : String(err) - ) - } - } - - const logFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.log') - const errFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.err.log') - // Why no credential write: see launchRelay — the daemon publishes after it owns the pipe. - const launchOutput = await execHostCommand( - conn, - hostPlatform, - windowsRelayLaunchCommand(hostPlatform, { - nodePath: launchOpts.nodePath, - remoteDir: launchOpts.remoteDir, - sockPath: launchOpts.sockPath, - endpointDir: launchOpts.endpointDir, - graceTime: launchOpts.graceTime, - logFile, - errFile, - credentialFile: launchOpts.credentialFile, - ripgrepPath: launchOpts.ripgrepPath - }), - { signal } - ).catch((error: unknown) => { - throw classifyWindowsRelayLaunchError(error) - }) - const launchReport = parseWindowsBreakawayLaunchReport( - RELAY_WINDOWS_BREAKAWAY_CONTRACT, - launchOutput - ) - console.log(`${WINDOWS_RELAY_LAUNCH_LOG_PREFIX}${JSON.stringify(launchReport)}`) - - const POLL_INTERVAL_MS = 200 - const POLL_TIMEOUT_MS = 10_000 - if ( - await waitForWindowsRelayPipe( - conn, - hostPlatform, - launchOpts, - POLL_TIMEOUT_MS, - POLL_INTERVAL_MS, - signal - ) - ) { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) - await rememberWindowsActiveRelayEndpoint( - conn, - hostPlatform, - launchOpts.activePipeMarkerPath, - launchOpts.sockPath, - signal - ) - return { - transport, - nodePath: launchOpts.nodePath, - sockPath: launchOpts.sockPath - } - } - - const logOutput = await execHostCommand( - conn, - hostPlatform, - windowsRelayTailLogCommand(logFile, errFile), - { signal } - ).catch(() => { - signal?.throwIfAborted() - return '(could not read log)' - }) - throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) -} - -async function connectWindowsRelay( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - opts: { - remoteDir: string - nodePath: string - sockPath: string - credentialFile: string - }, - signal?: AbortSignal -): Promise { - const channel = await conn.exec( - windowsRelayConnectCommand( - hostPlatform, - opts.nodePath, - opts.remoteDir, - opts.sockPath, - opts.credentialFile - ), - { wrapCommand: false, signal } - ) - return waitForSentinel(channel, signal) -} - -async function probeWindowsRelayPipe( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - opts: { - remoteDir: string - nodePath: string - sockPath: string - }, - signal?: AbortSignal -): Promise<'READY' | 'WAITING'> { - const result = await execHostCommand( - conn, - hostPlatform, - windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath), - { signal } - ) - return result.trim() === 'READY' ? 'READY' : 'WAITING' -} - -async function waitForWindowsRelayPipe( - conn: SshConnection, - hostPlatform: RemoteHostPlatform, - opts: { - remoteDir: string - nodePath: string - sockPath: string - }, - timeoutMs: number, - intervalMs: number, - signal?: AbortSignal -): Promise { - try { - const result = await execHostCommand( - conn, - hostPlatform, - windowsRelayWaitCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath, { - timeoutMs, - intervalMs - }), - { signal } - ) - return result.trim() === 'READY' - } catch { - signal?.throwIfAborted() - return false - } -} - -function windowsRelayProbeCommand( - hostPlatform: RemoteHostPlatform, - nodePath: string, - remoteDir: string, - sockPath: string -): string { - const js = [ - 'const net=require("net");', - 'const s=net.connect(process.argv[1]);', - 's.on("connect",()=>{s.destroy();process.stdout.write("READY")});', - 's.on("error",()=>{process.stdout.write("WAITING")});' - ].join('') - return commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - `& ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(js)} ${powerShellNativeArg(sockPath)}` - ) -} - -function windowsRelayWaitCommand( - hostPlatform: RemoteHostPlatform, - nodePath: string, - remoteDir: string, - sockPath: string, - opts: { timeoutMs: number; intervalMs: number } -): string { - const js = [ - 'const net=require("net");', - 'const pipe=process.argv[1];', - 'const timeoutMs=Number(process.argv[2]);', - 'const intervalMs=Number(process.argv[3]);', - 'const deadline=Date.now()+timeoutMs;', - 'function finish(value){process.stdout.write(value);process.exit(0)}', - 'function attempt(){', - 'const s=net.connect(pipe);', - 'let settled=false;', - 'function retry(){if(settled)return;settled=true;s.destroy();', - 'if(Date.now()>=deadline)finish("WAITING");else setTimeout(attempt,intervalMs)}', - 's.setTimeout(Math.min(intervalMs,500));', - 's.on("connect",()=>{if(settled)return;settled=true;s.destroy();finish("READY")});', - 's.on("timeout",retry);', - 's.on("error",retry);', - '}', - 'attempt();' - ].join('') - return commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - [ - `& ${powerShellLiteral(nodePath)}`, - '-e', - powerShellNativeArg(js), - powerShellNativeArg(sockPath), - powerShellLiteral(String(opts.timeoutMs)), - powerShellLiteral(String(opts.intervalMs)) - ].join(' ') - ) -} - -function windowsRelayTailLogCommand(logFile: string, errFile: string): string { - const script = [ - `$out = if (Test-Path -LiteralPath ${powerShellLiteral(logFile)}) { Get-Content -LiteralPath ${powerShellLiteral(logFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stdout log)' }`, - `$err = if (Test-Path -LiteralPath ${powerShellLiteral(errFile)}) { Get-Content -LiteralPath ${powerShellLiteral(errFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stderr log)' }`, - 'Write-Output $out', - "Write-Output '--- stderr ---'", - 'Write-Output $err' - ].join('; ') - return powerShellCommand(script) -} diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts deleted file mode 100644 index 9c65ab76fc6f..000000000000 --- a/src/main/ssh/ssh-relay-gc-retry.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+gc-retry') -})) -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn(), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - 'sshChannelCloseConfirmed' in error && - error.sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+gc-retry'), - computeRemoteRelayDir: (home: string, version: string) => `${home}/.orca-remote/relay-${version}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn(), - abandonInstall: vi.fn(), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn(), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (value: string) => `'${value}'`, - createSshOperationAbortError: () => Object.assign(new Error('cancelled'), { name: 'AbortError' }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' -import { - releaseRelayGcClaimWithRetry, - tryAcquireRelayGcClaim, - waitForRelayGcClaimRelease -} from './ssh-relay-gc-claim' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { abandonInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import type { SshConnection } from './ssh-connection' - -function makeConnection(): SshConnection { - const channel = { - on: vi.fn(), - stderr: { on: vi.fn() }, - close: vi.fn() - } - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue(channel), - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection -} - -describe('relay GC deploy retry', () => { - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - }) - - it('recomputes install state when GC wins before a healthy relay launch', async () => { - const conn = makeConnection() - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('gc') - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await deployAndLaunchRelay(conn) - - expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) - expect(conn.exec).toHaveBeenCalledTimes(2) - }) - - it('recomputes state when GC finishes between the install probe and lock acquisition', async () => { - const conn = makeConnection() - vi.mocked(isRelayAlreadyInstalled) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false) - .mockResolvedValue(true) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await deployAndLaunchRelay(conn) - - expect(abandonInstall).toHaveBeenCalledTimes(2) - expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(4) - expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(2) - }) - - it('owns a GC claim through launch when the install-lock state is indeterminate', async () => { - const conn = makeConnection() - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('error') - let resolveReady: ((value: Awaited>) => void) | undefined - vi.mocked(waitForSentinel).mockImplementationOnce( - async () => - new Promise>>((resolve) => { - resolveReady = resolve - }) - ) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - const deploy = deployAndLaunchRelay(conn) - await vi.waitFor(() => expect(resolveReady).toBeDefined()) - expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() - resolveReady?.({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - await deploy - - expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() - expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(1) - expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(1) - expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) - expect(conn.exec).toHaveBeenCalledTimes(2) - }) - - it('retains an owned install lock when launch never becomes live', async () => { - const conn = makeConnection() - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') - - expect(abandonInstall).not.toHaveBeenCalled() - expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() - }) - - it('retains an owned launch claim when launch never becomes live', async () => { - const conn = makeConnection() - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('busy') - vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') - - expect(abandonInstall).not.toHaveBeenCalled() - expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() - }) - - it.each(['acquired', 'busy'] as const)( - 'keeps the connected relay but stops setup after uncertain %s fence release', - async (lockResult) => { - const conn = makeConnection() - const error = Object.assign(new Error('release still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) - const release = - lockResult === 'acquired' - ? vi.mocked(abandonInstall) - : vi.mocked(releaseRelayGcClaimWithRetry) - release.mockRejectedValueOnce(error) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - return command.includes('test -S') ? 'DEAD' : '' - }) - - const deployed = await deployAndLaunchRelay(conn) - expect(deployed.transport).toBeDefined() - expect(release).toHaveBeenCalledOnce() - const execCount = vi.mocked(execCommand).mock.calls.length - await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) - await new Promise((resolve) => setImmediate(resolve)) - expect(execCommand).toHaveBeenCalledTimes(execCount) - } - ) - - it.each(['acquired', 'busy'] as const)( - 'preserves the %s fence after an unconfirmed installed-state recheck', - async (lockResult) => { - const conn = makeConnection() - const error = Object.assign(new Error('recheck still running'), { - sshChannelCloseConfirmed: false - }) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(true).mockRejectedValueOnce(error) - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - return 'ORCA-NATIVE-DEPS-OK' - }) - - await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) - expect(abandonInstall).not.toHaveBeenCalled() - expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() - expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() - expect(conn.exec).not.toHaveBeenCalled() - } - ) - - it('keeps retrying repeated launch-claim contention within the deploy bound', async () => { - const conn = makeConnection() - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('busy') - vi.mocked(tryAcquireRelayGcClaim) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValue('launch-token') - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('__ORCA_REMOTE_PLATFORM__')) { - return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' - } - if (command === 'echo $HOME') { - return '/home/user' - } - if (command.includes('node-pty')) { - return 'ORCA-NATIVE-DEPS-OK' - } - if (command.includes('var s=require("net").connect')) { - return 'READY' - } - if (command.includes('test -S')) { - return 'DEAD' - } - return '' - }) - - await deployAndLaunchRelay(conn) - - expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(2) - expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(3) - expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/ssh/ssh-relay-host-node-addons.test.ts b/src/main/ssh/ssh-relay-host-node-addons.test.ts deleted file mode 100644 index 63ea3bc8c0ca..000000000000 --- a/src/main/ssh/ssh-relay-host-node-addons.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' -import type { SshConnection } from './ssh-connection' -import { HOST_NODE_RELAY_RUNTIME_KEY, planHostNodeAddonRelay } from './ssh-relay-host-node-addons' -import { - pinnedNodeRelayFullVersion, - pinnedRelayAddonFiles, - RELAY_RUNTIME_REF_PREFIX -} from './ssh-relay-pinned-node' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const conn = {} as SshConnection -const linux = getRemoteHostPlatform('linux-x64') -const facts = { target: 'linux-x64-glibc' as const, glibc: { major: 2, minor: 31 } } -const dirs: string[] = [] - -function fakeOrcadSlot(): string { - const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) - dirs.push(dir) - for (const file of pinnedRelayAddonFiles('linux-x64-glibc')) { - const path = join(dir, ...file.split('/')) - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, file) - } - return dir -} - -afterEach(() => { - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -const hostNode = { - nodePath: '/usr/bin/node', - facts: { version: { major: 20, minor: 11 }, napi: 9 } -} - -describe('planHostNodeAddonRelay (rung C)', () => { - it('stages the slot addons without a pinned runtime ref, under a version apart from rung A', async () => { - const slot = fakeOrcadSlot() - const resolveHostNode = vi.fn().mockResolvedValue(hostNode) - const plan = await planHostNodeAddonRelay({ - conn, - host: linux, - facts, - baseVersion: '0.1.0+abcdef012345', - materializeOrcad: async () => slot, - resolveHostNode - }) - try { - expect(resolveHostNode).toHaveBeenCalledWith(conn, 8, { signal: undefined }) - expect(plan.nodePath).toBe('/usr/bin/node') - expect(plan.fullVersion).toBe( - pinnedNodeRelayFullVersion( - '0.1.0+abcdef012345', - HOST_NODE_RELAY_RUNTIME_KEY, - plan.addons.digest - ) - ) - expect(plan.fullVersion).not.toBe( - pinnedNodeRelayFullVersion( - '0.1.0+abcdef012345', - NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256, - plan.addons.digest - ) - ) - expect(readdirSync(plan.addons.dir).some((f) => f.startsWith(RELAY_RUNTIME_REF_PREFIX))).toBe( - false - ) - } finally { - await plan.addons.dispose() - } - }) - - it('refuses with host_node_missing when no qualifying host Node answered', async () => { - await expect( - planHostNodeAddonRelay({ - conn, - host: linux, - facts, - baseVersion: '0.1.0+abcdef012345', - resolveHostNode: vi.fn().mockResolvedValue(null) - }) - ).rejects.toMatchObject({ reason: 'host_node_missing' }) - }) - - it('refuses below the addons glibc floor before probing for a Node', async () => { - const resolveHostNode = vi.fn() - await expect( - planHostNodeAddonRelay({ - conn, - host: linux, - facts: { ...facts, glibc: { major: 2, minor: 17 } }, - baseVersion: '0.1.0+abcdef012345', - resolveHostNode - }) - ).rejects.toMatchObject({ reason: 'libc_floor' }) - expect(resolveHostNode).not.toHaveBeenCalled() - }) - - it('lets an unanswered host Node probe propagate instead of stepping down', async () => { - const lost = new Error('channel lost') - await expect( - planHostNodeAddonRelay({ - conn, - host: linux, - facts, - baseVersion: '0.1.0+abcdef012345', - resolveHostNode: vi.fn().mockRejectedValue(lost) - }) - ).rejects.toBe(lost) - }) -}) diff --git a/src/main/ssh/ssh-relay-host-node-addons.ts b/src/main/ssh/ssh-relay-host-node-addons.ts deleted file mode 100644 index 37a6f9032ebf..000000000000 --- a/src/main/ssh/ssh-relay-host-node-addons.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Rung C of the relay runtime ladder (design D6): the relay runs on the host's own Node - * (>= 18) with Orca's prebuilt N-API addons uploaded beside it. No npm, no compiler. - */ -import { ORCAD_ADDON_NAPI_VERSION } from '../../shared/orcad-artifacts' -import type { ServerTarget } from '../../shared/node-runtime-pin' -import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import type { GlibcVersion, OrcadDeploymentTargetFacts } from './orcad-deployment-target' -import type { SshConnection } from './ssh-connection' -import { - isGlibcBelow, - PINNED_NODE_GLIBC_FLOOR, - PinnedRelayFallbackError, - pinnedNodeRelayFullVersion, - stagePinnedRelayAddons, - type PinnedRelayAddons, - type PinnedRelayPlan -} from './ssh-relay-pinned-node' -import { resolveRemoteHostNodeForAddons } from './ssh-remote-node-addon-resolution' -import type { HostNodeAddonFacts } from './ssh-remote-node-toolchain-probe' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { tmpdir } from 'node:os' - -/** Folded into the relay version in place of a runtime hash, so C never shares A's dir. */ -export const HOST_NODE_RELAY_RUNTIME_KEY = 'host-node' - -/** The server slots' addons are built against this glibc, whichever Node loads them. */ -export const RELAY_ADDON_GLIBC_FLOOR: GlibcVersion = PINNED_NODE_GLIBC_FLOOR - -export type HostNodeAddonRelayPlan = { - kind: 'host-node-addons' - target: ServerTarget - glibc: GlibcVersion | null - fullVersion: string - addons: PinnedRelayAddons - nodePath: string - hostNode: HostNodeAddonFacts -} - -/** A relay launched from uploaded prebuilt addons rather than a host npm install. */ -export type PrebuiltRelayPlan = PinnedRelayPlan | HostNodeAddonRelayPlan - -export async function planHostNodeAddonRelay(options: { - conn: SshConnection - host: RemoteHostPlatform - facts: OrcadDeploymentTargetFacts - baseVersion: string - signal?: AbortSignal - materializeOrcad?: (target: ServerTarget, signal?: AbortSignal) => Promise - resolveHostNode?: typeof resolveRemoteHostNodeForAddons -}): Promise { - const { conn, host, facts, signal } = options - if (host.os === 'win32') { - throw new PinnedRelayFallbackError( - 'windows_host_unsupported', - 'Windows hosts keep the host-Node relay for now' - ) - } - if (facts.glibc && isGlibcBelow(facts.glibc, RELAY_ADDON_GLIBC_FLOOR)) { - throw new PinnedRelayFallbackError( - 'libc_floor', - `host glibc ${facts.glibc.major}.${facts.glibc.minor} is below the prebuilt addons' floor` - ) - } - const resolveHostNode = options.resolveHostNode ?? resolveRemoteHostNodeForAddons - const hostNode = await resolveHostNode(conn, ORCAD_ADDON_NAPI_VERSION, { signal }) - if (!hostNode) { - throw new PinnedRelayFallbackError( - 'host_node_missing', - `no host Node.js 18+ with N-API ${ORCAD_ADDON_NAPI_VERSION} was found` - ) - } - let addons: PinnedRelayAddons - try { - const materialize = - options.materializeOrcad ?? ((t, s) => materializeOrcadArtifact(t, { signal: s })) - addons = await stagePinnedRelayAddons( - await materialize(facts.target, signal), - facts.target, - tmpdir(), - { - runtimeRef: false - } - ) - } catch (error) { - signal?.throwIfAborted() - throw new PinnedRelayFallbackError( - 'artifacts_unavailable', - error instanceof Error ? error.message : String(error) - ) - } - try { - return { - kind: 'host-node-addons', - target: facts.target, - glibc: facts.glibc, - fullVersion: pinnedNodeRelayFullVersion( - options.baseVersion, - HOST_NODE_RELAY_RUNTIME_KEY, - addons.digest - ), - addons, - nodePath: hostNode.nodePath, - hostNode: hostNode.facts - } - } catch (error) { - await addons.dispose() - throw error - } -} diff --git a/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts b/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts deleted file mode 100644 index e5949b834114..000000000000 --- a/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Design D5/D6 hostile-host matrix: the real client-side relay deploy against container SSH -// targets, and against a macOS runner's own loopback sshd, asserting which rung of the runtime -// ladder each host lands on, and for cells naming one, the runtime managed orcad deploys on. -// -// Run: ORCA_RUN_SSH_HOSTILE_HOSTS=1 pnpm test src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts -// Needs `pnpm build:relay` and an orcad template holding each selected cell's slot. Docker cells -// need Linux Docker (the no-egress cell dials an internal bridge directly) and -// node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc,linux-x64-musl -// macOS cells need /usr/sbin/sshd and this runner's slot (`pnpm build:orcad-prebuilds`, then -// `--targets darwin-arm64` or `darwin-x64`). Only cells this machine can host run. -// ORCA_SSH_HOSTILE_HOST_CELLS=id,id narrows the run. .github/workflows/ssh-hostile-hosts.yml runs it. -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { posix } from 'node:path' -import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' -import { ORCAD_PARCEL_WATCHER_NATIVE } from '../../shared/orcad-artifacts' -import type { SshConnection } from './ssh-connection' -import { HOSTILE_HOST_CELLS, selectHostileHostCells } from './ssh-hostile-host-cells' -import { proveManagedOrcadCell } from './ssh-hostile-host-managed-orcad' -import { - assertCell, - connectHostileHost, - deployOnce, - exerciseLaunchedCell, - installHostileHostAppEnvironment, - TERMINAL_PROBES -} from './ssh-hostile-host-test-harness' -import { - hostExec, - hostExecStatus, - hostileHostObserver, - hostileHostSshTarget, - startHostileHostTarget, - stopHostileHostTarget, - type HostileHostTarget -} from './ssh-hostile-host-test-fixture' - -const RUN = process.env.ORCA_RUN_SSH_HOSTILE_HOSTS === '1' -const SELECTED = new Set( - RUN ? selectHostileHostCells(process.env.ORCA_SSH_HOSTILE_HOST_CELLS).map((cell) => cell.id) : [] -) -const CELL_TIMEOUT_MS = 15 * 60_000 - -/** - * Gatekeeper only evaluates files carrying com.apple.quarantine, which SFTP writes never get; the - * runtime must run as uploaded, with no `xattr -d` (the SSH side's xattr is a forbidden shim). - */ -async function assertRunsWithoutQuarantine( - target: HostileHostTarget, - nodePath: string -): Promise { - const runtimeDir = posix.dirname(posix.dirname(nodePath)) - expect(await hostExec(target, `xattr -r -l '${runtimeDir}'`)).not.toContain( - 'com.apple.quarantine' - ) - expect(await hostExec(target, `'${nodePath}' -p process.version`)).toBe( - `v${NODE_RUNTIME_PIN.version}` - ) -} - -describe('SSH relay hostile-host matrix', () => { - let cleanupAppEnvironment: (() => void) | null = null - - beforeAll(() => { - cleanupAppEnvironment = installHostileHostAppEnvironment() - }) - - afterAll(() => { - cleanupAppEnvironment?.() - }) - - // Why: with every cell skipped the job would pass having deployed nothing. - it.runIf(RUN)('selects at least one cell this machine can host', () => { - expect(SELECTED.size).toBeGreaterThan(0) - }) - - for (const cell of HOSTILE_HOST_CELLS) { - it.skipIf(!SELECTED.has(cell.id))( - `${cell.id} lands on ${cell.expect.outcome === 'launched' ? `rung ${cell.expect.rung}` : cell.expect.outcome}`, - async () => { - let target: HostileHostTarget | null = null - let conn: SshConnection | null = null - try { - target = await startHostileHostTarget(cell) - if (target.kind === 'docker' && target.cell.noEgress) { - expect( - await hostExecStatus(target, "timeout 5 bash -c 'exec 3<>/dev/tcp/1.1.1.1/443'") - ).not.toBe(0) - } - const observer = hostileHostObserver(target) - const sshTarget = hostileHostSshTarget(target) - conn = await connectHostileHost(sshTarget) - const first = await deployOnce(conn) - await assertCell(cell, observer, first) - if (cell.expect.outcome === 'launched') { - const launched = await exerciseLaunchedCell({ - cell, - observer, - sshTarget, - terminal: TERMINAL_PROBES.posix, - first, - firstConn: conn - }) - if (target.kind === 'local-sshd' && target.cell.runsOn.platform === 'darwin') { - await assertRunsWithoutQuarantine(target, launched.nodePath) - } - // The relay runs on without its watcher, so only a direct load proves the slot's build fits. - const watcher = `${first.deployed?.remoteRelayDir}/${ORCAD_PARCEL_WATCHER_NATIVE}` - await hostExec(target, `'${launched.nodePath}' -e "require('${watcher}')"`) - } else if ( - cell.expect.outcome !== 'legacy_opt_out' && - cell.expect.refusals[0]?.reason === 'libc_floor' - ) { - // Refused from the probe alone: nothing was uploaded. - expect( - await hostExecStatus(target, 'ls -d /root/.orca-remote/runtimes/node-*') - ).not.toBe(0) - } - } finally { - await conn?.disconnect().catch(() => {}) - await stopHostileHostTarget(target) - } - }, - CELL_TIMEOUT_MS - ) - } - - // A fresh host per cell: the relay pass above leaves a relay and its runtime behind. - for (const cell of HOSTILE_HOST_CELLS.filter((candidate) => candidate.managed)) { - it.skipIf(!SELECTED.has(cell.id))( - `${cell.id} deploys managed orcad on ${cell.managed?.runtime} (${cell.managed?.outcome})`, - async () => { - let target: HostileHostTarget | null = null - try { - target = await startHostileHostTarget(cell) - await proveManagedOrcadCell(cell, target, hostileHostSshTarget(target)) - } finally { - await stopHostileHostTarget(target) - } - }, - CELL_TIMEOUT_MS - ) - } -}) diff --git a/src/main/ssh/ssh-relay-live-connect.test.ts b/src/main/ssh/ssh-relay-live-connect.test.ts deleted file mode 100644 index 8ebf3f7f9f49..000000000000 --- a/src/main/ssh/ssh-relay-live-connect.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterAll, describe, expect, it, vi } from 'vitest' - -// Live end-to-end harness for ssh:connect against a real host. Skipped unless -// ORCA_LIVE_SSH_HOST is set; never runs in normal CI or unit-test loops. -vi.mock('electron', () => ({ - app: { getAppPath: () => process.cwd() } -})) - -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshConnection } from './ssh-connection' -import { resolveSshConfigHomePath } from './ssh-config-path-expansion' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import type { SshTarget } from '../../shared/ssh-types' - -const LIVE_HOST = process.env.ORCA_LIVE_SSH_HOST -const LIVE_USER = process.env.ORCA_LIVE_SSH_USER ?? process.env.USERNAME ?? process.env.USER ?? '' -const LIVE_IDENTITY = resolveSshConfigHomePath( - process.env.ORCA_LIVE_SSH_IDENTITY ?? '~/.ssh/id_ed25519' -) -const rawLivePort = process.env.ORCA_LIVE_SSH_PORT -const LIVE_PORT = rawLivePort ? Number.parseInt(rawLivePort, 10) : 22 - -const startedAt = Date.now() -function log(step: string): void { - const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1) - console.log(`[live-connect +${elapsed}s] ${step}`) -} - -describe.skipIf(!LIVE_HOST)('live ssh:connect pipeline', () => { - const cleanups: (() => Promise | void)[] = [] - - afterAll(async () => { - for (const cleanup of cleanups.toReversed()) { - try { - await cleanup() - } catch (err) { - // Best-effort teardown; the relay grace period reaps leftovers. - log(`cleanup error: ${err instanceof Error ? err.message : String(err)}`) - } - } - }) - - it('connects, deploys the relay, and spawns a real PTY', { timeout: 360_000 }, async () => { - if (!Number.isInteger(LIVE_PORT) || LIVE_PORT < 1 || LIVE_PORT > 65535) { - throw new Error(`Invalid ORCA_LIVE_SSH_PORT: ${rawLivePort}`) - } - - const target: SshTarget = { - id: 'live-connect-harness', - label: 'live-connect-harness', - host: LIVE_HOST!, - port: LIVE_PORT, - username: LIVE_USER, - identityFile: LIVE_IDENTITY, - source: 'manual' - } - - log(`connecting to ${LIVE_USER}@${LIVE_HOST}:${LIVE_PORT}`) - const conn = new SshConnection(target, { - onStateChange: (_id, state) => { - log(`state=${state.status}${state.error ? ` error=${state.error}` : ''}`) - } - }) - cleanups.push(() => conn.disconnect()) - await conn.connect() - log('ssh connection established') - - const deployed = await deployAndLaunchRelay( - conn, - (status) => log(`deploy: ${status}`), - 30, - 'live-connect-harness' - ) - log(`relay launched (remoteRelayDir=${deployed.remoteRelayDir})`) - - const mux = new SshChannelMultiplexer(deployed.transport) - cleanups.push(() => mux.dispose()) - - const home = await mux.request('session.resolveHome', { path: '~' }) - log(`session.resolveHome -> ${JSON.stringify(home)}`) - expect(home).toBeTruthy() - - const spawned = (await mux.request('pty.spawn', { - cols: 80, - rows: 24 - })) as { id: string } - log(`pty.spawn -> id=${spawned.id}`) - expect(spawned.id).toBeTruthy() - - await mux.request('pty.shutdown', { id: spawned.id }) - log('pty.shutdown ok: full connect pipeline verified') - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts deleted file mode 100644 index 10b1ed620f34..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -// The claim this file has to hold up: a second deploy of a *different bundle* to the same host -// runs no `npm install` at all. Everything else here is the fallback ladder underneath it — a -// host that cannot link, cannot publish, or answers nothing still deploys exactly as before. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install' -import { - makeMockConnection, - makeStagedFirstInstallExecPrefix, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' -import { - RELAY_NATIVE_CACHE_LINKED, - RELAY_NATIVE_CACHE_MISS, - RELAY_NATIVE_CACHE_PROMOTED -} from './ssh-relay-native-deps-cache-commands' - -// Everything after the probe on a healthy install: stderr cleanup, stage cleanup, launch. -// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a -// freshly installed node-pty loads (#17915). -const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' -const LAUNCH_TAIL: ExecResponse[] = ['', 'DEAD', '', 'READY'] - -describe('relay native-deps cache on the deploy path', () => { - let warnSpy: ReturnType - const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - sftpCapture.paths.length = 0 - for (const k of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[k] - } - for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[k] - } - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - }) - - function feed(responses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const r of responses) { - if (typeof r === 'string') { - mockExec.mockResolvedValueOnce(r) - } else { - mockExec.mockRejectedValueOnce(new Error(r.reject)) - } - } - } - - function execCommands(): string[] { - return vi.mocked(execCommand).mock.calls.map(([, command]) => command) - } - - /** - * A first install whose cache probe answers `cacheAnswer`. The prefix's last slot is the cache - * probe, so overriding it is the only difference between a hit and a miss. - */ - function firstInstall(cacheAnswer: string, tail: ExecResponse[]): ExecResponse[] { - const prefix = makeStagedFirstInstallExecPrefix() - prefix[prefix.length - 1] = cacheAnswer - return [...prefix, ...tail] - } - - it('runs no npm install when a different bundle finds a complete entry on the host', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_LINKED, [ - '', // chmod prebuilds, through the symlink - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const commands = execCommands() - expect(commands.some((c) => c.includes('npm install'))).toBe(false) - expect(commands.some((c) => c.includes('npm rebuild'))).toBe(false) - // The bundle still gets its own directory; only the native tree is shared. - expect(commands.some((c) => c.includes('.orca-remote/relay-0.1.0+testhash'))).toBe(true) - expect(commands.some((c) => /\.orca-remote\/native\/linux-x64-[0-9a-f]{16}/.test(c))).toBe(true) - }) - - it('still installs on the first deploy, then publishes the tree the probe loaded', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_MISS, [ - '', // npm install - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - RELAY_NATIVE_CACHE_PROMOTED, - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const commands = execCommands() - const install = commands.find((c) => c.includes('npm install')) ?? '' - expect(install).toContain('node-pty@1.1.0') - // The install runs in the relay directory; publication moves the finished tree afterwards. - expect(install).toContain('.orca-remote/relay-0.1.0+testhash') - const promote = commands.findLast((c) => c.includes('mkdir "$cache"')) ?? '' - expect(promote).toContain(': > "$cache/.deps-complete"') - }) - - it('does not publish a tree the probe could not load', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_MISS, [ - '', // npm install - '', // chmod prebuilds - 'MISSING\n', - '', // cat probe stderr - '', // rm probe stderr - '', // npm rebuild - '', // chmod prebuilds after rebuild - 'MISSING\n', - '', // cat stderr after rebuild - '', // rm stderr after rebuild - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - expect(execCommands().some((c) => c.includes('mkdir "$cache"'))).toBe(false) - }) - - it('installs per-directory when the host cannot answer the cache probe at all', async () => { - const conn = makeMockConnection(sftpCapture) - const prefix = makeStagedFirstInstallExecPrefix() - prefix[prefix.length - 1] = { reject: 'mkdir: Read-only file system' } - feed([ - ...prefix, - '', // npm install still runs - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - '', // publication is attempted and answers nothing - ...LAUNCH_TAIL - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - expect(execCommands().some((c) => c.includes('npm install'))).toBe(true) - }) - - it('falls back to its own install when a linked entry does not load on this host', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_LINKED, [ - '', // chmod prebuilds - 'MISSING\n', // the shared tree does not load here - '', // cat probe stderr - '', // rm probe stderr - '', // npm install, privately, after the prefix detaches the symlink - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - '', // promotion attempt (the entry already exists, so it is declined) - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const install = execCommands().find((c) => c.includes('npm install')) ?? '' - // Why this prefix is the whole point: npm follows the symlink, and every other relay on the - // host is running out of the tree on the other side of it. - expect(install).toContain('if [ -L node_modules ]; then rm -f node_modules; fi;') - const warnings = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnings.some((m) => m.includes('[ssh-relay][NATIVE-CACHE-UNUSABLE]'))).toBe(true) - }) - - it('detaches rather than resetting through the link when repairing an installed relay', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - const bothMissing = 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - bothMissing, // health probe before the repair lock - bothMissing, // re-probe under the lock - '', // install-owner marker - '', // npm install - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const commands = execCommands() - // A repair never consults the shared entry: its reset would rewrite a tree it does not own. - expect(commands.some((c) => c.includes('.orca-remote/native/'))).toBe(false) - const install = commands.find((c) => c.includes('npm install')) ?? '' - expect(install).toContain('if [ -L node_modules ]; then rm -f node_modules; fi;') - expect(install).toContain("rm -rf 'node_modules/node-pty'") - }) - - it('pins its own key so a GC pass cannot collect the entry this connection depends on', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_LINKED, [ - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - ...LAUNCH_TAIL - ]) - ) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - await vi.waitFor(() => expect(vi.mocked(gcOldRelayVersions)).toHaveBeenCalled()) - - const options = vi.mocked(gcOldRelayVersions).mock.calls.at(-1)?.[4] - expect(options?.nativeDepsCacheKeys).toEqual([ - expect.stringMatching(/^linux-x64-[0-9a-f]{16}$/) - ]) - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache-install.ts b/src/main/ssh/ssh-relay-native-deps-cache-install.ts deleted file mode 100644 index 30ff68862982..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-cache-install.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * The deploy-side half of the shared native-deps cache: resolve this build's key, try to link an - * existing entry, and publish a probe-verified tree afterwards. - * - * Both entry points answer with a value, never an exception. The cache is an optimization on a - * path that must still connect a host with a read-only home, no `ln`, or an SSH server that drops - * the channel — every one of those is a plain per-directory install, which is what the relay did - * before this existed. - */ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import type { SshConnection } from './ssh-connection' -import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' -import { execHostCommand } from './ssh-relay-host-exec' -import { NATIVE_DEPS_COMMAND_TIMEOUT_MS } from './ssh-relay-deploy-timing' -import { - computeRelayNativeDepsCacheKey, - supportsRelayNativeDepsCache, - RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN, - type RelayNativeDepsCachePatchSource -} from './ssh-relay-native-deps-cache' -import { - ensureRelayNativeDepsCacheCommand, - promoteRelayNativeDepsCacheCommand, - RELAY_NATIVE_CACHE_LINKED, - RELAY_NATIVE_CACHE_PROMOTED, - RELAY_NATIVE_CACHE_SEEDED, - type RelayNativeDepsCachePaths -} from './ssh-relay-native-deps-cache-commands' -import type { RemoteHostPlatform } from './ssh-remote-platform' - -/** - * `linked` — the relay directory now points at a complete shared entry and needs no install. - * `private` — it owns (or is about to own) its own tree, which promotion may later publish. - */ -export type RelayNativeDepsCacheAttachment = { - mode: 'linked' | 'private' - key: string -} - -export type RelayNativeDepsCacheContext = { - hostPlatform: RemoteHostPlatform - remoteHome: string - relayDir: string - platform: string - localRelayDir: string - deps: Readonly> - signal?: AbortSignal -} - -/** - * Every shipped artifact that patches the installed native tree, read for hashing. - * - * Reading is best-effort by design: a patch this client cannot read must not silently drop out of - * the key, so an unreadable one disables the cache rather than producing a key that claims the - * patch was applied. - */ -export function readRelayNativeDepsPatchSources( - localRelayDir: string -): RelayNativeDepsCachePatchSource[] | null { - const sources: RelayNativeDepsCachePatchSource[] = [] - for (const artifact of RELAY_ARTIFACTS) { - if (!RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(artifact.filename)) { - continue - } - const path = join(localRelayDir, artifact.filename) - try { - if (!existsSync(path)) { - continue - } - sources.push({ filename: artifact.filename, contents: readFileSync(path, 'utf-8') }) - } catch { - return null - } - } - return sources -} - -/** This build's cache key, or null when it cannot be computed and the cache must stay off. */ -export function resolveRelayNativeDepsCacheKey(context: { - platform: string - localRelayDir: string - deps: Readonly> -}): string | null { - const patchSources = readRelayNativeDepsPatchSources(context.localRelayDir) - if (!patchSources) { - return null - } - try { - return computeRelayNativeDepsCacheKey({ - platform: context.platform, - deps: context.deps, - patchSources - }) - } catch (err) { - console.warn( - `[ssh-relay] Native-deps cache key unavailable for ${context.platform}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return null - } -} - -/** - * Link a complete entry, or leave the relay directory to install privately. - * - * Returns null when the cache is off for this host, which keeps the caller on the exact command - * sequence it ran before the cache existed. - */ -export async function attachRelayNativeDepsCache( - conn: SshConnection, - context: RelayNativeDepsCacheContext -): Promise { - if (!supportsRelayNativeDepsCache(context.hostPlatform)) { - return null - } - const key = resolveRelayNativeDepsCacheKey(context) - if (!key) { - return null - } - const paths = cachePathsFor(context, key) - try { - // Why the native-deps budget and not the default 30s: a seeding copy moves a whole - // node_modules on the host's own disk, which is fast but not instant on a cold cache. - const output = await execHostCommand( - conn, - context.hostPlatform, - ensureRelayNativeDepsCacheCommand(paths, context.deps), - { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal: context.signal } - ) - if (output.includes(RELAY_NATIVE_CACHE_LINKED)) { - console.log(`[ssh-relay] Native deps linked from shared cache entry ${key}`) - return { mode: 'linked', key } - } - if (output.includes(RELAY_NATIVE_CACHE_SEEDED)) { - console.log(`[ssh-relay] Seeded native deps for ${key} from an existing install on this host`) - } - return { mode: 'private', key } - } catch (err) { - context.signal?.throwIfAborted() - // Why still 'private' and not null: the relay directory owns nothing yet either way, and the - // install that follows is identical. Promotion afterwards is separately best-effort. - console.warn( - `[ssh-relay] Native-deps cache probe for ${key} failed; installing per-directory: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return { mode: 'private', key } - } -} - -/** - * Publish a private tree the probe just loaded, and link the relay directory to it. - * - * Called only after `probeInstalledNativeDeps` reported both addons loadable on this host, so an - * entry is never published on the strength of a successful `npm install` alone. - */ -export async function promoteRelayNativeDepsCache( - conn: SshConnection, - context: RelayNativeDepsCacheContext, - key: string -): Promise { - try { - const output = await execHostCommand( - conn, - context.hostPlatform, - promoteRelayNativeDepsCacheCommand(cachePathsFor(context, key)), - { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal: context.signal } - ) - console.log( - output.includes(RELAY_NATIVE_CACHE_PROMOTED) - ? `[ssh-relay] Published native deps as shared cache entry ${key}` - : `[ssh-relay] Native deps stay per-directory; shared cache entry ${key} was not published` - ) - } catch (err) { - context.signal?.throwIfAborted() - console.warn( - `[ssh-relay] Could not publish native-deps cache entry ${key}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - } -} - -function cachePathsFor( - context: RelayNativeDepsCacheContext, - key: string -): RelayNativeDepsCachePaths { - return { - host: context.hostPlatform, - remoteHome: context.remoteHome, - relayDir: context.relayDir, - key - } -} diff --git a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts deleted file mode 100644 index 3a21573fbd33..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { - abandonInstall, - finalizeInstall, - isRelayAlreadyInstalled -} from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { - makeExecResponses, - makeStagedFirstInstallExecPrefix, - makeMockConnection, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' - -describe('installNativeDeps staged uploads', () => { - const sftpCapture: SftpWriteCapture = { - paths: [], - contents: {}, - execCallCountAtWrite: {} - } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - sftpCapture.paths.length = 0 - for (const key of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[key] - } - for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[key] - } - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) - }) - - function feed(execResponses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const response of execResponses) { - if (typeof response === 'string') { - mockExec.mockResolvedValueOnce(response) - } else { - mockExec.mockRejectedValueOnce(new Error(response.reject)) - } - } - } - - it('writes a hardcoded package.json BEFORE running npm install', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const pkgPath = sftpCapture.paths.find((path) => path.endsWith('/package.json')) - expect(pkgPath, 'package.json must be written via SFTP').toBeTruthy() - - const written = sftpCapture.contents[pkgPath as string] - expect(written).toBeTruthy() - const parsed = JSON.parse(written) as Record - expect(parsed.name).toBe('orca-relay') - expect(parsed.version).toBe('1.0.0') - expect(parsed.private).toBe(true) - expect(parsed.type).toBe('commonjs') - expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' }) - expect(parsed.allowScripts).toEqual({ - '@parcel/watcher@2.5.6': true, - 'node-pty@1.1.0': true - }) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const npmInstallIdx = execCalls.findIndex( - (command) => - command.includes('npm install') && - command.includes('node-pty') && - command.includes('@parcel/watcher') - ) - expect(npmInstallIdx).toBeGreaterThanOrEqual(0) - expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false') - const writeObservedAt = sftpCapture.execCallCountAtWrite[pkgPath as string] - expect(writeObservedAt).toBeLessThanOrEqual(npmInstallIdx) - }) - - it('exports the host Node headers dir to node-gyp on every command that can compile node-pty (STA-6674)', async () => { - const conn = makeMockConnection(sftpCapture) - // Install succeeds, the probe fails, the rebuild repairs it, then the cloexec patch rebuilds again. - feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const compiling = ['npm install', 'npm rebuild', 'node-pty-1.1.0-master-cloexec-patch.cjs'] - for (const compileStep of compiling) { - const command = commands.find((candidate) => candidate.includes(compileStep)) - expect(command, compileStep).toBeDefined() - // Both spellings: node-gyp 10 (Node 20) reads only npm_config_, node-gyp >= 11.4 prefers the other. - expect(command).toContain('export npm_config_nodedir=') - expect(command).toContain('npm_package_config_node_gyp_nodedir=') - // The export precedes the compile on the same command line, and only when the probe found headers. - expect(command!.indexOf('npm_config_nodedir')).toBeLessThan(command!.indexOf(compileStep)) - expect(command).toContain('node_version.h') - // The marker lands in the captured output, so a failure after it can say what was exported. - expect(command).toContain('echo "ORCA-NODE-HEADERS:${ORCA_NODE_HEADERS_DIR:-none}"') - } - }) - - // What execCommand actually rejects with: the whole command line (marker echo included) quoted - // ahead of the host's output. A fixture that omits the command hides the marker-parsing bug. - function rejectNpmInstallLikeExecCommand(hostOutput: string): void { - vi.mocked(execCommand).mockImplementationOnce(async (_conn, command) => { - throw new Error(`Command "${command}" failed (exit 1): ${hostOutput}`) - }) - } - const HEADERS_REFUSED = - 'npm error gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNREFUSED\nnpm error gyp ERR! configure error' - - it('names the fix when node-gyp cannot download headers and the host ships none (STA-6674)', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - rejectNpmInstallLikeExecCommand(`ORCA-NODE-HEADERS:none\n${HEADERS_REFUSED}`) - feed(['']) // clean stage root - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect((error as Error).message).toContain('could not download the Node.js headers') - expect((error as Error).message).toContain('no local headers matching its own version') - expect((error as Error).message).not.toContain('Orca defect') - expect((error as Error).message).toContain('ECONNREFUSED') - // A full toolchain: the toolchain probe must not run, and this is not a "build tools" error. - expect((error as Error).message).not.toContain('build tools') - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('command -v "$t"'))).toBe(false) - }) - - it('reports an Orca defect when headers were exported but node-gyp downloaded anyway', async () => { - // The marker says the export happened; a download after it means node-gyp never read the env. - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - rejectNpmInstallLikeExecCommand(`ORCA-NODE-HEADERS:/usr/local\n${HEADERS_REFUSED}`) - feed(['']) // clean stage root - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect((error as Error).message).toContain('/usr/local/include/node') - expect((error as Error).message).toContain('Orca defect') - expect((error as Error).message).not.toContain('no local headers matching its own version') - }) - - it('promotes only after the first-install lock is acquired', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - vi.mocked(acquireInstallLock).mockImplementationOnce(async () => { - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('cp -a'))).toBe(false) - }) - - await deployAndLaunchRelay(conn) - - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const promotionIndex = commands.findIndex((command) => command.includes('cp -a')) - const npmIndex = commands.findIndex((command) => command.includes('npm install')) - expect(promotionIndex).toBeGreaterThanOrEqual(0) - expect(npmIndex).toBeGreaterThan(promotionIndex) - }) - - it('cleans a staged upload when cancellation wins before lock acquisition', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - vi.mocked(acquireInstallLock).mockImplementationOnce( - (_conn, _remoteDir, _host, options) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { - once: true - }) - }) - ) - vi.mocked(execCommand).mockResolvedValueOnce('') - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - const result = await deploy - - expect(result).toBeInstanceOf(Error) - expect(vi.mocked(acquireInstallLock)).toHaveBeenCalledTimes(1) - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('cp -a'))).toBe(false) - expect(commands.some((command) => command.includes('rm -rf'))).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('retains the lock after an unconfirmed promotion termination', async () => { - const conn = makeMockConnection(sftpCapture) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/u') - .mockResolvedValueOnce('') - .mockResolvedValueOnce( - '__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0' - ) - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockRejectedValueOnce( - Object.assign(new Error('promotion termination was not confirmed'), { - sshChannelCloseConfirmed: false - }) - ) - .mockResolvedValueOnce('') - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow( - 'promotion termination was not confirmed' - ) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts deleted file mode 100644 index f6d16eaed8e4..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ /dev/null @@ -1,963 +0,0 @@ -// Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. - -import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -// Why: the post-launch ripgrep install would consume this file's queued exec mocks. -// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. -vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ - ...(await importOriginal()), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { - abandonInstall, - finalizeInstall, - gcOldRelayVersions, - isRelayAlreadyInstalled -} from './ssh-relay-versioned-install' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { - BOTH_NATIVE_DEPS_MISSING_PROBE, - decodePowerShellCommand, - makeExecResponses, - makeMockConnection, - makeRepairToolchainSkipExecResponses, - makeStagedFirstInstallExecPrefix, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' - -describe('installNativeDeps (via deployAndLaunchRelay)', () => { - let warnSpy: ReturnType - const sftpCapture: SftpWriteCapture = { - paths: [], - contents: {}, - execCallCountAtWrite: {} - } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - // mockReset because clearAllMocks keeps queued mockResolvedValueOnce entries, so a leaked response would bleed into the next test. - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - sftpCapture.paths.length = 0 - for (const k of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[k] - } - for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[k] - } - // Re-prime as defense-in-depth: factory mocks survive clearAllMocks but a test's own resetAllMocks would drop them. - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - }) - - function feed(execResponses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const r of execResponses) { - if (typeof r === 'string') { - mockExec.mockResolvedValueOnce(r) - } else { - mockExec.mockRejectedValueOnce(new Error(r.reject)) - } - } - } - - it('propagates a hard `npm install` failure so the deploy aborts before finalizeInstall', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'npm ERR! E404 Not Found node-pty' }, - probe: 'ok' - }) - ) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/npm ERR/) - - // Regression: previously the catch swallowed the throw and finalizeInstall ran anyway. - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NATIVE-DEPS-INSTALL-FAIL]'))).toBe(true) - }) - - it('connects without node-pty when the remote toolchain is missing, instead of failing the host', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - nodePtySkipRetry: 'ok', - toolchainProbe: 'PKG apk' - }) - ) - - // node-pty only backs terminals, so a host that cannot compile it still gets files and git. - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - expect(vi.mocked(finalizeInstall)).toHaveBeenCalled() - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const reinstall = execCalls.findLast((c) => c.includes('npm install')) ?? '' - expect(reinstall).toContain('@parcel/watcher@') - expect(reinstall).not.toContain('node-pty@') - // The skip path must stop here: rebuilding is pointless on a host with no compiler. - expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) - // node-pty is legitimately absent, so its probe result must not raise the degraded-mode alarm. - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED]'))).toBe( - false - ) - // The rewritten manifest must drop node-pty too, or npm reconciles it back and rebuilds. - const pkgPath = sftpCapture.paths.findLast((p) => p.endsWith('/package.json')) as string - // The capture concatenates every write to a path; the rewrite is the last manifest line. - const latest = sftpCapture.contents[pkgPath].trim().split('\n').at(-1) as string - const deps = (JSON.parse(latest) as { dependencies: object }).dependencies - expect(deps).toHaveProperty('@parcel/watcher') - expect(deps).not.toHaveProperty('node-pty') - }) - - it('warns when @parcel/watcher is also unloadable after node-pty was skipped', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - nodePtySkipRetry: 'ok', - nodePtySkipWatcher: 'missing' - }) - ) - - // Watcher failure (e.g. glibc below the floor) is non-fatal, but silent dead file watching is not acceptable. - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED]'))).toBe( - true - ) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) - }) - - it('hard-fails on a gyp error when the remote toolchain is actually complete', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - // Probe contradicts the gyp output: the tools are all there, so the real cause is unknown. - toolchainProbe: 'HAVE make\nHAVE g++\nHAVE python3\nPKG apt-get' - }) - ) - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - // Degrading here would silently drop terminals on a host that can build them. - expect((error as Error).message).toContain('gyp ERR!') - expect((error as Error).message).not.toContain('build tools') - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.filter((c) => c.includes('npm install'))).toHaveLength(1) - expect(execCalls.some((c) => c.includes("rm -rf 'node_modules/node-pty'"))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) - - it('still reports the actionable build-tools error when the node-pty-less retry also fails', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - // No HAVE lines + apk present: the tailored hint must come from the remote probe, not a hardcoded apt fallback. - toolchainProbe: 'PKG apk', - nodePtySkipRetry: { reject: 'npm ERR! registry unreachable' } - }) - ) - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect(error).toBeInstanceOf(Error) - const message = (error as Error).message - // Actionable: names the missing tools and the exact install command. - expect(message).toContain('build tools') - expect(message).toContain('make') - expect(message).toContain('sudo apk add build-base python3') - // The raw npm/node-gyp output is preserved for triage, not discarded. - expect(message).toContain('not found: make') - // The retry's own cause is unrelated to the toolchain, so it must survive as cause + a log line. - expect((error as Error).cause).toBeInstanceOf(Error) - expect(((error as Error).cause as Error).message).toContain('registry unreachable') - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect( - warnMessages.some( - (m) => m.includes('[ssh-relay][NPTY-SKIP-RETRY-FAIL]') && m.includes('registry unreachable') - ) - ).toBe(true) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) - - it('preserves the original npm error when it is not a native build-tool failure', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { reject: 'npm ERR! network ETIMEDOUT' }, - probe: 'ok', - // A network error must stay a network error even if the host also lacks build tools. - toolchainProbe: 'PKG apt-get' - }) - ) - - // Non-toolchain npm error (network/registry): surface the real error, not a misleading "install build tools". - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect((error as Error).message).toContain('npm ERR! network ETIMEDOUT') - expect((error as Error).message).not.toContain('build tools') - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('command -v "$t"'))).toBe(false) - }) - - it('preserves redirected npm stdout for non-toolchain failures without probing', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: { - reject: - 'Command "export PATH=/usr/bin:$PATH && cd /home/u/.orca-remote/relay && npm install node-pty@1.1.0 2>&1" failed (exit 1): npm ERR! network ETIMEDOUT' - }, - probe: 'ok', - toolchainProbe: 'PKG apt-get' - }) - ) - - const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) - expect((error as Error).message).toContain('npm ERR! network ETIMEDOUT') - expect((error as Error).message).not.toContain('build tools') - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('command -v "$t"'))).toBe(false) - }) - - it('warns clearly when node-pty installs but require() fails (built-but-unloadable)', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing' })) - - await deployAndLaunchRelay(conn) - - // Probe failure is non-fatal by design (docs/ssh-relay-versioned-install-dirs.md): throwing would loop reconnects forever where node-pty can't build. - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) - - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('rebuilds unloadable native deps and recovers before first relay launch', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const failedProbeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) - const rebuildIdx = execCalls.findIndex((c) => c.includes('npm rebuild')) - const repairedProbeIdx = execCalls.findIndex( - (c, index) => index > rebuildIdx && c.includes('require("node-pty")') - ) - expect(rebuildIdx).toBeGreaterThan(failedProbeIdx) - expect(execCalls[rebuildIdx]).toContain('--ignore-scripts=false') - expect(repairedProbeIdx).toBeGreaterThan(rebuildIdx) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - }) - - it('propagates an SSH-channel failure from the post-rebuild re-probe', async () => { - // Why: rebuild degrades gracefully, but the post-rebuild probe must surface transport death, else a dead channel finalizes a half-repaired install. - const conn = makeMockConnection(sftpCapture) - feed([ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install native deps - '', // chmod prebuilds - 'MISSING\n', // first probe: require() fails - '', // cat probe stderr - '', // rm probe stderr - '', // npm rebuild native deps - '', // chmod prebuilds after rebuild - { reject: 'SSH channel closed during native deps re-probe' } // re-probe rejects - ]) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel closed/) - - // Rebuild failure is swallowed; a re-probe transport failure must not be. - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('aborts an in-progress native install and releases its lock at deploy timeout', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - let installSignal: AbortSignal | undefined - vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { - expect(command).toContain('npm install') - installSignal = options?.signal - return new Promise((_resolve, reject) => { - installSignal?.addEventListener('abort', () => reject(installSignal?.reason), { - once: true - }) - }) - }) - - const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(installSignal).toBeDefined()) - - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - - const result = await promise - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe( - `Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s` - ) - expect(installSignal?.aborted).toBe(true) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('does not rewrite node_modules when the health probe never answered', async () => { - // Why (#14830): a wedged `require("node-pty")` makes the probe time out. Reading that silence - // as "every native dep is missing" sent a healthy install through npm install + rebuild that - // could not help, and the retry loop burned the whole deploy budget at "Deploying relay…". - const conn = makeMockConnection(sftpCapture) - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - { reject: 'Command "node -e ..." timed out after 30s' }, // health probe never answered - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - const outcome = await deployAndLaunchRelay(conn).catch((error: Error) => error) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) - expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('Repairing missing native deps'))).toBe(false) - // Probe diagnostic wording changes independently of the repair contract. - expect(execCalls.some((c) => c.includes("rm -rf 'node_modules/node-pty'"))).toBe(false) - expect(execCalls.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) - const healthProbeIndex = execCalls.findIndex((command) => - command.includes('ORCA-NATIVE-DEPS-OK') - ) - await expect(vi.mocked(execCommand).mock.results[healthProbeIndex].value).rejects.toThrow( - 'Command "node -e ..." timed out after 30s' - ) - expect( - outcome, - 'an unanswered health probe must still allow an intact relay to launch' - ).not.toBeInstanceOf(Error) - }) - - it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: 'ok', - probe: { reject: 'SSH channel closed unexpectedly' } - }) - ) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel/) - - // Pin the rejection to the PROBE call so slot-ordering drift can't pass this test via a different failure path. - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const probeCallIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) - const npmInstallIdx = execCalls.findIndex( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - expect(probeCallIdx, 'probe must have been invoked').toBeGreaterThanOrEqual(0) - // Probe must come strictly after npm install, else it probes an empty dir and doesn't represent the real-world race. - expect(probeCallIdx).toBeGreaterThan(npmInstallIdx) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - // Channel failure must not be conflated with "node-pty missing" or "npm install failed". - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NATIVE-DEPS-INSTALL-FAIL]'))).toBe( - false - ) - - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - // Lock must be released so a future reconnect can retry. - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('throws (rather than warns MISSING) when the install dir vanishes between npm install and probe', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'dir-gone' })) - - // Why: cd-failure short-circuits the probe's && so it rejects instead of resolving MISSING; conflating "dir gone" with "node-pty missing" strands the user in degraded mode. - await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/cd:/) - - // Pin the rejection to the probe slot so a refactor moving probe before npm install can't pass this test for the wrong reason. - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const probeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) - const npmInstallIdx = execCalls.findIndex( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - expect(probeIdx).toBeGreaterThan(npmInstallIdx) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('uses `node -e require()` rather than `test -d` so unloadable installs are caught', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const probeCmds = vi - .mocked(execCommand) - .mock.calls.map(([, c]) => c) - .filter((c) => c.includes(`require("node-pty")`)) - - // Why: probe must require('node-pty') via the node binary; a weaker test -d passes even when the native binding load is broken. - expect(probeCmds.length).toBeGreaterThan(0) - expect(probeCmds[0]).toMatch(/node['"]?\s+-e/) - - // Pin order npm install → chmod prebuilds → probe: chmod-after-probe breaks spawn-helper bits; probe-before-install tests an empty dir. - const all = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const npmIdx = all.findIndex( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - const chmodPrebuildsIdx = all.findIndex( - (c) => c.includes('spawn-helper') && c.includes('chmod +x') - ) - const probeIdx = all.findIndex((c) => c.includes('require("node-pty")')) - expect(npmIdx).toBeGreaterThanOrEqual(0) - expect(chmodPrebuildsIdx).toBeGreaterThan(npmIdx) - expect(probeIdx).toBeGreaterThan(chmodPrebuildsIdx) - - // Hold the install lock through launch, then release it exactly once. - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => { - const conn = makeMockConnection(sftpCapture) - // Why: noisy .bashrc/MOTD can prefix probe stdout; production uses .includes(PROBE_OK) so pre-sentinel noise still resolves OK. - feed( - makeExecResponses({ - npmInstall: 'ok', - probe: 'ok', - probeStdoutOverride: 'Welcome to Acme Corp\nLast login: ...\nORCA-NPTY-PROBE-OK\n' - }) - ) - - await deployAndLaunchRelay(conn) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - }) - - it('detects MISSING even when the shell prepends noise before the MISSING token', async () => { - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: 'ok', - probe: 'missing', - probeStdoutOverride: '(node:1234) [DEP0040] DeprecationWarning: ...\nMISSING\n' - }) - ) - - await deployAndLaunchRelay(conn) - - // Absence of PROBE_OK triggers the warn regardless of surrounding noise; finalize still runs by design. - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('keeps Windows node-pty probe failures non-fatal by checking LASTEXITCODE', async () => { - vi.mocked(parseUnameToRelayPlatform).mockReturnValueOnce('win32-x64') - vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Windows AMD64', - 'C:\\Users\\u', - '', // bounded stale-stage recovery - '__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0', - '__ORCA_UPLOAD_STAGE_PROMOTION__.sftp-namespace-00000000000000000000000000000000:PROMOTED', - '', // npm install native deps - 'MISSING\n', // native process exit normalized by PowerShell command - '', // npm rebuild native deps - 'MISSING\n', // rebuilt native process still cannot load - '', // clean stage root - '', // no persisted active pipe marker - 'WAITING', // initial pipe probe - '', // WMI relay launch - 'READY', // readiness poll - '' // persist active pipe marker - ]) - - await deployAndLaunchRelay(conn) - - const probeCommand = - vi - .mocked(execCommand) - .mock.calls.map(([, c]) => c) - .find((command) => decodePowerShellCommand(command)?.includes('require(\\"node-pty\\")')) ?? - '' - const probeScript = decodePowerShellCommand(probeCommand) ?? '' - expect(probeScript).toContain('$LASTEXITCODE -ne 0') - expect(probeScript).toContain("'MISSING'") - expect(probeScript).toContain('loadNativeModule') - - const npmScripts = vi - .mocked(execCommand) - .mock.calls.map(([, command]) => decodePowerShellCommand(command) ?? '') - .filter((script) => script.includes('npm install') || script.includes('npm rebuild')) - expect(npmScripts).toHaveLength(2) - expect(npmScripts.every((script) => script.includes('--ignore-scripts=false'))).toBe(true) - expect( - npmScripts.every((script) => - script.includes('if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }') - ) - ).toBe(true) - expect( - vi - .mocked(execCommand) - .mock.calls.some(([, command]) => command.includes('.npty-probe.stderr')) - ).toBe(false) - - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - }) - - it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => { - // Platform tuple lets bug reports be triaged for prebuild availability without asking the user for their arch. - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing' })) - await deployAndLaunchRelay(conn) - const missingMsgs = warnSpy.mock.calls - .map((args) => String(args[0] ?? '')) - .filter((m) => m.includes('[ssh-relay][NPTY-MISSING]')) - expect(missingMsgs.length).toBeGreaterThan(0) - expect(missingMsgs[0]).toContain('linux-x64') - }) - - it('writes an idempotent package.json (same bytes on every install)', async () => { - // First install run. - const conn1 = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - await deployAndLaunchRelay(conn1) - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalled()) - const firstPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string - const first = sftpCapture.contents[firstPath] - - // Reset capture, run again as if it were a fresh install of the same dir. - sftpCapture.paths.length = 0 - for (const k of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[k] - } - for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[k] - } - vi.mocked(execCommand).mockReset().mockResolvedValue('') - - const conn2 = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - await deployAndLaunchRelay(conn2) - const secondPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string - const second = sftpCapture.contents[secondPath] - - expect(second).toBe(first) - }) - - it('repairs an existing complete relay dir that is missing @parcel/watcher', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock - 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock - '', // SFTP-namespace install-owner marker (repair) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - 'ORCA-NPTY-CLOEXEC:patched\n', // pty-master cloexec patch on the loadable node-pty - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) - expect(vi.mocked(tryAcquireRelayRepairLock).mock.calls[0]?.[3]?.signal).toBeInstanceOf( - AbortSignal - ) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect( - execCalls.some( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - ).toBe(true) - const installCommand = execCalls.find((c) => c.includes('npm install')) ?? '' - expect(installCommand).toContain('node_modules/@parcel/watcher') - expect(installCommand).toContain("-name 'watcher-*'") - expect(installCommand).not.toContain("rm -rf 'node_modules/node-pty'") - }) - - it('keeps the caller resets when a repair reconnect has to drop node-pty', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed(makeRepairToolchainSkipExecResponses()) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const reinstall = execCalls.findLast((c) => c.includes('npm install')) ?? '' - expect(reinstall).not.toContain('node-pty@') - expect(reinstall).toContain("rm -rf 'node_modules/node-pty'") - // Dropping the repair's own resets here leaves npm calling the broken watcher up to date. - expect(reinstall).toContain("rm -rf 'node_modules/@parcel/watcher'") - expect(reinstall).toContain("-name 'watcher-*'") - }) - - it('launches an already-installed relay in degraded mode when repair throws', async () => { - // Why: a repair failure on a completed dir must not block the connection — relay still serves fs/git/preflight; next reconnect retries. - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - BOTH_NATIVE_DEPS_MISSING_PROBE, // health probe: require() names both deps - BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe after lock - '', // SFTP-namespace install-owner marker (repair) - { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) - 'DEAD', - '', // remote credential generation after degraded repair - 'READY' - ]) - - // Deploy must resolve (degraded), not reject. - await deployAndLaunchRelay(conn) - - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes('launching degraded'))).toBe(true) - }) - - it('retains the repair lock when remote command termination is unconfirmed', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/u') - .mockResolvedValueOnce(BOTH_NATIVE_DEPS_MISSING_PROBE) - .mockResolvedValueOnce(BOTH_NATIVE_DEPS_MISSING_PROBE) - .mockResolvedValueOnce('') // SFTP-namespace install-owner marker (repair) - .mockRejectedValueOnce( - Object.assign(new Error('npm termination was not confirmed'), { - sshChannelCloseConfirmed: false - }) - ) - .mockResolvedValueOnce('DEAD') - .mockResolvedValueOnce('') // remote credential generation after degraded repair - .mockResolvedValueOnce('READY') - - await deployAndLaunchRelay(conn) - - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((message) => message.includes('launching degraded'))).toBe(true) - }) - - it('retains the first-install lock when an aborted npm install has unconfirmed teardown', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection(sftpCapture) - feed(makeStagedFirstInstallExecPrefix()) - let installSignal: AbortSignal | undefined - vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { - expect(command).toContain('npm install') - installSignal = options?.signal - return new Promise((_resolve, reject) => { - installSignal?.addEventListener( - 'abort', - () => { - // Mirrors execCommand's bounded close grace when ssh2 never confirms npm stopped. - setTimeout( - () => - reject( - Object.assign(new Error('npm teardown remained unconfirmed'), { - sshChannelCloseConfirmed: false - }) - ), - 5_000 - ) - }, - { once: true } - ) - }) - }) - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(installSignal).toBeDefined()) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - await vi.advanceTimersByTimeAsync(5_000) - const result = await deploy - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toContain('Relay deployment timed out') - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('does not finalize or release a first-install lock after unconfirmed rebuild teardown', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install - '', // chmod prebuilds - 'MISSING', - 'rebuild diagnostics', - '' // remove probe diagnostics - ]) - vi.mocked(execCommand).mockRejectedValueOnce( - Object.assign(new Error('rebuild termination was not confirmed'), { - sshChannelCloseConfirmed: false - }) - ) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow( - 'rebuild termination was not confirmed' - ) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - }) - - it('retains the first-install lock when an aborted rebuild has unconfirmed teardown', async () => { - vi.useFakeTimers() - try { - const conn = makeMockConnection(sftpCapture) - feed([ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install - '', // chmod prebuilds - 'MISSING', - 'rebuild diagnostics', - '' // remove probe diagnostics - ]) - let rebuildSignal: AbortSignal | undefined - vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { - expect(command).toContain('npm rebuild') - rebuildSignal = options?.signal - return new Promise((_resolve, reject) => { - rebuildSignal?.addEventListener( - 'abort', - () => { - setTimeout( - () => - reject( - Object.assign(new Error('rebuild teardown remained unconfirmed'), { - sshChannelCloseConfirmed: false - }) - ), - 5_000 - ) - }, - { once: true } - ) - }) - }) - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(rebuildSignal).toBeDefined()) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - await vi.advanceTimersByTimeAsync(5_000) - const result = await deploy - expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toContain('Relay deployment timed out') - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it.each(['busy', 'error'] as const)('launches degraded when lock is %s', async (lockResult) => { - // Why: lock contention/wedge must not block a completed relay from launching in degraded mode. - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - BOTH_NATIVE_DEPS_MISSING_PROBE, - 'DEAD', - '', // remote credential generation without a namespace marker - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) - const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnMessages.some((m) => m.includes(`repair lock is ${lockResult}`))).toBe(true) - }) - - it('loads native bindings when checking whether a completed relay needs repair', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-OK', - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - const healthProbe = vi - .mocked(execCommand) - .mock.calls.map(([, c]) => c) - .find((c) => c.includes('ORCA-NATIVE-DEPS-OK')) - expect(healthProbe).toContain('require("node-pty")') - expect(healthProbe).toContain('loadNativeModule') - expect(healthProbe).toContain('require("@parcel/watcher")') - expect(healthProbe).not.toContain('require.resolve') - }) - - it('does not mutate an existing relay dir when required native deps are present', async () => { - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-OK', - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(vi.mocked(acquireInstallLock)).not.toHaveBeenCalled() - expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) - }) -}) diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts deleted file mode 100644 index 43ac50dbf524..000000000000 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -// Why: the repair path used to map ANY probe failure to "all deps missing", so one dropped exec -// channel rm -rf'd node-pty on a healthy relay and forced a node-gyp rebuild. Verdicts are -// ok / blocked / unverifiable — see docs/reference/ssh-execution-boundary.md. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { resolveRemoteNodePath } from './ssh-remote-node-resolution' -import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import { - BOTH_NATIVE_DEPS_MISSING_PROBE, - decodePowerShellCommand, - makeMockConnection, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' - -// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a -// freshly installed node-pty loads (#17915). -const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' -const NODE_PTY_RESET = "rm -rf 'node_modules/node-pty'" -const WATCHER_RESET = "rm -rf 'node_modules/@parcel/watcher'" - -describe('native-deps repair probe verdicts', () => { - const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } - let warnSpy: ReturnType - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - sftpCapture.paths.length = 0 - for (const key of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[key] - } - for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[key] - } - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - function feed(execResponses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const response of execResponses) { - if (typeof response === 'string') { - mockExec.mockResolvedValueOnce(response) - } else { - mockExec.mockRejectedValueOnce(new Error(response.reject)) - } - } - } - - function execCommands(): string[] { - return vi.mocked(execCommand).mock.calls.map(([, command]) => command) - } - - function warnings(): string[] { - return warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - } - - it('launches an intact relay when the health probe never answers', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - { reject: 'SSH channel closed unexpectedly' }, // health probe: unverifiable, not MISSING - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - // Assert the repair-avoidance facts before the launch outcome so a regression names the defect - // rather than the fixture drift that follows from an unexpected repair. - const outcome = await deployAndLaunchRelay(conn).then( - (result) => result, - (err: Error) => err - ) - - const commands = execCommands() - expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( - false - ) - // Why: the wrongful rebuild used to be the only visible symptom of a dropped exec channel. - expect( - warnings().some((message) => message.includes('Native deps probe unanswered')), - 'an unanswered probe must still leave a trace' - ).toBe(true) - expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) - expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) - expect(commands.some((command) => command.includes('npm install'))).toBe(false) - // Exactly one probe: an unverifiable answer must not fall through to the locked re-probe. - expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(outcome, 'lost contact must not abort the connection').not.toBeInstanceOf(Error) - }) - - it('leaves node_modules intact when the probe answers without naming a dep', async () => { - // The bare `MISSING` a `|| echo MISSING` subshell emits when node never reached the script - // (bad NODE_OPTIONS, OOM kill, exit 127). The shell answered; the answer is not about the deps. - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'MISSING', // answered, no marker line: nothing here names a dep - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - const outcome = await deployAndLaunchRelay(conn).then( - (result) => result, - (err: Error) => err - ) - - const commands = execCommands() - expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( - false - ) - expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) - expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) - expect(commands.some((command) => command.includes('npm install'))).toBe(false) - // One probe only: an unverifiable answer must not fall through to the locked re-probe. - expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(outcome, 'an unparseable answer must not abort the connection').not.toBeInstanceOf(Error) - expect(warnings().some((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE'))).toBe( - true - ) - }) - - it('carries the probe stderr into the unparseable-answer warning', async () => { - const conn = makeMockConnection(sftpCapture) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/u') - .mockImplementationOnce((_conn, _command, options) => { - options?.onStderr?.('node: --inspect-brk is not allowed in NODE_OPTIONS') - return Promise.resolve('MISSING') - }) - feed(['', 'DEAD', '', 'READY']) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - // Why: `2>/dev/null` used to drop the one line that says which host config broke node. - expect( - warnings().find((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE')) - ).toContain('not allowed in NODE_OPTIONS') - }) - - it('still resets both deps when the probe names both', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - BOTH_NATIVE_DEPS_MISSING_PROBE, // answered: both deps are genuinely broken - BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the repair lock - '', // SFTP-namespace install-owner marker (repair) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const install = execCommands().find((command) => command.includes('npm install')) ?? '' - expect(install).toContain(NODE_PTY_RESET) - expect(install).toContain(WATCHER_RESET) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - }) - - it('leaves a Windows relay intact when its probe answers without naming a dep', async () => { - // The PowerShell branch has the same hole: `try { & node -e ... } catch { 'MISSING' }` prints - // nothing when node exits non-zero without reaching the script. - vi.mocked(parseUnameToRelayPlatform).mockReturnValueOnce('win32-x64') - vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Windows AMD64', - 'C:\\Users\\u', - '', // health probe: PowerShell swallowed the native failure, so nothing names a dep - '', // no persisted active pipe marker - 'WAITING', // initial pipe probe - '', // WMI relay launch - 'READY', // readiness poll - '' // persist active pipe marker - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const scripts = execCommands().map((command) => decodePowerShellCommand(command) ?? command) - expect(scripts.some((script) => script.includes('node_modules/node-pty'))).toBe(false) - expect(scripts.some((script) => script.includes('node_modules/@parcel/watcher'))).toBe(false) - expect(scripts.some((script) => script.includes('npm install'))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(warnings().some((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE'))).toBe( - true - ) - }) - - it('resets only the dep the probe names', async () => { - const conn = makeMockConnection(sftpCapture) - const watcherMissing = 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING' - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - watcherMissing, - watcherMissing, // re-probe under the repair lock - '', // SFTP-namespace install-owner marker (repair) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const install = execCommands().find((command) => command.includes('npm install')) ?? '' - expect(install).toContain(WATCHER_RESET) - expect(install).not.toContain(NODE_PTY_RESET) - expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - }) - - it('skips repair entirely when the probe answers OK', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-OK', - '', // launch namespace marker - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - expect(execCommands().some((command) => command.includes('npm install'))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) - - it('keeps the answered reset scope when the locked re-probe cannot answer', async () => { - const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // answered: only the watcher is broken - { reject: 'SSH channel closed unexpectedly' }, // re-probe under the lock: unverifiable - '', // SFTP-namespace install-owner marker (repair) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - - const install = execCommands().find((command) => command.includes('npm install')) ?? '' - expect(install).toContain(WATCHER_RESET) - // The unanswered re-probe must not widen the reset to a dep no probe ever reported broken. - expect(install).not.toContain(NODE_PTY_RESET) - }) -}) diff --git a/src/main/ssh/ssh-relay-node-headers.test.ts b/src/main/ssh/ssh-relay-node-headers.test.ts deleted file mode 100644 index 84e9016738b1..000000000000 --- a/src/main/ssh/ssh-relay-node-headers.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { - chmodSync, - copyFileSync, - mkdtempSync, - mkdirSync, - rmSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import process from 'node:process' -import { afterEach, describe, expect, it } from 'vitest' -import { exportLocalNodeHeadersPrefix, localNodeHeadersFromOutput } from './ssh-relay-node-headers' - -const POSIX = process.platform !== 'win32' - -/** Runs the prefix under /bin/sh exactly as the relay does, then prints what node-gyp would see. */ -function runPrefix(nodePath: string): { - nodedir: string - pkgNodedir: string - marker: string | null | undefined -} { - const script = `${exportLocalNodeHeadersPrefix(nodePath)}printf '%s\\n%s\\n' "$npm_config_nodedir" "$npm_package_config_node_gyp_nodedir"` - const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' }) - expect(result.status).toBe(0) - const marker = localNodeHeadersFromOutput(result.stdout) - const [nodedir = '', pkgNodedir = ''] = result.stdout - .split('\n') - .filter((line) => !line.startsWith('ORCA-NODE-HEADERS:')) - return { nodedir, pkgNodedir, marker } -} - -/** A fake `/bin/node` whose `include/node/node_version.h` claims `version`. */ -function fakeNodePrefix(root: string, version: string): string { - const prefix = join(root, 'prefix') - mkdirSync(join(prefix, 'bin'), { recursive: true }) - mkdirSync(join(prefix, 'include', 'node'), { recursive: true }) - const [major, minor, patch] = version.split('.') - writeFileSync( - join(prefix, 'include', 'node', 'node_version.h'), - `#define NODE_MAJOR_VERSION ${major}\n#define NODE_MINOR_VERSION ${minor}\n#define NODE_PATCH_VERSION ${patch}\n` - ) - // Why a symlink to the real binary: the probe reads process.execPath, which Node resolves - // through symlinks -- so this stands in for `/usr/bin/node -> /opt/node/bin/node` shims too. - symlinkSync(process.execPath, join(prefix, 'bin', 'node')) - return join(prefix, 'bin', 'node') -} - -describe.skipIf(!POSIX)('exportLocalNodeHeadersPrefix', () => { - const roots: string[] = [] - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - it('exports nodedir when the running Node ships headers for its own version', () => { - // The test runner's Node is an official build, so its prefix has include/node. - const prefix = dirname(dirname(process.execPath)) - const { nodedir, pkgNodedir, marker } = runPrefix(process.execPath) - expect(nodedir).toBe(prefix) - expect(pkgNodedir).toBe(prefix) - expect(marker).toBe(prefix) - }) - - it('leaves nodedir unset when the shipped headers are for another Node version', () => { - // A symlinked node resolves execPath to the real binary, whose prefix is the real one; so - // to stage a mismatch the probe must run a node whose execPath lands in the fake prefix. - // A copy does that. - const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) - roots.push(root) - const prefix = join(root, 'prefix') - mkdirSync(join(prefix, 'bin'), { recursive: true }) - mkdirSync(join(prefix, 'include', 'node'), { recursive: true }) - writeFileSync( - join(prefix, 'include', 'node', 'node_version.h'), - '#define NODE_MAJOR_VERSION 1\n#define NODE_MINOR_VERSION 0\n#define NODE_PATCH_VERSION 0\n' - ) - const copied = join(prefix, 'bin', 'node') - copyFileSync(process.execPath, copied) - chmodSync(copied, 0o755) - const { nodedir, pkgNodedir, marker } = runPrefix(copied) - expect(nodedir).toBe('') - expect(pkgNodedir).toBe('') - expect(marker).toBeNull() - }) - - it('leaves nodedir unset when the prefix has no headers at all', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) - roots.push(root) - const copied = join(root, 'bin', 'node') - mkdirSync(dirname(copied), { recursive: true }) - copyFileSync(process.execPath, copied) - chmodSync(copied, 0o755) - const { nodedir } = runPrefix(copied) - expect(nodedir).toBe('') - }) - - it('follows a symlinked node to the install that owns the headers', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) - roots.push(root) - const shim = fakeNodePrefix(root, '0.0.0') - // The shim's own fake headers are ignored: execPath resolves to the real binary, and the - // real prefix's headers are the ones that match. - const { nodedir } = runPrefix(shim) - expect(nodedir).toBe(dirname(dirname(process.execPath))) - }) - - it('clears an inherited nodedir when the probe finds no matching headers', () => { - // A remote profile's stale nodedir must not survive past the version check. - const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) - roots.push(root) - const copied = join(root, 'bin', 'node') - mkdirSync(dirname(copied), { recursive: true }) - copyFileSync(process.execPath, copied) - chmodSync(copied, 0o755) - const script = `${exportLocalNodeHeadersPrefix(copied)}printf '%s|%s|%s' "$npm_config_nodedir" "$NPM_CONFIG_NODEDIR" "$npm_package_config_node_gyp_nodedir"` - const result = spawnSync('/bin/sh', ['-c', script], { - encoding: 'utf8', - env: { - ...process.env, - npm_config_nodedir: '/usr/stale-headers', - NPM_CONFIG_NODEDIR: '/usr/stale-headers', - npm_package_config_node_gyp_nodedir: '/usr/stale-headers' - } - }) - expect(result.status).toBe(0) - expect(result.stdout.split('\n').at(-1)).toBe('||') - }) - - it('does not fail the command line when node itself cannot run', () => { - const script = `${exportLocalNodeHeadersPrefix('/nonexistent/node')}echo "after:$npm_config_nodedir"` - const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' }) - expect(result.status).toBe(0) - expect(result.stdout.trim()).toBe('ORCA-NODE-HEADERS:none\nafter:') - }) -}) - -describe('localNodeHeadersFromOutput', () => { - it('reads the host answer, not the copy of the marker echo quoted in an exec-failure head', () => { - // The real shape: execCommand quotes the whole command line, prefix included, before the output. - const command = `export PATH='/usr/local/bin':$PATH && cd '/root/.orca-remote/relay-x' && ${exportLocalNodeHeadersPrefix('/usr/local/bin/node')}npm install node-pty 2>&1` - const failed = (hostOutput: string): string => - `Command "${command}" failed (exit 1): ${hostOutput}` - expect( - localNodeHeadersFromOutput(failed('ORCA-NODE-HEADERS:none\ngyp ERR! configure error')) - ).toBeNull() - expect( - localNodeHeadersFromOutput(failed('ORCA-NODE-HEADERS:/usr/local\ngyp ERR! configure error')) - ).toBe('/usr/local') - // No host output at all after the head: the command copy alone must not count as a marker. - expect(localNodeHeadersFromOutput(failed(''))).toBeUndefined() - }) - - it('distinguishes an exported dir, an explicit none, and no marker at all', () => { - expect(localNodeHeadersFromOutput('x\nORCA-NODE-HEADERS:/usr/local\ngyp ERR!')).toBe( - '/usr/local' - ) - expect(localNodeHeadersFromOutput('ORCA-NODE-HEADERS:none\ngyp ERR!')).toBeNull() - expect(localNodeHeadersFromOutput('gyp ERR! only')).toBeUndefined() - }) -}) diff --git a/src/main/ssh/ssh-relay-node-headers.ts b/src/main/ssh/ssh-relay-node-headers.ts deleted file mode 100644 index a590bd40fca0..000000000000 --- a/src/main/ssh/ssh-relay-node-headers.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Point node-gyp at the headers the host's Node install already ships, so compiling node-pty - * needs nothing from nodejs.org. - * - * Why: node-pty has no Linux prebuild, so every Linux relay compiles it, and node-gyp's default - * is to download `node-v-headers.tar.gz` before configuring. Every official Node build, and - * every version manager that unpacks one (nvm, fnm, volta, mise, n), already has those exact - * headers at `/include/node`. The download was the only step that needed the internet, - * so a firewalled host failed with ECONNREFUSED on work that never had to happen (STA-6674). - * - * Why both variables: node-gyp >= 11.4 prefers `npm_package_config_node_gyp_` and npm 11+ - * warns that arbitrary `npm_config_` is deprecated, but node-gyp 10 (bundled with Node 20) - * reads only `npm_config_`. Both together cover every Node the relay runs on. - * - * Why the version check: node-gyp trusts `nodedir` blindly, so a distro `/usr/include/node` left - * by an older headers package would be compiled against as-is. Whether that binding then misbehaves - * is not established (one measured run loaded a node-20-header build under node 24); refusing is - * the conservative default. A mismatch leaves the variables unset, which is today's path. - */ -import { shellEscape } from './ssh-connection-utils' - -/** Shell variable the probe answers into; namespaced so it cannot collide with npm's own. */ -const NODEDIR_SHELL_VAR = 'ORCA_NODE_HEADERS_DIR' - -/** - * Prints the running Node's install prefix when `/include/node/node_version.h` matches - * `process.versions.node`, and nothing otherwise. `process.execPath` is symlink-resolved, so a - * `/usr/bin/node` -> `/opt/node/bin/node` shim still finds `/opt/node/include`. - */ -export const LOCAL_NODE_HEADERS_PROBE_JS = [ - 'const p=require("path"),f=require("fs");', - 'const d=p.dirname(p.dirname(process.execPath));', - 'try{', - 'const h=f.readFileSync(p.join(d,"include","node","node_version.h"),"utf8");', - 'const v=["MAJOR","MINOR","PATCH"].map(k=>(h.match(new RegExp("#define NODE_"+k+"_VERSION ([0-9]+)"))||[])[1]).join(".");', - 'if(v===process.versions.node)process.stdout.write(d)', - '}catch{}' -].join('') - -/** - * Stdout marker naming what the probe found, printed before the compile so the answer is in the - * captured output of any failure that follows. `none` means no matching local headers. - */ -export const LOCAL_NODE_HEADERS_MARKER_PREFIX = 'ORCA-NODE-HEADERS:' - -/** - * POSIX-sh prefix (`...; `) that exports node-gyp's `nodedir` for the rest of the command line - * when the host's Node ships matching headers. Prepend to any command that may compile node-pty: - * `npm install`, `npm rebuild`, and the cloexec patch (its `npm rebuild` inherits the env). - */ -export function exportLocalNodeHeadersPrefix(nodePath: string): string { - const probe = `${shellEscape(nodePath)} -e ${shellEscape(LOCAL_NODE_HEADERS_PROBE_JS)} 2>/dev/null` - // Why the unset: a remote profile can already export a nodedir (a stale distro header dir), in - // either case npm accepts. Left alone it would bypass the version check above and compile - // against those headers. Deliberately env only: a `nodedir=` in ~/.npmrc is not reachable from here - // -- npm ignores an empty env override, and a CLI `--nodedir=` would also override the good - // export -- so an npmrc setting stays the operator's, as it was before this prefix existed. - return ( - `${NODEDIR_SHELL_VAR}=$(${probe}); ` + - `unset npm_config_nodedir NPM_CONFIG_NODEDIR npm_package_config_node_gyp_nodedir; ` + - `if [ -n "$${NODEDIR_SHELL_VAR}" ]; then ` + - `export npm_config_nodedir="$${NODEDIR_SHELL_VAR}" npm_package_config_node_gyp_nodedir="$${NODEDIR_SHELL_VAR}"; ` + - `fi; ` + - `echo "${LOCAL_NODE_HEADERS_MARKER_PREFIX}\${${NODEDIR_SHELL_VAR}:-none}"; ` - ) -} - -/** - * The headers dir the prefix exported, `null` when it found none, or `undefined` when the - * marker is absent (output truncated, or the command never reached the prefix). - */ -export function localNodeHeadersFromOutput(output: string): string | null | undefined { - // Why the head is stripped first: a failed exec's message is `Command "" failed - // (exit N): `, and quotes this prefix verbatim -- including the marker's - // `echo`. Scanning from the start would match that copy and return `${ORCA_NODE_HEADERS_DIR:- - // none}"...` as a "dir". Only what follows the head is the host's answer. - const head = output.match(EXEC_FAILURE_HEAD_RE) - const hostOutput = head ? output.slice(head[0].length) : output - // First match, not last: the host's own line comes first, and later lines are npm/gyp output - // that must not be able to spoof it. - for (const line of hostOutput.split(/\r?\n/)) { - const at = line.indexOf(LOCAL_NODE_HEADERS_MARKER_PREFIX) - if (at === -1) { - continue - } - const dir = line.slice(at + LOCAL_NODE_HEADERS_MARKER_PREFIX.length).trim() - return dir === 'none' || dir === '' ? null : dir - } - return undefined -} - -/** - * `Command "" failed (exit N): ` -- see ssh-relay-exec-command.ts. - * Lazy `[\s\S]*?` is safe: it stops at the first `" failed (exit N): `, and no command this - * module builds contains that literal, so the match cannot end early inside the command. - */ -const EXEC_FAILURE_HEAD_RE = /^Command "[\s\S]*?" failed \(exit -?\d+\): / diff --git a/src/main/ssh/ssh-relay-node-pty-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-repair.test.ts deleted file mode 100644 index 7c1b18002595..000000000000 --- a/src/main/ssh/ssh-relay-node-pty-repair.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -// Why: the once-only ledger is the whole safety story here — an unbounded rebuild loop against a -// remote is worse than the bug it chases, so every gate that stops one gets a test. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - forgetRelayNodePtyRepairs, - recoverRelayNodePtyForSpawn, - relayNodePtyRepairAttempts -} from './ssh-relay-node-pty-repair' -import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' - -const HOST: TerminalUnavailableCause['host'] = { - platform: 'linux', - arch: 'x64', - libc: 'glibc', - glibcVersion: '2.31', - nodeAbi: '115', - nodeVersion: 'v20.11.0' -} - -function cause(overrides: Partial = {}): TerminalUnavailableCause { - return { - status: 'blocked', - reason: 'abi_mismatch', - detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', - repairable: true, - host: HOST, - ...overrides - } -} - -describe('recoverRelayNodePtyForSpawn', () => { - const TARGET = 'host-a' - let warnSpy: ReturnType - - beforeEach(() => { - forgetRelayNodePtyRepairs(TARGET) - forgetRelayNodePtyRepairs('host-b') - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - forgetRelayNodePtyRepairs(TARGET) - forgetRelayNodePtyRepairs('host-b') - }) - - function harness(overrides: { hasLivePtys?: boolean; reconnect?: () => Promise } = {}) { - const reconnect = vi.fn(overrides.reconnect ?? (async () => {})) - const repaired = { name: 'post-repair-provider' } - const resolveProvider = vi.fn(() => repaired) - return { - reconnect, - resolveProvider, - repaired, - run: (c: TerminalUnavailableCause | null) => - recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: c, - hasLivePtys: () => overrides.hasLivePtys === true, - reconnect, - resolveProvider - }) - } - } - - it('repairs a repairable cause with exactly one reconnect and hands back the new provider', async () => { - const h = harness() - - const result = await h.run(cause()) - - expect(result.outcome).toBe('repaired') - expect(result.provider).toBe(h.repaired) - expect(h.reconnect).toHaveBeenCalledTimes(1) - expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual(['abi_mismatch']) - }) - - it('does not repair a second time for the same cause on the same host', async () => { - const first = harness() - await first.run(cause()) - const second = harness() - - const result = await second.run(cause()) - - expect(result.outcome).toBe('already-attempted') - expect(result.provider).toBeNull() - expect(second.reconnect).not.toHaveBeenCalled() - }) - - it('spends the attempt even when the repair reconnect fails, so it cannot loop', async () => { - const failing = harness({ - reconnect: async () => { - throw new Error('relay repair lock is wedged') - } - }) - - const first = await failing.run(cause()) - expect(first.outcome).toBe('reconnect-failed') - expect(first.provider).toBeNull() - - const second = harness() - const retry = await second.run(cause()) - - expect(retry.outcome).toBe('already-attempted') - expect(second.reconnect).not.toHaveBeenCalled() - }) - - it('keeps the ledger per host, so a second host still gets its one attempt', async () => { - const h = harness() - await h.run(cause()) - const other = vi.fn(async () => {}) - - const result = await recoverRelayNodePtyForSpawn({ - targetId: 'host-b', - cause: cause(), - hasLivePtys: () => false, - reconnect: other, - resolveProvider: () => ({}) - }) - - expect(result.outcome).toBe('repaired') - expect(other).toHaveBeenCalledTimes(1) - }) - - it('keeps the ledger per reason, so a different proved fault still gets its one attempt', async () => { - const h = harness() - await h.run(cause()) - const second = harness() - - const result = await second.run(cause({ reason: 'arch_mismatch' })) - - expect(result.outcome).toBe('repaired') - expect([...relayNodePtyRepairAttempts(TARGET)].sort()).toEqual([ - 'abi_mismatch', - 'arch_mismatch' - ]) - }) - - it('never repairs an unverifiable cause, whatever the peer claims about repairability', async () => { - const h = harness() - - // Why repairable:true here: #14830 is exactly a peer flag believed over the status. - const result = await h.run(cause({ status: 'unverifiable', repairable: true })) - - expect(result.outcome).toBe('not-repairable') - expect(h.reconnect).not.toHaveBeenCalled() - expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) - }) - - it('never repairs a toolchain_missing cause — the rebuild needs the missing compiler', async () => { - const h = harness() - - const result = await h.run(cause({ reason: 'toolchain_missing', repairable: false })) - - expect(result.outcome).toBe('not-repairable') - expect(h.reconnect).not.toHaveBeenCalled() - expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) - }) - - it('never repairs when the relay published no cause at all', async () => { - const h = harness() - - const result = await h.run(null) - - expect(result.outcome).toBe('not-repairable') - expect(h.reconnect).not.toHaveBeenCalled() - }) - - it('does not rebuild under live PTYs, and does not spend the attempt doing so', async () => { - const live = harness({ hasLivePtys: true }) - - const blocked = await live.run(cause()) - expect(blocked.outcome).toBe('ptys-live') - expect(live.reconnect).not.toHaveBeenCalled() - expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) - - const idle = harness() - expect((await idle.run(cause())).outcome).toBe('repaired') - }) - - it('withholds the retry when the reconnect produced no provider', async () => { - const reconnect = vi.fn(async () => {}) - - const result = await recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: cause(), - hasLivePtys: () => false, - reconnect, - resolveProvider: () => null - }) - - expect(result.outcome).toBe('no-provider') - expect(result.provider).toBeNull() - expect(reconnect).toHaveBeenCalledTimes(1) - }) - - it('gives a host a fresh attempt only after an explicit disconnect', async () => { - await harness().run(cause()) - forgetRelayNodePtyRepairs(TARGET) - const afterDisconnect = harness() - - expect((await afterDisconnect.run(cause())).outcome).toBe('repaired') - }) -}) diff --git a/src/main/ssh/ssh-relay-node-pty-repair.ts b/src/main/ssh/ssh-relay-node-pty-repair.ts deleted file mode 100644 index 149b6c486cbb..000000000000 --- a/src/main/ssh/ssh-relay-node-pty-repair.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Turning a spawn-time "remote terminals are unavailable" into an actual repair. - * - * The fault is proved on the relay at spawn time; the only thing that can fix it — - * `repairInstalledNativeDeps` in ssh-relay-deploy.ts — runs on the client during deploy, under - * `tryAcquireRelayRepairLock`. So the recovery here is deliberately indirect: it does not touch - * the remote `node_modules` itself, it drives one relay reconnect and lets the locked deploy path - * do the rebuild. All `node_modules` mutation stays behind that lock. - * - * The invariant that matters more than the recovery: **at most one repair per host per reason.** - * A rebuild loop against a remote is worse than the bug it is chasing, so the attempt is recorded - * before the reconnect starts, not after it succeeds. A repair that ran and failed is still an - * attempt, and this host will render the relay's message from then on. - */ -import { - mayRepairFromCause, - type TerminalUnavailableCause -} from '../../shared/terminal-unavailable-cause' - -/** targetId -> the cause reasons already spent on this host, for the life of the session. */ -const attemptedRepairsByTarget = new Map>() - -export type RelayNodePtyRepairOutcome = - /** The cause is not proved-and-rebuildable; render the relay's message. */ - | 'not-repairable' - /** This host already spent its one attempt on this reason. */ - | 'already-attempted' - /** The relay is still serving PTYs, so a rebuild under it is not accounted for. */ - | 'ptys-live' - /** No reconnect was possible, or it failed; the attempt is still spent. */ - | 'reconnect-failed' - /** Reconnected, but no provider came back to retry on. */ - | 'no-provider' - | 'repaired' - -/** Forget a host's spent attempts. Disconnect is user action, so it may earn a fresh one. */ -export function forgetRelayNodePtyRepairs(targetId: string): void { - attemptedRepairsByTarget.delete(targetId) -} - -/** Test/diagnostic view of the ledger. */ -export function relayNodePtyRepairAttempts(targetId: string): ReadonlySet { - return attemptedRepairsByTarget.get(targetId) ?? new Set() -} - -/** False when this host has already spent its attempt on this reason. Marks on success. */ -function claimRepairAttempt(targetId: string, reason: string): boolean { - const spent = attemptedRepairsByTarget.get(targetId) - if (spent) { - if (spent.has(reason)) { - return false - } - spent.add(reason) - return true - } - attemptedRepairsByTarget.set(targetId, new Set([reason])) - return true -} - -export type RelayNodePtyRepairRequest = { - targetId: string - /** Already parsed and schema-validated; null when the relay published no cause. */ - cause: TerminalUnavailableCause | null - /** Whether this client still holds live PTYs on the relay about to be rebuilt. */ - hasLivePtys: () => boolean - /** One relay reconnect, which runs the locked `repairInstalledNativeDeps`. */ - reconnect: () => Promise - /** The provider registered after the reconnect — never the one that failed. */ - resolveProvider: () => TProvider | null -} - -/** - * Drive one repair for a failed spawn, returning the provider to retry on. - * - * Gates on `mayRepairFromCause`, not on the peer's `repairable` flag: an `unverifiable` cause - * proves nothing and must never trigger a rebuild (#14830, docs/reference/ssh-execution-boundary.md). - */ -export async function recoverRelayNodePtyForSpawn( - request: RelayNodePtyRepairRequest -): Promise<{ outcome: RelayNodePtyRepairOutcome; provider: TProvider | null }> { - const { targetId, cause } = request - if (!mayRepairFromCause(cause) || !cause) { - return { outcome: 'not-repairable', provider: null } - } - // Why check before claiming: a live PTY means the rebuild's blast radius is unaccounted for, and - // that is a reason to wait, not a spent attempt. Costs nothing remote, so it cannot loop. - if (request.hasLivePtys()) { - console.warn( - `[ssh-relay-repair] Not rebuilding node-pty on ${targetId} for ${cause.reason}: the relay is still serving PTYs` - ) - return { outcome: 'ptys-live', provider: null } - } - if (!claimRepairAttempt(targetId, cause.reason)) { - console.warn( - `[ssh-relay-repair] node-pty repair for ${cause.reason} on ${targetId} already ran; not retrying` - ) - return { outcome: 'already-attempted', provider: null } - } - - console.warn( - `[ssh-relay-repair] Reconnecting ${targetId} once to rebuild node-pty (${cause.reason}): ${cause.detail}` - ) - try { - await request.reconnect() - } catch (error) { - console.warn( - `[ssh-relay-repair] Repair reconnect for ${targetId} failed: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return { outcome: 'reconnect-failed', provider: null } - } - const provider = request.resolveProvider() - return provider ? { outcome: 'repaired', provider } : { outcome: 'no-provider', provider: null } -} diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts deleted file mode 100644 index e43516fe0c58..000000000000 --- a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -// The other half of the #17830 recovery: proof that the reconnect a spawn-time cause triggers is -// the SAME locked deploy repair, not a second rebuild path. `tryAcquireRelayRepairLock` is the only -// thing standing between two clients and a concurrent `node_modules` rewrite, so a lock this path -// cannot take must degrade to the relay's message rather than proceed. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: () => false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { gcOldRelayVersions, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { - makeMockConnection, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' -import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-relay-node-pty-repair' -import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' - -const TARGET = 'repair-host' - -const ABI_MISMATCH: TerminalUnavailableCause = { - status: 'blocked', - reason: 'abi_mismatch', - detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', - repairable: true, - host: { - platform: 'linux', - arch: 'x64', - libc: 'glibc', - glibcVersion: '2.31', - nodeAbi: '115', - nodeVersion: 'v20.11.0' - } -} - -// The relay dir is complete but node-pty will not load, which is exactly what the spawn-time cause -// describes. @parcel/watcher is healthy, so only node-pty is reset and rebuilt. -// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a -// freshly installed node-pty loads (#17915). -const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' -const NODE_PTY_BROKEN = 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING' - -function repairSucceedsResponses(): ExecResponse[] { - return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - NODE_PTY_BROKEN, // health probe before the lock - NODE_PTY_BROKEN, // re-probe under the repair lock - '', // SFTP-namespace install-owner marker - '', // reset node-pty + npm install - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', // node-pty loads again - '', // rm -f probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ] -} - -function lockUnavailableResponses(): ExecResponse[] { - return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - NODE_PTY_BROKEN, // health probe before the lock - 'DEAD', - 'READY' - ] -} - -describe('spawn-time node-pty repair through the locked deploy path', () => { - let warnSpy: ReturnType - const sftpCapture: SftpWriteCapture = { - paths: [], - contents: {}, - execCallCountAtWrite: {} - } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - sftpCapture.paths.length = 0 - for (const key of Object.keys(sftpCapture.contents)) { - delete sftpCapture.contents[key] - } - for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { - delete sftpCapture.execCallCountAtWrite[key] - } - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') - forgetRelayNodePtyRepairs(TARGET) - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - forgetRelayNodePtyRepairs(TARGET) - }) - - function feed(responses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const response of responses) { - if (typeof response === 'string') { - mockExec.mockResolvedValueOnce(response) - } else { - mockExec.mockRejectedValueOnce(new Error(response.reject)) - } - } - } - - function recover(conn: ReturnType, deploys: { count: number }) { - return recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: ABI_MISMATCH, - hasLivePtys: () => false, - reconnect: async () => { - deploys.count += 1 - await deployAndLaunchRelay(conn) - }, - resolveProvider: () => ({ generation: deploys.count }) - }) - } - - function execCalls(): string[] { - return vi.mocked(execCommand).mock.calls.map(([, command]) => String(command)) - } - - it('rebuilds node-pty under the repair lock and returns the post-reconnect provider', async () => { - const conn = makeMockConnection(sftpCapture) - feed(repairSucceedsResponses()) - const deploys = { count: 0 } - - const result = await recover(conn, deploys) - - expect(result.outcome).toBe('repaired') - expect(result.provider).toEqual({ generation: 1 }) - expect(deploys.count).toBe(1) - expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) - const install = execCalls().find((command) => command.includes('npm install')) ?? '' - expect(install).toContain('npm install') - // The reset is what makes an ABI-mismatched binding recompile instead of being reported up to date. - expect(install).toContain("rm -rf 'node_modules/node-pty'") - }) - - it('does not repair or reconnect a second time for the same cause on the same host', async () => { - const conn = makeMockConnection(sftpCapture) - feed(repairSucceedsResponses()) - const deploys = { count: 0 } - await recover(conn, deploys) - await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) - vi.mocked(execCommand).mockReset().mockResolvedValue('') - - const second = await recover(conn, deploys) - - expect(second.outcome).toBe('already-attempted') - expect(second.provider).toBeNull() - expect(deploys.count).toBe(1) - expect(execCalls()).toEqual([]) - expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) - }) - - it.each(['busy', 'error'] as const)( - 'leaves the host untouched and degrades to the relay message when the repair lock is %s', - async (lockResult) => { - const conn = makeMockConnection(sftpCapture) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue(lockResult) - feed(lockUnavailableResponses()) - const deploys = { count: 0 } - - const result = await recover(conn, deploys) - - // The reconnect happened; the rebuild did not, so the retried spawn hits the same relay - // rejection and the user reads today's message. Nothing wrote to node_modules unlocked. - expect(deploys.count).toBe(1) - expect(execCalls().some((command) => command.includes('npm install'))).toBe(false) - expect(execCalls().some((command) => command.includes('node_modules/node-pty'))).toBe(false) - expect(result.outcome).toBe('repaired') - const warnings = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) - expect(warnings.some((line) => line.includes(`repair lock is ${lockResult}`))).toBe(true) - } - ) - - it('never reaches the deploy path for an unverifiable cause', async () => { - const conn = makeMockConnection(sftpCapture) - const deploys = { count: 0 } - - const result = await recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: { ...ABI_MISMATCH, status: 'unverifiable' }, - hasLivePtys: () => false, - reconnect: async () => { - deploys.count += 1 - await deployAndLaunchRelay(conn) - }, - resolveProvider: () => ({ generation: deploys.count }) - }) - - expect(result.outcome).toBe('not-repairable') - expect(deploys.count).toBe(0) - expect(vi.mocked(tryAcquireRelayRepairLock)).not.toHaveBeenCalled() - expect(execCalls()).toEqual([]) - }) - - it('never reaches the deploy path for a toolchain_missing cause', async () => { - const conn = makeMockConnection(sftpCapture) - const deploys = { count: 0 } - - const result = await recoverRelayNodePtyForSpawn({ - targetId: TARGET, - cause: { ...ABI_MISMATCH, reason: 'toolchain_missing', repairable: false }, - hasLivePtys: () => false, - reconnect: async () => { - deploys.count += 1 - await deployAndLaunchRelay(conn) - }, - resolveProvider: () => ({ generation: deploys.count }) - }) - - expect(result.outcome).toBe('not-repairable') - expect(deploys.count).toBe(0) - expect(execCalls()).toEqual([]) - }) -}) diff --git a/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts b/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts deleted file mode 100644 index c5700c991c19..000000000000 --- a/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -// Why this exists (STA-6674): a Linux host whose only unreachable endpoint is nodejs.org could -// not run a relay. node-pty ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp -// downloads `node-v-headers.tar.gz` unless told the host already has the headers -- which -// every official Node install does, at `/include/node`. This drives the real deploy at a -// Docker sshd whose nodejs.org resolves to 127.0.0.1 (ECONNREFUSED, exactly what the user saw). -// -// Run: ORCA_REVIEW_SSH_OFFLINE_HEADERS=1 pnpm test src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts -// Needs Docker and `pnpm build:relay`. ORCA_REVIEW_SSH_NODE_IMAGE picks the Node image -// (default node:24.12.0-bookworm, the user's version); ORCA_REVIEW_SSH_TARGET_HOST overrides -// the address the app connects to (default 127.0.0.1). -import { execFileSync, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { connect } from 'node:net' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { SshConnection } from './ssh-connection' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import type { SshTarget } from '../../shared/ssh-types' - -const RUN_REVIEW_ORACLE = process.env.ORCA_REVIEW_SSH_OFFLINE_HEADERS === '1' -const NODE_IMAGE = process.env.ORCA_REVIEW_SSH_NODE_IMAGE ?? 'node:24.12.0-bookworm' -const TARGET_HOST = process.env.ORCA_REVIEW_SSH_TARGET_HOST ?? '127.0.0.1' - -type TargetFixture = { - containerName: string - identityFile: string - port: number - tempDir: string -} - -function run(command: string, args: string[], timeout = 30_000, input?: string): string { - return execFileSync(command, args, { - encoding: 'utf8', - stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], - timeout, - input - }).trim() -} - -function dockerExec(fixture: TargetFixture, command: string): string { - return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 60_000) -} - -async function startTarget(): Promise { - const image = `orca-review-offline-headers:${NODE_IMAGE.replace(/[^A-Za-z0-9_.-]/g, '-')}` - run( - 'docker', - ['build', '-q', '-t', image, '-'], - 600_000, - [ - `FROM ${NODE_IMAGE}`, - 'RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server git && rm -rf /var/lib/apt/lists/* && mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh', - '' - ].join('\n') - ) - const tempDir = mkdtempSync(join(tmpdir(), 'orca-offline-headers-ssh-')) - const identityFile = join(tempDir, 'id_ed25519') - run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) - const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() - const containerName = `orca-offline-headers-${randomUUID().slice(0, 12)}` - // Why a refused connection and not a dropped one: a timeout takes node-gyp's retry path and - // burns the deploy budget; the user's host refused, and that is the path under test. - run( - 'docker', - [ - 'run', - '-d', - '--name', - containerName, - '--add-host', - 'nodejs.org:127.0.0.1', - '-p', - '0.0.0.0::22', - '-e', - `AUTHORIZED_KEY=${publicKey}`, - image, - 'bash', - '-lc', - 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' - ], - 120_000 - ) - const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) - // `docker run -d` returns before sshd binds; connect() against a closed port is a flake. - await waitForSshBanner(port) - return { containerName, identityFile, port, tempDir } -} - -/** Resolves once sshd answers with its banner on the mapped port, or throws after the deadline. */ -async function waitForSshBanner(port: number, deadlineMs = 60_000): Promise { - const deadline = Date.now() + deadlineMs - for (;;) { - const gotBanner = await new Promise((resolve) => { - const socket = connect({ host: TARGET_HOST, port }) - const done = (value: boolean): void => { - socket.destroy() - resolve(value) - } - socket.setTimeout(2_000, () => done(false)) - socket.once('data', (chunk) => done(chunk.toString('utf8').startsWith('SSH-'))) - socket.once('error', () => done(false)) - }) - if (gotBanner) { - return - } - if (Date.now() > deadline) { - throw new Error(`sshd on port ${port} did not answer within ${deadlineMs / 1000}s`) - } - await new Promise((resolve) => setTimeout(resolve, 500)) - } -} - -function stopTarget(fixture: TargetFixture | null): void { - if (!fixture) { - return - } - spawnSync('docker', ['rm', '-f', fixture.containerName], { stdio: 'ignore', timeout: 30_000 }) - rmSync(fixture.tempDir, { recursive: true, force: true }) -} - -function createConnection(fixture: TargetFixture): SshConnection { - const target: SshTarget = { - id: `offline-headers-${randomUUID()}`, - label: 'Offline node headers Docker SSH target', - source: 'manual', - host: TARGET_HOST, - port: fixture.port, - username: 'root', - identityFile: fixture.identityFile, - identitiesOnly: true - } - return new SshConnection(target, { onStateChange: vi.fn() }) -} - -describe.skipIf(!RUN_REVIEW_ORACLE)( - 'SSH relay deploy on a host that cannot reach nodejs.org', - () => { - let fixture: TargetFixture | null = null - - beforeAll(async () => { - fixture = await startTarget() - }, 900_000) - - afterAll(() => { - stopTarget(fixture) - }) - - it('compiles node-pty from the host Node install headers instead of downloading them', async () => { - const activeFixture = fixture as TargetFixture - expect(dockerExec(activeFixture, 'getent hosts nodejs.org')).toContain('127.0.0.1') - const connection = createConnection(activeFixture) - await connection.connect() - try { - const result = await deployAndLaunchRelay(connection, undefined, 60) - expect(result.remoteRelayDir).toBeTruthy() - - const evidence = dockerExec( - activeFixture, - [ - `cd '${result.remoteRelayDir}'`, - 'test -f node_modules/node-pty/build/Release/pty.node && echo PTY_NODE=built', - 'test -d /root/.cache/node-gyp && echo HEADERS=downloaded || echo HEADERS=local', - `node -e "require('node-pty'); require('@parcel/watcher'); console.log('NATIVE=loadable')"` - ].join('; ') - ) - console.log(`[offline-node-headers] ${NODE_IMAGE}: ${evidence.replace(/\n/g, ' ')}`) - expect(evidence).toContain('PTY_NODE=built') - expect(evidence).toContain('HEADERS=local') - expect(evidence).toContain('NATIVE=loadable') - } finally { - await connection.disconnect() - } - }, 600_000) - - it('names the missing-local-headers cause, not an Orca defect, when the host ships no headers', async () => { - // Same offline host, headers removed and the relay uninstalled so the deploy compiles again. - // This is the shape a review found misreported: the exec-failure message quotes the whole - // command (marker echo included) ahead of the output, and the parser must not read that copy. - const activeFixture = fixture as TargetFixture - dockerExec( - activeFixture, - 'rm -rf /usr/local/include/node /root/.orca-remote /root/.cache/node-gyp' - ) - const connection = createConnection(activeFixture) - await connection.connect() - try { - const error = await deployAndLaunchRelay(connection, undefined, 60).catch((e: Error) => e) - expect(error).toBeInstanceOf(Error) - const message = (error as Error).message - console.log(`[offline-node-headers] ${NODE_IMAGE} no-headers: ${message.split('\n')[0]}`) - expect(message).toContain('no local headers matching its own version') - expect(message).not.toContain('Orca defect') - expect(message).toContain('ECONNREFUSED') - } finally { - await connection.disconnect() - } - }, 600_000) - } -) diff --git a/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts b/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts deleted file mode 100644 index 897aafac33c4..000000000000 --- a/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeRuntimeStore from './orcad-remote-node-runtime' - -const mocks = vi.hoisted(() => ({ - target: vi.fn(), - archive: vi.fn(), - ensure: vi.fn() -})) -vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTargetFacts: mocks.target })) -vi.mock('./pinned-runtime-materializer', () => ({ - materializeNodeRuntimeArchive: mocks.archive -})) -vi.mock('./orcad-remote-node-runtime', async (original) => ({ - ...(await original()), - ensureRemoteOrcadNodeRuntime: mocks.ensure -})) - -import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS } from '../../shared/node-runtime-pin' -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { preparePinnedNodeForVault } from './ssh-relay-opencode-pinned-node' - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every remote call is mocked; the connection is only passed through. -const conn = {} as SshConnection - -function prepare(platform: 'linux-x64' | 'win32-x64', exec: (command: string) => Promise) { - const relayDir = `${platform === 'win32-x64' ? 'C:/Users/ada' : '/home/ada'}/.orca-remote/relay-build` - return preparePinnedNodeForVault({ - conn, - host: getRemoteHostPlatform(platform), - relayDir, - cacheRoot: '/cache', - signal: new AbortController().signal, - exec, - remote: (operation) => operation() - }) -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('pinned Node for the SSH vault reader', () => { - it.each([ - ['linux-x64', 'linux-x64-glibc', { major: 2, minor: 31 }, '/home/ada', 'bin/node'], - ['win32-x64', 'win32-x64', null, 'C:/Users/ada', 'node.exe'] - ] as const)( - 'installs the official archive into the shared runtimes/ store on %s hosts', - async (platform, target, glibc, home, executableName) => { - const sha = NODE_RUNTIME_ASSETS[target].executableSha256 - const executable = `${home}/.orca-remote/runtimes/node-${sha}/${executableName}` - mocks.target.mockResolvedValue({ target, glibc }) - mocks.ensure.mockImplementation(async (options) => { - expect(options).toMatchObject({ slotDir: `${home}/.orca-remote/relay-build`, target }) - await options.archivePath() - return { executable, transfer: 'uploaded' } - }) - mocks.archive.mockResolvedValue('/cache/node-archive') - - expect(await prepare(platform, vi.fn())).toEqual({ executable, runtimeSha256: sha }) - expect(mocks.archive).toHaveBeenCalledWith(target, '/cache', expect.any(Object)) - } - ) - - it('fetches no archive when the host store already has a verified runtime', async () => { - mocks.target.mockResolvedValue({ target: 'win32-x64', glibc: null }) - mocks.ensure.mockResolvedValue({ executable: 'C:/x/node.exe', transfer: 'cached' }) - await prepare('win32-x64', vi.fn()) - expect(mocks.archive).not.toHaveBeenCalled() - }) - - it('installs the glibc 2.17 compat Node for the reader on a host below the default floor', async () => { - const sha = NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256 - const executable = `/home/ada/.orca-remote/runtimes/node-${sha}/bin/node` - mocks.target.mockResolvedValue({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 17 } }) - mocks.ensure.mockImplementation(async (options) => { - expect(options).toMatchObject({ target: 'linux-x64-glibc217' }) - await options.archivePath() - return { executable, transfer: 'uploaded' } - }) - mocks.archive.mockResolvedValue('/cache/node-217.tar.gz') - - // The compat sha is the relay dir's store ref, so store GC keeps this runtime. - expect(await prepare('linux-x64', vi.fn())).toEqual({ executable, runtimeSha256: sha }) - expect(mocks.archive).toHaveBeenCalledWith('linux-x64-glibc217', '/cache', expect.any(Object)) - }) - - it('uploads nothing when no Orca-managed Node runs on the host glibc', async () => { - mocks.target.mockResolvedValue({ target: 'linux-arm64-glibc', glibc: { major: 2, minor: 17 } }) - - await expect(prepare('linux-x64', vi.fn())).rejects.toThrow( - "No Orca-managed Node runs on this host's glibc 2.17" - ) - expect(mocks.ensure).not.toHaveBeenCalled() - expect(mocks.archive).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-opencode-pinned-node.ts b/src/main/ssh/ssh-relay-opencode-pinned-node.ts deleted file mode 100644 index a8ae85175b6b..000000000000 --- a/src/main/ssh/ssh-relay-opencode-pinned-node.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin' -import type { SshConnection } from './ssh-connection' -import { resolveOrcadDeploymentTargetFacts } from './orcad-deployment-target' -import { ensureRemoteOrcadNodeRuntime, type RemoteRuntimeStep } from './orcad-remote-node-runtime' -import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { pinnedRuntimeTargetForHost } from './ssh-relay-runtime-ladder' - -const DOWNLOAD_TIMEOUT_MS = 180_000 -const downloads = new Map>() - -/** - * The pinned Node for hosts whose own Node cannot read OpenCode's database (design D4a). Every - * host, Windows included, installs it into the shared runtimes/ store as the official archive - * with a `.verified` marker; nothing here touches vault-sqlite/, which old relays' references - * still name. `runtimeSha256` is the ref the relay dir must carry so store GC keeps it. - */ -export async function preparePinnedNodeForVault(options: { - conn: SshConnection - host: RemoteHostPlatform - relayDir: string - cacheRoot?: string - signal: AbortSignal - exec: (command: string) => Promise - remote: RemoteRuntimeStep -}): Promise<{ executable: string; runtimeSha256: string }> { - const { conn, host, signal, exec } = options - const facts = await resolveOrcadDeploymentTargetFacts({ conn, host, signal, exec }) - // Why before any upload: below every runtime's glibc floor the self-test could only fail. - const target = pinnedRuntimeTargetForHost(facts) - if (!target) { - const glibc = facts.glibc ? `${facts.glibc.major}.${facts.glibc.minor}` : 'unknown' - throw new Error(`No Orca-managed Node runs on this host's glibc ${glibc}`) - } - const cacheRoot = - options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts') - const { executable } = await ensureRemoteOrcadNodeRuntime({ - conn, - host, - slotDir: options.relayDir, - target, - archivePath: () => cachedArchive(target, cacheRoot, signal), - signal, - remoteStep: options.remote - }) - return { executable, runtimeSha256: pinnedNodeRuntimeAsset(target).executableSha256 } -} - -function cachedArchive( - target: NodeRuntimeTarget, - cacheRoot: string, - signal: AbortSignal -): Promise { - const key = `${cacheRoot}\0${target}` - let pending = downloads.get(key) - if (!pending) { - pending = materializeNodeRuntimeArchive(target, cacheRoot, { - signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) - }).finally(() => downloads.delete(key)) - downloads.set(key, pending) - } - return waitForPromiseWithSignal(pending, signal) -} diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts deleted file mode 100644 index 11832aae7d8d..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { - parseOpenCodeRuntimeResult, - probeOpenCodeNodeSqliteCommand, - publishOpenCodeRuntimeReferenceCommand -} from './ssh-relay-opencode-runtime-commands' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - recoverOneStaleRelayUploadStageCommand, - reserveRelayUploadStageCommand -} from './ssh-relay-upload-stage-commands' - -const host = getRemoteHostPlatform('linux-x64') -const nodePath = process.execPath -const directories: string[] = [] -const runtimeSha = 'a'.repeat(64) -const markerName = '.sftp-namespace-0123456789abcdef0123456789abcdef' - -afterEach(async () => { - await Promise.all( - directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) - ) -}) - -async function directory(): Promise { - const result = await mkdtemp(join(tmpdir(), "orca-runtime spaces ' $-")) - directories.push(result) - return result -} - -async function command(text: string, environment: NodeJS.ProcessEnv = {}) { - return runProcess({ - program: 'sh', - args: ['-c', text], - timeoutMs: 10_000, - env: { ...process.env, OPENCODE_DB: '', XDG_DATA_HOME: '', ...environment } - }) -} - -async function reserveStage(root: string) { - const pool = join(root, '.upload-stages') - const result = await command(reserveRelayUploadStageCommand(host, pool, markerName)) - expect(result.code, result.stderr).toBe(0) - return parseReservedRelayUploadStage(host, pool, markerName, result.stdout) -} - -describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands', () => { - it('runs an actual SQLite read and identifies the executable', async () => { - const home = await directory() - const data = join(home, '.local', 'share', 'opencode') - await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') - const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home)) - expect(result.code).toBe(0) - expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ - status: 'ready', - executable: nodePath - }) - }) - - it('sends a host Node without the SyncDatabase surface to the pinned runtime', async () => { - const home = await directory() - const data = join(home, '.local', 'share', 'opencode') - await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') - // Node 22.13-22.15: DatabaseSync ships, the backup export does not. - const preload = join(home, 'node-22-13-sqlite.cjs') - await writeFile(preload, "delete require('node:sqlite').backup") - const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home), { - NODE_OPTIONS: `--require ${JSON.stringify(preload)}` - }) - expect(result.code, result.stderr).toBe(0) - expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ status: 'unsupported' }) - }) - - it('ignores the experimental SQLite warning older Node prints to stderr', async () => { - const home = await directory() - const data = join(home, '.local', 'share', 'opencode') - await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') - const preload = join(home, 'experimental-warning.cjs') - await writeFile( - preload, - "process.emitWarning('SQLite is an experimental feature and might change at any time','ExperimentalWarning')" - ) - const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home), { - NODE_OPTIONS: `--require ${JSON.stringify(preload)}` - }) - expect(result.stderr).toContain('ExperimentalWarning') - expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ - status: 'ready', - executable: nodePath - }) - }) - - it('publishes the reference atomically through a staged file under quoted paths', async () => { - const root = await directory() - const stage = await reserveStage(root) - const executable = join(root, 'runtimes', `node-${runtimeSha}`, 'bin', 'node') - const reference = join(root, 'opencode-sqlite-runtime.json') - await writeFile(reference, '{"old":true}') - const stagedReference = join(stage.slotDir, 'payload', 'ref.json') - await writeFile(stagedReference, JSON.stringify({ protocol: 1, executable })) - const published = await command( - publishOpenCodeRuntimeReferenceCommand({ - host, - nodePath, - stagedReference, - reference, - token: 'one' - }) - ) - expect(parseOpenCodeRuntimeResult(published.stdout).status).toBe('published') - expect(JSON.parse(await readFile(reference, 'utf8'))).toEqual({ protocol: 1, executable }) - await expect(stat(join(root, `.runtime-ref-node-${runtimeSha}`))).rejects.toMatchObject({ - code: 'ENOENT' - }) - await command(cleanupOwnedRelayUploadStageCommand(host, stage, markerName)) - await expect(stat(stage.slotDir)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('writes the store ref that holds a pinned runtime before the reference names it', async () => { - const root = await directory() - const stagedReference = join(root, 'staged.json') - await writeFile(stagedReference, '{"protocol":1}') - const ref = join(root, `.runtime-ref-node-${runtimeSha}`) - const published = await command( - publishOpenCodeRuntimeReferenceCommand({ - host, - nodePath, - stagedReference, - reference: join(root, 'opencode-sqlite-runtime.json'), - token: 'two', - runtimeRef: { path: ref, sha256: runtimeSha } - }) - ) - expect(parseOpenCodeRuntimeResult(published.stdout).status).toBe('published') - expect(await readFile(ref, 'utf8')).toBe(`${runtimeSha}\n`) - }) - - it('defers an empty host, honors database overrides, and ignores in-memory databases', async () => { - const home = await directory() - const probe = probeOpenCodeNodeSqliteCommand(host, nodePath, home) - expect(parseOpenCodeRuntimeResult((await command(probe)).stdout).status).toBe('not-needed') - const xdg = join(home, 'other data') - await mkdir(join(xdg, 'opencode'), { recursive: true }) - await writeFile(join(xdg, 'opencode', 'opencode-team.db'), '') - const environment = { XDG_DATA_HOME: xdg, OPENCODE_DB: 'opencode-team.db' } - expect(parseOpenCodeRuntimeResult((await command(probe, environment)).stdout).status).toBe( - 'ready' - ) - expect( - parseOpenCodeRuntimeResult( - (await command(probe, { ...environment, OPENCODE_DB: ':memory:' })).stdout - ).status - ).toBe('not-needed') - }) - - it('reclaims an abandoned binary through the shared pool while preserving fresh uploads', async () => { - const root = await directory() - const abandoned = await reserveStage(root) - await writeFile(join(abandoned.slotDir, 'payload', 'bun'), 'partial upload') - const fresh = await reserveStage(root) - await writeFile(join(fresh.slotDir, 'payload', 'bun'), 'active upload') - const old = new Date(Date.now() - 3_600_000) - await utimes(join(abandoned.slotDir, '.orca-upload-owner'), old, old) - const recovered = await command(recoverOneStaleRelayUploadStageCommand(host, abandoned.poolDir)) - expect(recovered.code, recovered.stderr).toBe(0) - await expect(stat(abandoned.slotDir)).rejects.toMatchObject({ code: 'ENOENT' }) - expect(await readFile(join(fresh.slotDir, 'payload', 'bun'), 'utf8')).toBe('active upload') - }) -}) - -it('carries Windows JavaScript and path arguments through the established PowerShell encoder', () => { - const windows = getRemoteHostPlatform('win32-x64') - const command = publishOpenCodeRuntimeReferenceCommand({ - host: windows, - nodePath: "C:/Program Files/O'Brien/node.exe", - stagedReference: 'C:/Users/a & b/.upload/ref.json', - reference: 'C:/Users/a & b/.orca-remote/relay-x/opencode-sqlite-runtime.json', - token: 'one', - runtimeRef: { - path: `C:/Users/a & b/.orca-remote/relay-x/.runtime-ref-node-${runtimeSha}`, - sha256: runtimeSha - } - }) - const decoded = decodeRemotePowerShellScript(command) - expect(decoded).toContain("& 'C:/Program Files/O''Brien/node.exe'") - expect(decoded).toContain('C:/Users/a & b/.upload/ref.json') - expect(decoded).toContain(`.runtime-ref-node-${runtimeSha}`) - expect(command).not.toContain('-ExecutionPolicy') - expect(decoded).not.toContain('Add-Type') -}) - -it('rejects missing or malformed host confirmations', () => { - expect(() => parseOpenCodeRuntimeResult('login banner')).toThrow('did not confirm') - expect(() => - parseOpenCodeRuntimeResult('ORCA_VAULT_SQLITE:{"status":"ready","executable":"node"}') - ).toThrow('invalid executable') -}) - -it('accepts an absolute Windows UNC executable path', () => { - const executable = String.raw`\\server\profile\vault-sqlite\bun.exe` - expect( - parseOpenCodeRuntimeResult( - `ORCA_VAULT_SQLITE:${JSON.stringify({ status: 'ready', executable })}` - ) - ).toEqual({ status: 'ready', executable }) -}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts deleted file mode 100644 index 419e90d127d7..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NODE_SQLITE_READER_API_SOURCE } from '../sqlite/node-sqlite-reader-api' -import { shellEscape } from './ssh-connection-utils' -import { posix, win32 } from 'node:path' -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' -import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' - -export const OPENCODE_RUNTIME_RESULT = 'ORCA_VAULT_SQLITE:' - -function nodeCommand( - host: RemoteHostPlatform, - nodePath: string, - script: string, - args: string[] -): string { - if (isWindowsRemoteHost(host)) { - return powerShellCommand( - `& ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(script)} -- ${args.map(powerShellNativeArg).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` - ) - } - return `${shellEscape(nodePath)} -e ${shellEscape(script)} -- ${args.map(shellEscape).join(' ')}` -} - -const SEND = `const send=(value)=>console.log(${JSON.stringify(OPENCODE_RUNTIME_RESULT)}+JSON.stringify(value));` - -/** Host Node needs backup() too: 22.13-22.15 have DatabaseSync without it (design D4). */ -export function probeOpenCodeNodeSqliteCommand( - host: RemoteHostPlatform, - nodePath: string, - homeDirectory: string -): string { - return nodeCommand( - host, - nodePath, - `${SEND} -const fs=require('node:fs/promises');const path=require('node:path'); -(async()=>{const data=path.join(process.env.XDG_DATA_HOME?.trim()||path.join(process.argv[1],'.local','share'),'opencode'); -const override=process.env.OPENCODE_DB?.trim();let present=false; -try{if(override&&override!==':memory:'){present=(await fs.stat(path.isAbsolute(override)?override:path.join(data,override))).isFile()} -else if(!override){const directory=await fs.opendir(data);for await(const entry of directory){if(entry.isFile()&&/^opencode(?:-[A-Za-z0-9_.-]+)?\\.db$/.test(entry.name)){present=true;break}}}} -catch(error){if(error.code!=='ENOENT'&&error.code!=='ENOTDIR')throw error} -if(!present){send({status:'not-needed'});return} -let db;try{const sqlite=require('node:sqlite');if(!(${NODE_SQLITE_READER_API_SOURCE})(sqlite))throw Error('SQLite reader API missing'); -db=new sqlite.DatabaseSync(':memory:'); -if(db.prepare('SELECT 1 AS ready').get().ready!==1)throw Error('SQLite read failed'); -send({status:'ready',executable:process.execPath})}catch{send({status:'unsupported'})}finally{if(db)db.close()} -})().catch(error=>{console.error(error.message);process.exitCode=1})`, - [homeDirectory] - ) -} - -/** - * Publishes the reference. With `runtimeRef`, the relay dir first gains the store ref file that - * keeps the pinned runtime from store GC, so the reference never names an unheld runtime. - */ -export function publishOpenCodeRuntimeReferenceCommand(args: { - host: RemoteHostPlatform - nodePath: string - stagedReference: string - reference: string - token: string - runtimeRef?: { path: string; sha256: string } -}): string { - return nodeCommand( - args.host, - args.nodePath, - `${SEND} -const fs=require('node:fs/promises');const path=require('node:path'); -(async()=>{const [source,destination,token,refPath,refSha]=process.argv.slice(1);const temporary=destination+'.upload-'+token; -try{if(refPath)await fs.writeFile(refPath,refSha+'\\n'); -await fs.copyFile(source,temporary,require('node:fs').constants.COPYFILE_EXCL); -await fs.rename(temporary,destination);send({status:'published'})} -finally{await fs.rm(temporary,{force:true})}})().catch(error=>{console.error(error.message);process.exitCode=1})`, - [ - args.stagedReference, - args.reference, - args.token, - ...(args.runtimeRef ? [args.runtimeRef.path, args.runtimeRef.sha256] : []) - ] - ) -} - -export function parseOpenCodeRuntimeResult(output: string): { - status: string - executable?: string -} { - const line = output.split(/\r?\n/).findLast((entry) => entry.startsWith(OPENCODE_RUNTIME_RESULT)) - if (!line) { - throw new Error('The host did not confirm SQLite runtime setup.') - } - const result: unknown = JSON.parse(line.slice(OPENCODE_RUNTIME_RESULT.length)) - if ( - typeof result !== 'object' || - result === null || - !('status' in result) || - typeof result.status !== 'string' - ) { - throw new Error('Invalid SQLite runtime setup result.') - } - if ('executable' in result) { - if ( - typeof result.executable !== 'string' || - !(posix.isAbsolute(result.executable) || win32.isAbsolute(result.executable)) || - /[\0\r\n]/.test(result.executable) - ) { - throw new Error('SQLite runtime setup returned an invalid executable path.') - } - return { status: result.status, executable: result.executable } - } - return { status: result.status } -} diff --git a/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts deleted file mode 100644 index be8fde1fc5e6..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createRemoteOpenCodeRuntimeRetry } from './ssh-relay-opencode-runtime-retry' - -afterEach(() => vi.useRealTimers()) - -describe('scan-triggered SSH OpenCode runtime preparation', () => { - it('rechecks an empty host after the cooldown, coalesces scans, and stops once ready', async () => { - vi.useFakeTimers() - const retry = vi.fn().mockResolvedValue('ready') - const prepare = createRemoteOpenCodeRuntimeRetry( - Promise.resolve('not-needed'), - Promise.resolve(true), - retry - ) - const signal = new AbortController().signal - await prepare(signal) - expect(retry).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(60_000) - await Promise.all([prepare(signal), prepare(signal), prepare(signal)]) - expect(retry).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(60_000) - await prepare(signal) - expect(retry).toHaveBeenCalledOnce() - }) - - it('waits for initial background cleanup before a scan can retry', async () => { - vi.useFakeTimers() - let finish!: () => void - const cleanup = new Promise((resolve) => { - finish = () => resolve(true) - }) - const retry = vi.fn().mockResolvedValue('ready') - const prepare = createRemoteOpenCodeRuntimeRetry(Promise.resolve('failed'), cleanup, retry) - await prepare(new AbortController().signal) - await vi.advanceTimersByTimeAsync(30_000) - const pending = prepare(new AbortController().signal) - await Promise.resolve() - expect(retry).not.toHaveBeenCalled() - finish() - await pending - expect(retry).toHaveBeenCalledOnce() - }) - - it('cancels a queued retry on session teardown without starting a remote command', async () => { - vi.useFakeTimers() - const retry = vi.fn() - const prepare = createRemoteOpenCodeRuntimeRetry( - Promise.resolve('not-needed'), - new Promise(() => {}), - retry - ) - const controller = new AbortController() - await prepare(controller.signal) - await vi.advanceTimersByTimeAsync(60_000) - const pending = prepare(controller.signal) - controller.abort() - await pending - await prepare(controller.signal) - expect(retry).not.toHaveBeenCalled() - }) - - it('never retries an unconfirmed remote teardown', async () => { - vi.useFakeTimers() - const retry = vi.fn() - const prepare = createRemoteOpenCodeRuntimeRetry( - Promise.resolve('teardown-unconfirmed'), - Promise.resolve(true), - retry - ) - await vi.advanceTimersByTimeAsync(3600_000) - await prepare(new AbortController().signal) - expect(retry).not.toHaveBeenCalled() - }) - - it('never retries after deployment cleanup reports an unconfirmed command', async () => { - vi.useFakeTimers() - const retry = vi.fn() - const prepare = createRemoteOpenCodeRuntimeRetry( - Promise.resolve('not-needed'), - Promise.resolve(false), - retry - ) - await prepare(new AbortController().signal) - await vi.advanceTimersByTimeAsync(60_000) - await prepare(new AbortController().signal) - await vi.advanceTimersByTimeAsync(3600_000) - await prepare(new AbortController().signal) - expect(retry).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-retry.ts b/src/main/ssh/ssh-relay-opencode-runtime-retry.ts deleted file mode 100644 index 103da40a6cda..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime-retry.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { RemoteOpenCodeRuntimeOutcome } from './ssh-relay-opencode-runtime' - -export type RemoteOpenCodeRuntimePreparation = (signal: AbortSignal) => Promise - -export function createRemoteOpenCodeRuntimeRetry( - initialSetup: Promise, - backgroundCleanup: Promise, - retry: (signal: AbortSignal) => Promise -): RemoteOpenCodeRuntimePreparation { - let nextAttempt = Infinity - let pending: Promise | undefined - const remember = (outcome: RemoteOpenCodeRuntimeOutcome): void => { - nextAttempt = - outcome === 'ready' || outcome === 'teardown-unconfirmed' - ? Infinity - : Date.now() + (outcome === 'not-needed' ? 60_000 : 30_000) - } - const initialized = initialSetup.then(remember).catch(() => {}) - return (signal) => { - if (signal.aborted) { - return Promise.resolve() - } - pending ??= (async () => { - await waitForPromiseWithSignal(initialized, signal) - if (Date.now() < nextAttempt) { - return - } - if (!(await waitForPromiseWithSignal(backgroundCleanup, signal))) { - nextAttempt = Infinity - return - } - signal.throwIfAborted() - remember(await retry(signal)) - })() - .catch(() => {}) - .finally(() => { - pending = undefined - }) - return pending - } -} diff --git a/src/main/ssh/ssh-relay-opencode-runtime.test.ts b/src/main/ssh/ssh-relay-opencode-runtime.test.ts deleted file mode 100644 index e96c15e2b2a4..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime.test.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - exec: vi.fn(), - upload: vi.fn(), - write: vi.fn(), - materialize: vi.fn(), - target: vi.fn(), - warm: false, - checksumError: false, - cleanupError: false, - reservationError: false -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: mocks.exec, - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - 'sshChannelCloseConfirmed' in error && - error.sshChannelCloseConfirmed === false -})) -vi.mock('./ssh-relay-install-transfers', () => ({ - uploadRelayDirectory: mocks.upload, - writeRelayFile: mocks.write -})) -vi.mock('./pinned-runtime-materializer', () => ({ - materializeNodeRuntimeArchive: mocks.materialize, - materializeCachedNodeRuntime: vi.fn() -})) -vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTargetFacts: mocks.target })) - -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' -import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' -import { OPENCODE_RUNTIME_RESULT } from './ssh-relay-opencode-runtime-commands' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' - -const host = getRemoteHostPlatform('linux-x64') -const remoteHome = '/home/ada' -const relayDir = `${remoteHome}/.orca-remote/relay-build` -const binary = `${remoteHome}/.orca-remote/runtimes/node-${NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256}/bin/node` -const archiveName = 'node-v24.21.0-linux-x64.tar.gz' -let cacheRoot: string -let runtime: string -const frame = (status: string, executable?: string) => - `${OPENCODE_RUNTIME_RESULT}${JSON.stringify({ status, executable })}\n` -const options = () => ({ nodePath: '/usr/bin/node', relayDir, cacheRoot }) - -function hostCommandResult(command: string): string { - if (command.includes('staging quota is full')) { - if (mocks.reservationError) { - throw new Error('staging quota is full') - } - return `__ORCA_UPLOAD_STAGE_SLOT__${command.match(/\.sftp-namespace-[0-9a-f]{32}/)?.[0]}:slot-0` - } - if (command.includes("/runtimes/.store-lock' 2>/dev/null")) { - return 'OK' - } - if (command.includes('SELECT 1 AS ready')) { - return frame('unsupported') - } - if (command.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) { - return mocks.checksumError ? 'ORCA_NODE_RUNTIME_HASH_MISMATCH' : 'ORCA_NODE_RUNTIME_READY' - } - if (command.includes('ORCA_NODE_RUNTIME_MISSING')) { - return mocks.warm ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' - } - if (command.includes('published')) { - return frame('published') - } - if (mocks.cleanupError && command.includes('claim_identity') && !command.includes('old=')) { - throw Object.assign(new Error('Cleanup teardown is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - } - return '' -} - -function connection(system = false): SshConnection { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Setup reads only the mocked transport flag and connection generation; remote I/O is mocked. - return { - usesSystemSshTransport: () => system, - getConnectGeneration: () => 1 - } as unknown as SshConnection -} - -beforeEach(async () => { - vi.resetAllMocks() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - cacheRoot = await mkdtemp(join(tmpdir(), 'orca-vault-runtime-')) - runtime = join(cacheRoot, archiveName) - await writeFile(runtime, 'verified runtime') - mocks.materialize.mockResolvedValue(runtime) - mocks.target.mockResolvedValue({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 31 } }) - mocks.warm = false - mocks.checksumError = false - mocks.cleanupError = false - mocks.reservationError = false - mocks.exec.mockImplementation(async (_conn, command: string) => hostCommandResult(command)) -}) - -afterEach(async () => { - vi.useRealTimers() - vi.restoreAllMocks() - await rm(cacheRoot, { recursive: true, force: true }) -}) - -describe('SSH OpenCode runtime setup', () => { - it('publishes a capable existing Node without materializing or uploading a runtime', async () => { - mocks.exec.mockResolvedValueOnce(frame('ready', '/opt/node 24/bin/node')) - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'ready' - ) - expect(mocks.target).not.toHaveBeenCalled() - expect(mocks.materialize).not.toHaveBeenCalled() - expect(mocks.upload).not.toHaveBeenCalled() - expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ - protocol: 1, - executable: '/opt/node 24/bin/node' - }) - }) - - it('installs the pinned Node archive into the shared runtimes/ store after the read probe fails', async () => { - mocks.upload.mockImplementation(async (_conn, localDir: string, remoteDir: string) => { - expect(remoteDir).toMatch( - /\/\.orca-remote\/runtimes\/\.stage-node-[0-9a-f]{64}-[0-9a-f]{16}$/ - ) - expect(await readdir(localDir)).toEqual([archiveName]) - expect(await readFile(join(localDir, archiveName), 'utf8')).toBe('verified runtime') - }) - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'ready' - ) - expect(mocks.target).toHaveBeenCalledWith( - expect.objectContaining({ host, signal: expect.any(AbortSignal) }) - ) - expect(mocks.materialize).toHaveBeenCalledWith('linux-x64-glibc', cacheRoot, { - signal: expect.any(AbortSignal) - }) - expect(await readdir(cacheRoot)).toEqual([archiveName]) - expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: binary }) - const commands: string[] = mocks.exec.mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('vault-sqlite'))).toBe(false) - }) - - it('reuses a runtime the host store already verified without downloading it again', async () => { - mocks.warm = true - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'ready' - ) - expect(mocks.materialize).not.toHaveBeenCalled() - expect(mocks.upload).not.toHaveBeenCalled() - expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: binary }) - }) - - it('writes the reference atomically through the reserved staging namespace', async () => { - await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) - const writeOptions = mocks.write.mock.calls[0][4] - expect(writeOptions.sftpNamespace.homeRelativeNamespaceRoot).toMatch( - /^\.orca-remote\/\.upload-stages\/slot-0$/ - ) - expect(writeOptions.sftpNamespace.homeRelativePath).toMatch(/\/opencode-sqlite-runtime\.json$/) - }) - - it('skips namespace probing on system SSH', async () => { - await ensureRemoteOpenCodeRuntime(connection(true), host, remoteHome, options()) - expect(mocks.write.mock.calls[0][4].sftpNamespace).toBeUndefined() - }) - - it('coalesces repeated setup for one execution connection and directory', async () => { - const conn = connection() - const result = await Promise.all([ - ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()), - ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - ]) - expect(result).toEqual(['ready', 'ready']) - expect(mocks.upload).toHaveBeenCalledOnce() - }) - - it('coalesces the bounded target cache fill across hosts', async () => { - let finish!: (path: string) => void - mocks.materialize.mockReturnValue( - new Promise((resolve) => { - finish = resolve - }) - ) - const first = ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) - const second = ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) - await vi.waitFor(() => expect(mocks.materialize).toHaveBeenCalledOnce()) - finish(runtime) - expect(await Promise.all([first, second])).toEqual(['ready', 'ready']) - expect(mocks.upload).toHaveBeenCalledTimes(2) - }) - - it('never publishes a reference after a failed remote runtime checksum', async () => { - mocks.checksumError = true - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'failed' - ) - expect(mocks.write).not.toHaveBeenCalled() - }) - - it('aborts an upload without publishing or running further host commands', async () => { - const controller = new AbortController() - mocks.upload.mockImplementation(async (_conn, _local, _remote, _host, transfer) => { - controller.abort() - transfer.signal.throwIfAborted() - }) - expect( - await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, { - ...options(), - signal: controller.signal - }) - ).toBe('teardown-unconfirmed') - expect(mocks.write).not.toHaveBeenCalled() - expect(mocks.exec).toHaveBeenCalledTimes(3) - }) - - it('bounds even an unresponsive setup operation at 180 seconds', async () => { - vi.useFakeTimers() - mocks.exec.mockReturnValue(new Promise(() => {})) - const conn = connection() - const result = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - await vi.advanceTimersByTimeAsync(180_000) - expect(await result).toBe('teardown-unconfirmed') - expect(mocks.exec.mock.calls[0][2].signal.aborted).toBe(true) - expect(mocks.materialize).not.toHaveBeenCalled() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledOnce() - }) - - it('admits setup on a new connection generation after an unconfirmed teardown', async () => { - const conn = connection() - const generation = vi.spyOn(conn, 'getConnectGeneration') - mocks.exec.mockRejectedValueOnce( - Object.assign(new Error('Channel teardown is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - ) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledOnce() - generation.mockReturnValue(2) - mocks.exec.mockResolvedValueOnce(frame('ready', '/usr/bin/node')) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') - expect(mocks.write).toHaveBeenCalledOnce() - }) - - it.each(['ready', 'not-needed'] as const)( - 'refuses a late %s result from a superseded setup', - async (status) => { - const conn = connection() - const generation = vi.spyOn(conn, 'getConnectGeneration') - mocks.exec.mockImplementationOnce(async () => { - generation.mockReturnValue(2) - return frame(status, '/usr/bin/node') - }) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('failed') - expect(mocks.exec).toHaveBeenCalledOnce() - expect(mocks.write).not.toHaveBeenCalled() - } - ) - - it.each(['publication', 'cleanup'] as const)( - 'refuses completed setup when its generation changes during %s', - async (stage) => { - const conn = connection() - const generation = vi.spyOn(conn, 'getConnectGeneration') - const started = Promise.withResolvers() - const finish = Promise.withResolvers() - mocks.exec.mockImplementation(async (_conn, command: string) => { - if (command.includes('SELECT 1 AS ready')) { - return frame('ready', '/usr/bin/node') - } - const selected = - stage === 'publication' - ? command.includes('published') - : command.includes('claim_identity') && !command.includes('old=') - if (selected) { - started.resolve() - return finish.promise - } - return hostCommandResult(command) - }) - const pending = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - await started.promise - generation.mockReturnValue(2) - finish.resolve(stage === 'publication' ? frame('published') : '') - - expect(await pending).toBe('failed') - expect(mocks.exec).toHaveBeenCalledTimes(stage === 'publication' ? 4 : 5) - expect(mocks.write).toHaveBeenCalledOnce() - } - ) - - it('keeps a newer setup registered when the superseded setup finishes late', async () => { - const conn = connection() - const generation = vi.spyOn(conn, 'getConnectGeneration') - const oldProbe = Promise.withResolvers() - const currentProbe = Promise.withResolvers() - mocks.exec.mockReturnValueOnce(oldProbe.promise).mockReturnValueOnce(currentProbe.promise) - const oldSetup = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - generation.mockReturnValue(2) - const currentSetup = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - oldProbe.resolve(frame('not-needed')) - expect(await oldSetup).toBe('failed') - - const joined = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) - expect(mocks.exec).toHaveBeenCalledTimes(2) - currentProbe.resolve(frame('not-needed')) - expect(await Promise.all([currentSetup, joined])).toEqual(['not-needed', 'not-needed']) - }) - - it.each(['deadline', 'caller'] as const)( - 'allows retry after local download cancellation by %s without reserving a stage', - async (cause) => { - const controller = new AbortController() - let finish!: (path: string) => void - mocks.materialize.mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve - }) - ) - if (cause === 'deadline') { - vi.useFakeTimers() - } - const conn = connection() - const pending = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, { - ...options(), - signal: controller.signal - }) - await vi.waitFor(() => expect(mocks.materialize).toHaveBeenCalledOnce()) - expect(mocks.exec).toHaveBeenCalledTimes(2) - if (cause === 'deadline') { - await vi.advanceTimersByTimeAsync(180_000) - } else { - controller.abort() - } - expect(await pending).toBe('failed') - finish(runtime) - vi.useRealTimers() - await new Promise((resolve) => setImmediate(resolve)) - expect(mocks.exec).toHaveBeenCalledTimes(2) - expect(mocks.upload).not.toHaveBeenCalled() - expect(mocks.write).not.toHaveBeenCalled() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') - expect(mocks.upload).toHaveBeenCalledOnce() - } - ) - - it('retains the upload stage when a failed transfer may still be running', async () => { - mocks.upload.mockRejectedValue( - Object.assign(new Error('Upload teardown is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - ) - const conn = connection() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledTimes(3) - expect(mocks.exec.mock.calls.some(([, command]) => command.startsWith('rm -rf'))).toBe(false) - expect(mocks.write).not.toHaveBeenCalled() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledTimes(3) - }) - - it('reports an unconfirmed stage cleanup to the deployment command queue', async () => { - mocks.exec.mockResolvedValueOnce(frame('ready', '/usr/bin/node')) - mocks.cleanupError = true - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'teardown-unconfirmed' - ) - expect(mocks.exec).toHaveBeenCalledTimes(5) - }) - - it('skips installation without data and retries when a database appears on the same connection', async () => { - const conn = connection() - mocks.exec.mockResolvedValueOnce(frame('not-needed')) - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('not-needed') - expect(mocks.exec).toHaveBeenCalledOnce() - expect(mocks.materialize).not.toHaveBeenCalled() - expect(mocks.upload).not.toHaveBeenCalled() - expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') - expect(mocks.upload).toHaveBeenCalledOnce() - }) - - it('fails optionally without publishing when all bounded stages are occupied', async () => { - mocks.reservationError = true - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'failed' - ) - // Includes the store-lock round trips (mkdir, acquire, re-probe+promote, release) around promotion. - expect(mocks.exec).toHaveBeenCalledTimes(10) - expect(mocks.write).not.toHaveBeenCalled() - }) - - it('does not mistake an unanswered Node probe for an old runtime', async () => { - mocks.exec.mockResolvedValue('login banner only') - expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( - 'failed' - ) - expect(mocks.materialize).not.toHaveBeenCalled() - }) -}) - -describe('SSH OpenCode runtime setup on a Windows host', () => { - const windows = getRemoteHostPlatform('win32-x64') - const home = 'C:/Users/ada' - const windowsRelayDir = `${home}/.orca-remote/relay-build` - const sha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 - const storeNode = `${home}/.orca-remote/runtimes/node-${sha}/node.exe` - - function answerWindows(storeReady: boolean): string[] { - const scripts: string[] = [] - mocks.target.mockResolvedValue({ target: 'win32-x64', glibc: null }) - mocks.exec.mockImplementation(async (_conn, command: string) => { - const script = decodeRemotePowerShellScript(command) - scripts.push(script) - if (script.includes('SELECT 1 AS ready')) { - return frame('unsupported') - } - if (script.includes('.store-lock') && script.includes('CreateNew')) { - return 'OK' - } - if (script.includes('Invoke-OrcaPromote')) { - return 'ORCA_NODE_RUNTIME_READY' - } - if (script.includes('ORCA_NODE_RUNTIME_MISSING')) { - return storeReady ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' - } - if (script.includes('staging quota is full')) { - return `__ORCA_UPLOAD_STAGE_SLOT__${script.match(/\.sftp-namespace-[0-9a-f]{32}/)?.[0]}:slot-0` - } - if (script.includes('COPYFILE_EXCL')) { - return frame('published') - } - return '' - }) - return scripts - } - - it('installs the official archive through the runtime store, not a client-extracted node.exe', async () => { - const scripts = answerWindows(false) - expect( - await ensureRemoteOpenCodeRuntime(connection(true), windows, home, { - nodePath: 'C:/Program Files/nodejs/node.exe', - relayDir: windowsRelayDir, - cacheRoot - }) - ).toBe('ready') - expect(mocks.materialize).toHaveBeenCalledWith('win32-x64', cacheRoot, expect.any(Object)) - expect(mocks.upload).toHaveBeenCalledOnce() - expect(mocks.upload.mock.calls[0][2]).toMatch( - /\/runtimes\/\.stage-node-[0-9a-f]{64}-[0-9a-f]{16}$/ - ) - expect(scripts.some((script) => script.includes('Invoke-OrcaPromote'))).toBe(true) - expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: storeNode }) - const publish = scripts.find((script) => script.includes('COPYFILE_EXCL')) - expect(publish).toContain(`${windowsRelayDir}/.runtime-ref-node-${sha}`) - // Stage fencing reads file IDs through the verified node.exe, so no script compiles C#. - const stageScripts = scripts.filter((script) => script.includes('$getFileIdentity')) - expect(stageScripts.length).toBeGreaterThan(0) - for (const script of stageScripts) { - expect(script).toContain(`$orcaIdentityNode = '${storeNode}'`) - expect(script).not.toContain('Add-Type') - } - }) - - it("reuses a verified store runtime and prefers the relay's own verified node.exe", async () => { - const scripts = answerWindows(true) - const relayNode = `${home}/.orca-remote/runtimes/node-other/node.exe` - expect( - await ensureRemoteOpenCodeRuntime(connection(true), windows, home, { - nodePath: relayNode, - verifiedNodePath: relayNode, - relayDir: windowsRelayDir, - cacheRoot - }) - ).toBe('ready') - expect(mocks.materialize).not.toHaveBeenCalled() - expect(mocks.upload).not.toHaveBeenCalled() - for (const script of scripts.filter((s) => s.includes('$getFileIdentity'))) { - expect(script).toContain(`$orcaIdentityNode = '${relayNode}'`) - } - }) -}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime.ts b/src/main/ssh/ssh-relay-opencode-runtime.ts deleted file mode 100644 index f51343cc683b..000000000000 --- a/src/main/ssh/ssh-relay-opencode-runtime.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { randomBytes } from 'node:crypto' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { SshConnection } from './ssh-connection' -import type { RemoteRuntimeStep } from './orcad-remote-node-runtime' -import { preparePinnedNodeForVault } from './ssh-relay-opencode-pinned-node' -import { RUNTIME_REF_NODE_PREFIX } from './remote-node-runtime-store-inventory' -import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { writeRelayFile } from './ssh-relay-install-transfers' -import { - createRelayUploadStageNamespace, - relayUploadStageSftpNamespaceMapping -} from './ssh-relay-install-namespace' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - recoverOneStaleRelayUploadStageCommand, - reserveRelayUploadStageCommand, - RELAY_UPLOAD_STAGE_POOL_NAME -} from './ssh-relay-upload-stage-commands' -import { - parseOpenCodeRuntimeResult, - probeOpenCodeNodeSqliteCommand, - publishOpenCodeRuntimeReferenceCommand -} from './ssh-relay-opencode-runtime-commands' - -const SETUP_TIMEOUT_MS = 180_000 -const RUNTIME_REFERENCE_NAME = 'opencode-sqlite-runtime.json' -export type RemoteOpenCodeRuntimeOutcome = - | 'ready' - | 'not-needed' - | 'failed' - | 'teardown-unconfirmed' -const installations = new WeakMap< - SshConnection, - { generation: number; byDirectory: Map> } ->() - -type SetupOptions = { - nodePath: string - /** The relay's verified pinned node.exe, if any; stage fencing then needs no Add-Type (D5). */ - verifiedNodePath?: string - relayDir: string - signal?: AbortSignal - cacheRoot?: string -} - -/** Optional companion setup; the host's relay and terminals never depend on it. */ -export function ensureRemoteOpenCodeRuntime( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: SetupOptions -): Promise { - const generation = conn.getConnectGeneration() - let current = installations.get(conn) - if (current?.generation !== generation) { - current = { generation, byDirectory: new Map() } - installations.set(conn, current) - } - const { byDirectory } = current - const active = byDirectory.get(options.relayDir) - if (active) { - return waitForPromiseWithSignal(active, options.signal).catch(() => 'teardown-unconfirmed') - } - const timeout = new AbortController() - const timer = setTimeout( - () => timeout.abort(new Error('SSH SQLite runtime setup timed out.')), - SETUP_TIMEOUT_MS - ) - timer.unref() - const signal = options.signal ? AbortSignal.any([options.signal, timeout.signal]) : timeout.signal - let remotePending = false - let remoteUnconfirmed = false - const assertCurrentGeneration = (): void => { - if (conn.getConnectGeneration() !== generation) { - throw new Error('SSH connection changed during SQLite runtime setup.') - } - } - const remote: RemoteRuntimeStep = async (operation) => { - signal.throwIfAborted() - assertCurrentGeneration() - remotePending = true - try { - const result = await operation() - assertCurrentGeneration() - return result - } catch (error) { - remoteUnconfirmed ||= signal.aborted || isUnconfirmedSshCommandTermination(error) - throw error - } finally { - remotePending = false - } - } - const pending = waitForPromiseWithSignal( - install(conn, host, remoteHome, options, signal, remote), - signal - ) - .then((outcome) => { - assertCurrentGeneration() - return outcome - }) - .catch((error: unknown) => { - console.warn( - '[ssh-relay] OpenCode history runtime setup did not finish:', - error instanceof Error ? error.message : String(error) - ) - return remotePending || remoteUnconfirmed || isUnconfirmedSshCommandTermination(error) - ? ('teardown-unconfirmed' as const) - : ('failed' as const) - }) - .then((outcome) => { - clearTimeout(timer) - // An unresolved channel must not admit another installer on this connection. - if (outcome !== 'teardown-unconfirmed') { - byDirectory.delete(options.relayDir) - } - return outcome - }) - byDirectory.set(options.relayDir, pending) - return pending -} - -async function install( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: SetupOptions, - signal: AbortSignal, - remote: RemoteRuntimeStep -): Promise { - const exec = async (command: string): Promise => { - signal.throwIfAborted() - const output = await remote(() => - execCommand(conn, command, { - signal, - wrapCommand: !isWindowsRemoteHost(host) - }) - ) - signal.throwIfAborted() - return output - } - const node = parseOpenCodeRuntimeResult( - await exec(probeOpenCodeNodeSqliteCommand(host, options.nodePath, remoteHome)) - ) - if (node.status === 'not-needed') { - return 'not-needed' - } - if (node.status !== 'ready' && node.status !== 'unsupported') { - throw new Error('The host did not complete its SQLite read probe.') - } - let executable = node.executable - let runtimeRef: { path: string; sha256: string } | undefined - let identityNode = options.verifiedNodePath - if (node.status === 'unsupported') { - const pinned = await preparePinnedNodeForVault({ - conn, - host, - relayDir: options.relayDir, - cacheRoot: options.cacheRoot, - signal, - exec, - remote - }) - executable = pinned.executable - identityNode ??= pinned.executable - runtimeRef = { - path: joinRemotePath( - host, - options.relayDir, - `${RUNTIME_REF_NODE_PREFIX}${pinned.runtimeSha256}` - ), - sha256: pinned.runtimeSha256 - } - } - if (!executable) { - throw new Error('The host did not identify its SQLite executable.') - } - const token = randomBytes(12).toString('hex') - const relativePool = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}` - const poolDir = joinRemotePath(host, remoteHome, relativePool) - const owner = createRelayInstallMarkerFileName() - const identity = identityNode ? { node: identityNode } : undefined - await exec(recoverOneStaleRelayUploadStageCommand(host, poolDir, undefined, identity)) - const stage = parseReservedRelayUploadStage( - host, - poolDir, - owner, - await exec(reserveRelayUploadStageCommand(host, poolDir, owner, identity)) - ) - const stageDir = stage.slotDir - const namespace = createRelayUploadStageNamespace(`${relativePool}/${stage.slotName}`, owner) - const mapping = (file?: string) => - !isWindowsRemoteHost(host) && conn.usesSystemSshTransport?.() !== true - ? relayUploadStageSftpNamespaceMapping(namespace, host, stageDir, file) - : undefined - let cleanupAllowed = true - try { - const referenceName = RUNTIME_REFERENCE_NAME - const stagedReference = joinRemotePath(host, stageDir, 'payload', referenceName) - signal.throwIfAborted() - await remote(() => - writeRelayFile(conn, host, stagedReference, JSON.stringify({ protocol: 1, executable }), { - signal, - sftpNamespace: mapping(referenceName) - }) - ) - const published = parseOpenCodeRuntimeResult( - await exec( - publishOpenCodeRuntimeReferenceCommand({ - host, - nodePath: options.nodePath, - stagedReference, - reference: joinRemotePath(host, options.relayDir, referenceName), - token, - runtimeRef - }) - ) - ) - return published.status === 'published' ? 'ready' : 'failed' - } catch (error) { - cleanupAllowed = !isUnconfirmedSshCommandTermination(error) - throw error - } finally { - if (cleanupAllowed && !signal.aborted) { - await exec(cleanupOwnedRelayUploadStageCommand(host, stage, owner, identity)).catch( - (error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - } - ) - } - } -} diff --git a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts deleted file mode 100644 index e1d685611642..000000000000 --- a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' -import { isProvenProcessExit } from '../../shared/terminal-exit-cause' - -const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(async (_mux: unknown, options: { clientInstanceId: string }) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - })) -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-previous-relay-terminals', () => import('./ssh-previous-relay-census-test-double')) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 17), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-remote-orca-cli', () => ({ - runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn() -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { getSshPtyProvider, getPtyIdsForConnection, clearProviderPtyState, deletePtyOwnership } = - await import('../ipc/pty') - -const APP_PTY_ID = 'ssh:target-1@@pty-live' -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - -function detachedLease() { - return { - targetId: 'target-1', - ptyId: 'pty-live', - state: 'detached' as const, - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: LEAF_ID - } -} - -/** - * STA-3376: every reattach failure walks away from a remote PTY that may still be running a user's - * work. Orca's rule is that unprovable liveness never authorizes destroying a session, so these - * assert the opposite of a leak test: the shell must survive, and the lease must stay in a state - * the user-facing terminate action can still reach. - */ -describe('SshRelaySession abandoned remote PTYs', () => { - beforeEach(() => { - vi.clearAllMocks() - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - }) - - async function establishWithFailingReattach(error: Error): Promise<{ - deps: ReturnType - shutdown: ReturnType - attachForReconnect: ReturnType - runtime: { onPtyExit: ReturnType; registerPty: ReturnType } - }> { - const deps = createMockDeps() - const shutdown = vi.fn().mockResolvedValue(undefined) - const attachForReconnect = vi.fn().mockRejectedValue(error) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect, - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof deps.mockStore.getSshRemotePtyLeases - >) - const runtime = { onPtyExit: vi.fn(), registerPty: vi.fn() } - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward, - runtime as never - ) - - await session.establish(deps.mockConn) - - return { deps, shutdown, attachForReconnect, runtime } - } - - it('leaves a live shell running and reachable when reattach attempts are exhausted', async () => { - // A transport stall proves nothing about the remote shell; the user's long-running process - // may be untouched, so the lease must stay terminable rather than be tombstoned as expired. - const { deps, shutdown, attachForReconnect, runtime } = await establishWithFailingReattach( - new Error('PTY reattach attempt timed out after 15000ms') - ) - - expect(attachForReconnect).toHaveBeenCalled() - expect(shutdown).not.toHaveBeenCalled() - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - expect(deletePtyOwnership).not.toHaveBeenCalledWith(APP_PTY_ID) - expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - }) - - it('leaves another pane live shell running when the relay reports an identity mismatch', async () => { - // The relay answered that a *live* PTY holds this id under a different pane identity. Killing - // it would destroy an unrelated terminal, so this path may only stop claiming the id. - const { deps, shutdown, attachForReconnect, runtime } = await establishWithFailingReattach( - new Error('PTY "pty-live" not found (identity mismatch)') - ) - - expect(attachForReconnect).toHaveBeenCalled() - expect(shutdown).not.toHaveBeenCalled() - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - expect(clearProviderPtyState).not.toHaveBeenCalledWith(APP_PTY_ID) - }) - - it('publishes a disowned-source signal when the relay answers not-found', async () => { - // A reachable relay answered for this exact id and disowned it. That licenses replacing the - // pane — respawning leaks the old process rather than killing it — but a restarted relay - // answers the same way for ids it never minted, so this is not an `exited` verdict - // (docs/reference/ssh-execution-boundary.md). - const { deps, shutdown, runtime } = await establishWithFailingReattach( - new Error('PTY "pty-live" not found') - ) - - expect(shutdown).not.toHaveBeenCalled() - // The runtime is where a death certificate would land (`hostExitConfirmed`), and this branch - // holds no evidence to write one from. - expect(runtime.onPtyExit).not.toHaveBeenCalled() - // 'expired' records that reattach gave up on the id, not that the shell died; ssh:terminateSessions still reaches it. - expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - expect(clearProviderPtyState).toHaveBeenCalledWith(APP_PTY_ID) - expect(deps.mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { - id: APP_PTY_ID, - code: -1, - ptySourceDisowned: true - }) - const exitCall = vi - .mocked(deps.mockWindow.webContents.send) - .mock.calls.find(([channel]) => channel === 'pty:exit') - if (!exitCall) { - throw new Error('expected a pty:exit publication') - } - // The ratchet: the verdict rides its own field, never the code. Swapping -1 for a provable - // status would make every reader that keys off the code close the tab and drop its leaf↔PTY - // binding, which is a far wider claim than "this id is gone from this relay". - expect(isProvenProcessExit((exitCall[1] as { code: number }).code)).toBe(false) - }) - - it('keeps a recovered session attached when reattach succeeds after an earlier drop', async () => { - const deps = createMockDeps() - const shutdown = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId: 'incarnation-1' }), - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof deps.mockStore.getSshRemotePtyLeases - >) - const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward, - runtime as never - ) - - await session.establish(deps.mockConn) - - expect(shutdown).not.toHaveBeenCalled() - expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { - tabId: 'tab-1', - leafId: LEAF_ID, - incarnationId: 'incarnation-1' - }) - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-pinned-node-install.test.ts b/src/main/ssh/ssh-relay-pinned-node-install.test.ts deleted file mode 100644 index 2c8f5ccbbe25..000000000000 --- a/src/main/ssh/ssh-relay-pinned-node-install.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import { - ensureRemoteOrcadNodeRuntime, - REMOTE_NODE_RUNTIME_MISSING, - REMOTE_NODE_RUNTIME_READY, - RemoteNodeRuntimeSecurityModifiedError, - RemoteNodeRuntimeSelfTestError -} from './orcad-remote-node-runtime' -import { - REMOTE_NODE_RUNTIME_EXIT_PREFIX, - REMOTE_NODE_RUNTIME_SELFTEST_FAILED -} from './orcad-remote-node-runtime-report' -import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' -import { - PinnedRelayFallbackError, - planPinnedNodeRelay, - type PinnedRelayPlan -} from './ssh-relay-pinned-node' -import { resetPinnedRuntimeRefusalsForTests } from './ssh-relay-pinned-refusal-cache' -import { runPinnedRuntimeSelfTest } from './ssh-relay-runtime-self-test' -import type { HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' -import { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' -import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) -vi.mock('./orcad-remote-node-runtime', async (importOriginal) => ({ - ...(await importOriginal>()), - ensureRemoteOrcadNodeRuntime: vi.fn() -})) -vi.mock('./remote-node-runtime-store-lock', () => ({ - withRuntimeStoreLock: vi.fn((_conn, _host, _store, task: () => Promise) => task()) -})) -vi.mock('./ssh-relay-runtime-self-test', async (importOriginal) => ({ - ...(await importOriginal>()), - runPinnedRuntimeSelfTest: vi.fn() -})) - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All connection operations are mocked. -const conn = {} as SshConnection -const host = getRemoteHostPlatform('linux-x64') -const plan: PinnedRelayPlan = { - kind: 'pinned-node', - target: 'linux-x64-glibc', - glibc: { major: 2, minor: 31 }, - fullVersion: '0.1.0+feedfacecafe', - addons: { dir: '/tmp/a', digest: 'd', dispose: async () => {} }, - runtimeArchive: async () => '/tmp/archive.tar.gz' -} -const context = { - conn, - host, - remoteRelayDir: '/home/u/.orca-remote/relay-0.1.0+feedfacecafe', - plan, - targetId: 'target-1' -} - -beforeEach(() => { - vi.mocked(execCommand).mockReset() - vi.mocked(ensureRemoteOrcadNodeRuntime).mockReset().mockResolvedValue({ - executable: '/home/u/.orca-remote/runtimes/node-test/bin/node', - transfer: 'uploaded' - }) - vi.mocked(runPinnedRuntimeSelfTest).mockReset() - resetPinnedRuntimeRefusalsForTests() - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) -}) - -describe('ensurePinnedRelayRuntime', () => { - it('trusts the verified marker on the warm path and never re-uploads', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_READY) - await ensurePinnedRelayRuntime(context, true) - expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() - }) - - it.each([ - [ - 'a library removed since the install', - 127, - 'libatomic.so.1: cannot open shared object file', - 'missing_lib' - ], - ['an exec policy that now denies it', 126, 'sh: node: Permission denied', 'noexec'] - ] as const)( - 'refuses a cached runtime that no longer runs (%s) before any launch', - async (_label, status, output, reason) => { - vi.mocked(execCommand).mockResolvedValueOnce( - `${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}\n${REMOTE_NODE_RUNTIME_EXIT_PREFIX}${status}\n${output}\n` - ) - const failure = await ensurePinnedRelayRuntime(context, true).catch((e: unknown) => e) - expect(failure).toMatchObject({ reason }) - expect(String(vi.mocked(execCommand).mock.calls[0]?.[1])).toContain('--version') - expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() - } - ) - - it('reinstalls a runtime that disappeared from under an installed relay', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) - await ensurePinnedRelayRuntime(context, true) - expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledWith( - expect.objectContaining({ slotDir: context.remoteRelayDir, target: 'linux-x64-glibc' }) - ) - }) - - it('turns a classified runtime refusal into a remembered fallback', async () => { - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSelfTestError(126, 'sh: node: Permission denied') - ) - const failure = await ensurePinnedRelayRuntime(context, false).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(PinnedRelayFallbackError) - expect(failure).toMatchObject({ reason: 'noexec' }) - - vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.31') - await expect( - planPinnedNodeRelay({ conn, host, baseVersion: '0.1.0+abc', targetId: 'target-1' }) - ).rejects.toMatchObject({ reason: 'noexec', remembered: true }) - }) - - it("steps down when NixOS's stub loader refuses the generic Linux runtime", async () => { - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSelfTestError( - 127, - 'Could not start dynamically linked executable: /home/u/.orca-remote/runtimes/node-x/bin/node\n' + - 'NixOS cannot run dynamically linked executables intended for generic\n' + - 'linux environments out of the box. For more information, see:\n' + - 'https://nix.dev/permalink/stub-ld' - ) - ) - const failure = await ensurePinnedRelayRuntime(context, false).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(PinnedRelayFallbackError) - expect(failure).toMatchObject({ reason: 'wrong_libc' }) - }) - - it('keeps an unclassified runtime failure as an error, not a step down', async () => { - const failure = new RemoteNodeRuntimeSelfTestError(1, 'something unexpected') - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce(failure) - await expect(ensurePinnedRelayRuntime(context, false)).rejects.toBe(failure) - }) -}) - -describe('verifyPinnedRelayInstall', () => { - beforeEach(() => { - vi.mocked(execCommand).mockResolvedValue(REMOTE_NODE_RUNTIME_READY) - }) - - it('confirms the runtime under the store lock once the relay ref is visible', async () => { - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) - await verifyPinnedRelayInstall(context).catch(() => {}) - expect(withRuntimeStoreLock).toHaveBeenCalledWith( - conn, - host, - '/home/u/.orca-remote/runtimes', - expect.any(Function), - undefined - ) - expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() - }) - - it('reinstalls a runtime a store GC collected before the ref existed', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) - await verifyPinnedRelayInstall(context).catch(() => {}) - expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce() - }) - - it('passes a verified runtime through', async () => { - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'passed', - report: { - ok: true, - nonce: 'n', - node: 'v24.21.0', - napi: '10', - glibcVersionRuntime: '2.31', - runtime: 'pinned-node' - } - }) - await expect(verifyPinnedRelayInstall(context)).resolves.toBeUndefined() - expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( - conn, - context.remoteRelayDir, - expect.stringMatching(/\/\.orca-remote\/runtimes\/node-[0-9a-f]{64}\/bin\/node$/), - undefined, - { host, expectPinnedVersion: true } - ) - }) - - it('self-tests rung C on the host Node without the pinned version check or a cached refusal', async () => { - vi.mocked(withRuntimeStoreLock).mockClear() - vi.mocked(execCommand).mockResolvedValue('') - const run = new RelayRuntimeLadderRun('target-1', null, true) - const hostPlan: HostNodeAddonRelayPlan = { - kind: 'host-node-addons', - target: 'linux-x64-glibc', - glibc: { major: 2, minor: 31 }, - fullVersion: '0.1.0+0123456789ab', - addons: { dir: '/tmp/a', digest: 'd', dispose: async () => {} }, - nodePath: '/opt/node18/bin/node', - hostNode: { version: { major: 18, minor: 20 }, napi: 9 } - } - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'refused', - refusal: 'libc_floor', - detail: "GLIBC_2.33' not found" - }) - await expect( - verifyPinnedRelayInstall({ ...context, plan: hostPlan, run }) - ).rejects.toMatchObject({ reason: 'libc_floor' }) - expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( - conn, - context.remoteRelayDir, - '/opt/node18/bin/node', - undefined, - { host, expectPinnedVersion: false } - ) - expect(run.selfTest).toBe('refused') - // Rung C runs no managed runtime, so there is nothing to hold under the store lock. - expect(withRuntimeStoreLock).not.toHaveBeenCalled() - // A host Node refusal says nothing about Orca's pinned Node on this host. - vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.31') - await expect( - planPinnedNodeRelay({ - conn, - host, - baseVersion: '0.1.0+abcdef012345', - targetId: 'target-1', - materializeOrcad: () => Promise.reject(new Error('stop here')) - }) - ).rejects.toMatchObject({ reason: 'artifacts_unavailable', remembered: false }) - }) - - it('falls back on a refusal', async () => { - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'refused', - refusal: 'libc_floor', - detail: "GLIBC_2.33' not found" - }) - await expect(verifyPinnedRelayInstall(context)).rejects.toMatchObject({ - reason: 'libc_floor' - }) - }) - - it('never falls back on an unverifiable self-test', async () => { - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'unverifiable', - detail: 'timed out' - }) - const failure = await verifyPinnedRelayInstall(context).catch((error: unknown) => error) - expect(failure).not.toBeInstanceOf(PinnedRelayFallbackError) - expect(String(failure)).toContain('unverifiable') - }) - - it('rethrows an unconfirmed teardown so the install lock stays held', async () => { - const lost = Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'unverifiable', - detail: 'lost', - cause: lost - }) - await expect(verifyPinnedRelayInstall(context)).rejects.toBe(lost) - }) - - it('makes the macOS spawn-helper executable before the self-test', async () => { - vi.mocked(execCommand).mockResolvedValue('') - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) - await verifyPinnedRelayInstall({ - ...context, - host: getRemoteHostPlatform('darwin-arm64'), - plan: { ...plan, target: 'darwin-arm64' } - }).catch(() => {}) - expect(execCommand).toHaveBeenCalledWith( - conn, - expect.stringContaining("node_modules/node-pty/build/Release/spawn-helper'"), - expect.anything() - ) - }) -}) - -describe('pinned relay on a Windows host', () => { - const windowsHost = getRemoteHostPlatform('win32-x64') - const windowsContext = { - ...context, - host: windowsHost, - remoteRelayDir: 'C:/Users/u/.orca-remote/relay-0.1.0+feedfacecafe', - plan: { ...plan, target: 'win32-x64' as const, glibc: null } - } - - it('checks the warm runtime through one unwrapped powershell.exe', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(`${REMOTE_NODE_RUNTIME_READY}\r\n`) - await ensurePinnedRelayRuntime(windowsContext, true) - const [, command, options] = vi.mocked(execCommand).mock.calls[0] - expect(command).toMatch(/^powershell\.exe /) - expect(options).toMatchObject({ wrapCommand: false }) - expect(decodeRemotePowerShellScript(command)).toContain('/.orca-remote/runtimes/node-') - expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() - }) - - it('steps down to host Node, and remembers it, when security software touched node.exe', async () => { - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSecurityModifiedError('node.exe changed after it ran') - ) - const failure = await ensurePinnedRelayRuntime(windowsContext, false).catch( - (error: unknown) => error - ) - expect(failure).toBeInstanceOf(PinnedRelayFallbackError) - expect(failure).toMatchObject({ reason: 'security_software' }) - await expect( - planPinnedNodeRelay({ - conn, - host: windowsHost, - baseVersion: '0.1.0+abc', - targetId: 'target-1' - }) - ).rejects.toMatchObject({ reason: 'security_software', remembered: true }) - }) - - it('classifies application control blocking node.exe as a refusal', async () => { - vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( - new RemoteNodeRuntimeSelfTestError(-1, 'This program is blocked by group policy.') - ) - await expect(ensurePinnedRelayRuntime(windowsContext, false)).rejects.toMatchObject({ - reason: 'noexec' - }) - }) - - it('confirms the runtime under the store lock, then self-tests on node.exe with no chmod step', async () => { - vi.mocked(execCommand).mockResolvedValueOnce(`${REMOTE_NODE_RUNTIME_READY}\r\n`) - vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ - verdict: 'passed', - report: { - ok: true, - nonce: 'n', - node: 'v24.21.0', - napi: '10', - glibcVersionRuntime: null, - runtime: 'pinned-node' - } - }) - await expect(verifyPinnedRelayInstall(windowsContext)).resolves.toBeUndefined() - // Windows store GC collects too, so the held-runtime check runs there as well (design D5). - expect(withRuntimeStoreLock).toHaveBeenCalledWith( - conn, - windowsHost, - 'C:/Users/u/.orca-remote/runtimes', - expect.any(Function), - undefined - ) - expect(execCommand).toHaveBeenCalledOnce() - const [, command, options] = vi.mocked(execCommand).mock.calls[0] - expect(command).toMatch(/^powershell\.exe /) - expect(options).toMatchObject({ wrapCommand: false }) - expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( - conn, - windowsContext.remoteRelayDir, - expect.stringMatching( - /^C:\/Users\/u\/\.orca-remote\/runtimes\/node-[0-9a-f]{64}\/node\.exe$/ - ), - undefined, - { host: windowsHost, expectPinnedVersion: true } - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-pinned-node-install.ts b/src/main/ssh/ssh-relay-pinned-node-install.ts deleted file mode 100644 index bc0c400218a6..000000000000 --- a/src/main/ssh/ssh-relay-pinned-node-install.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** Host-side steps of the pinned-Node relay install: the runtime store and the pre-launch self-test. */ -import { orcadNodePtyNativeArtifacts } from '../../shared/orcad-artifacts' -import { - ensureRemoteOrcadNodeRuntime, - remoteNodeRuntimeDir, - remoteNodeRuntimePresentCommand, - RemoteNodeRuntimeSecurityModifiedError, - RemoteNodeRuntimeSelfTestError, - REMOTE_NODE_RUNTIME_READY -} from './orcad-remote-node-runtime' -import { - assertRemoteNodeRuntimePromoted, - REMOTE_NODE_RUNTIME_SELFTEST_FAILED -} from './orcad-remote-node-runtime-report' -import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' -import type { SshConnection } from './ssh-connection' -import { shellEscape } from './ssh-connection-utils' -import { execCommand } from './ssh-relay-deploy-helpers' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { RelayHostAnsweredError } from './ssh-relay-host-answered-failure' -import { - isPinnedRuntimeRefusal, - PinnedRelayFallbackError, - pinnedRelayNodePath, - type PinnedRelayPlan -} from './ssh-relay-pinned-node' -import { recordPinnedRuntimeRefusal } from './ssh-relay-pinned-refusal-cache' -import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' -import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' -import { - classifyPinnedRuntimeFailure, - runPinnedRuntimeSelfTest -} from './ssh-relay-runtime-self-test' -import { - isWindowsRemoteHost, - joinRemotePath, - remoteDirname, - type RemoteHostPlatform -} from './ssh-remote-platform' - -type PinnedInstallContext = { - conn: SshConnection - host: RemoteHostPlatform - remoteRelayDir: string - plan: PrebuiltRelayPlan - targetId: string - signal?: AbortSignal - run?: RelayRuntimeLadderRun -} - -function refuse(context: PinnedInstallContext, error: PinnedRelayFallbackError): never { - // Only Orca's pinned Node is cached as refused; a host Node refusal names nothing reusable. - if (context.plan.kind === 'pinned-node' && isPinnedRuntimeRefusal(error.reason)) { - recordPinnedRuntimeRefusal(context.targetId, context.plan.target, error.reason) - } - throw error -} - -export function prebuiltRelayNodePath(context: { - host: RemoteHostPlatform - remoteRelayDir: string - plan: PrebuiltRelayPlan -}): string { - return context.plan.kind === 'pinned-node' - ? pinnedRelayNodePath(context.host, context.remoteRelayDir, context.plan.target) - : context.plan.nodePath -} - -/** - * The warm path checks the verified marker and runs `--version` in the same command; hashing - * ~120 MiB on every reconnect would spend the warm-reconnect budget (design D10 G2.6). A cold - * install verifies in full. - */ -export async function ensurePinnedRelayRuntime( - context: PinnedInstallContext & { plan: PinnedRelayPlan }, - relayAlreadyInstalled: boolean -): Promise { - const { conn, host, remoteRelayDir, plan, signal, run } = context - try { - if (relayAlreadyInstalled) { - const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) - // Why run it here: exec policy or a library can change after the install was verified. - const present = await execCommand( - conn, - remoteNodeRuntimePresentCommand(host, runtimeDir, true), - { signal, wrapCommand: !isWindowsRemoteHost(host) } - ) - if (present.trim() === REMOTE_NODE_RUNTIME_READY) { - if (run && run.runtimeTransfer === 'none') { - run.runtimeTransfer = 'cached' - } - return - } - if (present.includes(REMOTE_NODE_RUNTIME_SELFTEST_FAILED)) { - assertRemoteNodeRuntimePromoted(present) - } - } - const { transfer } = await ensureRemoteOrcadNodeRuntime({ - conn, - host, - slotDir: remoteRelayDir, - target: plan.target, - archivePath: plan.runtimeArchive, - signal - }) - if (run && run.runtimeTransfer !== 'uploaded') { - run.runtimeTransfer = transfer - } - } catch (error) { - if (error instanceof PinnedRelayFallbackError) { - refuse(context, error) - } - if (error instanceof RemoteNodeRuntimeSecurityModifiedError) { - refuse(context, new PinnedRelayFallbackError('security_software', error.detail)) - } - if (error instanceof RemoteNodeRuntimeSelfTestError) { - const refusal = classifyPinnedRuntimeFailure(error.exitStatus, error.output) - if (refusal) { - refuse(context, new PinnedRelayFallbackError(refusal, error.message)) - } - } - throw error - } -} - -/** - * The runtime was ensured before the relay dir carried its ref, so a store GC in between could - * have collected it. Now that the ref is visible, a check under the store lock is final: GC only - * deletes while holding that lock, and it never deletes a referenced runtime. - */ -async function confirmPinnedRuntimeHeld( - context: PinnedInstallContext & { plan: PinnedRelayPlan } -): Promise { - const { conn, host, remoteRelayDir, plan, signal } = context - const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) - const present = await withRuntimeStoreLock( - conn, - host, - remoteDirname(runtimeDir, host), - () => - execCommand(conn, remoteNodeRuntimePresentCommand(host, runtimeDir), { - signal, - wrapCommand: !isWindowsRemoteHost(host) - }), - signal - ) - if (present.trim() !== REMOTE_NODE_RUNTIME_READY) { - console.warn( - `[ssh-relay] Pinned Node runtime vanished before launch; reinstalling ${runtimeDir}` - ) - await ensurePinnedRelayRuntime(context, false) - } -} - -/** Runs after the payload is promoted and before `.install-complete`, so a refused dir never completes. */ -export async function verifyPinnedRelayInstall(context: PinnedInstallContext): Promise { - const { conn, host, remoteRelayDir, plan, signal } = context - // Rung C runs no store runtime, so it has nothing store GC can take. - if (plan.kind === 'pinned-node') { - await confirmPinnedRuntimeHeld({ ...context, plan }) - } - const spawnHelpers = orcadNodePtyNativeArtifacts(plan.target).filter((artifact) => - artifact.endsWith('/spawn-helper') - ) - if (spawnHelpers.length > 0) { - // SFTP drops executable modes; node-pty posix_spawns this helper on macOS. - await execCommand( - conn, - `chmod 755 ${spawnHelpers - .map((artifact) => - shellEscape(joinRemotePath(host, remoteRelayDir, ...artifact.split('/'))) - ) - .join(' ')}`, - { signal } - ) - } - const nodePath = prebuiltRelayNodePath(context) - const verdict = await runPinnedRuntimeSelfTest(conn, remoteRelayDir, nodePath, signal, { - host, - expectPinnedVersion: plan.kind === 'pinned-node' - }) - if (context.run) { - context.run.selfTest = verdict.verdict - } - switch (verdict.verdict) { - case 'passed': - console.log( - `[ssh-relay] ${plan.kind === 'pinned-node' ? 'Pinned' : 'Host'} Node self-test passed at ${remoteRelayDir} (${verdict.report.node}, glibc ${verdict.report.glibcVersionRuntime ?? 'n/a'})` - ) - return - case 'refused': - refuse(context, new PinnedRelayFallbackError(verdict.refusal, verdict.detail)) - break - case 'unverifiable': - // Why rethrow the cause: an unconfirmed teardown must keep the install lock held. - if (isUnconfirmedSshCommandTermination(verdict.cause)) { - throw verdict.cause - } - throw new Error( - `The relay runtime self-test at ${remoteRelayDir} is unverifiable; retrying on the next connect: ${verdict.detail}` - ) - case 'failed': - throw new RelayHostAnsweredError( - `The relay runtime self-test at ${remoteRelayDir} failed: ${verdict.detail}` - ) - } -} diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts deleted file mode 100644 index 7e2387e0850d..000000000000 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { - makeExecResponses, - makeStagedFirstInstallExecPrefix, - makeMockConnection, - type ExecResponse, - type SftpWriteCapture -} from './ssh-relay-native-deps-install-fixture' -import { RELAY_NATIVE_CACHE_LINKED } from './ssh-relay-native-deps-cache-commands' -import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' -import { - computeRelayNativeDepsCacheKey, - RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN -} from './ssh-relay-native-deps-cache' - -const PATCH_ASSET = 'node-pty-1.1.0-master-cloexec-patch.cjs' - -/** - * The relay installs stock node-pty from npm, so the app's pnpm patch never reaches it and the - * relay leaks a pty fd per terminal (#17915) -- the master into every later child on Linux, an - * orphaned /dev/ptmx throwaway in `pty_posix_spawn` on macOS. The compile that closes both sits on - * the connect path, so what these specs pin is the blast radius, not the patch itself. - */ -describe('relay pty fd-leak patch on the install path', () => { - const sftpCapture: SftpWriteCapture = { - paths: [], - contents: {}, - execCallCountAtWrite: {} - } - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - sftpCapture.paths.length = 0 - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - }) - - function feed(execResponses: ExecResponse[]): void { - const mockExec = vi.mocked(execCommand) - for (const response of execResponses) { - if (typeof response === 'string') { - mockExec.mockResolvedValueOnce(response) - } else { - mockExec.mockRejectedValueOnce(new Error(response.reject)) - } - } - } - - function firstInstall(cacheAnswer: string, tail: ExecResponse[]): ExecResponse[] { - const prefix = makeStagedFirstInstallExecPrefix() - // The prefix's last slot is the shared native-deps cache probe. - prefix[prefix.length - 1] = cacheAnswer - return [...prefix, ...tail] - } - - function patchCommands(): string[] { - return vi - .mocked(execCommand) - .mock.calls.map(([, command]) => command) - .filter((command) => command.includes(PATCH_ASSET)) - } - - /** Whether this deploy elected itself publisher of the shared entry. */ - function promoted(): boolean { - return vi - .mocked(execCommand) - .mock.calls.some(([, command]) => command.includes('mkdir "$cache"')) - } - - /** - * A cache-miss first install whose patch reports `status`. The promote slot is fed either way, - * so a run that wrongly promotes reads a valid response rather than falling off the end -- the - * assertion has to be the absence of the command itself, not a downstream crash. - */ - function firstInstallReporting(status: string): ExecResponse[] { - return [ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - `ORCA-NPTY-CLOEXEC:${status}\n`, - '', // promote into the shared native-deps cache, if this deploy still gets that far - '', // clean stage root - 'DEAD', - 'READY' - ] - } - - it('runs the patch on a Linux relay once node-pty is proven loadable', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toHaveLength(1) - expect(patchCommands()[0]).toContain("'/usr/bin/node'") - }) - - it('patches the private tree before it is published to the shared native-deps cache', async () => { - // Promotion moves `node_modules` into `~/.orca-remote/native/` and leaves a symlink - // behind, and a published entry is immutable by contract. Patching afterwards would rename, - // rebuild and roll back inside a tree every other relay on the host links -- and the - // `.deps-complete` written by promotion would have published an unpatched tree that every - // later host links and skips. The ordering is invisible in review, so pin it. - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) - const patchAt = commands.findIndex((command) => command.includes(PATCH_ASSET)) - const promoteAt = commands.findIndex((command) => command.includes('mkdir "$cache"')) - expect(patchAt).toBeGreaterThan(-1) - expect(promoteAt).toBeGreaterThan(-1) - expect(patchAt).toBeLessThan(promoteAt) - }) - - it('does not publish a tree whose patch failed and rolled back', async () => { - // The script rolls `pty.cc` and `build/Release` back to the pre-patch, still-leaky build and - // reports `failed:` with exit 0, so nothing throws. Publishing that tree would be worse than - // the leak this PR closes: the key hashes the patch's bytes, so every later host on the - // machine links the entry, probes it loadable, and skips patching. Stay private instead. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const conn = makeMockConnection(sftpCapture) - feed(firstInstallReporting('failed:npm rebuild node-pty failed: gyp ERR! not found: make')) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toHaveLength(1) - expect(promoted()).toBe(false) - expect(warn.mock.calls.map((args) => String(args[0] ?? '')).join('\n')).toContain( - '[ssh-relay][NPTY-CLOEXEC-UNSHARED]' - ) - } finally { - warn.mockRestore() - } - }) - - it('does not publish a tree the patch refused to touch', async () => { - // `skipped:` is not one verdict. Every form except `skipped:unsupported-platform` means the - // patch was declined and the leaky build is still on disk, which is indistinguishable from - // `failed:` as far as what would get published. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const conn = makeMockConnection(sftpCapture) - feed(firstInstallReporting('skipped:earlier-attempt-failed')) - - await deployAndLaunchRelay(conn) - - expect(promoted()).toBe(false) - // A refusal exits 0, so the warn is the only signal that this host stayed leaky. - expect(warn.mock.calls.map((args) => String(args[0] ?? '')).join('\n')).toContain( - '[ssh-relay][NPTY-CLOEXEC-UNFIXED]' - ) - } finally { - warn.mockRestore() - } - }) - - it('still publishes a tree that was patched but whose isolation check could not run', async () => { - // `patched-unverified` rebuilt from patched source; only the check that watches a later child - // could not observe the result. An unobservable check is not a failed patch, and refusing to - // publish here would disable the shared cache on every host without `lsof`. - const conn = makeMockConnection(sftpCapture) - feed(firstInstallReporting('patched-unverified')) - - await deployAndLaunchRelay(conn) - - expect(promoted()).toBe(true) - }) - - it('never patches through a symlink into an entry another relay already published', async () => { - // A linked entry was built under a key that hashes this patch's bytes, so it is already - // patched; re-running the patch would rebuild inside the shared tree. - const conn = makeMockConnection(sftpCapture) - feed( - firstInstall(RELAY_NATIVE_CACHE_LINKED, [ - '', // chmod prebuilds, through the symlink - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - '', // clean stage root - 'DEAD', - 'READY' - ]) - ) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toEqual([]) - }) - - it('leaves an unloadable node-pty alone rather than rebuilding it blind', async () => { - // A relay that could not build node-pty has nothing to fall back to, and the existing - // reinstall path owns that repair. - const conn = makeMockConnection(sftpCapture) - feed( - makeExecResponses({ - npmInstall: 'ok', - probe: 'missing', - repairProbe: 'missing' - }) - ) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toEqual([]) - }) - - it('runs the patch on a macOS relay, which orphans a /dev/ptmx fd per spawn', async () => { - // macOS takes `pty_posix_spawn`, not forkpty, so the asset's original replacements rewrote - // nothing macOS executes -- and this gate answered 'fixed' without running anything, which is - // exactly what publishes to the shared cache. Every later host on the machine then linked a - // tree that leaks one /dev/ptmx fd per terminal, measured +1 per open/close cycle on - // darwin-arm64. macOS pays a first compile here, unlike Linux's second, and that is the price. - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toHaveLength(1) - expect(promoted()).toBe(true) - }) - - it('does not publish a macOS tree whose compile failed', async () => { - // The darwin rollback restores the shipped prebuild, so the relay still works -- and that is - // precisely why the status, not the exit code, has to decide publishability: a rolled-back - // macOS tree probes loadable and still leaks. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') - const conn = makeMockConnection(sftpCapture) - feed(firstInstallReporting('failed:npm rebuild node-pty exited 1: gyp ERR! not ok')) - - await deployAndLaunchRelay(conn) - - expect(patchCommands()).toHaveLength(1) - expect(promoted()).toBe(false) - } finally { - warn.mockRestore() - } - }) - - it('connects anyway when the patch command fails outright', async () => { - const conn = makeMockConnection(sftpCapture) - const responses = makeExecResponses({ npmInstall: 'ok', probe: 'ok' }) - const patchSlot = responses.findIndex( - (response) => typeof response === 'string' && response.includes('ORCA-NPTY-CLOEXEC:') - ) - expect(patchSlot).toBeGreaterThan(-1) - responses[patchSlot] = { reject: 'no such file or directory' } - feed(responses) - - await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() - }) -}) - -describe('the shipped patch is part of the shared native-deps cache key', () => { - it('mints a new entry, so a pre-fix unpatched tree is never linked by a patched build', () => { - const artifact = RELAY_ARTIFACTS.find((entry) => entry.filename === PATCH_ASSET) - expect(artifact).toBeDefined() - // A windowsOnly artifact never reaches a Linux relay dir, so it would drop out of the key. - expect(artifact?.windowsOnly).toBeFalsy() - expect(RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(PATCH_ASSET)).toBe(true) - - const deps = { 'node-pty': '1.1.0' } - expect( - computeRelayNativeDepsCacheKey({ - platform: 'linux-x64', - deps, - patchSources: [{ filename: PATCH_ASSET, contents: 'patch bytes' }] - }) - ).not.toBe(computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps })) - }) -}) diff --git a/src/main/ssh/ssh-relay-repair-lock-termination.test.ts b/src/main/ssh/ssh-relay-repair-lock-termination.test.ts deleted file mode 100644 index db73b92e2393..000000000000 --- a/src/main/ssh/ssh-relay-repair-lock-termination.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) - -import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked execCommand never reads the connection. -const conn = {} as SshConnection -const mockExec = vi.mocked(execCommand) -const freshLockReplies = ['OPEN', 'LOCKED', '0'] -const contentionReplies = ['OPEN', '', 'BUSY', 'BUSY'] - -const failureStages = [ - { stage: 'initial claim probe', replies: [], recovery: [], result: 'error' }, - { stage: 'parent creation', replies: ['OPEN'], recovery: freshLockReplies, result: 'busy' }, - { stage: 'lock creation', replies: ['OPEN', ''], recovery: freshLockReplies, result: 'busy' }, - { - stage: 'stale takeover', - replies: ['OPEN', '', 'BUSY'], - recovery: freshLockReplies, - result: 'busy' - }, - { - stage: 'claim probe after creation', - replies: ['OPEN', '', 'OK'], - recovery: [''], - result: 'error' - }, - { - stage: 'release after creation', - replies: ['OPEN', '', 'OK', 'LOCKED'], - recovery: [], - result: 'gc' - }, - { - stage: 'claim probe after takeover', - replies: ['OPEN', '', 'BUSY', 'OK'], - recovery: [''], - result: 'error' - }, - { - stage: 'release after takeover', - replies: ['OPEN', '', 'BUSY', 'OK', 'LOCKED'], - recovery: [], - result: 'gc' - }, - { stage: 'contention claim probe', replies: contentionReplies, recovery: [], result: 'error' }, - { - stage: 'contention lock probe', - replies: [...contentionReplies, 'OPEN'], - recovery: [], - result: 'error' - }, - { - stage: 'contention lock age probe', - replies: [...contentionReplies, 'OPEN', 'LOCKED'], - recovery: [], - result: 'error' - } -] - -beforeEach(() => { - mockExec.mockReset() - mockExec.mockResolvedValue('OPEN') - vi.spyOn(console, 'warn').mockImplementation(() => {}) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe.each([ - { platform: 'linux-x64' as const, remoteDir: '/relay/version' }, - { platform: 'win32-x64' as const, remoteDir: 'C:/relay/version' } -])('repair lock termination on $platform', ({ platform, remoteDir }) => { - const host = getRemoteHostPlatform(platform) - - describe.each([false, true])('with caller aborted = %s', (aborted) => { - it.each(failureStages)('stops after unconfirmed $stage', async ({ replies }) => { - const controller = new AbortController() - const error = Object.assign(new Error('SSH teardown not confirmed'), { - sshChannelCloseConfirmed: false - }) - for (const reply of replies) { - mockExec.mockResolvedValueOnce(reply) - } - mockExec.mockImplementationOnce(async () => { - if (aborted) { - controller.abort(new Error('deploy aborted')) - } - throw error - }) - - await expect( - tryAcquireRelayRepairLock(conn, remoteDir, host, { signal: controller.signal }) - ).rejects.toBe(error) - expect(mockExec).toHaveBeenCalledTimes(replies.length + 1) - }) - }) - - it.each(failureStages)( - 'keeps recovery after confirmed $stage failure', - async ({ replies, recovery, result }) => { - for (const reply of replies) { - mockExec.mockResolvedValueOnce(reply) - } - mockExec.mockRejectedValueOnce( - Object.assign(new Error('SSH command failed after closing'), { - sshChannelCloseConfirmed: true - }) - ) - for (const reply of recovery) { - mockExec.mockResolvedValueOnce(reply) - } - - await expect(tryAcquireRelayRepairLock(conn, remoteDir, host)).resolves.toBe(result) - expect(mockExec).toHaveBeenCalledTimes(replies.length + recovery.length + 1) - } - ) - - it('stops when a contention probe after a confirmed acquisition failure is unconfirmed', async () => { - const error = Object.assign(new Error('SSH teardown not confirmed'), { - sshChannelCloseConfirmed: false - }) - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockRejectedValueOnce(new Error('lock creation failed')) - .mockRejectedValueOnce(error) - - await expect(tryAcquireRelayRepairLock(conn, remoteDir, host)).rejects.toBe(error) - expect(mockExec).toHaveBeenCalledTimes(4) - }) -}) diff --git a/src/main/ssh/ssh-relay-repair-lock.ts b/src/main/ssh/ssh-relay-repair-lock.ts deleted file mode 100644 index 9f918a962f0f..000000000000 --- a/src/main/ssh/ssh-relay-repair-lock.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { SshConnection } from './ssh-connection' -import { execHostCommand } from './ssh-relay-host-exec' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { - acquireInstallLockParentCommand, - lockAgeSecondsCommand, - probeInstallLockExistsCommand, - tryCreateInstallLockCommand, - tryStealInstallLockCommand -} from './ssh-relay-install-lock-commands' -import { isRelayGcClaimed } from './ssh-relay-gc-claim' -import { - getRemoteHostPlatform, - joinRemotePath, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { INSTALL_LOCK_STALE_SECONDS, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { removeRemoteTreeCommand } from './ssh-remote-commands' - -const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') - -export type RelayRepairLockResult = 'acquired' | 'busy' | 'gc' | 'error' - -/** - * Try once to acquire the install lock for best-effort repair work. - * - * Why: a completed relay can launch in degraded mode, so repair must not wait - * behind another installer. An already-stale lock is still recovered now. - */ -export async function tryAcquireRelayRepairLock( - conn: SshConnection, - remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { signal?: AbortSignal } -): Promise { - const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) - try { - const gcClaimedBeforeAcquire = await isRelayGcClaimed( - conn, - remoteRelayDir, - host, - options?.signal - ).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return undefined - }) - options?.signal?.throwIfAborted() - if (gcClaimedBeforeAcquire === true) { - return 'gc' - } - if (gcClaimedBeforeAcquire !== false) { - return 'error' - } - await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir), { - signal: options?.signal - }) - const firstAttempt = await execHostCommand( - conn, - host, - tryCreateInstallLockCommand(host, lockDir), - { signal: options?.signal } - ) - if (firstAttempt.trim().endsWith('OK')) { - return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) - } - const steal = await execHostCommand( - conn, - host, - tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), - { signal: options?.signal } - ) - if (steal.trim().endsWith('OK')) { - console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) - return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) - } - return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - options?.signal?.throwIfAborted() - return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) - } -} - -async function classifyRepairLockContention( - conn: SshConnection, - remoteRelayDir: string, - lockDir: string, - host: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return undefined - }) - signal?.throwIfAborted() - if (gcClaimed === true) { - return 'gc' - } - if (gcClaimed !== false) { - return 'error' - } - - const lockProbe = await execHostCommand( - conn, - host, - probeInstallLockExistsCommand(host, lockDir), - { signal } - ).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) - signal?.throwIfAborted() - if (lockProbe.trim() !== 'LOCKED') { - return 'error' - } - const ageOutput = await execHostCommand(conn, host, lockAgeSecondsCommand(host, lockDir), { - signal - }).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) - signal?.throwIfAborted() - const ageSeconds = Number.parseInt(ageOutput.trim(), 10) - // Why: GC may remove stale locks, so only a positively observed fresh lock - // proves that another launch/repair owner is fencing this directory. - return Number.isFinite(ageSeconds) && ageSeconds >= 0 && ageSeconds <= INSTALL_LOCK_STALE_SECONDS - ? 'busy' - : 'error' -} - -async function finishRepairLockAcquire( - conn: SshConnection, - remoteRelayDir: string, - lockDir: string, - host: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return undefined - }) - if (gcClaimed === false && !signal?.aborted) { - return 'acquired' - } - // Why: GC may win its stable sibling claim while this command creates the - // in-tree lock. Back out before npm can mutate a directory being renamed. - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - }) - signal?.throwIfAborted() - return gcClaimed ? 'gc' : 'error' -} diff --git a/src/main/ssh/ssh-relay-reset.test.ts b/src/main/ssh/ssh-relay-reset.test.ts deleted file mode 100644 index 47364fe5ed9a..000000000000 --- a/src/main/ssh/ssh-relay-reset.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { createServer, type Server } from 'node:net' -import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - -import { forceStopRelayForTarget } from './ssh-relay-reset' -import { execCommand } from './ssh-relay-deploy-helpers' -import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' -import type { SshConnection } from './ssh-connection' - -const TARGET_PID = '11111' -const UNRELATED_PID = '22222' - -type LsofMode = 'match' | 'empty' - -function writeExecutable(filePath: string, body: string): void { - writeFileSync(filePath, `#!/bin/sh\n${body}\n`, { mode: 0o755 }) -} - -async function listenOnSocket(server: Server, socketPath: string): Promise { - await new Promise((resolve, reject) => { - const onError = (error: Error): void => reject(error) - server.once('error', onError) - server.listen(socketPath, () => { - server.off('error', onError) - resolve() - }) - }) -} - -async function closeServer(server: Server): Promise { - if (!server.listening) { - return - } - await new Promise((resolve) => server.close(() => resolve())) -} - -async function capturedResetScript(): Promise { - const conn = {} as SshConnection - await forceStopRelayForTarget(conn, 'ssh-1') - return vi.mocked(execCommand).mock.lastCall?.[1] ?? '' -} - -async function runResetScript(lsofMode: LsofMode): Promise<{ - killCalls: string[] - pgrepCalls: string[] - socketExists: boolean -}> { - const script = await capturedResetScript() - const home = mkdtempSync(join(tmpdir(), 'orca-')) - const binDir = join(home, 'bin') - const socketDir = join(home, '.orca-remote') - const socketPath = join(socketDir, relaySocketNameForInstanceId('ssh-1')) - const killLog = join(home, 'kill.log') - const pgrepLog = join(home, 'pgrep.log') - mkdirSync(binDir) - mkdirSync(socketDir, { recursive: true }) - writeFileSync(killLog, '') - writeFileSync(pgrepLog, '') - - const lsofBody = - lsofMode === 'empty' - ? 'exit 1' - : `case " $* " in - *" -a "*) printf '%s\\n' "$TARGET_PID" ;; - *) printf '%s\\n' "$TARGET_PID" "$UNRELATED_PID" ;; -esac` - writeExecutable(join(binDir, 'lsof'), lsofBody) - writeExecutable( - join(binDir, 'pgrep'), - `printf 'called\\n' >> "$PGREP_LOG" -printf '%s\\n' "$TARGET_PID"` - ) - writeExecutable(join(binDir, 'sleep'), 'exit 0') - - const server = createServer() - try { - await listenOnSocket(server, socketPath) - execFileSync( - '/bin/sh', - [ - '-c', - `kill() { - printf '%s\\n' "$*" >> "$KILL_LOG" -} -eval "$RESET_SCRIPT"` - ], - { - env: { - ...process.env, - HOME: home, - KILL_LOG: killLog, - PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, - PGREP_LOG: pgrepLog, - RESET_SCRIPT: script, - TARGET_PID, - UNRELATED_PID - } - } - ) - return { - killCalls: readFileSync(killLog, 'utf8').split('\n').filter(Boolean), - pgrepCalls: readFileSync(pgrepLog, 'utf8').split('\n').filter(Boolean), - socketExists: existsSync(socketPath) - } - } finally { - await closeServer(server) - rmSync(home, { recursive: true, force: true }) - } -} - -describe('forceStopRelayForTarget', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('targets only the relay socket for the requested SSH target', async () => { - const conn = {} as SshConnection - - await forceStopRelayForTarget(conn, 'ssh-1') - - const command = vi.mocked(execCommand).mock.calls[0]?.[1] ?? '' - expect(execCommand).toHaveBeenCalledWith(conn, expect.any(String)) - expect(command).toContain(`sock_name='${relaySocketNameForInstanceId('ssh-1')}'`) - expect(command).toContain('lsof -t -a -U "$sock"') - expect(command).toContain('pgrep -f "$sock_name"') - expect(command).toContain('rm -f "$sock"') - }) - - it.skipIf(process.platform === 'win32')( - 'never passes unrelated unix-socket holders to kill', - async () => { - const result = await runResetScript('match') - - expect(result.killCalls).toEqual([`-TERM ${TARGET_PID}`, `-KILL ${TARGET_PID}`]) - expect(result.killCalls.join(' ')).not.toContain(UNRELATED_PID) - expect(result.pgrepCalls).toEqual([]) - expect(result.socketExists).toBe(false) - } - ) - - it.skipIf(process.platform === 'win32')( - 'uses the command-line fallback when lsof cannot match the socket', - async () => { - const result = await runResetScript('empty') - - expect(result.killCalls).toEqual([`-TERM ${TARGET_PID}`, `-KILL ${TARGET_PID}`]) - expect(result.pgrepCalls).toEqual(['called']) - expect(result.socketExists).toBe(false) - } - ) -}) diff --git a/src/main/ssh/ssh-relay-reset.ts b/src/main/ssh/ssh-relay-reset.ts deleted file mode 100644 index 3d585b525f2e..000000000000 --- a/src/main/ssh/ssh-relay-reset.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { SshConnection } from './ssh-connection' -import { shellEscape } from './ssh-connection-utils' -import { execCommand } from './ssh-relay-deploy-helpers' -import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' -import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit' - -export async function forceStopRelayForTarget( - conn: SshConnection, - relayInstanceId: string -): Promise { - const sockName = relaySocketNameForInstanceId(relayInstanceId) - const escapedSockName = shellEscape(sockName) - const script = [ - `sock_name=${escapedSockName}`, - 'base="${HOME}/.orca-remote"', - // Why: a long $HOME moves the socket to the sun_path-safe short base (#10726); reset must reach it there too. - `short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`, - 'if [ -d "$base" ] || [ -d "$short_base" ]; then', - ' for sock in "$base"/relay-*/"$sock_name" "$base"/"$sock_name" "$short_base"/relay-*/"$sock_name"; do', - ' [ -S "$sock" ] || continue', - ' pid=""', - // Why: lsof ORs selectors by default; -a prevents reset from targeting - // every Unix-socket holder instead of only the per-relay socket (#8762). - ' if command -v lsof >/dev/null 2>&1; then', - ' pid=$(lsof -t -a -U "$sock" 2>/dev/null | tr "\\n" " ")', - ' fi', - ' if [ -z "$pid" ] && command -v pgrep >/dev/null 2>&1; then', - ' pid=$(pgrep -f "$sock_name" 2>/dev/null | ' + - 'awk -v self="$$" -v parent="$PPID" \'$1 != self && $1 != parent\' | tr "\\n" " ")', - ' fi', - ' if [ -n "$pid" ]; then', - ' kill -TERM $pid 2>/dev/null || true', - ' sleep 0.2', - ' kill -KILL $pid 2>/dev/null || true', - ' fi', - ' rm -f "$sock"', - ' done', - 'fi' - ].join('\n') - - await execCommand(conn, script) -} diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts deleted file mode 100644 index 9e0358fa4ca2..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { shellEscape } from './ssh-connection-utils' -import { - REMOTE_RIPGREP_CACHE_DIR_NAME, - REMOTE_RIPGREP_REF_PREFIX -} from './ssh-relay-ripgrep-install' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -export const LIST_OK = '__ORCA_RG_CACHE__LIST_OK' -export const REFS_OK = '__ORCA_RG_CACHE__REFS_OK' -export const REFS_ERR = '__ORCA_RG_CACHE__REFS_ERR' -export const TOMBSTONE_PREFIX = '.rg-gc-' -export const MAX_LISTING_ENTRIES = 64 - -export function cacheDir(host: RemoteHostPlatform, remoteHome: string): string { - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, REMOTE_RIPGREP_CACHE_DIR_NAME) -} - -export function listEntriesCommand(host: RemoteHostPlatform, remoteHome: string): string { - if (isWindowsRemoteHost(host)) { - const dir = powerShellLiteral(cacheDir(host, remoteHome)) - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - `if (-not (Test-Path -LiteralPath ${dir})) { '${LIST_OK}'; exit 0 }`, - - // Why the ENTRY prefix and not bare names: PowerShell writes every uncaptured value to - // stdout, so the token is what separates this listing from anything else a cmdlet emits. - `Get-ChildItem -LiteralPath ${dir} -Directory -Force -ErrorAction Stop | ForEach-Object { 'ENTRY ' + $_.Name }`, - `'${LIST_OK}'` - ].join('\n') - ) - } - const dir = shellEscape(cacheDir(host, remoteHome)) - return [ - `d=${dir}`, - `[ -d "$d" ] || { printf '%s\\n' ${LIST_OK}; exit 0; }`, - `for e in "$d"/* "$d"/${TOMBSTONE_PREFIX}*; do`, - ' [ -d "$e" ] || continue', - ` printf 'ENTRY %s\\n' "$(basename "$e")"`, - 'done', - `printf '%s\\n' ${LIST_OK}` - ].join('\n') -} - -/** - * Every relay directory's recorded ripgrep entry. - * - * A relay directory with no readable marker answers `REFS_ERR`: it may be an older Orca's relay, - * running right now against a binary it never recorded. - */ -export function listReferencesCommand(host: RemoteHostPlatform, remoteHome: string): string { - if (isWindowsRemoteHost(host)) { - const root = powerShellLiteral(joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)) - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - `if (-not (Test-Path -LiteralPath ${root})) { '${REFS_OK}'; exit 0 }`, - `$dirs = @(Get-ChildItem -LiteralPath ${root} -Directory -Force -Filter 'relay-*' -ErrorAction Stop)`, - `if ($dirs.Count -ge ${MAX_LISTING_ENTRIES}) { '${REFS_ERR}'; exit 0 }`, - 'foreach ($d in $dirs) {', - // Why PSIsContainer and not `-File`: the remote-payload invariant test greps for that switch, - // because `powershell.exe -File` is execution-policy gated on the host. This is - // Get-ChildItem's unrelated filter, but the guard is worth more than the shorter spelling. - ` $refs = @(Get-ChildItem -LiteralPath $d.FullName -Force -Filter '${REMOTE_RIPGREP_REF_PREFIX}*' -ErrorAction Stop | Where-Object { -not $_.PSIsContainer })`, - `$legacy = Join-Path $d.FullName '.ripgrep-ref'`, - `if (Test-Path -LiteralPath $legacy -PathType Leaf) { 'REF ' + (Get-Content -LiteralPath $legacy -Raw -ErrorAction Stop).Trim() } elseif ($refs.Count -eq 0) { '${REFS_ERR}'; exit 0 }`, - ` foreach ($r in $refs) { 'REF ' + $r.Name.Substring(${REMOTE_RIPGREP_REF_PREFIX.length}) }`, - '}', - `'${REFS_OK}'` - ].join('\n') - ) - } - const root = shellEscape(joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)) - return [ - `root=${root}`, - `[ -d "$root" ] || { printf '%s\\n' ${REFS_OK}; exit 0; }`, - `[ -r "$root" ] && [ -x "$root" ] && ls -A "$root" >/dev/null 2>&1 || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, - 'n=0', - 'for d in "$root"/relay-*; do', - ' [ -d "$d" ] || continue', - ` [ -r "$d" ] && [ -x "$d" ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, - ' found=0', - ` if [ -e "$d/.ripgrep-ref" ]; then t=$(cat "$d/.ripgrep-ref") || { printf '%s\\n' ${REFS_ERR}; exit 0; }; printf 'REF %s\\n' "$t"; found=1; fi`, - ` for f in "$d"/${REMOTE_RIPGREP_REF_PREFIX}*; do`, - ' [ -f "$f" ] || continue', - ' found=1', - // Why the filename and not the contents: the name is what this client minted, and reading it - // costs nothing extra. The entry is re-validated against ENTRY_NAME before any deletion. - ` printf 'REF %s\\n' "\${f##*/${REMOTE_RIPGREP_REF_PREFIX}}"`, - ' done', - ` [ "$found" = 1 ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, - ' n=$((n+1))', - ` if [ "$n" -ge ${MAX_LISTING_ENTRIES} ]; then printf '%s\\n' ${REFS_ERR}; exit 0; fi`, - 'done', - `printf '%s\\n' ${REFS_OK}` - ].join('\n') -} diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts deleted file mode 100644 index f29a9c52638d..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - renameSync, - rmSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execMock })) -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' -import { recordRemoteRipgrepReference } from './ssh-relay-ripgrep-install' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' - -const platform = process.platform === 'win32' ? 'win32-x64' : 'linux-x64' -const host = getRemoteHostPlatform(platform) -const FIRST = `1111111111111111-${platform}` -const SECOND = `2222222222222222-${platform}` -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only execCommand touches the connection, and it is replaced with a local shell. -const conn = {} as SshConnection - -function runShell(command: string): string { - return process.platform === 'win32' - ? execFileSync( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', decodeRemotePowerShellScript(command)], - { encoding: 'utf8', timeout: 15_000 } - ) - : execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8', timeout: 15_000 }) -} - -describe('ripgrep cache shell transactions', () => { - let home: string - let root: string - let cache: string - let relay: string - - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'orca rg gc ')) - root = join(home, '.orca-remote') - cache = join(root, 'ripgrep') - relay = join(root, 'relay-0.1.0+abc') - mkdirSync(relay, { recursive: true }) - for (const entry of [FIRST, SECOND]) { - mkdirSync(join(cache, entry), { recursive: true }) - writeFileSync(join(cache, entry, 'rg'), entry) - } - execMock - .mockReset() - .mockImplementation(async (_conn: unknown, command: string) => runShell(command)) - }) - - afterEach(() => { - chmodSync(root, 0o755) - rmSync(home, { recursive: true, force: true }) - }) - - it('keeps both binaries recorded in one relay directory and collects them after that directory goes', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - rmSync(relay, { recursive: true }) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache)).toEqual([]) - }) - - it('preserves a legacy reference beside newer per-entry markers', async () => { - writeFileSync(join(relay, '.ripgrep-ref'), FIRST) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - }) - - it.each(['', ' \t\r\n'])( - 'blocks collection when a legacy marker is empty or whitespace: %j', - async (contents) => { - writeFileSync(join(relay, '.ripgrep-ref'), contents) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - } - ) - - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'preserves binaries when the relay directory listing is unreadable', - async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - chmodSync(root, 0o111) - await gcRemoteRipgrepCache(conn, host, home) - expect(existsSync(join(cache, FIRST, 'rg'))).toBe(true) - expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) - } - ) - - it('keeps abandoned tombstones while their references are unknown, then collects unreferenced ones', async () => { - const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` - mkdirSync(join(cache, tombstone)) - writeFileSync(join(cache, tombstone, 'rg'), SECOND) - await gcRemoteRipgrepCache(conn, host, home) - expect(existsSync(join(cache, tombstone, 'rg'))).toBe(true) - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache)).toEqual([FIRST]) - }) - - it('restores an abandoned tombstone that a relay still references', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` - renameSync(join(cache, SECOND), join(cache, tombstone)) - - await gcRemoteRipgrepCache(conn, host, home) - - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) - }) - - it('preserves a referenced tombstone when the clock moves backward during the reference scan', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - const now = Date.now() - const tombstone = `.rg-gc-${SECOND}.123.${now - 30 * 60_000 - 1}` - renameSync(join(cache, SECOND), join(cache, tombstone)) - const clock = vi.spyOn(Date, 'now').mockReturnValue(now) - let calls = 0 - execMock.mockImplementation(async (_conn: unknown, command: string) => { - if (++calls === 2) { - clock.mockReturnValue(now - 60_000) - } - return runShell(command) - }) - try { - await gcRemoteRipgrepCache(conn, host, home) - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) - } finally { - clock.mockRestore() - } - }) - - it('lets exactly one of two passes claim the same abandoned tombstone', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` - renameSync(join(cache, SECOND), join(cache, tombstone)) - let releaseClaim!: () => void - const claimHeld = new Promise((resolve) => { - releaseClaim = resolve - }) - let heldPassReachedClaim!: () => void - const reachedClaim = new Promise((resolve) => { - heldPassReachedClaim = resolve - }) - let held = false - execMock.mockImplementation(async (_conn: unknown, command: string) => { - const script = process.platform === 'win32' ? decodeRemotePowerShellScript(command) : command - if (!held && script.includes(tombstone)) { - held = true - heldPassReachedClaim() - await claimHeld - } - return runShell(command) - }) - - // The held pass sees SECOND as unreferenced; the other pins it and restores it. - const heldPass = gcRemoteRipgrepCache(conn, host, home) - await reachedClaim - await gcRemoteRipgrepCache(conn, host, home, { pinnedEntry: SECOND }) - releaseClaim() - await heldPass - - expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) - expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) - }) - - it('does not nest a tombstone inside a directory recreated by a concurrent installer', async () => { - await recordRemoteRipgrepReference(conn, host, relay, FIRST) - let raced = false - execMock.mockImplementation(async (_conn: unknown, command: string) => { - const output = runShell(command) - const script = process.platform === 'win32' ? decodeRemotePowerShellScript(command) : command - if ( - !raced && - (script.startsWith('mv ') || script.includes('Move-Item')) && - script.includes(SECOND) - ) { - raced = true - await recordRemoteRipgrepReference(conn, host, relay, SECOND) - mkdirSync(join(cache, SECOND)) - writeFileSync(join(cache, SECOND, 'rg'), SECOND) - } - return output - }) - await gcRemoteRipgrepCache(conn, host, home) - expect(raced).toBe(true) - expect(readdirSync(join(cache, SECOND))).toEqual(['rg']) - }) -}) diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts deleted file mode 100644 index bc61c5572bab..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const { execCommandMock } = vi.hoisted(() => ({ execCommandMock: vi.fn() })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execCommandMock })) - -import type { SshConnection } from './ssh-connection' -import { BUNDLED_RIPGREP_PLATFORMS } from '../../shared/bundled-ripgrep' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' - -const LINUX = getRemoteHostPlatform('linux-x64') -const WINDOWS = getRemoteHostPlatform('win32-x64') -const CURRENT = 'c0ffee0123456789-linux-x64' -const SUPERSEDED = 'dead000000000000-linux-x64' - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the GC only issues exec commands; nothing else on the connection is reached. -const conn = {} as unknown as SshConnection - -function scripts(): string[] { - return execCommandMock.mock.calls.map(([, command]) => String(command)) -} - -// Why exclude `find`: the entry listing carries its own `rm -rf` sweep for stale tombstones, and -// counting that as a collection would make every assertion here pass for the wrong reason. -function removedTrees(): string[] { - return scripts().filter( - (s) => s.includes('rm -rf') && s.includes('.rg-gc-') && !s.includes('find ') - ) -} - -function reply(entries: readonly string[], refs: readonly string[] | 'unreadable'): void { - execCommandMock.mockReset() - execCommandMock.mockImplementation((_conn: unknown, command: string) => { - const text = String(command) - if (text.includes('ENTRY %s')) { - return Promise.resolve( - `${entries.map((e) => `ENTRY ${e}`).join('\n')}\n__ORCA_RG_CACHE__LIST_OK` - ) - } - if (text.includes('REF %s')) { - return Promise.resolve( - refs === 'unreadable' - ? '__ORCA_RG_CACHE__REFS_ERR' - : `${refs.map((r) => `REF ${r}`).join('\n')}\n__ORCA_RG_CACHE__REFS_OK` - ) - } - if (text.includes('MOVED')) { - return Promise.resolve('MOVED') - } - return Promise.resolve('') - }) -} - -describe('remote ripgrep cache GC', () => { - it('collects a build no relay installation references', async () => { - reply([CURRENT, SUPERSEDED], [CURRENT]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toHaveLength(1) - expect(removedTrees()[0]).toContain(SUPERSEDED) - }) - - it.each([LINUX, WINDOWS])( - 'rejects an empty reference on %j before any mutation', - async (host) => { - execCommandMock.mockReset() - execCommandMock - .mockResolvedValueOnce(`ENTRY ${SUPERSEDED}\r\n__ORCA_RG_CACHE__LIST_OK`) - .mockResolvedValueOnce('REF \t\r\n__ORCA_RG_CACHE__REFS_OK') - await gcRemoteRipgrepCache(conn, host, '/home/me') - expect(execCommandMock).toHaveBeenCalledTimes(2) - } - ) - - // Why: the reference is the whole safety argument. A build a live relay was launched against - // must survive, however old its directory is. - it('keeps a referenced build', async () => { - reply([CURRENT, SUPERSEDED], [CURRENT, SUPERSEDED]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - - // Why assert the shell and not just the parse: the marker is per entry, so a scan that read a - // single fixed filename would silently see only one of the two references above. - it('scans every marker in a relay directory, not one fixed name', async () => { - execCommandMock.mockReset() - execCommandMock.mockResolvedValue('__ORCA_RG_CACHE__LIST_OK') - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - execCommandMock.mockReset() - execCommandMock.mockImplementation((_c: unknown, command: string) => { - const text = String(command) - if (text.includes('ENTRY %s')) { - return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) - } - return Promise.resolve('__ORCA_RG_CACHE__REFS_OK') - }) - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - - const refScan = scripts().find((s) => s.includes('REF %s')) ?? '' - expect(refScan).toContain('.ripgrep-ref-*') - expect(refScan).toContain('${f##*/.ripgrep-ref-}') - }) - - // Why the whole pass and not just that directory: a relay deployed by an older Orca records no - // reference, so its binary cannot be identified -- and guessing is what breaks a live search. - it('collects nothing when any relay directory cannot be accounted for', async () => { - reply([CURRENT, SUPERSEDED], 'unreadable') - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - - // Why a readable RECHECK behind an unreadable first scan: with both unreadable, the restore in - // removeUnreferencedEntry hides a missing up-front guard, and the test passes either way. This - // shape is the only one that fails if the pass stops treating an unaccountable scan as fatal. - it('does not even tombstone when the first reference scan is unreadable', async () => { - execCommandMock.mockReset() - let refCalls = 0 - execCommandMock.mockImplementation((_conn: unknown, command: string) => { - const text = String(command) - if (text.includes('ENTRY %s')) { - return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) - } - if (text.includes('REF %s')) { - refCalls += 1 - return Promise.resolve( - refCalls === 1 ? '__ORCA_RG_CACHE__REFS_ERR' : '__ORCA_RG_CACHE__REFS_OK' - ) - } - if (text.includes('MOVED')) { - return Promise.resolve('MOVED') - } - return Promise.resolve('') - }) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - - expect(removedTrees()).toEqual([]) - expect(scripts().filter((s) => s.includes('MOVED'))).toEqual([]) - }) - - it('keeps the pinned build even when nothing references it yet', async () => { - reply([CURRENT], []) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - - // Why restore rather than proceed: a deploy that read the entry as present can still be writing - // its marker, and the recheck is the only place that race becomes visible. - it('restores a tombstoned build when the recheck finds a new reference', async () => { - execCommandMock.mockReset() - let refCalls = 0 - execCommandMock.mockImplementation((_conn: unknown, command: string) => { - const text = String(command) - if (text.includes('ENTRY %s')) { - return Promise.resolve(`ENTRY ${SUPERSEDED}\n__ORCA_RG_CACHE__LIST_OK`) - } - if (text.includes('REF %s')) { - refCalls += 1 - return Promise.resolve( - refCalls === 1 - ? '__ORCA_RG_CACHE__REFS_OK' - : `REF ${SUPERSEDED}\n__ORCA_RG_CACHE__REFS_OK` - ) - } - if (text.includes('MOVED')) { - return Promise.resolve('MOVED') - } - return Promise.resolve('') - }) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - - expect(removedTrees()).toEqual([]) - // Moved out, then moved back. - expect(scripts().filter((s) => s.includes('MOVED'))).toHaveLength(2) - }) - - // Why tie this to the platform list: a platform the entry pattern did not know would make the - // reference scan read as unreadable, and the pass then collects nothing at all -- every - // superseded build on that host leaks, silently, with no other test failing. - it('accepts an entry for every bundled platform', async () => { - for (const platform of BUNDLED_RIPGREP_PLATFORMS) { - const entry = `c0ffee0123456789-${platform}` - reply([entry], [entry]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', {}) - - // Referenced, so not collected -- but it had to parse as an entry to be considered at all. - expect(removedTrees()).toEqual([]) - expect(scripts().some((s) => s.includes('REF %s'))).toBe(true) - } - }) - - it('only mints entry names it could have written', async () => { - reply([CURRENT, '../../etc', 'not-an-entry'], [CURRENT]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - - // Why Windows gets the same coverage and not a gate: leaving one dialect uncollected means the - // leak simply moves to Windows remotes, where a `.exe` is the larger of the two builds. - it('collects an unreferenced build on Windows remotes too', async () => { - const WIN_CURRENT = 'c0ffee0123456789-win32-x64' - const WIN_OLD = 'dead000000000000-win32-x64' - execCommandMock.mockReset() - execCommandMock.mockImplementation((_conn: unknown, command: string) => { - const text = decodeRemotePowerShellScript(String(command)) ?? String(command) - if (text.includes("'ENTRY '")) { - return Promise.resolve(`ENTRY ${WIN_CURRENT}\nENTRY ${WIN_OLD}\n__ORCA_RG_CACHE__LIST_OK`) - } - if (text.includes("'REF '")) { - return Promise.resolve(`REF ${WIN_CURRENT}\n__ORCA_RG_CACHE__REFS_OK`) - } - if (text.includes('MOVED')) { - return Promise.resolve('MOVED') - } - return Promise.resolve('') - }) - - await gcRemoteRipgrepCache(conn, WINDOWS, 'C:/Users/me', { pinnedEntry: WIN_CURRENT }) - - const removals = scripts() - .map((s) => decodeRemotePowerShellScript(s) ?? s) - .filter( - (s) => s.includes('Remove-Item') && s.includes('.rg-gc-') && !s.includes('AddMinutes') - ) - expect(removals).toHaveLength(1) - expect(removals[0]).toContain(WIN_OLD) - }) - - // Why: PowerShell writes every uncaptured value to stdout, so a listing that forgot its token - // prefix would mix cmdlet output into the entry list and feed `Remove-Item` a foreign name. - it('prefixes every Windows listing line with its token', async () => { - execCommandMock.mockReset() - execCommandMock.mockResolvedValue('__ORCA_RG_CACHE__LIST_OK') - - await gcRemoteRipgrepCache(conn, WINDOWS, 'C:/Users/me', {}) - - const listing = decodeRemotePowerShellScript(scripts()[0]) ?? '' - expect(listing).toContain("'ENTRY ' + $_.Name") - expect(listing).toContain('__ORCA_RG_CACHE__LIST_OK') - }) -}) diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts deleted file mode 100644 index 0f479548499e..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - LIST_OK, - REFS_OK, - REFS_ERR, - TOMBSTONE_PREFIX, - MAX_LISTING_ENTRIES, - cacheDir, - listEntriesCommand, - listReferencesCommand -} from './ssh-relay-ripgrep-cache-gc-commands' -// Relay installation references protect binaries until version GC removes their owners. -// Unknown references block deletion; tombstones are rechecked before removal. -import { randomInt } from 'node:crypto' -import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { BUNDLED_RIPGREP_PLATFORMS } from '../../shared/bundled-ripgrep' -import { - moveRemoteTreeCommand, - removeRemoteTreeCommand, - restoreRemoteTreeCommand -} from './ssh-remote-commands' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -function entryNamePattern(): RegExp { - const platforms = BUNDLED_RIPGREP_PLATFORMS.map((platform) => - platform.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - ).join('|') - return new RegExp(`^[0-9a-f]{16}-(?:${platforms})$`) -} - -const ENTRY_NAME = entryNamePattern() - -// Recover abandoned deletions only after the owning pass has had time to finish. -function staleTombstoneEntry(name: string): string | null { - if (!name.startsWith(TOMBSTONE_PREFIX)) { - return null - } - const match = /^(.*)\.(\d+)\.(\d+)$/.exec(name.slice(TOMBSTONE_PREFIX.length)) - if ( - !match || - !ENTRY_NAME.test(match[1]) || - !Number.isSafeInteger(Number(match[3])) || - Date.now() - Number(match[3]) < 30 * 60_000 - ) { - return null - } - return match[1] -} - -function exec(conn: SshConnection, host: RemoteHostPlatform, command: string): Promise { - return execCommand(conn, command, { - wrapCommand: !isWindowsRemoteHost(host) - }) -} - -type ReferenceScan = { readable: true; referenced: Set } | { readable: false } - -type CacheEntry = { name: string; entry: string } - -function parseEntries(output: string): CacheEntry[] { - const lines = output.split(/\r?\n/).map((line) => line.trim()) - if (!lines.includes(LIST_OK)) { - return [] - } - const entries: CacheEntry[] = [] - for (const line of lines) { - if (!line.startsWith('ENTRY ')) { - continue - } - const name = line.slice('ENTRY '.length) - // Why re-validate a name the host produced: it is about to be interpolated into `mv` and - // `rm -rf`. Only names this client could itself have minted are eligible. - const entry = ENTRY_NAME.test(name) ? name : staleTombstoneEntry(name) - if (entry && entries.length < MAX_LISTING_ENTRIES) { - entries.push({ name, entry }) - } - } - return entries -} - -async function scanReferences( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string -): Promise { - let output: string - try { - output = await exec(conn, host, listReferencesCommand(host, remoteHome)) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return { readable: false } - } - const lines = output.split(/\r?\n/).map((line) => line.trim()) - // An empty legacy marker is unknown ownership, not an empty reference set. - if (lines.includes(REFS_ERR) || lines.includes('REF') || !lines.includes(REFS_OK)) { - return { readable: false } - } - const referenced = new Set() - for (const line of lines) { - if (!line.startsWith('REF ')) { - continue - } - const name = line.slice('REF '.length).trim() - // An unrecognised marker is a reference this client cannot attribute, so it blocks the pass - // rather than being ignored. - if (!ENTRY_NAME.test(name)) { - return { readable: false } - } - referenced.add(name) - } - return { readable: true, referenced } -} - -/** Collect unreferenced ripgrep builds; unconfirmed termination stops the caller's cleanup. */ -export async function gcRemoteRipgrepCache( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: { pinnedEntry?: string | undefined } = {} -): Promise { - try { - const entries = parseEntries(await exec(conn, host, listEntriesCommand(host, remoteHome))) - if (entries.length === 0) { - return - } - const scan = await scanReferences(conn, host, remoteHome) - if (!scan.readable) { - return - } - const removed: string[] = [] - for (const { name, entry } of entries) { - const keep = scan.referenced.has(entry) || entry === options.pinnedEntry - const collected = - name === entry - ? !keep && (await removeUnreferencedEntry(conn, host, remoteHome, entry)) - : await recoverAbandonedTombstone(conn, host, remoteHome, name, entry, keep) - if (collected) { - removed.push(entry) - } - } - if (removed.length > 0) { - console.log(`[ssh-relay] ripgrep cache GC: removed ${removed.length}: ${removed.join(', ')}`) - } - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - // Confirmed cleanup failures are optional; the next connect tries again. - } -} - -// Why random rather than pid + clock: passes on different clients must never rename into one path. -function ownedTombstonePath(host: RemoteHostPlatform, base: string, entry: string): string { - return joinRemotePath( - host, - base, - `${TOMBSTONE_PREFIX}${entry}.${randomInt(1, 2 ** 47)}.${Date.now()}` - ) -} - -async function moveTree( - conn: SshConnection, - host: RemoteHostPlatform, - source: string, - destination: string -): Promise { - try { - return ( - (await exec(conn, host, moveRemoteTreeCommand(host, source, destination))).trim() === 'MOVED' - ) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - return false - } -} - -async function removeUnreferencedEntry( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - entry: string -): Promise { - const base = cacheDir(host, remoteHome) - const entryDir = joinRemotePath(host, base, entry) - const tombstone = ownedTombstonePath(host, base, entry) - if (!(await moveTree(conn, host, entryDir, tombstone))) { - return false - } - return collectOwnedTombstone(conn, host, remoteHome, entry, tombstone, entryDir) -} - -// Only the pass whose rename wins may restore or delete a tombstone other passes can also list. -async function recoverAbandonedTombstone( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - name: string, - entry: string, - keep: boolean -): Promise { - const base = cacheDir(host, remoteHome) - const tombstone = ownedTombstonePath(host, base, entry) - if (!(await moveTree(conn, host, joinRemotePath(host, base, name), tombstone))) { - return false - } - const entryDir = joinRemotePath(host, base, entry) - if (keep) { - await restoreCacheEntry(conn, host, tombstone, entryDir) - return false - } - return collectOwnedTombstone(conn, host, remoteHome, entry, tombstone, entryDir) -} - -async function collectOwnedTombstone( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - entry: string, - tombstone: string, - entryDir: string -): Promise { - // Why recheck under the rename: a deploy that read this entry as present can still be writing - // its marker. Its reference now names a path that no longer exists, so restoring the tree is - // the only outcome that leaves that relay with a working ripgrep. - const recheck = await scanReferences(conn, host, remoteHome).catch(async (err: unknown) => { - // A read-only scan cannot conflict with restoring this pass's renamed tree. - await restoreCacheEntry(conn, host, tombstone, entryDir).catch(() => {}) - throw err - }) - if (!recheck.readable || recheck.referenced.has(entry)) { - await restoreCacheEntry(conn, host, tombstone, entryDir) - return false - } - try { - await exec(conn, host, removeRemoteTreeCommand(host, tombstone)) - return true - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - // A later pass retries after verifying references again. - return false - } -} - -async function restoreCacheEntry( - conn: SshConnection, - host: RemoteHostPlatform, - tombstone: string, - entryDir: string -): Promise { - await exec(conn, host, restoreRemoteTreeCommand(host, tombstone, entryDir)).catch((err) => { - if (isUnconfirmedSshCommandTermination(err)) { - throw err - } - }) -} diff --git a/src/main/ssh/ssh-relay-ripgrep-install.test.ts b/src/main/ssh/ssh-relay-ripgrep-install.test.ts deleted file mode 100644 index 990e0ec0d54e..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-install.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - rmSync, - statSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { execCommandMock, uploadRelayDirectoryMock, resolveBundledRipgrepPathMock } = vi.hoisted( - () => ({ - execCommandMock: vi.fn(), - uploadRelayDirectoryMock: vi.fn(), - resolveBundledRipgrepPathMock: vi.fn() - }) -) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execCommandMock })) -vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: uploadRelayDirectoryMock })) -vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ - resolveBundledRipgrepPath: resolveBundledRipgrepPathMock, - bundledRipgrepContentKey: () => 'c0ffee0123456789' -})) - -import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform, joinRemotePath } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { ensureRemoteBundledRipgrep, remoteRipgrepLayout } from './ssh-relay-ripgrep-install' - -const LINUX = getRemoteHostPlatform('linux-x64') -const WINDOWS = getRemoteHostPlatform('win32-x64') - -function connection(usesSystemSsh = false): SshConnection { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the install only asks the connection which transport it uses; exec/upload are mocked. - return { usesSystemSshTransport: () => usesSystemSsh } as unknown as SshConnection -} - -function execScripts(): string[] { - return execCommandMock.mock.calls.map(([, command]) => { - const text = String(command) - return text.includes('-EncodedCommand') ? decodeRemotePowerShellScript(text) : text - }) -} - -describe('ensureRemoteBundledRipgrep', () => { - let localDir: string - let localBinary: string - - beforeEach(() => { - execCommandMock.mockReset() - uploadRelayDirectoryMock.mockReset().mockResolvedValue(undefined) - localDir = mkdtempSync(join(tmpdir(), 'orca-local-rg-')) - localBinary = join(localDir, 'linux-x64', 'rg') - mkdirSync(dirname(localBinary)) - writeFileSync(localBinary, 'x'.repeat(1234)) - resolveBundledRipgrepPathMock.mockReset().mockReturnValue(localBinary) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - rmSync(localDir, { recursive: true, force: true }) - vi.restoreAllMocks() - }) - - it('keys the cache on the binary content hash and platform, not the relay version', () => { - expect(remoteRipgrepLayout(LINUX, '/home/me')?.binaryPath).toBe( - '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg' - ) - expect(remoteRipgrepLayout(WINDOWS, 'C:/Users/me user')?.binaryPath).toBe( - 'C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe' - ) - }) - - it('limits Windows cache cleanup to abandoned upload stages', async () => { - execCommandMock.mockResolvedValueOnce('ORCA-RG-PRESENT\n') - - await ensureRemoteBundledRipgrep(connection(), WINDOWS, 'C:/Users/me user') - - const script = execScripts()[0] - expect(script.match(/Get-ChildItem/g)).toHaveLength(1) - expect(script).toContain("-Filter '.upload-*'") - }) - - it('skips the upload in one round trip when the binary is already installed', async () => { - execCommandMock.mockResolvedValueOnce('ORCA-RG-PRESENT\n') - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'present' - ) - - expect(execCommandMock).toHaveBeenCalledTimes(1) - expect(execScripts()[0]).toContain( - "-x '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'" - ) - expect(uploadRelayDirectoryMock).not.toHaveBeenCalled() - }) - - // Why the marker matters: it is the only thing that tells the cache GC this build is in use. - // Without it the GC cannot distinguish "nobody uses this" from "nobody recorded it", and the - // safe answer to the second is to collect nothing at all. - it('records the reference before checking or installing its binary', async () => { - execCommandMock.mockResolvedValueOnce('').mockResolvedValueOnce('ORCA-RG-PRESENT\n') - - await ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { - relayDir: '/home/me/.orca-remote/relay-1.2.3' - }) - - const ref = execScripts()[0] - expect(ref).toContain('c0ffee0123456789-linux-x64') - expect(ref).toContain('/home/me/.orca-remote/relay-1.2.3/.ripgrep-ref') - }) - - it('does not inspect or upload the binary after its reference write fails', async () => { - execCommandMock.mockRejectedValueOnce(new Error('read-only relay directory')) - await expect( - ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { - relayDir: '/home/me/.orca-remote/relay-1.2.3' - }) - ).resolves.toBe('failed') - expect(execCommandMock).toHaveBeenCalledTimes(1) - expect(uploadRelayDirectoryMock).not.toHaveBeenCalled() - }) - - it('records nothing when the host has no bundled build to reference', async () => { - resolveBundledRipgrepPathMock.mockReturnValue(null) - - await ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me', { - relayDir: '/home/me/.orca-remote/relay-1.2.3' - }) - - expect(execScripts().some((script) => script.includes('.ripgrep-ref'))).toBe(false) - }) - - it('uploads into a private stage, then verifies, chmods and renames into place', async () => { - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\n') - .mockResolvedValueOnce('ORCA-RG-INSTALLED\n') - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'installed' - ) - - const [, source, payloadDir, host, options] = uploadRelayDirectoryMock.mock.calls[0] - expect(source).toBe(dirname(localBinary)) - expect(payloadDir).toMatch( - /^\/home\/me\/\.orca-remote\/ripgrep\/\.upload-[0-9a-f]{16}\/payload$/ - ) - expect(host).toBe(LINUX) - // Why: a chrooted SFTP subsystem must resolve the stage through its marker, like relay uploads. - expect(options.sftpNamespace?.homeRelativePath).toBe(payloadDir.slice('/home/me/'.length)) - const promote = execScripts()[1] - expect(promote).toContain('wc -c') - expect(promote).toContain('"1234"') - expect(promote).toContain('chmod 755') - expect(promote).toContain( - `mv -f '${payloadDir}/rg' '/home/me/.orca-remote/ripgrep/c0ffee0123456789-linux-x64/rg'` - ) - }) - - it('reports an upload failure instead of throwing, and removes its stage', async () => { - execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n').mockResolvedValueOnce('') - uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('sftp: permission denied')) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'failed' - ) - - expect(execScripts()[1]).toMatch(/^rm -rf '\/home\/me\/\.orca-remote\/ripgrep\/\.upload-/) - }) - - describe.each([ - { platform: 'Linux', host: LINUX, home: '/home/me' }, - { platform: 'Windows', host: WINDOWS, home: 'C:/Users/me user' } - ])('interrupted install on $platform', ({ host, home }) => { - it.each([ - ['reference', 1, 0], - ['probe', 1, 0], - ['upload', 1, 1], - ['promotion', 2, 1], - ['cleanup', 2, 1] - ] as const)( - 'preserves unconfirmed %s termination and starts no further operation', - async (phase, commands, uploads) => { - const error = Object.assign(new Error('Remote termination is unconfirmed'), { - sshChannelCloseConfirmed: false - }) - if (phase === 'reference' || phase === 'probe') { - execCommandMock.mockRejectedValueOnce(error) - } else { - execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n') - if (phase === 'upload') { - uploadRelayDirectoryMock.mockRejectedValueOnce(error) - } else { - execCommandMock.mockRejectedValueOnce(error) - if (phase === 'cleanup') { - uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('Upload failed')) - } - } - } - - await expect( - ensureRemoteBundledRipgrep(connection(), host, home, { - relayDir: - phase === 'reference' - ? joinRemotePath(host, home, '.orca-remote', 'relay-1.2.3') - : undefined - }) - ).rejects.toBe(error) - - expect(execCommandMock).toHaveBeenCalledTimes(commands) - expect(uploadRelayDirectoryMock).toHaveBeenCalledTimes(uploads) - } - ) - - it.each(['upload', 'promotion'] as const)( - 'removes the stage after confirmed %s termination and remains nonfatal', - async (phase) => { - const error = Object.assign(new Error('Remote operation stopped'), { - sshChannelCloseConfirmed: true - }) - execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n') - if (phase === 'upload') { - uploadRelayDirectoryMock.mockRejectedValueOnce(error) - } else { - execCommandMock.mockRejectedValueOnce(error) - } - execCommandMock.mockResolvedValueOnce('') - - await expect(ensureRemoteBundledRipgrep(connection(), host, home)).resolves.toBe('failed') - - expect(execCommandMock).toHaveBeenCalledTimes(phase === 'upload' ? 2 : 3) - expect(execScripts().at(-1)).toContain( - host === WINDOWS ? 'Remove-Item -LiteralPath' : 'rm -rf' - ) - } - ) - - it('keeps an ordinary stage cleanup failure nonfatal', async () => { - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\n') - .mockRejectedValueOnce(new Error('Cleanup failed')) - uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('Upload failed')) - - await expect(ensureRemoteBundledRipgrep(connection(), host, home)).resolves.toBe('failed') - - expect(execCommandMock).toHaveBeenCalledTimes(2) - }) - }) - - it('reports a failed size verification', async () => { - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\n') - .mockResolvedValueOnce('ORCA-RG-FAILED\n') - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'failed' - ) - }) - - it('does nothing when this install has no binary for the host', async () => { - resolveBundledRipgrepPathMock.mockReturnValue(null) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, '/home/me')).resolves.toBe( - 'unavailable' - ) - expect(execCommandMock).not.toHaveBeenCalled() - }) - - it('installs rg.exe on Windows hosts with PowerShell and no SFTP namespace mapping', async () => { - resolveBundledRipgrepPathMock.mockReturnValue(localBinary) - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\r\n') - .mockResolvedValueOnce('ORCA-RG-INSTALLED\r\n') - - await expect( - ensureRemoteBundledRipgrep(connection(), WINDOWS, 'C:/Users/me user') - ).resolves.toBe('installed') - - expect(execCommandMock.mock.calls.every(([, , opts]) => opts.wrapCommand === false)).toBe(true) - const [probe, promote] = execScripts() - expect(probe).toContain( - "Test-Path -LiteralPath 'C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe'" - ) - expect(promote).toContain('Move-Item -LiteralPath $src -Destination $bin') - expect(promote).toContain('.Length -eq 1234') - expect(uploadRelayDirectoryMock.mock.calls[0][4].sftpNamespace).toBeUndefined() - }) - - it('skips the SFTP namespace mapping on the system-ssh transport', async () => { - execCommandMock - .mockResolvedValueOnce('ORCA-RG-STAGED\n') - .mockResolvedValueOnce('ORCA-RG-INSTALLED\n') - - await ensureRemoteBundledRipgrep(connection(true), LINUX, '/home/me') - - expect(uploadRelayDirectoryMock.mock.calls[0][4].sftpNamespace).toBeUndefined() - }) -}) - -// Debian and Ubuntu point /bin/sh at dash; run both so a bashism cannot pass here and fail on a host. -const SHELLS = ['/bin/sh', '/bin/dash'].filter((shell) => existsSync(shell)) - -describe.runIf(process.platform !== 'win32').each(SHELLS)( - 'remote ripgrep install scripts (%s)', - (shell) => { - let home: string - let localDir: string - let localBinary: string - - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'orca-remote-home-')) - localDir = mkdtempSync(join(tmpdir(), 'orca-local-rg-')) - localBinary = join(localDir, 'linux-x64', 'rg') - mkdirSync(dirname(localBinary)) - writeFileSync(localBinary, '#!/bin/sh\necho ripgrep 15.0.0\n') - resolveBundledRipgrepPathMock.mockReset().mockReturnValue(localBinary) - execCommandMock - .mockReset() - .mockImplementation(async (_conn: unknown, command: string) => - execFileSync(shell, ['-c', command], { encoding: 'utf-8' }) - ) - uploadRelayDirectoryMock - .mockReset() - .mockImplementation(async (_conn: unknown, source: string, target: string) => { - cpSync(source, target, { recursive: true }) - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - rmSync(home, { recursive: true, force: true }) - rmSync(localDir, { recursive: true, force: true }) - vi.restoreAllMocks() - }) - - const installed = (): string => - join(home, '.orca-remote', 'ripgrep', 'c0ffee0123456789-linux-x64', 'rg') - const cacheEntries = (): string[] => readdirSync(join(home, '.orca-remote', 'ripgrep')) - - it('installs an executable binary once and leaves no stage behind', async () => { - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') - expect(statSync(installed()).mode & 0o111).not.toBe(0) - expect(execFileSync(installed(), { encoding: 'utf-8' })).toContain('ripgrep 15.0.0') - expect(cacheEntries()).toEqual(['c0ffee0123456789-linux-x64']) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('present') - expect(uploadRelayDirectoryMock).toHaveBeenCalledTimes(1) - }) - - it('never publishes a truncated upload', async () => { - uploadRelayDirectoryMock.mockImplementation( - async (_conn: unknown, source: string, target: string) => { - cpSync(source, target, { recursive: true }) - truncateSync(join(target, 'rg'), 5) - } - ) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('failed') - expect(existsSync(installed())).toBe(false) - expect(cacheEntries()).toEqual([]) - }) - - it('replaces a truncated binary left at the installed path', async () => { - mkdirSync(dirname(installed()), { recursive: true }) - writeFileSync(installed(), '#!/bin/sh\n', { mode: 0o755 }) - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') - expect(execFileSync(installed(), { encoding: 'utf-8' })).toContain('ripgrep 15.0.0') - }) - - it('keeps an old binary usable by a relay pinned to an earlier client build', async () => { - const previous = join(home, '.orca-remote', 'ripgrep', 'previous-linux-x64', 'rg') - mkdirSync(dirname(previous), { recursive: true }) - writeFileSync(previous, '#!/bin/sh\necho ripgrep previous\n', { mode: 0o755 }) - execFileSync('touch', ['-t', '200001010000', dirname(previous)]) - expect(execFileSync(previous, { encoding: 'utf-8' })).toContain('ripgrep previous') - - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('installed') - await expect(ensureRemoteBundledRipgrep(connection(), LINUX, home)).resolves.toBe('present') - - expect(execFileSync(previous, { encoding: 'utf-8' })).toContain('ripgrep previous') - }) - - it('sweeps an abandoned stage older than an hour but keeps a live one', async () => { - const cache = join(home, '.orca-remote', 'ripgrep') - mkdirSync(join(cache, '.upload-stale', 'payload'), { recursive: true }) - mkdirSync(join(cache, '.upload-live', 'payload'), { recursive: true }) - execFileSync('touch', ['-t', '200001010000', join(cache, '.upload-stale')]) - - await ensureRemoteBundledRipgrep(connection(), LINUX, home) - - expect(cacheEntries().sort()).toEqual(['.upload-live', 'c0ffee0123456789-linux-x64']) - }) - } -) diff --git a/src/main/ssh/ssh-relay-ripgrep-install.ts b/src/main/ssh/ssh-relay-ripgrep-install.ts deleted file mode 100644 index 333a21801bba..000000000000 --- a/src/main/ssh/ssh-relay-ripgrep-install.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Installs Orca's own ripgrep on an SSH host so remote Quick Open and text search do not depend - * on the user having `rg`. The binary lives at - * `~/.orca-remote/ripgrep/-/rg[.exe]`, a sibling of the - * `relay-` dirs keyed on the binary's bytes alone, so a relay upgrade never re-uploads it. - * - * Uploads land in a private `.upload-` stage and are renamed into place only after a size - * check, so an interrupted or concurrent deploy never leaves a truncated binary at the final path. - * Ordinary failures use the relay's PATH `rg` and git/readdir fallback; unconfirmed remote stops - * propagate so deployment cannot start more work while the previous operation may still run. - */ -import { randomBytes } from 'node:crypto' -import { statSync } from 'node:fs' -import { dirname } from 'node:path' -import type { SshConnection } from './ssh-connection' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { execCommand } from './ssh-relay-deploy-helpers' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import { uploadRelayDirectory } from './ssh-relay-install-transfers' -import { - createRelayUploadStageNamespace, - makeRelayUploadStageDirectoryCommand, - relayUploadStageSftpNamespaceMapping, - type RelayUploadStageNamespace -} from './ssh-relay-install-namespace' -import { shellEscape } from './ssh-connection-utils' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' -import { - assertSafeRemotePathSegment, - isWindowsRemoteHost, - joinRemotePath, - remoteDirname, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { removeRemoteTreeCommand } from './ssh-remote-commands' -import { - bundledRipgrepContentKey, - resolveBundledRipgrepPath -} from '../ripgrep/bundled-ripgrep-path' -import { - bundledRipgrepBinaryName, - toBundledRipgrepPlatform, - type BundledRipgrepPlatform -} from '../../shared/bundled-ripgrep' - -/** Sibling of `relay-`, `orcad-` and `native/`. */ -export const REMOTE_RIPGREP_CACHE_DIR_NAME = 'ripgrep' - -// Ripgrep-only updates share relay bytes, so one relay directory can reference multiple builds. -export const REMOTE_RIPGREP_REF_PREFIX = '.ripgrep-ref-' - -export function remoteRipgrepRefFileName(entryName: string): string { - return `${REMOTE_RIPGREP_REF_PREFIX}${entryName}` -} - -/** Record the relay's ripgrep build; only an unconfirmed remote stop rejects. */ -export async function recordRemoteRipgrepReference( - conn: SshConnection, - host: RemoteHostPlatform, - relayDir: string, - entryName: string -): Promise { - const refPath = joinRemotePath(host, relayDir, remoteRipgrepRefFileName(entryName)) - try { - assertSafeRemotePathSegment(entryName, host.pathFlavor) - await execCommand( - conn, - isWindowsRemoteHost(host) - ? powerShellCommand( - `Set-Content -LiteralPath ${powerShellLiteral(refPath)} -Value ${powerShellLiteral(entryName)} -NoNewline -Encoding ascii` - ) - : `printf %s ${shellEscape(entryName)} > ${shellEscape(refPath)}`, - { wrapCommand: !isWindowsRemoteHost(host) } - ) - return true - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - console.warn( - '[ssh-relay] Could not record the ripgrep reference; skipping the bundled binary:', - error instanceof Error ? error.message : String(error) - ) - return false - } -} -const UPLOAD_STAGE_PREFIX = '.upload-' -// Why an hour: long enough that no live upload of ~5 MB is still writing, short enough to drain crashes. -const STALE_UPLOAD_STAGE_MINUTES = 60 -const PRESENT = 'ORCA-RG-PRESENT' -const STAGED = 'ORCA-RG-STAGED' -const INSTALLED = 'ORCA-RG-INSTALLED' - -export type RemoteRipgrepLayout = { - platform: BundledRipgrepPlatform - cacheDir: string - /** `-`; the sibling directories are superseded builds. */ - entryName: string - binaryPath: string -} - -export type RemoteRipgrepInstallOutcome = 'present' | 'installed' | 'unavailable' | 'failed' - -export function remoteRipgrepLayout( - host: RemoteHostPlatform, - remoteHome: string -): RemoteRipgrepLayout | null { - const platform = toBundledRipgrepPlatform(host.os, host.arch) - const contentKey = platform ? bundledRipgrepContentKey(platform) : null - if (!platform || !contentKey) { - return null - } - const entryName = `${contentKey}-${platform}` - assertSafeRemotePathSegment(entryName, host.pathFlavor) - const cacheDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, REMOTE_RIPGREP_CACHE_DIR_NAME) - return { - platform, - cacheDir, - entryName, - binaryPath: joinRemotePath(host, cacheDir, entryName, bundledRipgrepBinaryName(platform)) - } -} - -/** Ensure the host has Orca's ripgrep; only an unconfirmed remote stop rejects. */ -export async function ensureRemoteBundledRipgrep( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: { signal?: AbortSignal; relayDir?: string } = {} -): Promise { - const layout = remoteRipgrepLayout(host, remoteHome) - if (options.relayDir && layout && resolveBundledRipgrepPath(layout.platform)) { - if (!(await recordRemoteRipgrepReference(conn, host, options.relayDir, layout.entryName))) { - return 'failed' - } - } - return installOrReport(conn, host, remoteHome, options) -} - -async function installOrReport( - conn: SshConnection, - host: RemoteHostPlatform, - remoteHome: string, - options: { signal?: AbortSignal } = {} -): Promise { - try { - const layout = remoteRipgrepLayout(host, remoteHome) - const localBinary = layout ? resolveBundledRipgrepPath(layout.platform) : null - if (!layout || !localBinary) { - console.warn( - `[ssh-relay] No bundled ripgrep for ${host.relayPlatform}; the relay will use rg from PATH` - ) - return 'unavailable' - } - return await installRemoteRipgrep(conn, host, layout, localBinary, options.signal) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - console.warn( - '[ssh-relay] Bundled ripgrep install failed; the relay will use rg from PATH:', - error instanceof Error ? error.message : String(error) - ) - return 'failed' - } -} - -async function installRemoteRipgrep( - conn: SshConnection, - host: RemoteHostPlatform, - layout: RemoteRipgrepLayout, - localBinary: string, - signal: AbortSignal | undefined -): Promise { - const exec = (command: string): Promise => - execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host), signal }) - const stageName = `${UPLOAD_STAGE_PREFIX}${randomBytes(8).toString('hex')}` - const stageDir = joinRemotePath(host, layout.cacheDir, stageName) - const stageNamespace = createRelayUploadStageNamespace( - `${RELAY_REMOTE_DIR}/${REMOTE_RIPGREP_CACHE_DIR_NAME}/${stageName}` - ) - - const size = statSync(localBinary).size - // Why one round trip: the warm path (already installed) must cost a single exec. - const probe = await exec(probeOrStageCommand(host, layout, stageDir, stageNamespace, size)) - if (probe.includes(PRESENT)) { - return 'present' - } - if (!probe.includes(STAGED)) { - throw new Error(`could not stage upload: ${probe.trim().slice(0, 200)}`) - } - - let promoted = false - let cleanupAllowed = true - try { - await uploadRelayDirectory( - conn, - dirname(localBinary), - joinRemotePath(host, stageDir, 'payload'), - host, - { - signal, - sftpNamespace: usesOrcaOwnedSftp(conn, host) - ? relayUploadStageSftpNamespaceMapping(stageNamespace, host, stageDir) - : undefined - } - ) - const result = await exec(promoteCommand(host, layout, stageDir, size)) - promoted = true - if (!result.includes(INSTALLED)) { - throw new Error(`upload did not verify: ${result.trim().slice(0, 200)}`) - } - console.log(`[ssh-relay] Installed bundled ripgrep at ${layout.binaryPath} (${size} bytes)`) - return 'installed' - } catch (error) { - cleanupAllowed = !isUnconfirmedSshCommandTermination(error) - throw error - } finally { - if (!promoted && cleanupAllowed) { - // Why best-effort: the next deploy's probe also sweeps stale stages. - await execCommand(conn, removeRemoteTreeCommand(host, stageDir), { - wrapCommand: !isWindowsRemoteHost(host) - }).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - }) - } - } -} - -/** Split shell/SFTP namespaces only arise on POSIX hosts reached over the bundled ssh2 SFTP. */ -function usesOrcaOwnedSftp(conn: SshConnection, host: RemoteHostPlatform): boolean { - if (isWindowsRemoteHost(host)) { - return false - } - return typeof conn.usesSystemSshTransport === 'function' ? !conn.usesSystemSshTransport() : true -} - -// Why size: the path is content-keyed, so a wrong length can only be a truncated or foreign file. -function posixInstalledTest(bin: string, bytes: string): string { - return `[ -f ${bin} ] && [ -x ${bin} ] && [ "$(wc -c < ${bin} 2>/dev/null | tr -d ' \\t')" = "${bytes}" ]` -} - -function windowsInstalledTest(bin: string, bytes: string): string { - return `(Test-Path -LiteralPath ${bin} -PathType Leaf) -and ((Get-Item -LiteralPath ${bin}).Length -eq ${bytes})` -} - -export function probeOrStageCommand( - host: RemoteHostPlatform, - layout: RemoteRipgrepLayout, - stageDir: string, - stageNamespace: RelayUploadStageNamespace, - expectedBytes: number -): string { - const bytes = String(Math.trunc(expectedBytes)) - if (!isWindowsRemoteHost(host)) { - const bin = shellEscape(layout.binaryPath) - const cache = shellEscape(layout.cacheDir) - const sweep = `find ${cache} -mindepth 1 -maxdepth 1 -type d -name '${UPLOAD_STAGE_PREFIX}*' -mmin +${STALE_UPLOAD_STAGE_MINUTES} -exec rm -rf {} + 2>/dev/null` - // Installed builds may still serve an older client's live relay; age is not disuse. - const stage = makeRelayUploadStageDirectoryCommand(stageNamespace, host, stageDir) - // Why the sweep runs before the branch, not inside the else: once rg is installed every later - // deploy takes the PRESENT path, so a stage orphaned by a dropped connection would never be - // collected. It stays one exec round trip either way. - return `${sweep}; if ${posixInstalledTest(bin, bytes)}; then echo ${PRESENT}; else ${stage} && echo ${STAGED}; fi` - } - return powerShellCommand( - [ - `Get-ChildItem -LiteralPath ${powerShellLiteral(layout.cacheDir)} -Directory -Filter '${UPLOAD_STAGE_PREFIX}*' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-${STALE_UPLOAD_STAGE_MINUTES}) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue`, - `if (${windowsInstalledTest(powerShellLiteral(layout.binaryPath), bytes)}) { '${PRESENT}' } else {`, - `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(joinRemotePath(host, stageDir, 'payload'))} -ErrorAction Stop`, - `'${STAGED}' }` - ].join('\n') - ) -} - -/** Verify the staged size, then rename into place; a concurrent winner's binary also counts. */ -export function promoteCommand( - host: RemoteHostPlatform, - layout: RemoteRipgrepLayout, - stageDir: string, - expectedBytes: number -): string { - const staged = joinRemotePath( - host, - stageDir, - 'payload', - bundledRipgrepBinaryName(layout.platform) - ) - if (!isWindowsRemoteHost(host)) { - const src = shellEscape(staged) - const bin = shellEscape(layout.binaryPath) - const bytes = String(Math.trunc(expectedBytes)) - return [ - `if [ "$(wc -c < ${src} 2>/dev/null | tr -d ' \\t')" = "${bytes}" ] && chmod 755 ${src} && mkdir -p ${shellEscape(remoteDirname(layout.binaryPath, host))} && mv -f ${src} ${bin}; then r=${INSTALLED};`, - `elif ${posixInstalledTest(bin, bytes)}; then r=${INSTALLED}; else r=ORCA-RG-FAILED; fi;`, - `rm -rf ${shellEscape(stageDir)}; echo "$r"` - ].join(' ') - } - return powerShellCommand( - [ - `$src = ${powerShellLiteral(staged)}`, - `$bin = ${powerShellLiteral(layout.binaryPath)}`, - "$r = 'ORCA-RG-FAILED'", - 'try {', - `if ((Get-Item -LiteralPath $src -ErrorAction Stop).Length -eq ${Math.trunc(expectedBytes)}) {`, - `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remoteDirname(layout.binaryPath, host))} -ErrorAction Stop`, - // Why replace only a wrong-size file: a running relay may hold a good rg.exe open. - `if (-not (${windowsInstalledTest('$bin', String(Math.trunc(expectedBytes)))})) { Move-Item -LiteralPath $src -Destination $bin -Force -ErrorAction Stop }`, - `$r = '${INSTALLED}'`, - '}', - `} catch { if (${windowsInstalledTest('$bin', String(Math.trunc(expectedBytes)))}) { $r = '${INSTALLED}' } }`, - `finally { Remove-Item -LiteralPath ${powerShellLiteral(stageDir)} -Recurse -Force -ErrorAction SilentlyContinue }`, - '$r' - ].join('\n') - ) -} diff --git a/src/main/ssh/ssh-relay-runtime-step-plan.ts b/src/main/ssh/ssh-relay-runtime-step-plan.ts deleted file mode 100644 index 69ada96fadd2..000000000000 --- a/src/main/ssh/ssh-relay-runtime-step-plan.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** Turns one ladder step into a relay plan, or a classified refusal the ladder steps past. */ -import type { SshConnection } from './ssh-connection' -import { planHostNodeAddonRelay, type PrebuiltRelayPlan } from './ssh-relay-host-node-addons' -import { - PinnedRelayFallbackError, - planPinnedNodeRelay, - resolvePinnedRelayTargetFacts -} from './ssh-relay-pinned-node' -import { rungBCompatRuntimeFor, type RelayRuntimeStep } from './ssh-relay-runtime-ladder' -import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { RemoteNodeNotFoundError, resolveRemoteNodePath } from './ssh-remote-node-resolution' -import type { GlibcVersion, OrcadDeploymentTargetFacts } from './orcad-deployment-target' -import type { CompatServerTarget } from '../../shared/node-runtime-pin' - -type StepPlanOptions = { - conn: SshConnection - host: RemoteHostPlatform - baseVersion: string - step: RelayRuntimeStep - run: RelayRuntimeLadderRun - signal?: AbortSignal -} - -/** Resolved once per ladder pass; every rung above legacy keys on the same answer. */ -async function ladderTargetFacts(options: StepPlanOptions): Promise { - const { run } = options - run.facts ??= await resolvePinnedRelayTargetFacts(options) - return run.facts -} - -function planPinned( - options: StepPlanOptions, - facts: OrcadDeploymentTargetFacts, - compat?: { target: CompatServerTarget; glibcFloor: GlibcVersion | null } -): Promise { - const { conn, host, baseVersion, run, signal } = options - return planPinnedNodeRelay({ - conn, - host, - baseVersion, - signal, - targetId: run.targetId, - facts, - persistedRefusal: (known) => run.persistedPinnedRefusal(known), - compat - }) -} - -/** Why strict: only an answered "no Node here" settles D; a lost probe stays a retryable failure. */ -async function proveHostNodeForFallback( - conn: SshConnection, - host: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - try { - return await resolveRemoteNodePath(conn, host, { signal, strict: true }) - } catch (error) { - if (error instanceof RemoteNodeNotFoundError) { - throw new PinnedRelayFallbackError('host_node_missing', 'no host Node.js 18+ with npm') - } - throw error - } -} - -/** Undefined for the host-npm path; throws `PinnedRelayFallbackError` when a rung cannot run. */ -export async function planRelayRuntimeStep( - options: StepPlanOptions -): Promise { - const { conn, host, baseVersion, step, run, signal } = options - run.host = host - switch (step) { - case 'legacy': - if (run.laddered) { - run.hostNodePath = await proveHostNodeForFallback(conn, host, signal) - } - return undefined - case 'A': - return planPinned(options, await ladderTargetFacts(options)) - case 'B': { - if (host.os === 'win32') { - throw new PinnedRelayFallbackError( - 'runtime_unavailable', - 'no compat runtime serves Windows' - ) - } - const facts = await ladderTargetFacts(options) - // run.lastRefusal is rung A's: B is entered only by stepping down from A. - const compat = rungBCompatRuntimeFor(facts, run.lastRefusal) - if (!compat) { - throw new PinnedRelayFallbackError( - 'runtime_unavailable', - 'no compat runtime serves this host' - ) - } - return planPinned(options, facts, { - target: compat.runtimeTarget, - glibcFloor: compat.glibcFloor - }) - } - case 'C': { - if (host.os === 'win32') { - throw new PinnedRelayFallbackError( - 'windows_host_unsupported', - 'Windows hosts have no host-Node addon relay' - ) - } - const plan = await planHostNodeAddonRelay({ - conn, - host, - facts: await ladderTargetFacts(options), - baseVersion, - signal - }) - run.hostNode = plan.hostNode.version - return plan - } - case 'D': - throw new Error('Rung D has no relay to plan') - } -} diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts deleted file mode 100644 index 77b39183f642..000000000000 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ /dev/null @@ -1,802 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' -import type { SshConnection } from './ssh-connection' -import type { MultiplexerTransport } from './ssh-channel-multiplexer' -import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' -import { RelayDispatcher } from '../../wsl-guest/dispatcher' -import { - AGENT_HOOK_NOTIFICATION_METHOD, - AGENT_HOOK_REQUEST_REPLAY_METHOD, - ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV, - REMOTE_AGENT_HOOK_ENV -} from '../../shared/agent-hook-relay' -import { agentHookServer, _internals as agentHookInternals } from '../agent-hooks/server' -import { getSshPtyProvider } from '../ipc/pty' -import { toAppSshPtyId } from '../providers/ssh-pty-id' -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -vi.mock('./ssh-relay-deploy', () => ({ - deployAndLaunchRelay: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const { SshRelaySession } = await import('./ssh-relay-session') - -const SSH_LEAF_ID = '11111111-1111-4111-8111-111111111111' -const REPLAY_LEAF_ID = '22222222-2222-4222-8222-222222222222' -const BAD_LEAF_ID = '33333333-3333-4333-8333-333333333333' -const COMPACT_PROMPT_ID = '44444444-4444-4444-8444-444444444444' -const PREVIOUS_PROMPT_ID = '55555555-5555-4555-8555-555555555555' - -type CapturedStatus = { - paneKey: string - tabId?: string - worktreeId?: string - connectionId: string | null - payload: { - state: string - workingMode?: 'monitoring' - prompt: string - agentType?: string - toolName?: string - } -} - -type FakeRelay = { - transport: MultiplexerTransport - dispatcher: RelayDispatcher - ptySpawnRequests: Record[] - replayEnvelopes: AgentHookRelayEnvelope[] - notifyAgentHook: (envelope: AgentHookRelayEnvelope | Record) => void - dispose: () => void -} - -// Why: mock below SSH at the relay transport boundary so CI covers session, -// mux, provider, and hook-ingest wiring without relying on a local sshd. -function createFakeRelay(): FakeRelay { - let relayFeed: ((data: Buffer) => void) | null = null - const clientDataCallbacks: ((data: Buffer) => void)[] = [] - const clientCloseCallbacks: (() => void)[] = [] - const ptySpawnRequests: Record[] = [] - const replayEnvelopes: AgentHookRelayEnvelope[] = [] - - const transport: MultiplexerTransport = { - write: (data) => { - setImmediate(() => relayFeed?.(data)) - }, - onData: (cb) => { - clientDataCallbacks.push(cb) - }, - onClose: (cb) => { - clientCloseCallbacks.push(cb) - }, - close: () => { - for (const cb of clientCloseCallbacks) { - cb() - } - } - } - - const dispatcher = new RelayDispatcher((data) => { - setImmediate(() => { - for (const cb of clientDataCallbacks) { - cb(data) - } - }) - }) - relayFeed = (data) => dispatcher.feed(data) - - dispatcher.onRequest('pty.openClient', async (params) => ({ - protocolVersion: 1, - serverBuildId: 'test-relay-build', - clientGeneration: 1, - role: 'session-owner', - ownerGeneration: - typeof (params.resume as { ownerGeneration?: unknown } | undefined)?.ownerGeneration === - 'number' - ? (params.resume as { ownerGeneration: number }).ownerGeneration + 1 - : 1, - ownerLease: 'test-owner-lease', - resumed: params.resume !== undefined, - capabilities: { - outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } - } - })) - dispatcher.onRequest('session.resolveHome', async (params) => ({ - resolvedPath: params.path === '~' ? '/home/orca' : params.path - })) - dispatcher.onRequest('git.listWorktrees', async () => []) - dispatcher.onRequest('ports.detect', async () => ({ ports: [], platform: 'linux' })) - dispatcher.onRequest('pty.spawn', async (params) => { - ptySpawnRequests.push(params) - return { id: `remote-pty-${ptySpawnRequests.length}` } - }) - dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => { - // Why: relay replay must arrive after Orca wires its listener and before - // the request resolves, matching the real relay ordering contract. - for (const envelope of replayEnvelopes) { - dispatcher.notify( - AGENT_HOOK_NOTIFICATION_METHOD, - envelope as unknown as Record - ) - } - return { replayed: replayEnvelopes.length } - }) - - return { - transport, - dispatcher, - ptySpawnRequests, - replayEnvelopes, - notifyAgentHook: (envelope) => { - dispatcher.notify(AGENT_HOOK_NOTIFICATION_METHOD, envelope as Record) - }, - dispose: () => dispatcher.dispose() - } -} - -function createSession(targetId: string): InstanceType { - const store = { - getRepos: vi.fn().mockReturnValue([]), - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn(), - removeSshPtyConsumerRecovery: vi.fn(), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - reconcileSshRemotePtyLeasesForTarget: vi.fn(), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn(), - markSshRemotePtyLeasesAsync: vi.fn(), - markSshRemotePtyLeasesAttachedAsync: vi.fn(), - getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]), - pruneExpiredSshRemotePtyKillIntents: vi.fn(), - recordSshRemotePtyKillIntent: vi.fn(), - clearSshRemotePtyKillIntent: vi.fn(), - noteSshRemotePtyKillReplayAttempt: vi.fn() - } as unknown as Store - const portForwardManager = { - removeAllForwards: vi.fn().mockResolvedValue(undefined) - } as unknown as SshPortForwardManager - const getMainWindow = vi.fn().mockReturnValue({ - isDestroyed: () => false, - webContents: { send: vi.fn() } - }) - return new SshRelaySession(targetId, getMainWindow, store, portForwardManager) -} - -async function waitForStatusCount(events: CapturedStatus[], count: number): Promise { - await vi.waitFor(() => expect(events).toHaveLength(count), { timeout: 1500 }) -} - -function captureAgentStatuses(events: CapturedStatus[]): void { - agentHookServer.setListener((event) => { - events.push({ - paneKey: event.paneKey, - tabId: event.tabId, - worktreeId: event.worktreeId, - connectionId: event.connectionId, - payload: { - state: event.payload.state, - ...(event.payload.workingMode ? { workingMode: event.payload.workingMode } : {}), - prompt: event.payload.prompt, - agentType: event.payload.agentType, - toolName: event.payload.toolName - } - }) - }) -} - -function makeEnvelope(overrides: Partial = {}): AgentHookRelayEnvelope { - return { - source: 'codex', - paneKey: `tab-ssh:${SSH_LEAF_ID}`, - tabId: 'tab-ssh', - worktreeId: 'wt-ssh', - connectionId: null, - env: REMOTE_AGENT_HOOK_ENV, - version: '1', - payload: { - state: 'working', - prompt: 'remote prompt', - agentType: 'codex' - }, - ...overrides - } -} - -describe('SshRelaySession agent hooks over a fake relay transport', () => { - let previousRemoteHooksFlag: string | undefined - let warnSpy: ReturnType - let session: InstanceType | null = null - let relay: FakeRelay | null = null - - beforeEach(() => { - vi.clearAllMocks() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 4 }) - previousRemoteHooksFlag = process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] - process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] = '1' - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - agentHookServer.setListener(null) - agentHookInternals.resetCachesForTests() - }) - - afterEach(() => { - session?.dispose() - relay?.dispose() - session = null - relay = null - agentHookServer.setListener(null) - agentHookServer.setPaneStatusClearListener(null) - agentHookInternals.resetCachesForTests() - warnSpy.mockRestore() - if (previousRemoteHooksFlag === undefined) { - delete process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] - } else { - process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] = previousRemoteHooksFlag - } - }) - - it('establishes through a fake relay, spawns a remote PTY, and forwards agent status', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - - session = createSession('conn-fake') - await session.establish({} as SshConnection) - - const provider = getSshPtyProvider('conn-fake') - expect(provider).toBeDefined() - const spawn = await provider!.spawn({ - cols: 120, - rows: 40, - cwd: '/home/orca/project', - env: { - ORCA_PANE_KEY: `tab-ssh:${SSH_LEAF_ID}`, - ORCA_TAB_ID: 'tab-ssh', - ORCA_WORKTREE_ID: 'wt-ssh' - } - }) - - expect(spawn.id).toBe(toAppSshPtyId('conn-fake', 'remote-pty-1')) - expect(relay.ptySpawnRequests).toHaveLength(1) - expect(relay.ptySpawnRequests[0]).toMatchObject({ - cwd: '/home/orca/project', - env: { - ORCA_PANE_KEY: `tab-ssh:${SSH_LEAF_ID}`, - ORCA_TAB_ID: 'tab-ssh', - ORCA_WORKTREE_ID: 'wt-ssh' - } - }) - - relay.notifyAgentHook(makeEnvelope()) - - await waitForStatusCount(events, 1) - expect(events[0]).toEqual({ - paneKey: `tab-ssh:${SSH_LEAF_ID}`, - tabId: 'tab-ssh', - worktreeId: 'wt-ssh', - connectionId: 'conn-fake', - payload: { - state: 'working', - prompt: 'remote prompt', - agentType: 'codex', - toolName: undefined - } - }) - }) - - it('preserves tmux evidence age and unavailable across the real notification adapter', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - session = createSession('conn-tmux') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Mocked deployment never reads the connection. - await session.establish({} as SshConnection) - const envelope = makeEnvelope({ - source: 'opencode', - evidenceAgeMs: 60_000, - payload: { state: 'done', prompt: 'older inner turn', agentType: 'opencode' } - }) - const before = Date.now() - relay.notifyAgentHook(envelope) - await waitForStatusCount(events, 1) - const row = agentHookServer - .getStatusSnapshot() - .find((entry) => entry.paneKey === envelope.paneKey) - expect(row?.evidenceObservedAt).toBeGreaterThanOrEqual(before - 60_000) - expect(row?.evidenceObservedAt).toBeLessThanOrEqual(Date.now() - 60_000) - relay.notifyAgentHook({ ...envelope, statusUnavailable: true, payload: null }) - await vi.waitFor(() => - expect( - agentHookServer.getStatusSnapshot().find((entry) => entry.paneKey === envelope.paneKey) - ).toBeUndefined() - ) - }) - - it('preserves Claude monitoring mode across the SSH relay boundary', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - session = createSession('conn-monitoring') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - source: 'claude', - claudeRunningNonAgentTask: true, - payload: { - state: 'working', - workingMode: 'monitoring', - prompt: 'watch the build', - agentType: 'claude' - } - }) - ) - - await waitForStatusCount(events, 1) - expect(events[0]).toMatchObject({ - connectionId: 'conn-monitoring', - payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } - }) - expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - workingMode: 'monitoring' - }) - }) - - it('stamps SSH ownership and settles only the exact manual compact identity', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - session = createSession('conn-compact') - await session.establish({} as SshConnection) - - const compactEnvelope = ( - hookEventName: 'UserPromptSubmit' | 'PreCompact' | 'PostCompact', - state: 'working' | 'done' - ): AgentHookRelayEnvelope => - makeEnvelope({ - source: 'claude', - hookEventName, - providerPromptId: - hookEventName === 'UserPromptSubmit' ? PREVIOUS_PROMPT_ID : COMPACT_PROMPT_ID, - compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : 'manual', - providerSession: { key: 'session_id', id: 'claude-session' }, - hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined, - payload: { - state, - prompt: 'work before compact', - agentType: 'claude' - } - }) - - relay.notifyAgentHook(compactEnvelope('UserPromptSubmit', 'working')) - await waitForStatusCount(events, 1) - - // Why: PreCompact fires before the compact is validated, so it may never move the pane — - // over SSH just as locally. - relay.notifyAgentHook(compactEnvelope('PreCompact', 'working')) - await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(1) - - relay.notifyAgentHook({ - ...compactEnvelope('PostCompact', 'done'), - providerPromptId: undefined - }) - await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(1) - - relay.notifyAgentHook(compactEnvelope('PostCompact', 'done')) - await waitForStatusCount(events, 2) - - expect(events.at(-1)).toMatchObject({ - connectionId: 'conn-compact', - payload: { state: 'done', prompt: 'work before compact', agentType: 'claude' } - }) - expect( - agentHookServer._getStateForTests().lastStatusByPaneKey.values().next().value - ).toMatchObject({ - source: 'claude', - providerPromptId: COMPACT_PROMPT_ID, - connectionId: 'conn-compact', - // Why: a compact that finished must not read as a completed turn to notification and - // automation consumers, over SSH just as locally. - payload: { sessionBoundary: true } - }) - - relay.notifyAgentHook(compactEnvelope('PostCompact', 'done')) - await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(2) - }) - - it('keeps stamped status unverifiable across reconnect loss and final shutdown', async () => { - const initialRelay = createFakeRelay() - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay) - .mockResolvedValueOnce({ - transport: initialRelay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - .mockResolvedValueOnce({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const clearListener = vi.fn() - agentHookServer.setPaneStatusClearListener(clearListener) - session = createSession('conn-clear') - await session.establish({} as SshConnection) - initialRelay.notifyAgentHook(makeEnvelope()) - await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1)) - - await session.reconnect({} as SshConnection) - initialRelay.dispose() - - expect(agentHookServer.getStatusSnapshot()).toEqual([ - expect.objectContaining({ connectionId: 'conn-clear', state: 'working' }) - ]) - expect(clearListener).not.toHaveBeenCalled() - session.dispose() - session = null - expect(clearListener).not.toHaveBeenCalled() - }) - - it('asks the fake relay for cached hook replay after the session wires its listener', async () => { - relay = createFakeRelay() - relay.replayEnvelopes.push( - makeEnvelope({ - paneKey: `tab-replay:${REPLAY_LEAF_ID}`, - tabId: 'tab-replay', - worktreeId: 'wt-replay', - payload: { - state: 'waiting', - prompt: 'cached remote prompt', - agentType: 'claude', - toolName: 'Bash' - } - }) - ) - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - - session = createSession('conn-replay') - await session.establish({} as SshConnection) - - await waitForStatusCount(events, 1) - expect(events[0]).toMatchObject({ - paneKey: `tab-replay:${REPLAY_LEAF_ID}`, - tabId: 'tab-replay', - worktreeId: 'wt-replay', - connectionId: 'conn-replay', - payload: { - state: 'waiting', - prompt: 'cached remote prompt', - agentType: 'claude', - toolName: 'Bash' - } - }) - }) - - it('drops malformed remote hook notifications at Orca main before caching', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const events: CapturedStatus[] = [] - captureAgentStatuses(events) - - session = createSession('conn-validate') - await session.establish({} as SshConnection) - - relay.notifyAgentHook({ - source: 'codex', - paneKey: `tab-bad:${BAD_LEAF_ID}`, - connectionId: null, - env: REMOTE_AGENT_HOOK_ENV, - version: '1', - payload: { - state: 'not-a-real-state', - prompt: 'should not be cached', - agentType: 'codex' - } - }) - - await new Promise((resolve) => setImmediate(resolve)) - expect(events).toHaveLength(0) - expect(agentHookServer.getStatusSnapshot()).toEqual([]) - }) - - it('preserves explicit-prompt metadata from remote hook notifications', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - - session = createSession('conn-explicit-prompt') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'retry same prompt', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }) - ) - await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), { - timeout: 1500 - }) - const first = agentHookServer.getStatusSnapshot()[0] - - expect( - agentHookServer.inferInterrupt({ - paneKey: first.paneKey, - baselineUpdatedAt: first.receivedAt, - baselineStateStartedAt: first.stateStartedAt, - baselinePrompt: 'retry same prompt', - baselineAgentType: 'opencode', - intent: 'ctrl-c' - }) - ).toBe(true) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'retry same prompt', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }) - ) - - await vi.waitFor(() => - expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - prompt: 'retry same prompt', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - }) - ) - }) - - it('forwards remote hook transition metadata into main ingest', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote') - - session = createSession('conn-hook-metadata') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - source: 'pi', - hookEventName: 'session_start', - promptInteractionKey: 'command-code-transcript-user-3', - toolUseId: 'toolu-1', - toolAgentId: 'agent-subagent-a', - teammateName: 'reviewer', - toolAgentType: 'Review', - claudeRunningNonAgentTask: true, - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'ssh-relay-session-1', - transcriptPath: '/tmp/ssh-relay-session-1.jsonl' - }, - payload: { - state: 'done', - prompt: '', - agentType: 'pi' - } - }) - ) - - await vi.waitFor(() => - expect(ingestSpy).toHaveBeenCalledWith( - expect.objectContaining({ - hookEventName: 'session_start', - promptInteractionKey: 'command-code-transcript-user-3', - toolUseId: 'toolu-1', - toolAgentId: 'agent-subagent-a', - teammateName: 'reviewer', - toolAgentType: 'Review', - claudeRunningNonAgentTask: true, - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'ssh-relay-session-1', - transcriptPath: '/tmp/ssh-relay-session-1.jsonl' - } - }), - 'conn-hook-metadata' - ) - ) - ingestSpy.mockRestore() - }) - - it('tracks prompt sent from live SSH agent hooks but not replayed hooks', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - - session = createSession('conn-live-telemetry') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'ssh live user prompt', - agentType: 'codex' - } - }) - ) - - await vi.waitFor(() => - expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { - agent_kind: 'codex', - launch_source: 'unknown', - request_kind: 'followup', - nth_repo_added: 4 - }) - ) - - trackMock.mockClear() - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - isReplay: true, - payload: { - state: 'working', - prompt: 'ssh replayed user prompt', - agentType: 'codex' - } - }) - ) - - await new Promise((resolve) => setImmediate(resolve)) - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - }) - - it.each([{ isReplay: 'true' }, { isReplay: null }, { launchToken: 42 }])( - 'rejects malformed OMP authority metadata before forwarding: %j', - async (invalid) => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - session = createSession('conn-omp-invalid') - await session.establish({} as SshConnection) - const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote') - const envelope = makeEnvelope({ - source: 'omp', - hookEventName: 'before_agent_start', - payload: { agentType: 'omp', state: 'working', prompt: 'new turn' } - }) - relay.notifyAgentHook(JSON.parse(JSON.stringify({ ...envelope, ...invalid }))) - await new Promise((resolve) => setImmediate(resolve)) - await new Promise((resolve) => setImmediate(resolve)) - expect(ingestSpy).not.toHaveBeenCalled() - ingestSpy.mockRestore() - } - ) - - it('preserves replay metadata from remote hook notifications', async () => { - relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - - session = createSession('conn-replay-marker') - await session.establish({} as SshConnection) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'replayed prompt', - agentType: 'opencode' - } - }) - ) - await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), { - timeout: 1500 - }) - const first = agentHookServer.getStatusSnapshot()[0] - - expect( - agentHookServer.inferInterrupt({ - paneKey: first.paneKey, - baselineUpdatedAt: first.receivedAt, - baselineStateStartedAt: first.stateStartedAt, - baselinePrompt: 'replayed prompt', - baselineAgentType: 'opencode', - intent: 'ctrl-c' - }) - ).toBe(true) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - isReplay: true, - payload: { - state: 'working', - prompt: 'replayed prompt', - agentType: 'opencode' - } - }) - ) - - await new Promise((resolve) => setImmediate(resolve)) - expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - prompt: 'replayed prompt', - agentType: 'opencode', - interrupted: true - }) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts deleted file mode 100644 index da528f7f95fb..000000000000 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ /dev/null @@ -1,856 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { - acceptOutputDataMock, - muxRequestMock, - onNotificationByMethodMock, - notifyWithSettlementMock, - openConsumerSessionMock, - pauseAdapterMock, - muxDisposeMock, - attachForReconnectMock, - beginMigrationMock, - ptyDataHandlerRef -} = vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - onNotificationByMethodMock: vi.fn(), - notifyWithSettlementMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - pauseAdapterMock: vi.fn(), - muxDisposeMock: vi.fn(), - attachForReconnectMock: vi.fn().mockResolvedValue({}), - beginMigrationMock: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - ptyDataHandlerRef: { current: undefined as undefined | ((payload: unknown) => void) } -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: acceptOutputDataMock, - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 23), - beginSshPtyOutputGenerationMigration: beginMigrationMock, - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}), - applySshPtySourceCancellationProof: vi.fn(), - applySshPtySourceRecoveryCancellationProof: vi.fn() -})) - -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = notifyWithSettlementMock - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = onNotificationByMethodMock.mockImplementation(() => () => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = muxDisposeMock - isDisposed = vi.fn().mockReturnValue(false) - } -})) - -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) -})) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: vi.fn().mockReturnValue(false), - isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockImplementation((handler) => { - ptyDataHandlerRef.current = handler - return () => {} - }) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = attachForReconnectMock - setPtyDeliveryPauseAdapter = pauseAdapterMock - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - setPtyOwnership: vi.fn() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { - clearProviderPtyState, - clearPtyOwnershipForConnection, - deletePtyOwnership, - getSshPtyProvider, - getPtyIdsForConnection, - registerSshPtyProvider, - setPtyOwnership -} = await import('../ipc/pty') -const { closeSshPtyOutputGeneration } = await import('../ipc/ssh-pty-output-intake-registry') -const { applySshPtySourceCancellationProof } = await import('../ipc/ssh-pty-output-intake-registry') -const { applySshPtySourceRecoveryCancellationProof } = - await import('../ipc/ssh-pty-output-intake-registry') -const { getSshPtyAcceptedSourceCheckpoints } = await import('../ipc/ssh-pty-output-intake-registry') -const { installSshPtySourceAckPublisher } = await import('../ipc/ssh-pty-output-intake-registry') -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') - -describe('SshRelaySession data delivery', () => { - beforeEach(() => { - vi.clearAllMocks() - ptyDataHandlerRef.current = undefined - attachForReconnectMock.mockResolvedValue({}) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) - vi.mocked(applySshPtySourceCancellationProof).mockReturnValue(true) - vi.mocked(applySshPtySourceRecoveryCancellationProof).mockReturnValue(true) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease', - ...(options.outputFlowControl - ? { - outputFlowControl: { - version: 1, - windowSu: options.outputFlowControl.requestedWindowSu - } - } - : {}) - }, - resumed: options.resume !== undefined - })) - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - }) - - async function runRecoverySequence(args: { - targetId: string - recoveryEndSu: number - recoveryFrame?: readonly [startSu: number, endSu: number] - liveFrame?: readonly [startSu: number, endSu: number] - }) { - muxRequestMock.mockImplementation(async (method) => - method === 'pty.cancelDelivery' - ? { canceled: true, sentEndSu: args.recoveryEndSu, creditedEndSu: 4 } - : [] - ) - let generation = 0 - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: ++generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: options.resume !== undefined - })) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: `ssh:${args.targetId}@@pty-1`, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const deps = createMockDeps() - const session = new SshRelaySession( - args.targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue([`ssh:${args.targetId}@@pty-1`]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - attachForReconnectMock.mockImplementation(async () => { - queueMicrotask(() => { - if (args.recoveryFrame) { - const [sourceStartSu, sourceEndSu] = args.recoveryFrame - ptyDataHandlerRef.current?.({ - id: `ssh:${args.targetId}@@pty-1`, - data: 'recovery', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: sourceEndSu - sourceStartSu, - source: { - relayPtyId: 'pty-1', - spanId: `new-token:${sourceStartSu}:${sourceEndSu}`, - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu, - sourceEndSu - } - }) - } - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: args.recoveryEndSu - }) - if (args.liveFrame) { - const [sourceStartSu, sourceEndSu] = args.liveFrame - ptyDataHandlerRef.current?.({ - id: `ssh:${args.targetId}@@pty-1`, - data: 'live', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: sourceEndSu - sourceStartSu, - source: { - relayPtyId: 'pty-1', - spanId: `new-token:${sourceStartSu}:${sourceEndSu}`, - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu, - sourceEndSu - } - }) - } - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: args.recoveryEndSu - } - } - }) - await session.reconnect(deps.mockConn) - return { ...deps, session } - } - - it('transfers negotiated owner recovery exactly once across an explicit detach', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' - }) - const first = new SshRelaySession('recovery-target', getMainWindow, mockStore, mockPortForward) - - await first.establish(mockConn) - first.detach() - const second = new SshRelaySession('recovery-target', getMainWindow, mockStore, mockPortForward) - await second.establish(mockConn) - - const firstOpen = openConsumerSessionMock.mock.calls[0]?.[1] - const recoveredOpen = openConsumerSessionMock.mock.calls[1]?.[1] - expect(recoveredOpen).toMatchObject({ - clientInstanceId: firstOpen.clientInstanceId, - resume: { ownerGeneration: 1, ownerLease: 'test-owner-lease' } - }) - - second.dispose() - const fresh = new SshRelaySession('recovery-target', getMainWindow, mockStore, mockPortForward) - await fresh.establish(mockConn) - const freshOpen = openConsumerSessionMock.mock.calls[2]?.[1] - expect(freshOpen.clientInstanceId).not.toBe(firstOpen.clientInstanceId) - expect(freshOpen).not.toHaveProperty('resume') - fresh.dispose() - }) - - it('resumes authenticated ownership from a persisted main-process recovery record', async () => { - const targetId = 'persisted-recovery-target' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 7, - ownerGeneration: 11, - ownerLease: 'persisted-owner-lease', - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }) - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' - }) - - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(openConsumerSessionMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - clientInstanceId: 'persisted-client', - resume: { ownerGeneration: 11, ownerLease: 'persisted-owner-lease' } - }) - ) - expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( - expect.objectContaining({ - targetId, - clientInstanceId: 'persisted-client', - ownerLease: 'test-owner-lease' - }) - ) - session.dispose() - const removeRecovery = mockStore.removeSshPtyConsumerRecovery - expect(removeRecovery).toHaveBeenCalledWith(targetId, 'persisted-client') - }) - - it('voids checkpoints for a fresh claim without a second owner request', async () => { - const targetId = 'fresh-relay-retry' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' - }) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: `ssh:${targetId}@@pty-1`, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const first = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await first.establish(mockConn) - first.detach() - - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'fresh-owner-lease', - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: false - })) - vi.mocked(getPtyIdsForConnection).mockReturnValue([`ssh:${targetId}@@pty-1`]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } - }) - const second = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - const openCallCountBeforeRetry = openConsumerSessionMock.mock.calls.length - - await second.establish(mockConn) - - const retryCalls = openConsumerSessionMock.mock.calls - .slice(openCallCountBeforeRetry) - .map(([, options]) => options) - // Why one call: the relay answers a proof it cannot match with a fresh claim, so the client never - // needs a second, resume-less request to get owner authority back. - expect(retryCalls).toHaveLength(1) - expect(retryCalls[0]).toHaveProperty('resume') - expect(attachForReconnectMock).toHaveBeenCalledWith( - 'pty-1', - undefined, - Object.freeze({ status: 'checkpointUnavailable' }) - ) - second.dispose() - }) - - it('delivers empty transformed relay spans with raw sequence metadata', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const runtime = { onPtyData: vi.fn(() => 17), onPtyExit: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - providerGeneration: number - ptyIncarnation: string - source: { - relayPtyId: string - spanId: string - clientGeneration: number - ownerGeneration: number - deliveryToken: string - sourceStartSu: number - sourceEndSu: number - } - }) => void - const source = { - relayPtyId: 'pty-1', - spanId: 'token-1:0:9', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-1', - sourceStartSu: 0, - sourceEndSu: 9 - } - - onData({ - id: 'ssh-pty-1', - data: '', - sequenceChars: 9, - transformed: true, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - source - }) - - expect(acceptOutputDataMock).toHaveBeenCalledWith({ - id: 'ssh-pty-1', - data: '', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - rawLength: 9, - transformed: true, - source - }) - expect(runtime.onPtyData).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything()) - }) - - it('forwards negotiated source identity to the bounded intake exactly once', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { - id: string - data: string - providerGeneration: number - ptyIncarnation: string - source: { - relayPtyId: string - spanId: string - clientGeneration: number - ownerGeneration: number - deliveryToken: string - sourceStartSu: number - sourceEndSu: number - } - }) => void - const source = { - relayPtyId: 'pty-1', - spanId: 'token-1:0:4', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-1', - sourceStartSu: 0, - sourceEndSu: 4 - } - - onData({ - id: 'ssh-pty-1', - data: 'data', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - source - }) - - expect(acceptOutputDataMock).toHaveBeenCalledOnce() - expect(acceptOutputDataMock).toHaveBeenCalledWith( - expect.objectContaining({ id: 'ssh-pty-1', rawLength: 4, source }) - ) - }) - - it('quarantines missing negotiated source identity before main admission', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - const provider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = provider.onData.mock.calls[0]?.[0] as (payload: Record) => void - - onData({ - id: 'ssh-pty-1', - data: 'data', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1' - }) - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(closeSshPtyOutputGeneration).not.toHaveBeenCalled() - expect(muxDisposeMock).not.toHaveBeenCalled() - }) - - it('keeps unoffered source metadata out of legacy intake', async () => { - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - const provider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = provider.onData.mock.calls[0]?.[0] as (payload: Record) => void - - onData({ - id: 'ssh-pty-1', - data: 'data', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - source: { - relayPtyId: 'pty-1', - spanId: 'token-1:0:4', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-1', - sourceStartSu: 0, - sourceEndSu: 4 - } - }) - - expect(acceptOutputDataMock).toHaveBeenCalledWith( - expect.not.objectContaining({ source: expect.anything() }) - ) - }) - - it('keeps same-build method-not-found fallback token-free', async () => { - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(session.getState()).toBe('ready') - expect(pauseAdapterMock).not.toHaveBeenCalled() - expect(openConsumerSessionMock.mock.calls[0][1]).toHaveProperty('outputFlowControl') - expect(installSshPtySourceAckPublisher).not.toHaveBeenCalled() - }) - - it('publishes negotiated ACK batches through mux settlement', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - const publish = vi.mocked(installSshPtySourceAckPublisher).mock.calls[0]?.[1] - const settled = vi.fn() - const batch = { - acknowledgements: [ - { - id: 'pty-1', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-1', - creditedEndSu: 4 - } - ] - } - - publish?.(batch, settled) - - expect(openConsumerSessionMock.mock.calls[0][1]).toMatchObject({ - outputFlowControl: { requestedWindowSu: 256 * 1024 } - }) - expect(deployAndLaunchRelay).toHaveBeenCalledWith(mockConn, undefined, undefined, 'target-1') - // The ACK publisher consumes the two-valued projection of the write settlement. - const [method, published] = notifyWithSettlementMock.mock.calls[0]! - notifyWithSettlementMock.mock.calls[0]![2]({ outcome: 'accepted' }) - expect([method, published]).toEqual(['pty.ackData', batch]) - expect(settled).toHaveBeenCalledWith({ ok: true }) - }) - - it('offers V1 through reconnect negotiation', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - await session.reconnect(mockConn) - - expect(openConsumerSessionMock.mock.calls[0][1]).toHaveProperty('outputFlowControl') - expect(openConsumerSessionMock.mock.calls[1][1]).toHaveProperty('outputFlowControl') - expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( - 1, - mockConn, - undefined, - undefined, - 'target-1' - ) - expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( - 2, - mockConn, - undefined, - undefined, - 'target-1' - ) - }) - - it('reattaches V1 from an exact checkpoint and quarantines live data until recoveryComplete', async () => { - let generation = 0 - openConsumerSessionMock.mockImplementation(async (_mux, options) => { - generation++ - return { - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: options.resume !== undefined - } - }) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: 'ssh:target-1@@pty-1', - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:target-1@@pty-1']) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - let transferDisposedMux = false - const publishHeldRecovery = (sink: (payload: unknown) => void): void => { - for (const [data, sourceStartSu, sourceEndSu] of [ - ['re', 4, 6], - ['co', 6, 8], - ['live', 8, 12] - ] as const) { - sink({ - id: 'ssh:target-1@@pty-1', - data, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: sourceEndSu - sourceStartSu, - source: { - relayPtyId: 'pty-1', - spanId: `new-token:${sourceStartSu}:${sourceEndSu}`, - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu, - sourceEndSu - } - }) - } - } - const recoveryActivationLease = { - commit: vi.fn(), - retire: vi.fn() - } - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn(async () => true), - transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - publishHeldRecovery(sink) - return recoveryActivationLease - }) - } - attachForReconnectMock.mockImplementation(async () => { - const canceled = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.deliveryCanceled' - )?.[1] as ((params: Record) => void) | undefined - const disposeCount = muxDisposeMock.mock.calls.length - canceled?.({ - id: 'pty-1', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - replacementDeliveryToken: 'new-token' - }) - transferDisposedMux = muxDisposeMock.mock.calls.length !== disposeCount - queueMicrotask(() => { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }) - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, - sourceActivationLease - } - }) - - await session.reconnect(mockConn) - - expect(attachForReconnectMock).toHaveBeenCalledWith( - 'pty-1', - undefined, - expect.objectContaining({ - status: 'checkpoint', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - }) - ) - expect(acceptOutputDataMock.mock.calls.map(([payload]) => payload.data)).toEqual([ - 're', - 'co', - 'live' - ]) - expect(transferDisposedMux).toBe(false) - expect(sourceActivationLease.transferToRecovery).toHaveBeenCalledOnce() - expect(sourceActivationLease.commit).not.toHaveBeenCalled() - expect(sourceActivationLease.rollback).not.toHaveBeenCalled() - expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() - expect(recoveryActivationLease.retire).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:replay', expect.anything()) - - const closeCount = vi.mocked(closeSshPtyOutputGeneration).mock.calls.length - const canceled = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.deliveryCanceled' - )?.[1] as ((params: Record) => void) | undefined - canceled?.({ - id: 'pty-1', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token' - }) - expect(closeSshPtyOutputGeneration).toHaveBeenCalledTimes(closeCount) - }) - - it.each([ - ['gap', [5, 8] as const], - ['overlap', [3, 8] as const], - ['incomplete suffix', [4, 6] as const], - ['missing body', undefined] - ])('rejects %s recovery without destroying the physical PTY or lease', async (label, frame) => { - const targetId = `invalid-recovery-${label.replace(' ', '-')}` - const { mockStore, mockWindow } = await runRecoverySequence({ - targetId, - recoveryEndSu: 8, - ...(frame ? { recoveryFrame: frame } : {}) - }) - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(muxRequestMock).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token' - }) - expect(applySshPtySourceRecoveryCancellationProof).toHaveBeenCalledWith( - { - id: `ssh:${targetId}@@pty-1`, - code: -1, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1' - }, - { sentEndSu: 8, creditedEndSu: 4 } - ) - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(targetId, 'pty-1', 'detached') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(targetId, 'pty-1', 'expired') - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(setPtyOwnership).not.toHaveBeenCalled() - expect(muxDisposeMock).not.toHaveBeenCalledWith('shutdown') - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - }) - - it('accepts empty recovery only when the checkpoint equals the recovery end', async () => { - const { mockStore } = await runRecoverySequence({ - targetId: 'empty-recovery', - recoveryEndSu: 4, - liveFrame: [4, 8] - }) - - expect(acceptOutputDataMock.mock.calls.map(([payload]) => payload.data)).toEqual(['live']) - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('empty-recovery', [ - 'pty-1' - ]) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-incarnation.test.ts b/src/main/ssh/ssh-relay-session-incarnation.test.ts deleted file mode 100644 index 129a98b3ebc9..000000000000 --- a/src/main/ssh/ssh-relay-session-incarnation.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { acceptOutputExitMock, muxRequestMock } = vi.hoisted(() => ({ - acceptOutputExitMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn() -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: vi.fn(async (_mux, options) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - })) -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: acceptOutputExitMock, - allocateSshPtyProviderGeneration: vi.fn(() => 31), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { registerSshPtyProvider, clearProviderPtyState, deletePtyOwnership, isCurrentPtyExit } = - await import('../ipc/pty') - -describe('SSH relay PTY incarnation exits', () => { - beforeEach(() => { - vi.clearAllMocks() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(isCurrentPtyExit).mockReturnValue(true) - }) - - it('drops a stale exit before ownership cleanup and propagates a current incarnation', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - const provider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onExit: ReturnType - } - const onExit = provider.onExit.mock.calls[0]?.[0] as (payload: { - id: string - code: number - incarnationId: string - providerGeneration: number - ptyIncarnation: string - }) => void - vi.mocked(isCurrentPtyExit).mockReturnValueOnce(false) - - onExit({ - id: 'ssh:target-1@@pty-reused', - code: 0, - incarnationId: 'old-incarnation', - providerGeneration: 31, - ptyIncarnation: 'old-incarnation' - }) - - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() - expect(acceptOutputExitMock).not.toHaveBeenCalled() - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - - onExit({ - id: 'ssh:target-1@@pty-reused', - code: 7, - incarnationId: 'current-incarnation', - providerGeneration: 31, - ptyIncarnation: 'current-incarnation' - }) - await vi.waitFor(() => - expect(acceptOutputExitMock).toHaveBeenCalledWith({ - id: 'ssh:target-1@@pty-reused', - code: 7, - providerGeneration: 31, - ptyIncarnation: 'current-incarnation' - }) - ) - expect(runtime.onPtyExit).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts deleted file mode 100644 index 1ef197e170d0..000000000000 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - AGENT_HOOK_INSTALL_PLUGINS_METHOD -} from '../../shared/agent-hook-relay' -import { getDefaultSettings } from '../../shared/constants' -import type { Store } from '../persistence' -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn() -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: vi.fn(() => false), - isSshPtyIdentityMismatchError: vi.fn(() => false), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn() -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { registerSshPtyProvider } = await import('../ipc/pty') - -describe('SshRelaySession managed hooks', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - mockDeploySuccess() - }) - - it('installs only detected hooks without blocking provider registration', async () => { - muxRequestMock.mockImplementation(async (method: string) => { - if (method === 'preflight.detectAgents') { - return { agents: ['codex'] } - } - return method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD - ? { installers: 1, errors: 0 } - : { ok: true } - }) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const sftp = vi.fn() - const connection = { - sftp, - getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') - } as unknown as SshConnection - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(connection) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, { - hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - agents: ['codex'] - }) - ) - - const managedIndex = muxRequestMock.mock.calls.findIndex( - ([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD - ) - const pluginsIndex = muxRequestMock.mock.calls.findIndex( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ) - expect(muxRequestMock.mock.calls[pluginsIndex]?.[1]).toMatchObject({ - opencode2PluginSource: expect.stringContaining('/hook/opencode2'), - piExtensionSource: expect.stringContaining('/hook/pi'), - ompExtensionSource: expect.stringContaining('/hook/omp'), - primeAgentExtensionSource: expect.stringContaining('/hook/prime-agent') - }) - expect(sftp).not.toHaveBeenCalled() - expect(muxRequestMock.mock.invocationCallOrder[pluginsIndex]).toBeLessThan( - vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0] - ) - expect(vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]).toBeLessThan( - muxRequestMock.mock.invocationCallOrder[managedIndex] - ) - }) - - it('forwards the execution-host Claude version to the remote installer', async () => { - muxRequestMock.mockImplementation(async (method: string) => { - if (method === 'preflight.detectAgents') { - return { - agents: ['claude'], - versions: { claude: '2.1.261 (Claude Code)' } - } - } - return method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD - ? { installers: 1, errors: 0 } - : { ok: true } - }) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: establish only reads these mocked connection members in this harness. - const connection = { - sftp: vi.fn(), - getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') - } as unknown as SshConnection - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(connection) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, { - hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - agents: ['claude'], - claudeVersion: '2.1.261' - }) - ) - }) - it('refreshes OpenCode sources on settings changes and releases its subscription', async () => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - settings.disabledTuiAgents = ['opencode'] - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - const cleanup = vi.fn(() => { - listener = undefined - }) - mockStore.onSettingsChanged = (callback) => { - listener = callback - return cleanup - } - const session = new SshRelaySession( - 'target-settings', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - const lastSources = () => - muxRequestMock.mock.calls.findLast( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - )?.[1] - expect(lastSources()).toMatchObject({ - opencodePluginSource: '', - opencode2PluginSource: expect.stringContaining('/hook/opencode2') - }) - settings.disabledTuiAgents = ['opencode2'] - listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) - expect(lastSources()).toMatchObject({ - opencodePluginSource: expect.stringContaining('/hook/opencode'), - opencode2PluginSource: '' - }) - settings.agentStatusHooksEnabled = false - listener?.({ agentStatusHooksEnabled: false }, settings) - expect(lastSources()).toMatchObject({ opencodePluginSource: '', opencode2PluginSource: '' }) - session.dispose() - expect(cleanup).toHaveBeenCalledOnce() - }) - - // Why: a timed-out install left the relay on the old sources (a disabled agent kept its plugin) until the next reconnect. - it('retries a settings-triggered plugin install that failed, using the latest settings', async () => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - mockStore.onSettingsChanged = (callback) => { - listener = callback - return vi.fn() - } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-settings', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - const installCalls = () => - muxRequestMock.mock.calls.filter(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) - - vi.useFakeTimers() - try { - const before = installCalls().length - muxRequestMock.mockImplementationOnce(async (method: string) => { - if (method !== AGENT_HOOK_INSTALL_PLUGINS_METHOD) { - return { agents: [] } - } - throw Object.assign(new Error('timed out'), { code: 'SSH_MUX_REQUEST_TIMEOUT' }) - }) - settings.disabledTuiAgents = ['opencode2'] - listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) - await vi.advanceTimersByTimeAsync(60_000) - - const calls = installCalls().slice(before) - expect(calls.length).toBeGreaterThan(1) - expect(calls.at(-1)?.[1]).toMatchObject({ opencode2PluginSource: '' }) - } finally { - vi.useRealTimers() - warn.mockRestore() - session.dispose() - } - }) - - // Why: two overlapping failed installs must leave one retry chain, not two the next install can't cancel. - it('retries once when overlapping settings-triggered installs both fail', async () => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - mockStore.onSettingsChanged = (callback) => { - listener = callback - return vi.fn() - } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-settings', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - const installCalls = () => - muxRequestMock.mock.calls.filter(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) - - vi.useFakeTimers() - try { - const before = installCalls().length - const pendingInstalls: ((error: Error) => void)[] = [] - muxRequestMock.mockImplementation((method: string) => { - if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD && pendingInstalls.length < 2) { - return new Promise((_resolve, reject) => pendingInstalls.push(reject)) - } - return Promise.resolve({ agents: [] }) - }) - settings.disabledTuiAgents = ['opencode2'] - listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) - settings.disabledTuiAgents = ['opencode'] - listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) - expect(pendingInstalls).toHaveLength(2) - - for (const reject of pendingInstalls) { - reject(Object.assign(new Error('timed out'), { code: 'SSH_MUX_REQUEST_TIMEOUT' })) - } - await vi.advanceTimersByTimeAsync(60_000) - - expect(installCalls().slice(before)).toHaveLength(3) - } finally { - vi.useRealTimers() - warn.mockRestore() - session.dispose() - } - }) - it.each(['PERMISSION_DENIED', -32601, 'CONNECTION_LOST', 'DISPOSED'])( - 'does not retry permanent or retired transport failure %s', - async (code) => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - mockStore.onSettingsChanged = (callback) => { - listener = callback - return vi.fn() - } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-no-retry', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - warn.mockClear() - vi.useFakeTimers() - try { - const before = muxRequestMock.mock.calls.filter( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ).length - muxRequestMock.mockRejectedValueOnce(Object.assign(new Error('controlled error'), { code })) - listener?.({ disabledTuiAgents: ['opencode2'] }, settings) - await vi.advanceTimersByTimeAsync(60_000) - expect( - muxRequestMock.mock.calls.filter( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ) - ).toHaveLength(before + 1) - expect(warn).toHaveBeenCalledTimes(code === 'PERMISSION_DENIED' ? 1 : 0) - } finally { - vi.useRealTimers() - warn.mockRestore() - session.dispose() - } - } - ) - it.each([false, true])( - 'bounds timeout retries and cancels them on disposal=%s', - async (dispose) => { - muxRequestMock.mockResolvedValue({ agents: [] }) - const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() - const settings = getDefaultSettings('/synthetic-home') - mockStore.getSettings = () => settings - let listener: Parameters[0] | undefined - mockStore.onSettingsChanged = (callback) => { - listener = callback - return vi.fn() - } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-bounded', - getMainWindow, - mockStore, - mockPortForward - ) - await session.establish(mockConn) - const before = muxRequestMock.mock.calls.filter( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ).length - muxRequestMock.mockImplementation(async (method) => { - if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) { - throw Object.assign(new Error('timeout'), { code: 'SSH_MUX_REQUEST_TIMEOUT' }) - } - return { agents: [] } - }) - vi.useFakeTimers() - try { - listener?.({ disabledTuiAgents: ['opencode2'] }, settings) - if (dispose) { - session.dispose() - } - await vi.advanceTimersByTimeAsync(60_000) - expect( - muxRequestMock.mock.calls.filter( - ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD - ) - ).toHaveLength(before + (dispose ? 1 : 4)) - } finally { - vi.useRealTimers() - warn.mockRestore() - session.dispose() - } - } - ) -}) diff --git a/src/main/ssh/ssh-relay-session-model-migration.test.ts b/src/main/ssh/ssh-relay-session-model-migration.test.ts deleted file mode 100644 index ae5db55e0cc3..000000000000 --- a/src/main/ssh/ssh-relay-session-model-migration.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -type SettledMigration = { - status: 'settled' - checkpoint: { - id: string - providerGeneration: number - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - deliveryToken: string - acceptedSourceEndSu: number - } -} - -const { attachForReconnectMock, beginMigrationMock, muxRequestMock, openConsumerSessionMock } = - vi.hoisted(() => ({ - attachForReconnectMock: vi.fn().mockResolvedValue({}), - beginMigrationMock: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn() - })) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 23), - beginSshPtyOutputGenerationMigration: beginMigrationMock, - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}), - applySshPtySourceCancellationProof: vi.fn(), - applySshPtySourceRecoveryCancellationProof: vi.fn() -})) - -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) - -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = attachForReconnectMock - setPtyDeliveryPauseAdapter = vi.fn() - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - setPtyOwnership: vi.fn() -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { getPtyIdsForConnection, getSshPtyProvider, registerSshPtyProvider } = - await import('../ipc/pty') -const { getSshPtyAcceptedSourceCheckpoints } = await import('../ipc/ssh-pty-output-intake-registry') - -function pendingMigration() { - let resolve!: (result: SettledMigration) => void - const result = new Promise((promiseResolve) => { - resolve = promiseResolve - }) - return { result, resolve } -} - -function settledMigration(appPtyId: string, acceptedSourceEndSu: number): SettledMigration { - return { - status: 'settled', - checkpoint: { - id: appPtyId, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu - } - } -} - -describe('SshRelaySession model migration', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'owner-lease-1', - ...(options.outputFlowControl - ? { - outputFlowControl: { - version: 1, - windowSu: options.outputFlowControl.requestedWindowSu - } - } - : {}) - }, - resumed: options.resume !== undefined - })) - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - }) - - it('keeps overlapping reconnects behind the old-generation model migration', async () => { - const targetId = 'migration-fence' - const appPtyId = `ssh:${targetId}@@pty-1` - const migration = pendingMigration() - beginMigrationMock.mockReturnValueOnce({ - byPty: new Map([[appPtyId, migration.result]]), - completion: migration.result.then(() => {}) - }) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: appPtyId, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const deps = createMockDeps() - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue([appPtyId]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'relayRestoreRequired' } - }) - - const firstReconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(registerSshPtyProvider).toHaveBeenCalledTimes(2)) - const secondReconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(beginMigrationMock).toHaveBeenCalledTimes(2)) - expect(attachForReconnectMock).not.toHaveBeenCalled() - - migration.resolve(settledMigration(appPtyId, 8)) - await Promise.all([firstReconnect, secondReconnect]) - - expect(attachForReconnectMock).toHaveBeenCalledWith( - 'pty-1', - undefined, - Object.freeze({ - status: 'checkpoint', - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 8 - }) - ) - }) - - it('waits for a voided-checkpoint migration before requesting restore', async () => { - const targetId = 'migration-stale-owner' - const appPtyId = `ssh:${targetId}@@pty-1` - const migration = pendingMigration() - beginMigrationMock.mockReturnValueOnce({ - byPty: new Map([[appPtyId, migration.result]]), - completion: migration.result.then(() => {}) - }) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - settledMigration(appPtyId, 4).checkpoint - ]) - const deps = createMockDeps() - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue([appPtyId]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 2, - ownerGeneration: 2, - ownerLease: 'fresh-owner-lease', - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: false - })) - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } - }) - - const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(openConsumerSessionMock).toHaveBeenCalledTimes(2)) - expect(attachForReconnectMock).not.toHaveBeenCalled() - - migration.resolve(settledMigration(appPtyId, 8)) - await reconnect - - expect(attachForReconnectMock).toHaveBeenCalledWith( - 'pty-1', - undefined, - Object.freeze({ status: 'checkpointUnavailable' }) - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-orphan-sweep.test.ts b/src/main/ssh/ssh-relay-session-orphan-sweep.test.ts deleted file mode 100644 index fd4cbea8c341..000000000000 --- a/src/main/ssh/ssh-relay-session-orphan-sweep.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -// #9819 end to end on the client: the sweep runs only after reattach, only under a negotiated -// session-owner grant, and only against PTYs this relay itself attributes to this client. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(async (_mux: unknown, options: { clientInstanceId: string }) => ({ - state: { - mode: 'negotiated' as const, - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - }, - resumed: false - })) -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 17), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-remote-orca-cli', () => ({ - runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn() -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { getSshPtyProvider, getPtyIdsForConnection } = await import('../ipc/pty') - -const OUR_CLIENT = 'client-instance-1' - -// One target per test. `claimSshPtyConsumerRecovery` keeps a module-level map keyed on target and -// mints a FRESH clientInstanceId whenever it is asked for a target it already holds a live entry -// for — so a second `establish` on a shared target silently stops matching the host attestation and -// every assertion after the first passes for the wrong reason. -let targetSeq = 0 -function nextTarget(): string { - targetSeq += 1 - return `target-${targetSeq}` -} - -const OBSERVATION = { authorityGeneration: 'gen-1', observationEpoch: 1, capturedAgeMs: 0 } - -function hostEntry( - target: string, - overrides: Record = {} -): Record { - return { - id: `ssh:${target}@@pty-orphan`, - incarnationId: 'inc-orphan', - cwd: '/home/user', - title: 'zsh', - // The relay stamps this from the live consumer grant, so it names THIS client. - ownerClientInstanceId: OUR_CLIENT, - hostAgeMs: 120_000, - paneBound: true, - // The same listing's host observation: the shell owns the terminal, nothing is running. - foregroundProcessEvidence: { - ...OBSERVATION, - verdict: 'live', - processName: null, - shellOwnsEveryTtyProcessGroup: true - }, - ...overrides - } -} - -describe('SshRelaySession orphaned relay PTY sweep', () => { - let warn: ReturnType - - beforeEach(() => { - vi.clearAllMocks() - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - }) - - afterEach(() => { - warn.mockRestore() - }) - - async function establish( - target: string, - processes: Record[], - leases: { ptyId: string; state: string }[] = [] - ): Promise<{ shutdown: ReturnType; listProcesses: ReturnType }> { - const deps = createMockDeps() - // Why the recovery row: it pins this session's clientInstanceId, and the comparison is - // meaningless unless the id it uses is the persisted one. - vi.mocked(deps.mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId: target, - clientInstanceId: OUR_CLIENT, - serverBuildId: 'build-1', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - } as ReturnType) - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue( - leases.map((lease) => ({ targetId: target, ...lease })) as ReturnType< - typeof deps.mockStore.getSshRemotePtyLeases - > - ) - const shutdown = vi.fn().mockResolvedValue(undefined) - const listProcesses = vi.fn().mockResolvedValue(processes) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({}), - listProcesses, - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - - const session = new SshRelaySession( - target, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - // Both guards exist because every "never stops" case below is trivially satisfiable. The pass - // has to have run, and it has to have run under the identity the host attests — a session that - // minted a fresh one compares against nothing and skips everything for the wrong reason. - expect(listProcesses).toHaveBeenCalledTimes(1) - expect(warn).not.toHaveBeenCalledWith( - expect.stringContaining('minting a new consumer identity') - ) - return { shutdown, listProcesses } - } - - it('stops an attested orphan the client has no lease for', async () => { - const target = nextTarget() - const { shutdown } = await establish(target, [hostEntry(target)]) - - expect(shutdown).toHaveBeenCalledWith( - `ssh:${target}@@pty-orphan`, - expect.objectContaining({ immediate: true, expectedIncarnationId: 'inc-orphan' }) - ) - }) - - it('never stops a PTY that still holds a live lease', async () => { - const target = nextTarget() - const { shutdown } = await establish( - target, - [hostEntry(target, { id: `ssh:${target}@@pty-live`, incarnationId: 'inc-live' })], - [{ ptyId: 'pty-live', state: 'detached' }] - ) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops a PTY whose lease this client expired rather than ordered stopped', async () => { - // What reaches this state in the field: a pane re-leased under a new relay id, a reattach that - // failed on the transport (dropStalePty), or a pane surface missing from the layout. All three - // leave the remote process running on purpose. - const target = nextTarget() - const { shutdown } = await establish( - target, - [hostEntry(target, { id: `ssh:${target}@@pty-gone`, incarnationId: 'inc-gone' })], - [{ ptyId: 'pty-gone', state: 'expired' }] - ) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops a pane this relay observes running a foreground process', async () => { - // agentSessionOwners is empty here — the user typed `claude` themselves — so the only thing - // between a live agent and a stop is the host's own foreground observation. - const target = nextTarget() - const { shutdown } = await establish(target, [ - hostEntry(target, { - foregroundProcessEvidence: { - ...OBSERVATION, - verdict: 'live', - processName: 'claude', - shellOwnsEveryTtyProcessGroup: false - } - }) - ]) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops a pane whose foreground observation the relay could not make', async () => { - const target = nextTarget() - const { shutdown } = await establish(target, [ - hostEntry(target, { - foregroundProcessEvidence: { - ...OBSERVATION, - verdict: 'unverifiable', - reason: 'table_unreadable' - } - }) - ]) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops a PTY this relay attributes to a different client', async () => { - const target = nextTarget() - const { shutdown } = await establish(target, [ - hostEntry(target, { ownerClientInstanceId: 'someone-elses-laptop' }) - ]) - - expect(shutdown).not.toHaveBeenCalled() - }) - - it('never stops anything a relay predating the attestation lists', async () => { - const target = nextTarget() - const legacy = hostEntry(target) - delete legacy.ownerClientInstanceId - delete legacy.hostAgeMs - delete legacy.paneBound - delete legacy.foregroundProcessEvidence - - const { shutdown } = await establish(target, [legacy]) - - expect(shutdown).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts b/src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts deleted file mode 100644 index 5eeb78fdea1e..000000000000 --- a/src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { muxRequestMock, openConsumerSessionMock, listProcessesMock, shutdownMock } = vi.hoisted( - () => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - listProcessesMock: vi.fn(), - shutdownMock: vi.fn() - }) -) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 41), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = vi.fn() - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ - dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined), - attachForReconnect: vi.fn().mockResolvedValue({}), - listProcesses: listProcessesMock, - shutdown: shutdownMock - }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const NOW = 1_800_000_000_000 - -describe('SshRelaySession pending PTY kill replay', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.spyOn(console, 'log').mockImplementation(() => {}) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - }) - - it('replays a stop the client could not deliver, before reattach reads the leases', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const order: string[] = [] - vi.mocked(mockStore.getSshRemotePtyKillIntents).mockReturnValue([ - { ptyId: 'pty-3', intent: { requestedAt: NOW, incarnationId: 'inc-a', attempts: 0 } } - ]) - vi.mocked(mockStore.getSshRemotePtyLeases).mockImplementation(() => { - order.push('read-leases') - return [] - }) - let live = true - listProcessesMock.mockImplementation(async () => - live ? [{ id: 'ssh:target-1@@pty-3', incarnationId: 'inc-a' }] : [] - ) - shutdownMock.mockImplementation(async (id: string) => { - order.push(`shutdown:${id}`) - live = false - }) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(shutdownMock).toHaveBeenCalledWith('ssh:target-1@@pty-3', { - immediate: true, - expectedIncarnationId: 'inc-a' - }) - expect(order).toEqual(['shutdown:ssh:target-1@@pty-3', 'read-leases']) - expect(mockStore.clearSshRemotePtyKillIntent).toHaveBeenCalledWith('target-1', 'pty-3') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-3', 'terminated') - }) - - it('costs no host round trip when nothing is pending', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(listProcessesMock).not.toHaveBeenCalled() - expect(shutdownMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts b/src/main/ssh/ssh-relay-session-plain-ssh.test.ts deleted file mode 100644 index a850fa275087..000000000000 --- a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ClientChannel } from 'ssh2' -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import { createMockDeps } from './ssh-relay-session-test-fixtures' -import { - RelayRuntimeLadderRun, - RemoteRuntimeUnavailableError -} from './ssh-relay-runtime-resolution' -import { getSshPlainSshMode } from './ssh-plain-ssh-mode' -import { SshPlainShellPtyProvider } from '../providers/ssh-plain-shell-pty-provider' -import { SshSftpFilesystemProvider } from '../providers/ssh-sftp-filesystem-provider' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type * as IntakeRegistry from '../ipc/ssh-pty-output-intake-registry' - -type IntakeRegistryModule = typeof IntakeRegistry - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn().mockReturnValue(true) -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -vi.mock('../ipc/ssh-pty-output-intake-registry', async (importOriginal) => ({ - ...(await importOriginal()), - acceptSshPtyOutputData: vi.fn().mockResolvedValue({}), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - closeSshPtyOutputGeneration: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const pty = await import('../ipc/pty') -const fsDispatch = await import('../providers/ssh-filesystem-dispatch') -const intake = await import('../ipc/ssh-pty-output-intake-registry') - -class FakeShellChannel extends EventEmitter { - readonly stderr = new EventEmitter() - readonly write = vi.fn() - readonly setWindow = vi.fn() - readonly close = vi.fn() -} - -function runtimeUnavailable(): RemoteRuntimeUnavailableError { - const run = new RelayRuntimeLadderRun('target-1', null, true) - run.host = getRemoteHostPlatform('linux-x64') - run.refused('A', 'libc_floor') - run.refused('C', 'host_node_missing') - return new RemoteRuntimeUnavailableError(run) -} - -function createConnection(systemSsh = false) { - const channel = new FakeShellChannel() - const conn = { - usesSystemSshTransport: () => systemSsh, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeShellChannel implements every ClientChannel member the provider touches. - shell: vi.fn(async () => channel as unknown as ClientChannel), - sftp: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Plain SSH mode only calls usesSystemSshTransport, shell and sftp on the connection. - return { conn: conn as unknown as SshConnection, channel, shell: conn.shell } -} - -function registeredPlainProvider(): SshPlainShellPtyProvider { - const provider = vi.mocked(pty.registerSshPtyProvider).mock.calls[0]?.[1] - if (!(provider instanceof SshPlainShellPtyProvider)) { - throw new Error('plain SSH PTY provider was not registered') - } - return provider -} - -function createSession() { - const deps = createMockDeps() - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - return { session, deps } -} - -describe('SshRelaySession plain SSH mode (runtime rung D)', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(deployAndLaunchRelay).mockRejectedValue(runtimeUnavailable()) - }) - - it('connects with shell and SFTP providers and records the classified reason', async () => { - const { session } = createSession() - const onReady = vi.fn() - session.setOnReady(onReady) - const { conn } = createConnection() - - await session.establish(conn) - - expect(session.getState()).toBe('ready') - expect(onReady).toHaveBeenCalledWith('target-1') - expect(session.getMux()).toBeNull() - expect(vi.mocked(pty.registerSshPtyProvider).mock.calls[0]?.[1]).toBeInstanceOf( - SshPlainShellPtyProvider - ) - expect(vi.mocked(fsDispatch.registerSshFilesystemProvider).mock.calls[0]?.[1]).toBeInstanceOf( - SshSftpFilesystemProvider - ) - expect(getSshPlainSshMode('target-1')).toMatchObject({ reason: 'no_runtime' }) - expect(getSshPlainSshMode('target-1')?.message).toContain('plain SSH terminals') - }) - - it('keeps the OS the runtime ladder detected so agent launches quote for that host', async () => { - const { session } = createSession() - const { conn } = createConnection() - - await session.establish(conn) - - expect(session.getHostPlatform()).toBeNull() - expect(session.getPlainSshSession()?.remotePlatform).toBe('linux') - }) - - it('routes shell output and a proven exit through the SSH output intake', async () => { - const { session, deps } = createSession() - const { conn, channel, shell } = createConnection() - await session.establish(conn) - const provider = registeredPlainProvider() - - const { id } = await provider.spawn({ cols: 90, rows: 20 }) - expect(shell).toHaveBeenCalledWith({ cols: 90, rows: 20, term: 'xterm-256color' }) - channel.emit('data', Buffer.from('$ ')) - expect(intake.acceptSshPtyOutputData).toHaveBeenCalledWith( - expect.objectContaining({ id, data: '$ ', rawLength: 2 }) - ) - - channel.emit('exit', 0) - channel.emit('close') - await vi.waitFor(() => - expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'target-1', - expect.stringContaining('plain-'), - 'terminated' - ) - ) - expect(intake.acceptSshPtyOutputExit).toHaveBeenCalledWith( - expect.objectContaining({ id, code: 0 }) - ) - }) - - it('leaves open shells unverifiable when the session is torn down', async () => { - const { session } = createSession() - const { conn, channel } = createConnection() - await session.establish(conn) - const provider = registeredPlainProvider() - const { id } = await provider.spawn({ cols: 80, rows: 24 }) - - session.detach() - channel.emit('close') - - expect(intake.acceptSshPtyOutputExit).not.toHaveBeenCalled() - expect(intake.closeSshPtyOutputGeneration).toHaveBeenCalledWith( - provider.providerGeneration, - 'connection_lost' - ) - await expect(provider.probePtyLiveness(id)).resolves.toBeNull() - expect(getSshPlainSshMode('target-1')).toBeUndefined() - }) - - it('lets only the current reconnect enter plain SSH mode', async () => { - const { session } = createSession() - const onReady = vi.fn() - session.setOnReady(onReady) - const { conn } = createConnection() - await session.establish(conn) - onReady.mockClear() - vi.mocked(pty.registerSshPtyProvider).mockClear() - - let rejectStale: (error: Error) => void = () => {} - vi.mocked(deployAndLaunchRelay).mockImplementationOnce( - () => new Promise((_resolve, reject) => (rejectStale = reject)) - ) - const stale = session.reconnect(conn) - await vi.waitFor(() => expect(deployAndLaunchRelay).toHaveBeenCalledTimes(2)) - const current = session.reconnect(conn) - await current - rejectStale(runtimeUnavailable()) - await stale - - expect(onReady).toHaveBeenCalledTimes(1) - expect(pty.registerSshPtyProvider).toHaveBeenCalledTimes(1) - expect(session.getPlainSshSession()).not.toBeNull() - session.detach() - }) - - it('still fails the connect on system SSH, which has no shell or SFTP channel', async () => { - const { session } = createSession() - const { conn } = createConnection(true) - - await expect(session.establish(conn)).rejects.toBeInstanceOf(RemoteRuntimeUnavailableError) - expect(pty.registerSshPtyProvider).not.toHaveBeenCalled() - expect(getSshPlainSshMode('target-1')).toBeUndefined() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts b/src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts deleted file mode 100644 index 0a4d41267dc9..000000000000 --- a/src/main/ssh/ssh-relay-session-previous-relay-hold.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// After an app update the new relay answers "not found" for a PTY the previous build's relay still -// runs. While that older relay is live, the session must keep the lease and the pane instead of -// disowning the id, which is what replaced a user's running terminal with an empty shell. -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { acceptOutputDataMock, muxRequestMock, openConsumerSessionMock, pauseAdapterMock } = - vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - pauseAdapterMock: vi.fn() - })) - -vi.mock('./ssh-relay-deploy', () => ({ - deployAndLaunchRelay: vi.fn() -})) - -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) - -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: acceptOutputDataMock, - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 41), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - -vi.mock('./ssh-channel-multiplexer', () => { - return { - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } - } -}) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('not found'), - isSshPtyIdentityMismatchError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('identity mismatch'), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = pauseAdapterMock - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ - dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined), - attachForReconnect: vi.fn().mockResolvedValue({}) - }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { isReattachHeldByPreviousRelay, startPreviousRelayCensus } = vi.hoisted(() => ({ - isReattachHeldByPreviousRelay: vi.fn(), - startPreviousRelayCensus: vi.fn() -})) -vi.mock('./ssh-previous-relay-terminals', () => ({ - isReattachHeldByPreviousRelay, - startPreviousRelayCensus -})) - -const { attachHeldPtyThroughPreviousRelay } = vi.hoisted(() => ({ - attachHeldPtyThroughPreviousRelay: vi.fn() -})) -vi.mock('../providers/ssh-pty-legacy-relay-delegation', () => ({ - attachHeldPtyThroughPreviousRelay -})) - -const { - getSshPtyProvider, - getPtyIdsForConnection, - clearProviderPtyState, - deletePtyOwnership, - setPtyOwnership -} = await import('../ipc/pty') - -describe('SshRelaySession reattach while a previous relay is live', () => { - beforeEach(() => { - vi.clearAllMocks() - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - attachHeldPtyThroughPreviousRelay.mockResolvedValue(null) - }) - - async function reconnectWithStalePty(holds: boolean) { - const deps = createMockDeps() - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.clearAllMocks() - mockDeploySuccess() - isReattachHeldByPreviousRelay.mockResolvedValue(holds) - // The reconnect reaches only these two members of the provider. - vi.mocked(getSshPtyProvider).mockReturnValue( - Object.assign(Object.create(null), { - attachForReconnect: vi.fn().mockRejectedValue(new Error('PTY "pty-old" not found')), - dispose: vi.fn() - }) - ) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-old']) - await session.reconnect(deps.mockConn) - return deps - } - - it('starts a census of older relays on every deploy', async () => { - await reconnectWithStalePty(false) - expect(startPreviousRelayCensus).toHaveBeenCalledWith( - expect.anything(), - 'target-1', - expect.objectContaining({ platform: 'linux-x64' }) - ) - }) - - it('keeps the lease and the pane when an older relay may run the PTY', async () => { - const { mockStore, mockWindow } = await reconnectWithStalePty(true) - - expect(isReattachHeldByPreviousRelay).toHaveBeenCalledWith( - 'target-1', - expect.objectContaining({ message: 'PTY "pty-old" not found' }) - ) - expect(clearProviderPtyState).not.toHaveBeenCalledWith('ssh:target-1@@pty-old') - expect(deletePtyOwnership).not.toHaveBeenCalledWith('ssh:target-1@@pty-old') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-old', - 'expired' - ) - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'pty:exit', - expect.objectContaining({ id: 'ssh:target-1@@pty-old' }) - ) - }) - - it('disowns the id as before when no older relay may run it', async () => { - const { mockStore, mockWindow } = await reconnectWithStalePty(false) - - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-old', 'expired') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { - id: 'ssh:target-1@@pty-old', - code: -1, - ptySourceDisowned: true - }) - }) - - it('resumes a held PTY on reconnect through the older relay that serves it', async () => { - attachHeldPtyThroughPreviousRelay.mockResolvedValue({ replay: 'old screen' }) - const { mockStore, mockWindow } = await reconnectWithStalePty(true) - - expect(attachHeldPtyThroughPreviousRelay).toHaveBeenCalledWith( - expect.anything(), - 'ssh:target-1@@pty-old', - undefined - ) - expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-old', 'ssh:target-1') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { - id: 'ssh:target-1@@pty-old', - data: 'old screen' - }) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-old', - 'expired' - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts deleted file mode 100644 index 54ce869a7b96..000000000000 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ /dev/null @@ -1,830 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { randomUUID } from 'node:crypto' -import type * as NodeCrypto from 'node:crypto' -import { SshRelaySession } from './ssh-relay-session' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { - createMockDeps, - mockDeploySuccess, - recordedPtyBindings -} from './ssh-relay-session-test-fixtures' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { SshRemotePtyLease } from '../../shared/ssh-types' - -type MockMuxInstance = { - requestHandlers: Map) => Promise> -} - -const { acceptOutputExitMock, muxRequestMock, openConsumerSessionMock, muxInstancesRaw } = - vi.hoisted(() => ({ - acceptOutputExitMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn( - async ( - _mux: unknown, - options: { clientInstanceId: string; outputFlowControl?: unknown } - ) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - }) - ), - muxInstancesRaw: [] as unknown[] - })) -const muxInstances = muxInstancesRaw as MockMuxInstance[] - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: acceptOutputExitMock, - allocateSshPtyProviderGeneration: vi.fn(() => 17), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) -vi.mock('node:crypto', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, randomUUID: vi.fn() } -}) -vi.mock('./ssh-remote-orca-cli', () => ({ - runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - requestHandlers = new Map) => Promise>() - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn( - (method: string, handler: (params: Record) => Promise) => { - this.requestHandlers.set(method, handler) - return () => this.requestHandlers.delete(method) - } - ) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - - constructor() { - muxInstancesRaw.push(this) - } - } -})) -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn() -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: (error: unknown) => String(error).includes('not found'), - isSshPtyIdentityMismatchError: (error: unknown) => String(error).includes('identity mismatch'), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { - registerSshPtyProvider, - getSshPtyProvider, - getPtyIdsForConnection, - clearProviderPtyState, - deletePtyOwnership, - setPtyOwnership, - restorePtyIncarnation -} = await import('../ipc/pty') -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') - -const APP_PTY_ID = 'ssh:target-1@@pty-live' -const INCARNATION_LEAF_ID = '11111111-1111-4111-8111-111111111111' - -function detachedLease() { - return { - targetId: 'target-1', - ptyId: 'pty-live', - state: 'detached' as const, - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID - } -} - -function emitExitDuringAttach(payload: { - id: string - code: number - incarnationId?: string - providerGeneration?: number - ptyIncarnation?: string -}): void { - const registeredProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onExit: ReturnType - } - const exitHandler = registeredProvider.onExit.mock.calls[0]?.[0] as - | ((exit: typeof payload) => void) - | undefined - queueMicrotask(() => - exitHandler?.({ - providerGeneration: 17, - ptyIncarnation: payload.incarnationId ?? `legacy:${payload.id}`, - ...payload - }) - ) -} - -describe('SshRelaySession reconnect incarnation ordering', () => { - beforeEach(() => { - vi.clearAllMocks() - muxInstances.splice(0) - delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - vi.mocked(randomUUID).mockReset() - vi.mocked(randomUUID).mockReturnValue('00000000-0000-4000-8000-000000000001') - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - }) - - it('bounds fifty reattaches to eight workers without slow or failed sibling head-of-line delay', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const ptyIds = Array.from({ length: 50 }, (_, index) => `pty-${index}`) - let active = 0 - let peakActive = 0 - const attempts = new Map() - const mockAttach = vi.fn((id: string) => { - attempts.set(id, (attempts.get(id) ?? 0) + 1) - if (id === 'pty-0') { - return new Promise(() => {}) - } - if (id === 'pty-1') { - return Promise.reject(new Error('isolated attach failure')) - } - active++ - peakActive = Math.max(peakActive, active) - return new Promise((resolve) => { - setTimeout(() => { - active-- - resolve() - }, 100) - }) - }) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(ptyIds) - const random = vi.spyOn(Math, 'random').mockReturnValue(0) - vi.useFakeTimers() - - try { - const reconnect = session.reconnect(mockConn) - await vi.advanceTimersByTimeAsync(750) - - expect(setPtyOwnership).toHaveBeenCalledTimes(48) - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - expect(peakActive).toBeLessThanOrEqual(8) - - await vi.advanceTimersByTimeAsync(20_000) - await reconnect - - expect(mockAttach).toHaveBeenCalledTimes(52) - expect(attempts.get('pty-0')).toBe(2) - expect(attempts.get('pty-1')).toBe(2) - expect(session.getState()).toBe('ready') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith( - 'target-1', - expect.arrayContaining(ptyIds.slice(2)) - ) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - expect.any(String), - 'expired' - ) - } finally { - vi.useRealTimers() - random.mockRestore() - } - }) - - it('offers source credit through reconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - await session.reconnect(mockConn) - - expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( - 1, - mockConn, - undefined, - undefined, - 'target-1' - ) - expect(deployAndLaunchRelay).toHaveBeenNthCalledWith( - 2, - mockConn, - undefined, - undefined, - 'target-1' - ) - expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) - for (const [, options] of openConsumerSessionMock.mock.calls) { - expect(options.outputFlowControl).toBeDefined() - } - }) - - it('rolls back a timed-out activation that resolves after its replacement commits', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - const timedOutLease = { commit: vi.fn(), rollback: vi.fn() } - const replacementLease = { commit: vi.fn(), rollback: vi.fn() } - let resolveTimedOut!: (result: { - incarnationId: string - sourceActivationLease: typeof timedOutLease - }) => void - const mockAttach = vi - .fn() - .mockReturnValueOnce( - new Promise((resolve) => { - resolveTimedOut = resolve - }) - ) - .mockResolvedValueOnce({ - incarnationId: 'incarnation-replacement', - sourceActivationLease: replacementLease - }) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - const random = vi.spyOn(Math, 'random').mockReturnValue(0) - vi.useFakeTimers() - - try { - const reconnect = session.reconnect(mockConn) - await vi.advanceTimersByTimeAsync(11_000) - await reconnect - resolveTimedOut({ - incarnationId: 'incarnation-timed-out', - sourceActivationLease: timedOutLease - }) - await Promise.resolve() - - expect(replacementLease.commit).toHaveBeenCalledOnce() - expect(replacementLease.rollback).not.toHaveBeenCalled() - expect(timedOutLease.rollback).toHaveBeenCalledOnce() - expect(timedOutLease.commit).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - random.mockRestore() - } - }) - - it('keeps the winning reconnect incarnation when a stale health check resolves last', async () => { - const consumerInstanceId = '00000000-0000-4000-8000-000000000000' - const initialIncarnation = '00000000-0000-4000-8000-000000000001' - const winningIncarnation = '00000000-0000-4000-8000-000000000002' - const staleIncarnation = '00000000-0000-4000-8000-000000000003' - let resolveStaleHealthCheck!: (value: unknown) => void - const staleHealthCheck = new Promise((resolve) => { - resolveStaleHealthCheck = resolve - }) - let resolveHomeCalls = 0 - muxRequestMock.mockImplementation((method: string) => { - if (method !== 'session.resolveHome') { - return Promise.resolve([]) - } - resolveHomeCalls += 1 - return resolveHomeCalls === 2 ? staleHealthCheck : Promise.resolve('/') - }) - vi.mocked(randomUUID) - .mockReturnValueOnce(consumerInstanceId) - .mockReturnValueOnce(initialIncarnation) - .mockReturnValueOnce(winningIncarnation) - .mockReturnValue(staleIncarnation) - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const runtime = { - registerOrchestrationCompatibilitySshAttachment: vi.fn( - (_targetId: string, connectionIncarnation: string) => ({ - attachmentId: `attachment-${connectionIncarnation}`, - connectionIncarnation - }) - ), - releaseOrchestrationCompatibilitySshAttachment: vi.fn() - } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - - const staleReconnect = session.reconnect(mockConn) - await vi.waitFor(() => expect(resolveHomeCalls).toBe(2)) - await session.reconnect(mockConn) - expect(session.getState()).toBe('ready') - - resolveStaleHealthCheck('/') - await staleReconnect - - const winningCliHandler = muxInstances[2]?.requestHandlers.get('orca.cli') - expect(winningCliHandler).toBeDefined() - await winningCliHandler?.({ - argv: ['artifacts', 'share', 'report.html'], - cwd: '/srv/repo', - env: {}, - stdin: '

Remote

', - artifactInput: { - sourceKey: '/srv/repo/report.html', - fileName: 'report.html', - contentType: 'text/html' - } - }) - - expect(runtime.registerOrchestrationCompatibilitySshAttachment).toHaveBeenCalledWith( - 'target-1', - winningIncarnation - ) - expect(vi.mocked(runRemoteOrcaCli)).toHaveBeenCalledWith( - runtime, - expect.objectContaining({ - stdin: '

Remote

', - artifactInput: { - sourceKey: '/srv/repo/report.html', - fileName: 'report.html', - contentType: 'text/html' - } - }) - ) - expect(randomUUID).toHaveBeenCalledTimes(3) - }) - - it('restores and persists exact incarnation proof from reconnect attach', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const incarnationId = 'incarnation-reconnect' - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }), - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof mockStore.getSshRemotePtyLeases - >) - const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await session.establish(mockConn) - - expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) - expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { - tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID, - incarnationId - }) - expect(runtime.onPtySpawned).not.toHaveBeenCalled() - expect(recordedPtyBindings(mockStore)).toContainEqual({ - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID, - ptyId: APP_PTY_ID, - incarnationId, - mayReviveRetiredSurface: false, - origin: 'relay_reattach' - }) - // Into the pane's home partition: a `local` copy shadowed it at the next startup (STA-9544). - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), 'ssh:target-1') - expect(vi.mocked(mockStore.persistPtyBinding).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(mockStore.markSshRemotePtyLeasesAttachedAsync).mock.invocationCallOrder[0]! - ) - }) - - it.each([ - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local', retiredBy: 'surface' }, - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host', retiredBy: 'surface' }, - { - relay: 'legacy', - incarnationId: undefined, - tombstonePartition: 'local', - retiredBy: 'surface' - }, - { relay: 'legacy', incarnationId: undefined, tombstonePartition: 'host', retiredBy: 'surface' }, - // A closed tab whose pane is in no tab: the close record is the backstop. - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local', retiredBy: 'close' }, - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host', retiredBy: 'close' } - ])( - 'suppresses a $tombstonePartition-partition $retiredBy retirement from a $relay relay', - async ({ incarnationId, tombstonePartition, retiredBy }) => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const attachForReconnect = vi.fn().mockResolvedValue({ - ...(incarnationId ? { incarnationId } : {}), - replay: 'retired-output' - }) - const shutdown = vi.fn().mockRejectedValue(new Error('transport lost')) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect, - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - const worktreeId = 'repo-1::/worktree' - const tabId = 'tab-retired' - const leafId = INCARNATION_LEAF_ID - const paneKey = `${tabId}:${leafId}` - const leases: SshRemotePtyLease[] = [ - { - targetId: 'target-1', - ptyId: 'pty-live', - state: 'detached', - worktreeId, - tabId, - createdAt: 1, - updatedAt: 1, - leafId - } - ] - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue(leases) - const sessionWithTombstone: ReturnType = { - ...getDefaultWorkspaceSession(), - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf', leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: APP_PTY_ID } - } - }, - terminalSurfaceTombstonesByPaneKey: { - [paneKey]: { - worktreeId, - parentTabId: tabId, - leafId, - ptyId: APP_PTY_ID, - incarnationId: 'inc-1', - retiredAt: 1 - } - } - } - const closedTab: ReturnType = { - ...getDefaultWorkspaceSession(), - closedTerminalTabTombstonesByTabId: { [tabId]: { closedAt: Date.now(), worktreeId } } - } - vi.mocked(mockStore.getWorkspaceSession).mockImplementation((hostId) => - (hostId ? 'host' : 'local') !== tombstonePartition - ? getDefaultWorkspaceSession() - : retiredBy === 'close' - ? closedTab - : sessionWithTombstone - ) - const runtime = { registerPty: vi.fn(), onPtySpawned: vi.fn() } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - } finally { - warn.mockRestore() - } - - expect(runtime.registerPty).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'target-1', - APP_PTY_ID, - 'expired' - ) - if (incarnationId) { - expect(mockStore.recordSshRemotePtyKillIntent).toHaveBeenCalledWith( - 'target-1', - 'pty-live', - { requestedAt: expect.any(Number), incarnationId, attempts: 0 } - ) - expect(shutdown).toHaveBeenCalledWith(APP_PTY_ID, { - immediate: true, - expectedIncarnationId: incarnationId - }) - } else { - expect(mockStore.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() - expect(shutdown).not.toHaveBeenCalled() - } - expect(clearProviderPtyState).toHaveBeenCalledWith(APP_PTY_ID) - expect(deletePtyOwnership).toHaveBeenCalledWith(APP_PTY_ID) - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:replay', { - id: APP_PTY_ID, - data: 'retired-output' - }) - } - ) - - it.each([ - { - case: 'stale PTY id', - tombstonePtyId: 'ssh:target-1@@pty-old', - tombstoneIncarnationId: 'incarnation-old' - }, - { - case: 'stale incarnation of the reused PTY id', - tombstonePtyId: APP_PTY_ID, - tombstoneIncarnationId: 'incarnation-old' - }, - { - case: 'same identity at the pane lease old location', - tombstonePtyId: APP_PTY_ID, - tombstoneIncarnationId: 'incarnation-live' - } - ])('reattaches a moved pane despite a $case tombstone', async (tombstone) => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const worktreeId = 'repo-1::/worktree' - const oldTabId = 'tab-old' - const movedTabId = 'tab-moved' - const incarnationId = 'incarnation-live' - const shutdown = vi.fn() - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId, replay: 'live-output' }), - shutdown, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { - targetId: 'target-1', - ptyId: 'pty-live', - state: 'detached', - worktreeId, - tabId: oldTabId, - leafId: INCARNATION_LEAF_ID, - createdAt: 1, - updatedAt: 1 - } - ]) - const movedSession: ReturnType = { - ...getDefaultWorkspaceSession(), - terminalLayoutsByTabId: { - [movedTabId]: { - root: { type: 'leaf', leafId: INCARNATION_LEAF_ID }, - activeLeafId: INCARNATION_LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [INCARNATION_LEAF_ID]: APP_PTY_ID } - } - }, - terminalPtyIncarnationsByPaneKey: { - [`${movedTabId}:${INCARNATION_LEAF_ID}`]: incarnationId - }, - terminalSurfaceTombstonesByPaneKey: { - [`${oldTabId}:${INCARNATION_LEAF_ID}`]: { - worktreeId, - parentTabId: oldTabId, - leafId: INCARNATION_LEAF_ID, - ptyId: tombstone.tombstonePtyId, - incarnationId: tombstone.tombstoneIncarnationId, - retiredAt: 1 - } - } - } - vi.mocked(mockStore.getWorkspaceSession).mockImplementation((hostId) => - hostId ? getDefaultWorkspaceSession() : movedSession - ) - const runtime = { registerPty: vi.fn(), onPtySpawned: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await session.establish(mockConn) - - expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, worktreeId, 'target-1', { - tabId: movedTabId, - leafId: INCARNATION_LEAF_ID, - incarnationId - }) - expect(recordedPtyBindings(mockStore)).toContainEqual( - expect.objectContaining({ tabId: movedTabId, ptyId: APP_PTY_ID, incarnationId }) - ) - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - APP_PTY_ID, - 'expired' - ) - expect(mockStore.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() - expect(shutdown).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ - 'pty-live' - ]) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { - id: APP_PTY_ID, - data: 'live-output' - }) - }) - - it('does not restore a PTY whose matching exit shares the attach reply batch', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const incarnationId = 'incarnation-exited-during-attach' - const runtime = { - acceptPtyIncarnationForExit: vi.fn(), - onPtyExit: vi.fn(), - onPtySpawned: vi.fn(), - registerPty: vi.fn() - } - const sourceActivationLease = { commit: vi.fn(), rollback: vi.fn() } - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockImplementation(async () => { - emitExitDuringAttach({ id: APP_PTY_ID, code: 0, incarnationId }) - emitExitDuringAttach({ id: APP_PTY_ID, code: 0, incarnationId: 'incarnation-stale' }) - return { incarnationId, replay: 'dead-output', sourceActivationLease } - }), - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof mockStore.getSshRemotePtyLeases - >) - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await session.establish(mockConn) - - expect(acceptOutputExitMock).toHaveBeenCalledWith({ - id: APP_PTY_ID, - code: 0, - providerGeneration: 17, - ptyIncarnation: incarnationId - }) - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(runtime.acceptPtyIncarnationForExit).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) - expect(runtime.registerPty).not.toHaveBeenCalled() - expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) - expect(setPtyOwnership).not.toHaveBeenCalled() - expect(mockStore.persistPtyBinding).not.toHaveBeenCalled() - expect(sourceActivationLease.rollback).toHaveBeenCalledOnce() - expect(sourceActivationLease.commit).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'terminated' - ) - expect( - vi - .mocked(mockWindow.webContents.send) - .mock.calls.some(([channel]) => channel === 'pty:replay') - ).toBe(false) - }) - - it('ignores an older incarnation exit while reconnecting a reused PTY id', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const currentIncarnationId = 'incarnation-current' - const runtime = { - acceptPtyIncarnationForExit: vi.fn(), - onPtyExit: vi.fn(), - onPtySpawned: vi.fn(), - registerPty: vi.fn() - } - const sourceActivationLease = { commit: vi.fn(), rollback: vi.fn() } - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockImplementation(async () => { - emitExitDuringAttach({ - id: APP_PTY_ID, - code: 0, - incarnationId: 'incarnation-old' - }) - return { incarnationId: currentIncarnationId, replay: 'live-output', sourceActivationLease } - }), - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof mockStore.getSshRemotePtyLeases - >) - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await session.establish(mockConn) - - expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(runtime.acceptPtyIncarnationForExit).not.toHaveBeenCalled() - expect(sourceActivationLease.commit).toHaveBeenCalledOnce() - expect(sourceActivationLease.rollback).not.toHaveBeenCalled() - expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { - tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID, - incarnationId: currentIncarnationId - }) - expect(setPtyOwnership).toHaveBeenCalledWith(APP_PTY_ID, 'ssh:target-1') - expect(recordedPtyBindings(mockStore)).toContainEqual( - expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId }) - ) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { - id: APP_PTY_ID, - data: 'live-output' - }) - }) - - it('keeps the PTY detached when incarnation backfill persistence fails', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const incarnationId = 'incarnation-reconnect' - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }), - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< - typeof mockStore.getSshRemotePtyLeases - >) - vi.mocked(mockStore.persistPtyBinding).mockImplementationOnce(() => { - throw new Error('disk full') - }) - const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() } - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - - await expect(session.establish(mockConn)).resolves.toBeUndefined() - - expect(runtime.registerPty).not.toHaveBeenCalled() - expect(setPtyOwnership).not.toHaveBeenCalled() - expect(restorePtyIncarnation).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:replay', expect.anything()) - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('Leaving PTY pty-live detached for target-1') - ) - consoleWarn.mockRestore() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts b/src/main/ssh/ssh-relay-session-recovery-durability.test.ts deleted file mode 100644 index ed0136f9110a..000000000000 --- a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts +++ /dev/null @@ -1,680 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR, - PTY_CONSUMER_OWNER_HELD_SELF_ERROR, - PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, - PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR -} from '../../shared/pty-consumer-session' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps } from './ssh-relay-session-test-fixtures' -import { getSshPtyConsumerRecovery } from './ssh-pty-consumer-recovery' - -const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn() -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 23), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}), - applySshPtySourceCancellationProof: vi.fn(), - applySshPtySourceRecoveryCancellationProof: vi.fn() -})) - -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } -})) - -vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ - installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) -})) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: vi.fn().mockReturnValue(false), - isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = vi.fn() - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - setPtyOwnership: vi.fn() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const { clearPtyOwnershipForConnection, unregisterSshPtyProvider } = await import('../ipc/pty') - -describe('SshRelaySession consumer recovery durability', () => { - beforeEach(() => { - vi.clearAllMocks() - muxRequestMock.mockResolvedValue([]) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - }, - resumed: false - })) - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' - }) - }) - - it('holds establish open until the consumer recovery write is durable', async () => { - const deps = createMockDeps() - let settleWrite!: () => void - let signalWriteStarted!: () => void - const writeStarted = new Promise((resolve) => { - signalWriteStarted = resolve - }) - vi.mocked(deps.mockStore.upsertSshPtyConsumerRecovery).mockImplementation(() => { - signalWriteStarted() - return new Promise((resolve) => { - settleWrite = resolve - }) - }) - - const session = new SshRelaySession( - 'durability-barrier-target', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - let established = false - const establishing = session.establish(deps.mockConn).then(() => { - established = true - }) - - await writeStarted - // Why a macrotask, not a microtask count: establish() has several awaits after the write starts, - // so only yielding past the whole microtask queue proves the write is the thing blocking it. - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(established).toBe(false) - - settleWrite() - await establishing - expect(established).toBe(true) - session.dispose() - }) - - it('retries a pending incumbent publication before recovering its persisted lease', async () => { - vi.useFakeTimers() - try { - const targetId = 'target-owner-publication-pending' - const deps = createMockDeps() - vi.mocked(deps.mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'persisted-owner' - }) - openConsumerSessionMock.mockRejectedValueOnce( - Object.assign(new Error('Owner grant publication is still pending'), { - code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR - }) - ) - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - - const establishing = session.establish(deps.mockConn) - await vi.advanceTimersByTimeAsync(25) - await establishing - - expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) - expect(openConsumerSessionMock.mock.calls[0]?.[1]).toMatchObject({ - clientInstanceId: 'persisted-client', - resume: { ownerGeneration: 1, ownerLease: 'persisted-owner' } - }) - session.dispose() - } finally { - vi.useRealTimers() - } - }) - - it('preserves persisted recovery while a superseding transport is still live', async () => { - vi.useFakeTimers() - try { - const targetId = 'target-owner-generation-superseded' - const deps = createMockDeps() - vi.mocked(deps.mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'persisted-owner' - }) - const superseded = Object.assign(new Error('Owner recovery generation was superseded'), { - code: PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR - }) - openConsumerSessionMock.mockRejectedValue(superseded) - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - - const failed = expect(session.establish(deps.mockConn)).rejects.toBe(superseded) - await vi.advanceTimersByTimeAsync(3_000) - await failed - - expect(openConsumerSessionMock.mock.calls.length).toBeGreaterThan(1) - expect(deps.mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - session.dispose() - } finally { - vi.useRealTimers() - } - }) - - it('keeps destructive disposal pending until consumer recovery is removed', async () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId: 'target-disposal-durability', - clientInstanceId: 'client-disposal-durability', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'owner-lease' - }) - let settleRemoval!: () => void - vi.mocked(mockStore.removeSshPtyConsumerRecovery).mockImplementation( - () => - new Promise((resolve) => { - settleRemoval = resolve - }) - ) - const session = new SshRelaySession( - 'target-disposal-durability', - getMainWindow, - mockStore, - mockPortForward - ) - let completed = false - - const disposal = session.disposeAndPersist().then(() => { - completed = true - }) - await Promise.resolve() - - expect(session.getState()).toBe('disposed') - expect(completed).toBe(false) - expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalled() - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith( - 'target-disposal-durability', - 'terminated' - ) - - settleRemoval() - await disposal - expect(completed).toBe(true) - }) - - it('reports an attached owner as terminal instead of a lost relay', async () => { - const targetId = 'target-owner-held-attached' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - const onRelayLost = vi.fn() - session.setOnTerminalRelayError(onTerminal) - session.setOnRelayLost(onRelayLost) - await session.establish(mockConn) - - openConsumerSessionMock.mockRejectedValueOnce( - Object.assign(new Error('PTY session owner is held by an attached connection'), { - code: PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR - }) - ) - await session.reconnect(mockConn) - - // Why not relay-lost: the relay answered and refused on purpose, so redeploy backoff would spend - // its whole budget on a link that is working. - expect(onRelayLost).not.toHaveBeenCalled() - expect(onTerminal).toHaveBeenCalledWith( - targetId, - expect.objectContaining({ name: 'SshOwnerAdmissionBlockedError' }) - ) - expect(onTerminal.mock.calls[0]?.[1]?.message).toContain('owns the remote terminals') - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) - session.dispose() - }) - - it("routes this client's own attached connection to relay-lost recovery, not to a parked error", async () => { - const targetId = 'target-owner-held-self' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - const onRelayLost = vi.fn() - session.setOnTerminalRelayError(onTerminal) - session.setOnRelayLost(onRelayLost) - await session.establish(mockConn) - - // The incumbent is this app's own half-open connection, which the relay has not yet observed - // closing — the ordinary single-app case, since only SshRelaySession ever requests session-owner. - openConsumerSessionMock.mockRejectedValue( - Object.assign( - new Error("PTY session owner is held by this client's own earlier connection"), - { - code: PTY_CONSUMER_OWNER_HELD_SELF_ERROR - } - ) - ) - await session.reconnect(mockConn) - - // Why not terminal: parking here clears backoff and never retries, so the user stays locked out - // of their own session until they restart the app. Backoff is what lets keepalive reap the zombie. - expect(onTerminal).not.toHaveBeenCalled() - expect(onRelayLost).toHaveBeenCalled() - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - session.dispose() - }) - - it('recovers a forgotten owner record in one request without deleting recovery identity', async () => { - const targetId = 'target-forgotten-owner-record' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'forgotten-owner' - }) - openConsumerSessionMock.mockImplementationOnce(async () => ({ - state: { - mode: 'negotiated', - clientInstanceId: 'persisted-client', - clientGeneration: 4, - ownerGeneration: 9, - ownerLease: 'fresh-owner' - }, - resumed: false - })) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { - id: 'pty-1' - } as unknown as never) - - await session.establish(mockConn) - - expect(openConsumerSessionMock).toHaveBeenCalledTimes(1) - expect(openConsumerSessionMock.mock.calls[0]?.[1]).toMatchObject({ - resume: { ownerGeneration: 1, ownerLease: 'forgotten-owner' } - }) - // Why both: the fresh claim voids checkpoints taken under the old lease, but the recovery identity - // is what lets this client keep resuming the target at all — a refusal must never cost it. - expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.size).toBe(0) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( - expect.objectContaining({ - targetId, - clientInstanceId: 'persisted-client', - ownerGeneration: 9, - ownerLease: 'fresh-owner' - }) - ) - session.dispose() - }) - - it('keeps checkpoints when a resumed claim comes back', async () => { - const targetId = 'target-resumed-owner-record' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'persisted-owner' - }) - openConsumerSessionMock.mockImplementationOnce(async () => ({ - state: { - mode: 'negotiated', - clientInstanceId: 'persisted-client', - clientGeneration: 4, - ownerGeneration: 2, - ownerLease: 'persisted-owner' - }, - resumed: true - })) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { - id: 'pty-1' - } as unknown as never) - - await session.establish(mockConn) - - expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - session.dispose() - }) - - it('leaves recovery state alone when a newer owner already claimed the target record', async () => { - const targetId = 'target-stale-owner-loser' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'stale-owner' - }) - const winner = { - mode: 'negotiated' as const, - clientInstanceId: 'persisted-client', - clientGeneration: 2, - ownerGeneration: 5, - ownerLease: 'winner-owner' - } - openConsumerSessionMock.mockImplementationOnce(async () => { - // Why inside the open: the record is target-scoped, so the winner can land while this attempt is - // still waiting on its own resume, leaving this attempt's `previousOwner` snapshot stale. - const record = getSshPtyConsumerRecovery(targetId)! - record.owner = winner - record.checkpointsByAppPtyId.set('pty-1', { id: 'pty-1' } as unknown as never) - return { - state: { - mode: 'negotiated', - clientInstanceId: 'persisted-client', - clientGeneration: 3, - ownerGeneration: 1, - ownerLease: 'loser-owner' - }, - resumed: false - } - }) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - session.dispose() - }) - - it('mutates no recovery state after a local attempt loses its authority', async () => { - const targetId = 'target-owner-attempt-superseded' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ - targetId, - clientInstanceId: 'persisted-client', - serverBuildId: 'test-relay-build', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'persisted-owner' - }) - let signalOpenStarted!: () => void - const openStarted = new Promise((resolve) => { - signalOpenStarted = resolve - }) - let finishOpen!: (value: unknown) => void - openConsumerSessionMock.mockImplementationOnce(() => { - signalOpenStarted() - return new Promise((resolve) => { - finishOpen = resolve - }) - }) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.set('pty-1', { - id: 'pty-1' - } as unknown as never) - - const superseded = session.establish(mockConn) - const failed = expect(superseded).rejects.toThrow('Session disposed during establish') - await openStarted - // Why detach and not disposal: disposal legitimately removes the record, which would hide whether - // the attempt that lost its authority mutated anything on its way out. - await session.detachAndPersist() - finishOpen({ - state: { - mode: 'negotiated', - clientInstanceId: 'persisted-client', - clientGeneration: 3, - ownerGeneration: 3, - ownerLease: 'superseded-owner' - }, - resumed: false - }) - await failed - - expect(getSshPtyConsumerRecovery(targetId)!.checkpointsByAppPtyId.has('pty-1')).toBe(true) - expect(mockStore.upsertSshPtyConsumerRecovery).not.toHaveBeenCalledWith( - expect.objectContaining({ ownerLease: 'superseded-owner' }) - ) - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - }) - - it('does not remember a consumer opened after establish was disposed', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - let signalOpenStarted!: () => void - const openStarted = new Promise((resolve) => { - signalOpenStarted = resolve - }) - let finishOpen!: (value: unknown) => void - openConsumerSessionMock.mockImplementationOnce(() => { - signalOpenStarted() - return new Promise((resolve) => { - finishOpen = resolve - }) - }) - const session = new SshRelaySession( - 'target-open-disposal', - getMainWindow, - mockStore, - mockPortForward - ) - - const establishing = session.establish(mockConn) - const failed = expect(establishing).rejects.toThrow('Session disposed during establish') - await openStarted - await session.disposeAndPersist() - finishOpen({ - state: { - mode: 'negotiated', - clientInstanceId: 'late-client', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'late-owner' - }, - resumed: false - }) - await failed - - expect(mockStore.upsertSshPtyConsumerRecovery).not.toHaveBeenCalled() - }) - - it('detaches for shutdown in memory without a per-session durable write', async () => { - const targetId = 'target-shutdown-detach' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() - - session.beginShutdownDetach() - session.beginShutdownDetach() - - // Why in-memory only: the committed quit path runs the final store flush immediately after this, - // and a durable write here would race that flush rather than be captured by it. - expect(mockStore.markSshRemotePtyLeasesForShutdown).toHaveBeenCalledExactlyOnceWith( - targetId, - 'detached' - ) - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() - // Why the recovery record survives: 'detached' means this app let go, not that the remote shell - // died — the identity is what lets the next launch reattach those still-running PTYs. - expect(mockStore.removeSshPtyConsumerRecovery).not.toHaveBeenCalled() - expect(getSshPtyConsumerRecovery(targetId)?.detached).toBe(true) - expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBeTypeOf('string') - }) - - it('keeps ordinary detach durability reporting unchanged after a shutdown detach exists', async () => { - const targetId = 'target-shutdown-vs-ordinary-detach' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( - new Error('lease write failed') - ) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - // Why still rejecting: only the shutdown path delegates durability; an ordinary detach must keep - // surfacing a failed write to its caller. - await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') - expect(mockStore.markSshRemotePtyLeasesForShutdown).not.toHaveBeenCalled() - }) - - it('upgrades a pending detach to a full disposal', async () => { - const targetId = 'target-teardown-upgrade' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - let settleDetachPersistence!: () => void - let settleDisposalPersistence!: () => void - vi.mocked(mockStore.markSshRemotePtyLeasesAsync) - .mockImplementationOnce( - () => - new Promise((resolve) => { - settleDetachPersistence = resolve - }) - ) - .mockImplementationOnce( - () => - new Promise((resolve) => { - settleDisposalPersistence = resolve - }) - ) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - let detachCompleted = false - const detach = session.detachAndPersist().then(() => { - detachCompleted = true - }) - const disposal = session.disposeAndPersist() - - // Why: dispose supersedes the in-flight detach, so the destructive half must still run. - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'terminated') - expect(getSshPtyConsumerRecovery(targetId)).toBeUndefined() - // Why: 'shutdown' teardown, not detach's 'connection_lost' — PTY ownership is released for good. - expect(clearPtyOwnershipForConnection).toHaveBeenCalledWith(targetId) - - settleDetachPersistence() - await Promise.resolve() - await Promise.resolve() - expect(detachCompleted).toBe(false) - settleDisposalPersistence() - await Promise.all([detach, disposal]) - - // Why: the reverse order is not an upgrade — a detach after disposal must not re-open ownership. - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() - await session.detachAndPersist() - expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() - }) - - it('re-issues only the lease write after a rejected detach persistence', async () => { - const targetId = 'target-detach-write-retry' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( - new Error('lease write failed') - ) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.mocked(clearPtyOwnershipForConnection).mockClear() - - await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') - - const teardownCalls = vi.mocked(unregisterSshPtyProvider).mock.calls.length - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() - await session.detachAndPersist() - - // Why: the retry re-issues the write only — re-running provider teardown would tear down - // whatever a replacement session has already registered for this target. - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'detached') - expect(unregisterSshPtyProvider).toHaveBeenCalledTimes(teardownCalls) - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - }) - - it('still upgrades to disposal after a rejected detach persistence', async () => { - const targetId = 'target-detach-write-failure-disposal' - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( - new Error('lease write failed') - ) - const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') - await session.disposeAndPersist() - - expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'terminated') - expect(getSshPtyConsumerRecovery(targetId)).toBeUndefined() - }) -}) diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts deleted file mode 100644 index f4600bb23e6c..000000000000 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ /dev/null @@ -1,839 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { - acceptOutputDataMock, - acceptOutputExitMock, - muxRequestMock, - onNotificationByMethodMock, - openConsumerSessionMock, - muxDisposeMock, - ptyProviderDisposeMock, - sourceAckCleanupMock, - sourceCancellationCleanupMock, - attachForReconnectMock, - ptyDataHandlerRef, - ptyExitHandlerRef -} = vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - acceptOutputExitMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - onNotificationByMethodMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - muxDisposeMock: vi.fn(), - ptyProviderDisposeMock: vi.fn(), - sourceAckCleanupMock: vi.fn(), - sourceCancellationCleanupMock: vi.fn(), - attachForReconnectMock: vi.fn().mockResolvedValue({}), - ptyDataHandlerRef: { current: undefined as undefined | ((payload: unknown) => void) }, - ptyExitHandlerRef: { current: undefined as undefined | ((payload: unknown) => void) } -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: acceptOutputDataMock, - acceptSshPtyOutputExit: acceptOutputExitMock, - allocateSshPtyProviderGeneration: vi.fn(() => 23), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - installSshPtySourceAckPublisher: vi.fn(() => sourceAckCleanupMock), - installSshPtySourceCancellationPublisher: vi.fn(() => sourceCancellationCleanupMock), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true) -})) -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = onNotificationByMethodMock.mockImplementation(() => () => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = muxDisposeMock - isDisposed = vi.fn().mockReturnValue(false) - } -})) -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: vi.fn().mockReturnValue(false), - isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockImplementation((handler) => { - ptyDataHandlerRef.current = handler - return () => {} - }) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockImplementation((handler) => { - ptyExitHandlerRef.current = handler - return () => {} - }) - attachForReconnect = attachForReconnectMock - setPtyDeliveryPauseAdapter = vi.fn() - dispose = ptyProviderDisposeMock - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - isCurrentPtyExit: vi.fn(() => true), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - setPtyOwnership: vi.fn() -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { - clearProviderPtyState, - clearPtyOwnershipForConnection, - deletePtyOwnership, - getSshPtyProvider, - getPtyIdsForConnection, - registerSshPtyProvider, - setPtyOwnership, - unregisterSshPtyProvider -} = await import('../ipc/pty') -const { closeSshPtyOutputGeneration, getSshPtyAcceptedSourceCheckpoints } = - await import('../ipc/ssh-pty-output-intake-registry') -const { applySshPtySourceCancellationProof } = await import('../ipc/ssh-pty-output-intake-registry') -const { applySshPtySourceRecoveryCancellationProof } = - await import('../ipc/ssh-pty-output-intake-registry') - -describe('SshRelaySession recovery race fencing', () => { - beforeEach(() => { - vi.clearAllMocks() - ptyDataHandlerRef.current = undefined - ptyExitHandlerRef.current = undefined - attachForReconnectMock.mockResolvedValue({}) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) - vi.mocked(applySshPtySourceCancellationProof).mockReturnValue(true) - vi.mocked(applySshPtySourceRecoveryCancellationProof).mockReturnValue(true) - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - }) - - function emitSourceFrame( - args: { - targetId: string - token: string - clientGeneration: number - ownerGeneration: number - sourceStartSu: number - sourceEndSu: number - data?: string - }, - sink = ptyDataHandlerRef.current - ): void { - sink?.({ - id: `ssh:${args.targetId}@@pty-1`, - data: args.data ?? 'late', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: args.sourceEndSu - args.sourceStartSu, - source: { - relayPtyId: 'pty-1', - spanId: `${args.token}:${args.sourceStartSu}:${args.sourceEndSu}`, - clientGeneration: args.clientGeneration, - ownerGeneration: args.ownerGeneration, - deliveryToken: args.token, - sourceStartSu: args.sourceStartSu, - sourceEndSu: args.sourceEndSu - } - }) - } - - function pendingRecovery(recoveryEndSu: number) { - return { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu - } - } - - function completeRecovery(params: Record): void { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.(params) - } - - async function prepareRecovery(targetId: string): Promise<{ - session: SshRelaySession - deps: ReturnType - }> { - let generation = 0 - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - state: { - mode: 'negotiated', - clientInstanceId: options.clientInstanceId, - clientGeneration: ++generation, - ownerGeneration: generation, - ownerLease: `owner-lease-${generation}`, - outputFlowControl: { version: 1, windowSu: 256 * 1024 } - }, - resumed: options.resume !== undefined - })) - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ - { - id: `ssh:${targetId}@@pty-1`, - providerGeneration: 23, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - } - ]) - const deps = createMockDeps() - const session = new SshRelaySession( - targetId, - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - await session.establish(deps.mockConn) - vi.mocked(getPtyIdsForConnection).mockReturnValue([`ssh:${targetId}@@pty-1`]) - vi.mocked(getSshPtyProvider).mockImplementation( - () => vi.mocked(registerSshPtyProvider).mock.calls.at(-1)?.[1] - ) - return { session, deps } - } - - it('keeps source output contiguous while the recovered pane binding waits for disk', async () => { - const targetId = 'recovery-binding-disk-wait' - const { session, deps } = await prepareRecovery(targetId) - const binding = Promise.withResolvers() - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([ - { - targetId, - ptyId: 'pty-1', - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: 'leaf-1', - state: 'detached', - createdAt: 1, - updatedAt: 1 - } - ]) - vi.mocked(deps.mockStore.persistPtyBinding).mockReturnValue(binding.promise) - const emit = (sourceStartSu: number, sink = ptyDataHandlerRef.current): void => - emitSourceFrame( - { - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu, - sourceEndSu: sourceStartSu + 4 - }, - sink - ) - let recoverySink: typeof ptyDataHandlerRef.current - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8), - sourceActivationLease: { - commit: vi.fn(), - rollback: vi.fn(async () => true), - transferToRecovery: (sink: (payload: unknown) => void) => { - recoverySink = sink - emit(4, sink) - completeRecovery({ id: 'pty-1', ...pendingRecovery(8) }) - return recoveryActivationLease - } - } - }) - - const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(deps.mockStore.persistPtyBinding).toHaveBeenCalledOnce()) - emit(8, recoverySink) - expect(recoveryActivationLease.commit).not.toHaveBeenCalled() - binding.resolve(true) - await reconnect - emit(12) - - expect( - acceptOutputDataMock.mock.calls.map(([payload]) => payload.source.sourceStartSu) - ).toEqual([4, 8, 12]) - expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() - }) - - it('publishes held recovery data before an exact exit without waiting for completion', async () => { - const targetId = 'exit-with-complete-private-body' - const { session, deps } = await prepareRecovery(targetId) - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn(async () => true), - transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - emitSourceFrame( - { - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 4, - sourceEndSu: 8, - data: 'held' - }, - sink - ) - return recoveryActivationLease - }) - } - attachForReconnectMock.mockImplementation(async () => { - ptyExitHandlerRef.current?.({ - id: `ssh:${targetId}@@pty-1`, - code: 0, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - incarnationId: 'incarnation-1' - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8), - sourceActivationLease - } - }) - - await session.reconnect(deps.mockConn) - - expect(acceptOutputDataMock).toHaveBeenCalledWith(expect.objectContaining({ data: 'held' })) - expect(acceptOutputExitMock).toHaveBeenCalledOnce() - expect(acceptOutputDataMock.mock.invocationCallOrder[0]).toBeLessThan( - acceptOutputExitMock.mock.invocationCallOrder[0]! - ) - expect(sourceActivationLease.transferToRecovery).toHaveBeenCalledOnce() - expect(sourceActivationLease.rollback).not.toHaveBeenCalled() - expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() - expect(recoveryActivationLease.retire).not.toHaveBeenCalled() - expect(muxRequestMock).not.toHaveBeenCalledWith('pty.cancelDelivery', expect.anything()) - expect(setPtyOwnership).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - }) - - it('settles exact cancellation before publishing an exit with incomplete recovery data', async () => { - const targetId = 'exit-with-incomplete-private-body' - let settleCancellation: ((proof: unknown) => void) | undefined - muxRequestMock.mockImplementation((method) => - method === 'pty.cancelDelivery' - ? new Promise((resolve) => { - settleCancellation = resolve - }) - : Promise.resolve([]) - ) - const { session, deps } = await prepareRecovery(targetId) - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn(async () => true), - transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - emitSourceFrame( - { - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 4, - sourceEndSu: 6, - data: 'partial' - }, - sink - ) - return recoveryActivationLease - }) - } - attachForReconnectMock.mockImplementation(async () => { - ptyExitHandlerRef.current?.({ - id: `ssh:${targetId}@@pty-1`, - code: 0, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - incarnationId: 'incarnation-1' - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8), - sourceActivationLease - } - }) - - const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token' - }) - ) - expect(acceptOutputExitMock).not.toHaveBeenCalled() - - settleCancellation?.({ canceled: true, sentEndSu: 6, creditedEndSu: 4 }) - await reconnect - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(acceptOutputExitMock).toHaveBeenCalledOnce() - expect(recoveryActivationLease.commit).not.toHaveBeenCalled() - expect(recoveryActivationLease.retire).toHaveBeenCalledOnce() - expect(sourceActivationLease.rollback).not.toHaveBeenCalled() - expect(applySshPtySourceRecoveryCancellationProof).toHaveBeenCalledOnce() - }) - - it('awaits provisional cancellation proof before publishing an exact recovery exit', async () => { - const targetId = 'exit-before-recovery-identity' - let settleRollback: ((settled: boolean) => void) | undefined - const { session, deps } = await prepareRecovery(targetId) - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn( - () => - new Promise((resolve) => { - settleRollback = resolve - }) - ), - transferToRecovery: vi.fn() - } - attachForReconnectMock.mockImplementation(async () => { - ptyExitHandlerRef.current?.({ - id: `ssh:${targetId}@@pty-1`, - code: 0, - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - incarnationId: 'incarnation-1' - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' }, - sourceActivationLease - } - }) - - const reconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(sourceActivationLease.rollback).toHaveBeenCalledOnce()) - expect(acceptOutputExitMock).not.toHaveBeenCalled() - - settleRollback?.(true) - await reconnect - - expect(acceptOutputExitMock).toHaveBeenCalledOnce() - expect(sourceActivationLease.transferToRecovery).not.toHaveBeenCalled() - expect(sourceActivationLease.commit).not.toHaveBeenCalled() - }) - - it('retains the empty recovery end as the first post-activation source anchor', async () => { - const targetId = 'empty-recovery-gap' - const { session, deps } = await prepareRecovery(targetId) - attachForReconnectMock.mockImplementation(async () => { - queueMicrotask(() => { - completeRecovery({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }) - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(4) - } - }) - await session.reconnect(deps.mockConn) - const closeCount = vi.mocked(closeSshPtyOutputGeneration).mock.calls.length - const muxDisposeCount = muxDisposeMock.mock.calls.length - - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 5, - sourceEndSu: 8 - }) - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(closeSshPtyOutputGeneration).toHaveBeenCalledTimes(closeCount) - expect(muxDisposeMock).toHaveBeenCalledTimes(muxDisposeCount) - await vi.waitFor(() => expect(attachForReconnectMock).toHaveBeenCalledTimes(2)) - }) - - it('drops late frames from a token after its cancellation proof is validated', async () => { - const targetId = 'late-after-cancel' - muxRequestMock.mockImplementation(async (method) => - method === 'pty.cancelDelivery' ? { canceled: true, sentEndSu: 8, creditedEndSu: 4 } : [] - ) - const { session, deps } = await prepareRecovery(targetId) - attachForReconnectMock.mockImplementation(async () => { - queueMicrotask(() => { - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 5, - sourceEndSu: 8 - }) - completeRecovery({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }) - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8) - } - }) - await session.reconnect(deps.mockConn) - const closeCount = vi.mocked(closeSshPtyOutputGeneration).mock.calls.length - - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 8, - sourceEndSu: 12 - }) - - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(closeSshPtyOutputGeneration).toHaveBeenCalledTimes(closeCount) - expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(targetId, 'pty-1', 'detached') - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - expect(muxDisposeMock).not.toHaveBeenCalledWith('shutdown') - }) - - it('rejects cancellation proof below capacity-rejected and later private frames', async () => { - const targetId = 'capacity-watermark' - muxRequestMock.mockImplementation(async (method) => - method === 'pty.cancelDelivery' ? { canceled: true, sentEndSu: 8, creditedEndSu: 4 } : [] - ) - const { session, deps } = await prepareRecovery(targetId) - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - const sourceActivationLease = { - commit: vi.fn(), - rollback: vi.fn(), - transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - for (const [data, sourceStartSu] of [ - ['x'.repeat(2 * 1024 * 1024 + 1), 4], - ['later', 8] - ] as const) { - emitSourceFrame( - { - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu, - sourceEndSu: sourceStartSu + 4, - data - }, - sink - ) - } - return recoveryActivationLease - }) - } - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(12), - sourceActivationLease - }) - - await session.reconnect(deps.mockConn) - - expect(muxRequestMock).toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ deliveryToken: 'new-token' }) - ) - expect(vi.mocked(closeSshPtyOutputGeneration).mock.calls).toContainEqual([ - 23, - 'ssh_source_recovery_cancellation_failed' - ]) - expect(applySshPtySourceRecoveryCancellationProof).not.toHaveBeenCalled() - expect(recoveryActivationLease.commit).not.toHaveBeenCalled() - expect(recoveryActivationLease.retire).toHaveBeenCalledOnce() - }) - - it.each([ - ['publication', undefined], - ['proof', { canceled: true, sentEndSu: 8, creditedEndSu: 4 }], - ['negative-end', { canceled: true, sentEndSu: -1, creditedEndSu: 4 }], - ['credited-ahead', { canceled: true, sentEndSu: 8, creditedEndSu: 9 }], - ['checkpoint-mismatch', { canceled: true, sentEndSu: 8, creditedEndSu: 3 }], - ['sent-before-private-end', { canceled: true, sentEndSu: 7, creditedEndSu: 4 }] - ] as const)( - 'closes one provider generation when recovery cancellation %s rejects', - async (failure, cancellationResult) => { - const targetId = `cancel-${failure}-failure` - muxRequestMock.mockImplementation(async (method) => { - if (method !== 'pty.cancelDelivery') { - return [] - } - if (failure === 'publication') { - throw new Error('cancel publication failed') - } - return cancellationResult - }) - if (failure === 'proof') { - vi.mocked(applySshPtySourceRecoveryCancellationProof).mockImplementation(() => { - throw new Error('cancel proof rejected') - }) - } - const { session, deps } = await prepareRecovery(targetId) - const onRelayLost = vi.fn() - const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } - const activationLease = { - commit: vi.fn(), - rollback: vi.fn(), - transferToRecovery: vi.fn(() => recoveryActivationLease) - } - session.setOnRelayLost(onRelayLost) - let cleanupCountsBeforeFailure: - | { - generation: number - mux: number - provider: number - ack: number - cancellation: number - unregister: number - } - | undefined - attachForReconnectMock.mockImplementation(async () => { - cleanupCountsBeforeFailure = { - generation: vi.mocked(closeSshPtyOutputGeneration).mock.calls.length, - mux: muxDisposeMock.mock.calls.length, - provider: ptyProviderDisposeMock.mock.calls.length, - ack: sourceAckCleanupMock.mock.calls.length, - cancellation: sourceCancellationCleanupMock.mock.calls.length, - unregister: vi.mocked(unregisterSshPtyProvider).mock.calls.length - } - queueMicrotask(() => { - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 5, - sourceEndSu: 8 - }) - }) - return { - incarnationId: 'incarnation-1', - sourceRecovery: pendingRecovery(8), - sourceActivationLease: activationLease - } - }) - - await session.reconnect(deps.mockConn) - - expect(cleanupCountsBeforeFailure).toBeDefined() - const before = cleanupCountsBeforeFailure! - expect(vi.mocked(closeSshPtyOutputGeneration).mock.calls).toHaveLength(before.generation + 1) - expect(closeSshPtyOutputGeneration).toHaveBeenLastCalledWith( - 23, - 'ssh_source_recovery_cancellation_failed' - ) - expect(muxDisposeMock.mock.calls).toHaveLength(before.mux + 1) - expect(muxDisposeMock).toHaveBeenLastCalledWith('connection_lost') - expect(ptyProviderDisposeMock.mock.calls).toHaveLength(before.provider + 1) - expect(sourceAckCleanupMock.mock.calls).toHaveLength(before.ack + 1) - expect(sourceCancellationCleanupMock.mock.calls).toHaveLength(before.cancellation + 1) - expect(vi.mocked(unregisterSshPtyProvider).mock.calls).toHaveLength(before.unregister + 1) - expect(unregisterSshPtyProvider).toHaveBeenLastCalledWith(targetId) - expect(activationLease.transferToRecovery).toHaveBeenCalledOnce() - expect(activationLease.commit).not.toHaveBeenCalled() - expect(activationLease.rollback).not.toHaveBeenCalled() - expect(recoveryActivationLease.commit).not.toHaveBeenCalled() - expect(recoveryActivationLease.retire).toHaveBeenCalledOnce() - expect(onRelayLost).toHaveBeenCalledOnce() - expect(session.getState()).toBe('reconnecting') - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() - expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'pty:exit', - expect.anything() - ) - expect(muxDisposeMock).not.toHaveBeenCalledWith('shutdown') - expect(vi.mocked(closeSshPtyOutputGeneration).mock.calls).not.toContainEqual([ - 99, - expect.anything() - ]) - expect(unregisterSshPtyProvider).not.toHaveBeenCalledWith('unrelated-target') - } - ) - - it('overwrites the migration checkpoint with the post-recovery one for the same pty', async () => { - const targetId = 'post-recovery-checkpoint-rekey' - const { session, deps } = await prepareRecovery(targetId) - attachForReconnectMock.mockImplementation(async () => { - queueMicrotask(() => { - emitSourceFrame({ - targetId, - token: 'new-token', - clientGeneration: 2, - ownerGeneration: 2, - sourceStartSu: 4, - sourceEndSu: 8 - }) - completeRecovery({ - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }) - }) - return { incarnationId: 'incarnation-1', sourceRecovery: pendingRecovery(4) } - }) - await session.reconnect(deps.mockConn) - expect(acceptOutputDataMock).toHaveBeenCalledOnce() - - // The pre-migration identity is gone from the intake, so only the checkpoint - // recorded after recovery can answer the next reconnect. - vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) - attachForReconnectMock.mockResolvedValue({ - incarnationId: 'incarnation-1', - sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } - }) - await session.reconnect(deps.mockConn) - - expect(attachForReconnectMock).toHaveBeenCalledTimes(2) - expect(attachForReconnectMock.mock.calls.at(-1)?.[2]).toMatchObject({ - status: 'checkpoint', - deliveryToken: 'new-token', - acceptedSourceEndSu: 8 - }) - }) - - it('keeps a stale overlapping recovery from canceling or mutating its replacement', async () => { - const targetId = 'overlapping-recovery' - const { session, deps } = await prepareRecovery(targetId) - const staleRecoveryLease = { commit: vi.fn(), retire: vi.fn() } - const replacementRecoveryLease = { commit: vi.fn(), retire: vi.fn() } - const staleLease = { - commit: vi.fn(), - rollback: vi.fn(), - transferToRecovery: vi.fn(() => staleRecoveryLease) - } - const replacementLease = { - commit: vi.fn(), - rollback: vi.fn(), - transferToRecovery: vi.fn(() => replacementRecoveryLease) - } - attachForReconnectMock.mockImplementation(async () => { - const ownerGeneration = openConsumerSessionMock.mock.calls.length - if (ownerGeneration === 3) { - queueMicrotask(() => { - completeRecovery({ - id: 'pty-1', - clientGeneration: 3, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'replacement-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }) - }) - } - return { - incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: ownerGeneration, - ownerGeneration, - ptyIncarnation: 'incarnation-1', - deliveryToken: ownerGeneration === 2 ? 'stale-token' : 'replacement-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - }, - sourceActivationLease: ownerGeneration === 2 ? staleLease : replacementLease - } - }) - - const staleReconnect = session.reconnect(deps.mockConn) - await vi.waitFor(() => expect(attachForReconnectMock).toHaveBeenCalledTimes(1)) - await Promise.resolve() - const replacementReconnect = session.reconnect(deps.mockConn) - await Promise.all([staleReconnect, replacementReconnect]) - - const recoveryRequests = attachForReconnectMock.mock.calls.map((call) => call[2]) - expect(recoveryRequests).toHaveLength(2) - expect(recoveryRequests[1]).toMatchObject({ - status: 'checkpoint', - deliveryToken: 'old-token', - acceptedSourceEndSu: 4 - }) - expect(muxRequestMock.mock.calls.filter(([method]) => method === 'pty.cancelDelivery')).toEqual( - [] - ) - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith(targetId, [ - 'pty-1' - ]) - expect(setPtyOwnership).toHaveBeenCalledTimes(1) - expect(staleLease.transferToRecovery).toHaveBeenCalledOnce() - expect(staleLease.commit).not.toHaveBeenCalled() - expect(staleLease.rollback).not.toHaveBeenCalled() - expect(staleRecoveryLease.commit).not.toHaveBeenCalled() - expect(staleRecoveryLease.retire).toHaveBeenCalledOnce() - expect(replacementLease.transferToRecovery).toHaveBeenCalledOnce() - expect(replacementLease.commit).not.toHaveBeenCalled() - expect(replacementLease.rollback).not.toHaveBeenCalled() - expect(replacementRecoveryLease.commit).toHaveBeenCalledOnce() - expect(replacementRecoveryLease.retire).not.toHaveBeenCalled() - expect(clearProviderPtyState).not.toHaveBeenCalled() - expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() - expect(deletePtyOwnership).not.toHaveBeenCalled() - expect(deps.mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - expect(muxDisposeMock).not.toHaveBeenCalledWith('shutdown') - expect(session.getState()).toBe('ready') - }) -}) diff --git a/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts b/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts deleted file mode 100644 index e0f1572d4d3d..000000000000 --- a/src/main/ssh/ssh-relay-session-rejected-delivery.test.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' -import type { SshPtyProvider } from '../providers/ssh-pty-provider' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import type { SshPtyConsumerSessionState } from './ssh-pty-consumer-session' -import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps } from './ssh-relay-session-test-fixtures' - -const { acceptOutputDataMock, getSshPtyProviderMock } = vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - getSshPtyProviderMock: vi.fn() -})) - -vi.mock('../ipc/ssh-pty-output-intake-registry', async (importOriginal) => { - const original = (await importOriginal()) as object - return { ...original, acceptSshPtyOutputData: acceptOutputDataMock } -}) - -vi.mock('../ipc/pty', async (importOriginal) => { - const original = (await importOriginal()) as object - return { ...original, getSshPtyProvider: getSshPtyProviderMock } -}) - -type SshPtyDataPayload = Parameters[0] - -type RejectedDeliverySession = { - mux: SshChannelMultiplexer | null - activePtyProviderGeneration: number | null - ptyConsumerSessionState: SshPtyConsumerSessionState | null - acceptPtyData: (payload: SshPtyDataPayload) => Promise - reattachKnownPty: (args: { - ptyId: string - activeLeaseByPtyId: Map - expectedIdentityByPtyId: Map - attachedLeaseIds: Set - targetedDeliveryRecovery?: 'confirm-existing' | 'fresh-activation' - }) => Promise - reattachRejectedPty: ( - relayPtyId: string, - mux: SshChannelMultiplexer, - providerGeneration: number, - targetedDeliveryRecovery: 'confirm-existing' | 'fresh-activation' - ) => Promise - sourceRecoveryRequest: (appPtyId: string) => Promise< - | { - status: 'checkpoint' - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - deliveryToken: string - acceptedSourceEndSu: number - } - | undefined - > - rejectedPtyRecoveryAttempts: Map - sourceIdentityByRelayPtyId: Map - retireExitedPty: (payload: { - id: string - code: number - providerGeneration: number - ptyIncarnation: string - }) => void -} - -function source(overrides: Partial> = {}) { - return { - relayPtyId: 'pty-bad', - spanId: 'token-bad:0:4', - clientGeneration: 1, - ownerGeneration: 1, - deliveryToken: 'token-bad', - sourceStartSu: 0, - sourceEndSu: 4, - ...overrides - } -} - -function rejectedPayload(overrides: Partial = {}): SshPtyDataPayload { - return { - id: 'ssh:target-1@@pty-bad', - data: 'rejected', - providerGeneration: 23, - ptyIncarnation: 'incarnation-bad', - source: source(), - sourceRejected: true, - ...overrides - } -} - -function prepareSession() { - const deps = createMockDeps() - const mux = { - isDisposed: vi.fn(() => false), - dispose: vi.fn() - } as unknown as SshChannelMultiplexer - const session = new SshRelaySession( - 'target-1', - deps.getMainWindow, - deps.mockStore, - deps.mockPortForward - ) - const internals = session as unknown as RejectedDeliverySession - internals.mux = mux - internals.activePtyProviderGeneration = 23 - internals.ptyConsumerSessionState = { - mode: 'negotiated', - clientInstanceId: 'client-1', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'owner-lease', - outputFlowControl: { version: 1, windowSu: 64 } - } - return { deps, internals, mux, session } -} - -describe('SshRelaySession rejected PTY delivery recovery', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('quarantines source-less output queued before a flow-control grant', async () => { - const { internals, mux } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData(rejectedPayload({ source: undefined, sourceRejected: undefined })) - - expect(warn).toHaveBeenCalledWith( - '[ssh-relay-session] Rejected PTY delivery', - expect.objectContaining({ - sourceState: 'source-missing', - recoveryAction: 'quarantine-legacy-frame' - }) - ) - expect(reattach).not.toHaveBeenCalled() - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(mux.dispose).not.toHaveBeenCalled() - }) - - it.each([ - ['client', 9, 1], - ['owner', 1, 9] - ] as const)( - 'retires a superseded %s generation without disposing the mux', - async (_generation, clientGeneration, ownerGeneration) => { - const { internals, mux } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData( - rejectedPayload({ source: source({ clientGeneration, ownerGeneration }) }) - ) - - expect(reattach).toHaveBeenCalledWith('pty-bad', mux, 23, 'confirm-existing') - expect(acceptOutputDataMock).not.toHaveBeenCalled() - expect(mux.dispose).not.toHaveBeenCalled() - } - ) - - it('logs malformed source metadata and reattaches from the exact outer PTY identity', async () => { - const { internals } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData( - rejectedPayload({ source: undefined, sourceMalformed: true, sourceRejected: undefined }) - ) - - expect(warn).toHaveBeenCalledWith( - '[ssh-relay-session] Rejected PTY delivery', - expect.objectContaining({ - ptyId: 'ssh:target-1@@pty-bad', - providerGeneration: 23, - expectedClientGeneration: 1, - expectedOwnerGeneration: 1, - sourceState: 'source-malformed', - recoveryAction: 'retire-and-reattach-delivery' - }) - ) - expect(reattach).toHaveBeenCalledWith('pty-bad', expect.anything(), 23, 'confirm-existing') - expect(acceptOutputDataMock).not.toHaveBeenCalled() - }) - - it('reattaches only the rejected PTY while a healthy sibling keeps delivering', async () => { - const { deps, internals, mux } = prepareSession() - const provider = {} as SshPtyProvider - getSshPtyProviderMock.mockReturnValue(provider) - vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([ - { - targetId: 'target-1', - ptyId: 'pty-bad', - state: 'detached', - createdAt: 1, - updatedAt: 1 - }, - { - targetId: 'target-1', - ptyId: 'pty-healthy', - state: 'attached', - createdAt: 1, - updatedAt: 1 - } - ]) - const reattachKnownPty = vi.fn( - async (args: Parameters[0]) => { - args.attachedLeaseIds.add(args.ptyId) - } - ) - internals.reattachKnownPty = reattachKnownPty - - await internals.acceptPtyData(rejectedPayload({ rejectedSourceRecovery: 'fresh-activation' })) - await vi.waitFor(() => expect(reattachKnownPty).toHaveBeenCalledOnce()) - await internals.acceptPtyData( - rejectedPayload({ - id: 'ssh:target-1@@pty-healthy', - data: 'healthy', - ptyIncarnation: 'incarnation-healthy', - source: source({ - relayPtyId: 'pty-healthy', - spanId: 'token-healthy:0:7', - deliveryToken: 'token-healthy', - sourceEndSu: 7 - }), - sourceRejected: undefined - }) - ) - - const recovery = reattachKnownPty.mock.calls[0]?.[0] - expect(recovery?.ptyId).toBe('pty-bad') - expect(Array.from(recovery?.activeLeaseByPtyId.keys() ?? [])).toEqual(['pty-bad']) - expect(Array.from(recovery?.expectedIdentityByPtyId.keys() ?? [])).toEqual([]) - expect(recovery?.targetedDeliveryRecovery).toBe('fresh-activation') - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ - 'pty-bad' - ]) - expect(acceptOutputDataMock).toHaveBeenCalledOnce() - expect(acceptOutputDataMock).toHaveBeenCalledWith( - expect.objectContaining({ id: 'ssh:target-1@@pty-healthy', data: 'healthy' }) - ) - expect(mux.dispose).not.toHaveBeenCalled() - }) - - it('reconnects instead of canceling an unprovable malformed delivery', async () => { - const { internals, mux } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData( - rejectedPayload({ - source: undefined, - sourceMalformed: true, - sourceRejected: undefined, - rejectedSourceRecovery: 'reconnect-channel' - }) - ) - - expect(mux.dispose).toHaveBeenCalledWith('connection_lost') - expect(reattach).not.toHaveBeenCalled() - }) - - it('opens a fresh activation after exact rejected-delivery cancellation', async () => { - const { deps, internals, mux } = prepareSession() - internals.sourceIdentityByRelayPtyId.set('pty-bad', { - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-bad', - deliveryToken: 'token-old', - nextSourceSu: 4 - }) - const commit = vi.fn() - const attachForReconnect = vi.fn(async () => ({ - incarnationId: 'incarnation-bad', - sourceActivation: { - status: 'pending' as const, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-bad', - deliveryToken: 'token-fresh', - checkpointSourceEndSu: 0, - recoveryEndSu: 0 - }, - sourceActivationLease: { commit, rollback: vi.fn(async () => true) } - })) - getSshPtyProviderMock.mockReturnValue({ attachForReconnect } as unknown as SshPtyProvider) - - await expect( - internals.reattachRejectedPty('pty-bad', mux, 23, 'fresh-activation') - ).resolves.toBe(true) - - expect(attachForReconnect).toHaveBeenCalledWith('pty-bad') - expect(commit).toHaveBeenCalledOnce() - expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ - 'pty-bad' - ]) - await internals.acceptPtyData( - rejectedPayload({ - data: 'fresh', - sourceRejected: undefined, - source: source({ deliveryToken: 'token-fresh' }) - }) - ) - expect(acceptOutputDataMock).toHaveBeenCalledWith(expect.objectContaining({ data: 'fresh' })) - expect(mux.dispose).not.toHaveBeenCalled() - }) - - it('accepts an exact existing activation as stale-frame confirmation', async () => { - const { internals, mux } = prepareSession() - const checkpoint = { - status: 'checkpoint' as const, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-bad', - deliveryToken: 'token-current', - acceptedSourceEndSu: 7 - } - internals.sourceRecoveryRequest = vi.fn(async () => checkpoint) - const commit = vi.fn() - const attachForReconnect = vi.fn(async () => ({ - incarnationId: 'incarnation-bad', - sourceActivation: { - status: 'pending' as const, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-bad', - deliveryToken: 'token-current', - checkpointSourceEndSu: 0, - recoveryEndSu: 0 - }, - sourceActivationLease: { commit, rollback: vi.fn(async () => true) } - })) - getSshPtyProviderMock.mockReturnValue({ attachForReconnect } as unknown as SshPtyProvider) - - await expect( - internals.reattachRejectedPty('pty-bad', mux, 23, 'confirm-existing') - ).resolves.toBe(true) - - expect(attachForReconnect).toHaveBeenCalledWith('pty-bad', undefined, checkpoint) - expect(commit).toHaveBeenCalledOnce() - expect(mux.dispose).not.toHaveBeenCalled() - }) - - it('forgets rejected-delivery recovery history when the PTY exits', () => { - const { internals } = prepareSession() - internals.rejectedPtyRecoveryAttempts.set('ssh:target-1@@pty-bad', {}) - - internals.retireExitedPty({ - id: 'ssh:target-1@@pty-bad', - code: 0, - providerGeneration: 23, - ptyIncarnation: 'incarnation-bad' - }) - - expect(internals.rejectedPtyRecoveryAttempts).toHaveLength(0) - }) - - // Why a channel drop rather than a terminal relay error: a terminal error clears the reconnect - // backoff and rotates provider authority, aborting every fs and git request on the target, so one - // PTY's undeliverable output would strand the whole connection in manual recovery. - it('bounds failed targeted recovery and escalates to a recoverable relay reconnect', async () => { - const { internals, mux, session } = prepareSession() - const reattach = vi.fn().mockResolvedValue(false) - internals.reattachRejectedPty = reattach - getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) - const onTerminalError = vi.fn() - session.setOnTerminalRelayError(onTerminalError) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await internals.acceptPtyData(rejectedPayload()) - await vi.waitFor(() => expect(mux.dispose).toHaveBeenCalledOnce(), { timeout: 2000 }) - - expect(reattach).toHaveBeenCalledTimes(2) - expect(reattach.mock.calls).toEqual([ - ['pty-bad', mux, 23, 'confirm-existing'], - ['pty-bad', mux, 23, 'confirm-existing'] - ]) - expect(mux.dispose).toHaveBeenCalledWith('connection_lost') - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('PTY pty-bad delivery recovery exhausted') - ) - expect(onTerminalError).not.toHaveBeenCalled() - expect(acceptOutputDataMock).not.toHaveBeenCalled() - }) - - it('stops without an error when the rejected PTY exited during recovery', async () => { - const { internals, mux, session } = prepareSession() - const reattach = vi.fn().mockResolvedValue(false) - internals.reattachRejectedPty = reattach - // Why: reattachKnownPty resolves without claiming the lease when the PTY exits mid-attach, so a - // plain "not recovered" is indistinguishable from a failure until liveness is checked. - getSshPtyProviderMock.mockReturnValue({ hasPty: () => false } as unknown as SshPtyProvider) - const onTerminalError = vi.fn() - session.setOnTerminalRelayError(onTerminalError) - - await internals.acceptPtyData(rejectedPayload()) - await vi.waitFor(() => expect(reattach).toHaveBeenCalledOnce()) - await new Promise((resolve) => setTimeout(resolve, 400)) - - expect(reattach).toHaveBeenCalledOnce() - expect(onTerminalError).not.toHaveBeenCalled() - expect(mux.dispose).not.toHaveBeenCalled() - }) - - // Why bounded: the bulk reconnect path caps the identical attach work at 8, and a relay that - // starts rejecting across every PTY at once would otherwise open one attach round trip per PTY. - it('caps concurrent targeted reattaches and coalesces repeats for one PTY', async () => { - const { internals } = prepareSession() - getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const release: (() => void)[] = [] - let active = 0 - let peak = 0 - const reattach = vi.fn(async () => { - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => release.push(resolve)) - active-- - return true - }) - internals.reattachRejectedPty = reattach - - for (let index = 0; index < 20; index++) { - const relayPtyId = `pty-${index}` - await internals.acceptPtyData( - rejectedPayload({ - id: `ssh:target-1@@${relayPtyId}`, - source: source({ relayPtyId, deliveryToken: `token-${index}` }) - }) - ) - // Why twice: a repeat for a PTY already recovering must not consume a second slot. - await internals.acceptPtyData( - rejectedPayload({ - id: `ssh:target-1@@${relayPtyId}`, - source: source({ relayPtyId, deliveryToken: `token-${index}-repeat` }) - }) - ) - } - - expect(peak).toBe(8) - expect(reattach).toHaveBeenCalledTimes(8) - for (const resolve of release.splice(0)) { - resolve() - } - await vi.waitFor(() => expect(reattach).toHaveBeenCalledTimes(16)) - expect(peak).toBe(8) - for (const resolve of release.splice(0)) { - resolve() - } - }) - - it('does not let accepted frames refill the recovery budget indefinitely', async () => { - const { internals, mux, session } = prepareSession() - const reattach = vi.fn().mockResolvedValue(true) - internals.reattachRejectedPty = reattach - getSshPtyProviderMock.mockReturnValue({ hasPty: () => true } as unknown as SshPtyProvider) - const onTerminalError = vi.fn() - session.setOnTerminalRelayError(onTerminalError) - - // Why alternating, with a fresh bad token each round: every rejection retires its own delivery, - // so a flapping PTY only keeps asking for recovery by moving onto new ones, and the accepted - // frame in between is what used to clear the budget outright. Each reattach here succeeds, so - // the consecutive budget is cleared legitimately too — only the per-generation ceiling can stop - // it. - for (let round = 0; round < 40; round++) { - await internals.acceptPtyData( - rejectedPayload({ - source: source({ - spanId: `token-bad-${round}:0:4`, - deliveryToken: `token-bad-${round}`, - clientGeneration: 9 - }) - }) - ) - await internals.acceptPtyData( - rejectedPayload({ - sourceRejected: undefined, - source: source({ - spanId: `token-good:${round * 4}:${round * 4 + 4}`, - deliveryToken: 'token-good', - sourceStartSu: round * 4, - sourceEndSu: round * 4 + 4 - }) - }) - ) - await Promise.resolve() - } - await vi.waitFor(() => expect(mux.dispose).toHaveBeenCalledOnce(), { timeout: 2000 }) - - expect(onTerminalError).not.toHaveBeenCalled() - expect(acceptOutputDataMock).toHaveBeenCalledTimes(40) - expect(reattach.mock.calls.length).toBeLessThanOrEqual(12) - }) -}) diff --git a/src/main/ssh/ssh-relay-session-relay-loss.test.ts b/src/main/ssh/ssh-relay-session-relay-loss.test.ts deleted file mode 100644 index 5053426131d3..000000000000 --- a/src/main/ssh/ssh-relay-session-relay-loss.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -// #11953: the grace-time notify is the last thing establish()/reconnect() do -// before latching 'ready', and it can dispose the mux synchronously (writer -// admission cap / throwing transport). Latching 'ready' there left the status -// bar on "connected" with fs/pty/git providers bound to a dead mux and no -// relay-loss watcher, so nothing ever scheduled a reconnect. - -const { muxRequestMock, openConsumerSessionMock, registeredPtyProvider } = vi.hoisted(() => ({ - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - registeredPtyProvider: { dispose: vi.fn(), attachForReconnect: vi.fn() } -})) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 41), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - -// Mirrors the real multiplexer contract: dispose() latches, and a failed write -// during notify disposes the mux synchronously via handleProtocolError. -vi.mock('./ssh-channel-multiplexer', () => ({ - SshChannelMultiplexer: class MockSshChannelMultiplexer { - private disposed = false - private disposeReason: string | null = null - private disposeHandlers: ((reason: string) => void)[] = [] - /** Set by the test to kill the channel from inside notify(). */ - failNotifyMethod: string | null = null - notify = vi.fn((method: string) => { - if (method === this.failNotifyMethod) { - this.dispose('connection_lost') - } - }) - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn((handler: (reason: string) => void) => { - if (this.disposed) { - handler(this.disposeReason ?? 'shutdown') - return () => {} - } - this.disposeHandlers.push(handler) - return () => { - const idx = this.disposeHandlers.indexOf(handler) - if (idx !== -1) { - this.disposeHandlers.splice(idx, 1) - } - } - }) - dispose = vi.fn((reason = 'shutdown') => { - if (this.disposed) { - return - } - this.disposed = true - this.disposeReason = reason - for (const handler of this.disposeHandlers.splice(0)) { - handler(reason) - } - }) - isDisposed = vi.fn(() => this.disposed) - } -})) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: () => false, - isSshPtyIdentityMismatchError: () => false, - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = vi.fn() - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue(registeredPtyProvider), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = - await import('../providers/ssh-filesystem-dispatch') -const { getPtyIdsForConnection } = await import('../ipc/pty') - -describe('SshRelaySession relay loss during setup', () => { - /** Armed by a test so the *next* mux dies inside its grace-time notify. */ - let armGraceTimeFailure = false - let armProviderRegistrationFailure = false - - beforeEach(() => { - vi.clearAllMocks() - armGraceTimeFailure = false - armProviderRegistrationFailure = false - muxRequestMock.mockReset() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - registeredPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - mockDeploySuccess() - }) - - function createSession(): { - session: SshRelaySession - onRelayLost: ReturnType - onReady: ReturnType - mockStore: ReturnType['mockStore'] - } { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onRelayLost = vi.fn() - const onReady = vi.fn() - session.setOnRelayLost(onRelayLost) - session.setOnReady(onReady) - // Arm once the mux exists; every attempt issues requests before the notify. - muxRequestMock.mockImplementation(async (method: string) => { - const mux = session.getMux() as unknown as { - failNotifyMethod: string | null - dispose: (reason: string) => void - } | null - if (mux && armGraceTimeFailure) { - mux.failNotifyMethod = SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD - } - if (mux && armProviderRegistrationFailure && method === 'git.listWorktrees') { - mux.dispose('connection_lost') - throw new Error('SSH connection lost, reconnecting...') - } - return [] - }) - return { session, onRelayLost, onReady, mockStore } - } - - it('fails establish instead of reporting ready on a dead channel', async () => { - const { session, onReady } = createSession() - armGraceTimeFailure = true - - await expect(session.establish({} as SshConnection)).rejects.toThrow( - 'Relay connection lost during establish' - ) - - expect(registerSshFilesystemProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect(session.getState()).not.toBe('ready') - expect(onReady).not.toHaveBeenCalled() - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - }) - - it('routes a dead channel during reconnect into relay-loss recovery', async () => { - const { session, onRelayLost, onReady } = createSession() - - await session.establish({} as SshConnection) - expect(session.getState()).toBe('ready') - onReady.mockClear() - - armGraceTimeFailure = true - await session.reconnect({} as SshConnection) - - expect(session.getState()).not.toBe('ready') - expect(onReady).not.toHaveBeenCalled() - expect(onRelayLost).toHaveBeenCalledTimes(1) - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - }) - - it('routes a mux that dies during provider registration into relay-loss recovery', async () => { - const { session, onRelayLost, onReady, mockStore } = createSession() - - await session.establish({} as SshConnection) - expect(session.getState()).toBe('ready') - onReady.mockClear() - - vi.mocked(mockStore.getRepos).mockReturnValue([ - { connectionId: 'target-1', path: '/repo' } as ReturnType[number] - ]) - armProviderRegistrationFailure = true - - await session.reconnect({} as SshConnection) - - expect(session.getState()).not.toBe('ready') - expect(onReady).not.toHaveBeenCalled() - expect(onRelayLost).toHaveBeenCalledTimes(1) - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - }) - - // #13548 follow-up: cancelling the in-flight port scan emits rpc.cancel on the control - // lane, which a saturated writer turns into mux.dispose('connection_lost'). That fires - // during our own teardown, so it must not be reported as a lost relay. - it.each([ - ['detach', (session: SshRelaySession) => session.beginShutdownDetach()], - ['dispose', (session: SshRelaySession) => session.dispose()] - ])( - 'does not report relay loss when the port-scan cancel kills the mux on %s', - async (_path, teardown) => { - const { session, onRelayLost } = createSession() - // Mirrors SshChannelMultiplexer.request: an in-flight request whose signal aborts - // emits rpc.cancel, and the mock mux dies on that notify like a saturated writer. - muxRequestMock.mockImplementation( - async (method: string, _params?: unknown, options?: { signal?: AbortSignal }) => { - if (method !== 'ports.detect') { - return [] - } - const mux = session.getMux() as unknown as { - failNotifyMethod: string | null - notify: (method: string, params?: unknown) => void - } - mux.failNotifyMethod = 'rpc.cancel' - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - 'abort', - () => { - mux.notify('rpc.cancel', { id: 1 }) - reject(new Error('cancelled')) - }, - { once: true } - ) - }) - } - ) - - await session.establish({} as SshConnection) - expect(session.getState()).toBe('ready') - expect(muxRequestMock).toHaveBeenCalledWith('ports.detect', undefined, expect.anything()) - const mux = session.getMux() as unknown as { notify: ReturnType } - - teardown(session) - - expect(mux.notify).toHaveBeenCalledWith('rpc.cancel', { id: 1 }) - expect(onRelayLost).not.toHaveBeenCalled() - } - ) - - // #11953: reattachKnownPtys swallows every per-PTY failure, so a mux killed by the - // reattach burst itself never reaches the catch — the post-reattach gate has to notice. - it('routes a mux that dies during PTY reattach into relay-loss recovery', async () => { - const { session, onRelayLost, onReady } = createSession() - - await session.establish({} as SshConnection) - expect(session.getState()).toBe('ready') - onReady.mockClear() - - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - registeredPtyProvider.attachForReconnect.mockImplementation(async () => { - const mux = session.getMux() as unknown as { dispose: (reason: string) => void } | null - mux?.dispose('connection_lost') - throw new Error('SSH connection lost, reconnecting...') - }) - - await session.reconnect({} as SshConnection) - - expect(registeredPtyProvider.attachForReconnect).toHaveBeenCalled() - expect(session.getState()).not.toBe('ready') - expect(onReady).not.toHaveBeenCalled() - expect(onRelayLost).toHaveBeenCalledTimes(1) - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - }) -}) diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts deleted file mode 100644 index 9ce9410ecd4e..000000000000 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' -import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' -import { RelayEndpointUnresponsiveError } from './ssh-relay-endpoint-incumbent' -import type { BrowserWindow } from 'electron' - -const { filesystemProviderConstructorMock } = vi.hoisted(() => ({ - filesystemProviderConstructorMock: vi.fn() -})) - -vi.mock('./ssh-relay-deploy', () => ({ - deployAndLaunchRelay: vi.fn() -})) - -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: vi.fn(async (_mux, options) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - })) -})) - -vi.mock('./ssh-channel-multiplexer', () => { - return { - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - request = vi.fn().mockResolvedValue([]) - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } - } -}) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('not found'), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - constructor(...args: unknown[]) { - filesystemProviderConstructorMock(...args) - } - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ - dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined) - }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') - -function createMockDeps(): { - mockConn: SshConnection - mockStore: Store - mockPortForward: SshPortForwardManager - getMainWindow: () => BrowserWindow | null -} { - const mockConn = {} as SshConnection - const mockStore = { - getRepos: vi.fn().mockReturnValue([]), - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn(), - removeSshPtyConsumerRecovery: vi.fn(), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - reconcileSshRemotePtyLeasesForTarget: vi.fn(), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn(), - markSshRemotePtyLeasesAsync: vi.fn(), - markSshRemotePtyLeasesAttachedAsync: vi.fn(), - getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]), - pruneExpiredSshRemotePtyKillIntents: vi.fn(), - recordSshRemotePtyKillIntent: vi.fn(), - clearSshRemotePtyKillIntent: vi.fn(), - noteSshRemotePtyKillReplayAttempt: vi.fn() - } as unknown as Store - const mockPortForward = { - removeAllForwards: vi.fn() - } as unknown as SshPortForwardManager - const mockWindow = { - isDestroyed: (): boolean => false, - webContents: { send: vi.fn() } - } as unknown as BrowserWindow - const getMainWindow = vi.fn().mockReturnValue(mockWindow) as unknown as () => BrowserWindow | null - return { mockConn, mockStore, mockPortForward, getMainWindow } -} - -function mockDeploySuccess(): void { - const mockTransport = { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - } - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: mockTransport, - platform: 'linux-x64' - }) -} - -describe('SshRelaySession terminal relay error (RelayVersionMismatchError)', () => { - beforeEach(() => { - vi.clearAllMocks() - filesystemProviderConstructorMock.mockReset() - mockDeploySuccess() - }) - - it('omits the SFTP folder factory while retaining raw transfer on system SSH', async () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = { - usesSystemSshTransport: vi.fn(() => true) - } as unknown as SshConnection - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(filesystemProviderConstructorMock).toHaveBeenCalledWith( - 'target-1', - expect.anything(), - undefined, - expect.objectContaining({ downloadFile: expect.any(Function) }), - undefined - ) - }) - - it('fires onTerminalRelayError on initial establish() and rethrows', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - const onLost = vi.fn() - session.setOnTerminalRelayError(onTerminal) - session.setOnRelayLost(onLost) - - const mismatchErr = new RelayVersionMismatchError( - '0.1.0+aaa', - '0.1.0+bbb', - '[relay-connect] Handshake mismatch...' - ) - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(mismatchErr) - - await expect(session.establish(mockConn)).rejects.toBe(mismatchErr) - expect(onTerminal).toHaveBeenCalledTimes(1) - expect(onTerminal).toHaveBeenCalledWith('target-1', mismatchErr) - expect(onLost).not.toHaveBeenCalled() - expect(session.getState()).toBe('idle') - }) - - it('does NOT fire onTerminalRelayError on a generic establish failure', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - session.setOnTerminalRelayError(onTerminal) - - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(new Error('boom')) - - await expect(session.establish(mockConn)).rejects.toThrow('boom') - expect(onTerminal).not.toHaveBeenCalled() - expect(session.getState()).toBe('idle') - }) - - it('routes RelayEndpointUnresponsiveError on reconnect() to onRelayLost, not the terminal path', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - const onLost = vi.fn() - session.setOnTerminalRelayError(onTerminal) - session.setOnRelayLost(onLost) - - await session.establish(mockConn) - const silent = new RelayEndpointUnresponsiveError({ - sockPath: '/home/u/.orca-remote/relay-x/relay.sock', - verdict: 'live', - evidence: 'accepted-connection', - socketPresent: true, - holders: [], - holdersEnumerable: false - }) - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(silent) - - await session.reconnect(mockConn) - expect(onTerminal).not.toHaveBeenCalled() - expect(onLost).toHaveBeenCalledWith('target-1') - }) - - it('fires onTerminalRelayError on reconnect() when deploy throws RelayVersionMismatchError', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onTerminal = vi.fn() - session.setOnTerminalRelayError(onTerminal) - - await session.establish(mockConn) - expect(session.getState()).toBe('ready') - - const mismatchErr = new RelayVersionMismatchError('0.1.0+old', '0.1.0+new', '') - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(mismatchErr) - - await session.reconnect(mockConn) - expect(onTerminal).toHaveBeenCalledTimes(1) - expect(onTerminal).toHaveBeenCalledWith('target-1', mismatchErr) - expect(session.getState()).toBe('idle') - }) -}) diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts deleted file mode 100644 index 0e9e0c03579d..000000000000 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { vi, type Mock } from 'vitest' -import type { BrowserWindow } from 'electron' -import type { SshConnection } from './ssh-connection' -import type { PersistPtyBindingArgs } from '../persistence/loading-store/pty-binding-persistence' -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' -import { deployAndLaunchRelay } from './ssh-relay-deploy' - -type SshRelaySessionTestDeps = { - mockConn: SshConnection - mockStore: Store - mockPortForward: SshPortForwardManager - getMainWindow: Mock<() => BrowserWindow | null> - mockWindow: BrowserWindow -} - -const persistedBindings = new WeakMap() - -export function recordedPtyBindings(store: Store): readonly PersistPtyBindingArgs[] { - return persistedBindings.get(store) ?? [] -} - -export function createMockDeps(): SshRelaySessionTestDeps { - const bindings: PersistPtyBindingArgs[] = [] - const mockConn = {} as SshConnection - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The relay fixture implements the Store methods exercised by session establishment and teardown. - const mockStore = { - getRepos: vi.fn().mockReturnValue([]), - getSshTarget: vi.fn().mockReturnValue(undefined), - getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), - upsertSshPtyConsumerRecovery: vi.fn(), - removeSshPtyConsumerRecovery: vi.fn(), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - reconcileSshRemotePtyLeasesForTarget: vi.fn(), - getWorkspaceSession: vi.fn(), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn(), - markSshRemotePtyLeasesAsync: vi.fn(), - markSshRemotePtyLeasesForShutdown: vi.fn(), - markSshRemotePtyLeasesAttachedAsync: vi.fn(), - getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]), - pruneExpiredSshRemotePtyKillIntents: vi.fn(), - recordSshRemotePtyKillIntent: vi.fn(), - clearSshRemotePtyKillIntent: vi.fn(), - noteSshRemotePtyKillReplayAttempt: vi.fn(), - persistPtyBinding: vi.fn(async (input: Parameters[0]) => { - const binding = typeof input === 'function' ? input() : input - if (!binding) { - return false - } - bindings.push(binding) - return true - }) - } as unknown as Store - persistedBindings.set(mockStore, bindings) - const mockPortForward = { - removeAllForwards: vi.fn() - } as unknown as SshPortForwardManager - const mockWindow = { - isDestroyed: () => false, - // Why: the port scanner visibility-gates its ticks; a visible mock window - // keeps establish-path tests exercising the scan-on-ready behavior. - isVisible: () => true, - isMinimized: () => false, - webContents: { send: vi.fn() } - } as unknown as BrowserWindow - const getMainWindow = vi.fn().mockReturnValue(mockWindow) - return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } -} - -export function mockDeploySuccess(): void { - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }, - platform: 'linux-x64' - }) -} diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts deleted file mode 100644 index 589523e9a448..000000000000 --- a/src/main/ssh/ssh-relay-session.test.ts +++ /dev/null @@ -1,935 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { SshRelaySession } from './ssh-relay-session' -import type { SshConnection } from './ssh-connection' -import { - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - AGENT_HOOK_INSTALL_PLUGINS_METHOD -} from '../../shared/agent-hook-relay' -import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' - -const { acceptOutputDataMock, muxRequestMock, openConsumerSessionMock, pauseAdapterMock } = - vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - pauseAdapterMock: vi.fn() - })) - -vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) -vi.mock('./ssh-previous-relay-terminals', () => import('./ssh-previous-relay-census-test-double')) - -vi.mock('./ssh-pty-consumer-session', () => ({ - openSshPtyConsumerSession: openConsumerSessionMock -})) - -vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ - acceptSshPtyOutputData: acceptOutputDataMock, - acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), - allocateSshPtyProviderGeneration: vi.fn(() => 41), - beginSshPtyOutputGenerationMigration: vi.fn(() => ({ - byPty: new Map(), - completion: Promise.resolve() - })), - closeSshPtyOutputGeneration: vi.fn(), - getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), - applySshPtySourceCancellationProof: vi.fn(() => true), - applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), - installSshPtySourceAckPublisher: vi.fn(() => () => {}), - installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - -vi.mock('./ssh-channel-multiplexer', () => { - return { - SshChannelMultiplexer: class MockSshChannelMultiplexer { - notify = vi.fn() - notifyWithSettlement = vi.fn() - request = muxRequestMock - onNotification = vi.fn().mockReturnValue(() => {}) - onNotificationByMethod = vi.fn().mockReturnValue(() => {}) - onRequest = vi.fn().mockReturnValue(() => {}) - onDispose = vi.fn().mockReturnValue(() => {}) - dispose = vi.fn() - isDisposed = vi.fn().mockReturnValue(false) - } - } -}) - -vi.mock('../providers/ssh-pty-provider', () => ({ - isSshPtyNotFoundError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('not found'), - isSshPtyIdentityMismatchError: (err: unknown) => - (err instanceof Error ? err.message : String(err)).includes('identity mismatch'), - SshPtyProvider: class MockSshPtyProvider { - onData = vi.fn().mockReturnValue(() => {}) - onReplay = vi.fn().mockReturnValue(() => {}) - onExit = vi.fn().mockReturnValue(() => {}) - attach = vi.fn().mockResolvedValue(undefined) - attachForReconnect = vi.fn().mockResolvedValue({}) - setPtyDeliveryPauseAdapter = pauseAdapterMock - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-filesystem-provider', () => ({ - SshFilesystemProvider: class MockSshFilesystemProvider { - dispose = vi.fn() - } -})) - -vi.mock('../providers/ssh-git-provider', () => ({ - SshGitProvider: class MockSshGitProvider {} -})) - -vi.mock('../ipc/pty', () => ({ - registerSshPtyProvider: vi.fn(), - unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn().mockReturnValue({ - dispose: vi.fn(), - attach: vi.fn().mockResolvedValue(undefined), - attachForReconnect: vi.fn().mockResolvedValue({}) - }), - getPtyIdsForConnection: vi.fn().mockReturnValue([]), - clearPtyOwnershipForConnection: vi.fn(), - clearProviderPtyState: vi.fn(), - deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - restorePtyIncarnation: vi.fn(), - isCurrentPtyExit: vi.fn(() => true) -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) -})) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), - unregisterSshGitProvider: vi.fn() -})) - -const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const { execCommand } = await import('./ssh-relay-deploy-helpers') -const { getRemoteHostPlatform } = await import('./ssh-remote-platform') -const { - registerSshPtyProvider, - unregisterSshPtyProvider, - getPtyIdsForConnection, - clearProviderPtyState, - deletePtyOwnership, - setPtyOwnership -} = await import('../ipc/pty') -const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = - await import('../providers/ssh-filesystem-dispatch') -const { registerSshGitProvider, unregisterSshGitProvider } = - await import('../providers/ssh-git-dispatch') - -describe('SshRelaySession', () => { - beforeEach(() => { - vi.clearAllMocks() - openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) - delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue([]) - mockDeploySuccess() - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - }) - - it('hands each PTY data event exactly once to the bounded main intake', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() } - const session = new SshRelaySession( - 'target-1', - getMainWindow, - mockStore, - mockPortForward, - runtime as never - ) - await session.establish(mockConn) - const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { - onData: ReturnType - } - const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { - id: string - data: string - providerGeneration: number - ptyIncarnation: string - }) => void - - onData({ - id: 'ssh-pty-1', - data: 'ssh output', - providerGeneration: 41, - ptyIncarnation: 'incarnation-1' - }) - - expect(acceptOutputDataMock).toHaveBeenCalledTimes(1) - expect(acceptOutputDataMock).toHaveBeenCalledWith({ - id: 'ssh-pty-1', - data: 'ssh output', - providerGeneration: 41, - ptyIncarnation: 'incarnation-1', - rawLength: 'ssh output'.length, - transformed: false - }) - expect(runtime.onPtyData).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything()) - }) - - it('starts in idle state', () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - expect(session.getState()).toBe('idle') - expect(session.getMux()).toBeNull() - }) - - it('transitions idle → deploying → ready on establish', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(session.getState()).toBe('ready') - expect(session.getMux()).not.toBeNull() - expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect(registerSshFilesystemProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) - }) - - it('rechecks OpenCode preparation from scans and aborts it when the relay session disconnects', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const prepareOpenCodeRuntime = vi.fn().mockResolvedValue(undefined) - vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - prepareOpenCodeRuntime - }) - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - await session.requestAiVaultSessionList({}) - await session.requestSessionSearch('sessionSearch.search', {}) - expect(prepareOpenCodeRuntime).toHaveBeenCalledTimes(2) - const signal = prepareOpenCodeRuntime.mock.calls[0][0] - expect(signal.aborted).toBe(false) - await session.dispose() - expect(signal.aborted).toBe(true) - await expect(session.requestAiVaultSessionList({})).rejects.toThrow('not ready') - expect(prepareOpenCodeRuntime).toHaveBeenCalledTimes(2) - }) - - it('continues provider registration when the relay managed-hook request fails', async () => { - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - muxRequestMock.mockImplementation(async (method: string) => { - if (method === 'preflight.detectAgents') { - return { agents: ['codex'] } - } - if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) { - throw new Error('runtime unavailable') - } - return { ok: true } - }) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish({} as SshConnection) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith( - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - expect.anything() - ) - ) - - expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect( - muxRequestMock.mock.calls.some(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) - ).toBe(true) - }) - - it('suppresses expected managed-hook teardown errors during disconnect', async () => { - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - muxRequestMock.mockImplementation(async (method: string) => { - if (method === 'preflight.detectAgents') { - return { agents: ['codex'] } - } - if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) { - throw Object.assign(new Error('request disposed'), { code: 'DISPOSED' }) - } - return { ok: true } - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - try { - await session.establish({} as SshConnection) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith( - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - expect.anything() - ) - ) - - expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) - expect(warn.mock.calls.flat().join(' ')).not.toContain('relay managed hook install failed') - } finally { - warn.mockRestore() - } - }) - - it('does not run POSIX managed hook installers on Windows remotes', async () => { - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = { - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection - vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ - transport: { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }, - platform: 'win32-x64', - hostPlatform: getRemoteHostPlatform('win32-x64'), - remoteHome: 'C:/Users/me', - remoteRelayDir: 'C:/Users/me/.orca-remote/relay-v1', - nodePath: 'C:/Program Files/nodejs/node.exe', - sockPath: '\\\\.\\pipe\\orca-relay-123' - }) - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect( - muxRequestMock.mock.calls.some( - ([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD - ) - ).toBe(false) - expect( - muxRequestMock.mock.calls.some(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) - ).toBe(true) - }) - - it('does not register providers if dispose wins during initial plugin sync', async () => { - process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' - let resolvePluginInstall!: () => void - muxRequestMock.mockImplementation(async (method: string) => { - if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) { - return new Promise((resolve) => { - resolvePluginInstall = () => resolve({ ok: true }) - }) - } - return { ok: true } - }) - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = {} as SshConnection - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - const establish = session.establish(mockConn) - await vi.waitFor(() => - expect(muxRequestMock).toHaveBeenCalledWith( - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - expect.anything() - ) - ) - session.dispose() - resolvePluginInstall() - - await expect(establish).rejects.toThrow('Session disposed during establish') - expect(registerSshPtyProvider).not.toHaveBeenCalled() - expect(registerSshFilesystemProvider).not.toHaveBeenCalled() - expect(registerSshGitProvider).not.toHaveBeenCalled() - }) - - it('rejects establish when not idle', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - await expect(session.establish(mockConn)).rejects.toThrow('Cannot establish relay session') - }) - - it('reverts to idle on establish failure', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(new Error('deploy failed')) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await expect(session.establish(mockConn)).rejects.toThrow('deploy failed') - expect(session.getState()).toBe('idle') - }) - - it('reconnect tears down old providers and registers new ones', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - const oldMux = session.getMux() - - vi.clearAllMocks() - mockDeploySuccess() - - await session.reconnect(mockConn) - - expect(session.getState()).toBe('ready') - expect(session.getMux()).not.toBe(oldMux) - expect(unregisterSshPtyProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshGitProvider).toHaveBeenCalledWith('target-1') - expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) - }) - - it('compiles a native Windows Orca CLI bridge without a cmd.exe shim', async () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = { - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection - vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ - transport: { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }, - platform: 'win32-x64', - hostPlatform: getRemoteHostPlatform('win32-x64'), - remoteHome: 'C:/Users/me', - remoteRelayDir: 'C:/Users/me/.orca-remote/relay-v1', - nodePath: 'C:/Program Files/nodejs/node.exe', - sockPath: '\\\\.\\pipe\\orca-relay-123' - }) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(execCommand).toHaveBeenCalledTimes(2) - expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe') - expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false }) - expect(mockConn.writeFile).toHaveBeenCalledWith( - 'C:/Users/me/.orca-relay/bin/orca-launcher.cs', - expect.stringContaining('ProcessStartInfo'), - { hostPlatform: getRemoteHostPlatform('win32-x64') } - ) - const launcherSource = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string - expect(launcherSource).toContain('ORCA_RELAY_SOCKET_PATH') - expect(launcherSource).not.toContain('cmd.exe') - expect(launcherSource).not.toContain('%*') - expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe') - expect(vi.mocked(execCommand).mock.calls[1]?.[2]).toEqual({ wrapCommand: false }) - expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe( - false - ) - }) - - it('reconnect re-attaches live PTYs', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2']) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2']) - - await session.reconnect(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1') - expect(mockAttach).toHaveBeenCalledWith('pty-2') - }) - - it('forwards reconnect replay after the attach attempt is still current', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue({ replay: 'restored-output' }) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - - await session.reconnect(mockConn) - - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { - id: 'ssh:target-1@@pty-1', - data: 'restored-output' - }) - }) - - it('does not wall-clock dedupe identical replay payloads from distinct reconnects', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue({ replay: 'same-output' }) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - - await session.reconnect(mockConn) - await session.reconnect(mockConn) - - const replaySends = vi - .mocked(mockWindow.webContents.send) - .mock.calls.filter(([channel]) => channel === 'pty:replay') - expect(mockAttach).toHaveBeenCalledTimes(2) - expect(replaySends).toHaveLength(2) - }) - - it('establish re-attaches owned PTYs after explicit disconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['ssh:target-1@@pty-1']) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1') - expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-1', 'ssh:target-1') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ - 'pty-1' - ]) - }) - - it('establish re-attaches durable leases after app restart', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { targetId: 'target-1', ptyId: 'pty-live', state: 'detached' }, - { targetId: 'target-1', ptyId: 'pty-live-2', state: 'detached' }, - // `expired` records that the CLIENT lost its route, so this is an orphan, not a corpse — the - // reattach is the only thing that can find out which. - { targetId: 'target-1', ptyId: 'pty-orphaned', state: 'expired' }, - // Retired routes, each for its own reason. Re-adopting the first is the lease fan-out; the - // second would hand this pane to whatever shell now holds the recycled id. - { targetId: 'target-1', ptyId: 'pty-superseded', state: 'expired', supersededBy: 'pty-live' }, - { - targetId: 'target-1', - ptyId: 'pty-recycled', - state: 'expired', - relayIdRecycled: true - }, - { targetId: 'target-1', ptyId: 'pty-terminated', state: 'terminated' } - ] as ReturnType) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-live') - expect(mockAttach).toHaveBeenCalledWith('pty-live-2') - expect(mockAttach).toHaveBeenCalledWith('pty-orphaned') - expect(mockAttach).not.toHaveBeenCalledWith('pty-superseded') - expect(mockAttach).not.toHaveBeenCalledWith('pty-recycled') - expect(mockAttach).not.toHaveBeenCalledWith('pty-terminated') - expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-live', 'ssh:target-1') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith( - 'target-1', - expect.arrayContaining(['pty-live', 'pty-live-2', 'pty-orphaned']) - ) - }) - - it('forwards a lease tab identity to reattach so a reset relay cannot cross-wire it', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { targetId: 'target-1', ptyId: 'pty-1', state: 'detached', tabId: 'tab-a' } - ] as ReturnType) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1', { tabId: 'tab-a' }) - }) - - it('forwards a lease pane identity when leaf identity is available', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockResolvedValue(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - const leafId = '11111111-1111-4111-8111-111111111111' - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { targetId: 'target-1', ptyId: 'pty-1', state: 'detached', tabId: 'tab-a', leafId } - ] as ReturnType) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1', { - paneKey: `tab-a:${leafId}`, - tabId: 'tab-a' - }) - }) - - it('does not expire a live reused relay id when attach rejects identity mismatch', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi - .fn() - .mockRejectedValueOnce(new Error('PTY "pty-1" not found (identity mismatch)')) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue([]) - const staleLeafId = '11111111-1111-4111-8111-111111111111' - vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ - { - targetId: 'target-1', - ptyId: 'pty-1', - state: 'detached', - tabId: 'tab-old', - leafId: staleLeafId - } - ] as ReturnType) - - await session.reconnect(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-1', { - paneKey: `tab-old:${staleLeafId}`, - tabId: 'tab-old' - }) - expect(clearProviderPtyState).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') - expect(deletePtyOwnership).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('target-1', 'pty-1', 'expired') - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'pty:exit', - expect.objectContaining({ id: 'ssh:target-1@@pty-1' }) - ) - }) - - it('rejects establish if detach wins while reattach is in flight', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const { getSshPtyProvider } = await import('../ipc/pty') - let resolveAttach!: () => void - const mockAttach = vi.fn().mockReturnValue( - new Promise((resolve) => { - resolveAttach = resolve - }) - ) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const establish = session.establish(mockConn) - await vi.waitFor(() => expect(mockAttach).toHaveBeenCalledWith('pty-1')) - session.detach() - resolveAttach() - - await expect(establish).rejects.toThrow('Session disposed during establish') - expect(setPtyOwnership).not.toHaveBeenCalledWith('pty-1', 'ssh:target-1') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - }) - - it('does not mark PTYs attached if detach wins while reattach is in flight', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - let resolveAttach!: () => void - const mockAttach = vi.fn().mockReturnValue( - new Promise((resolve) => { - resolveAttach = resolve - }) - ) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1']) - - const reconnect = session.reconnect(mockConn) - await vi.waitFor(() => expect(mockAttach).toHaveBeenCalledWith('pty-1')) - session.detach() - resolveAttach() - await reconnect - - expect(setPtyOwnership).not.toHaveBeenCalledWith('pty-1', 'ssh:target-1') - expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() - }) - - it('invalidates and broadcasts remote PTYs that cannot reattach after relay reconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi - .fn() - .mockRejectedValueOnce(new Error('PTY "pty-stale" not found')) - .mockResolvedValueOnce(undefined) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-stale', 'pty-live']) - - await session.reconnect(mockConn) - - expect(mockAttach).toHaveBeenCalledWith('pty-stale') - expect(mockAttach).toHaveBeenCalledWith('pty-live') - expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:target-1@@pty-stale') - expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-stale') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { - id: 'ssh:target-1@@pty-stale', - code: -1, - ptySourceDisowned: true - }) - }) - - it('retries transient reattach failure without tearing down provider registration', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - const onRelayLost = vi.fn() - session.setOnRelayLost(onRelayLost) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - const { getSshPtyProvider } = await import('../ipc/pty') - const mockAttach = vi.fn().mockRejectedValue(new Error('Multiplexer disposed')) - vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: mockAttach, - dispose: vi.fn() - } as unknown as ReturnType) - vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-live']) - const random = vi.spyOn(Math, 'random').mockReturnValue(0) - - try { - await session.reconnect(mockConn) - } finally { - random.mockRestore() - } - - expect(mockAttach).toHaveBeenCalledTimes(2) - expect(onRelayLost).not.toHaveBeenCalled() - expect(session.getState()).toBe('ready') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-live', - 'expired' - ) - }) - - it('dispose transitions to disposed and unregisters providers', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - session.dispose() - - expect(session.getState()).toBe('disposed') - expect(unregisterSshPtyProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshGitProvider).toHaveBeenCalledWith('target-1') - }) - - it('dispose is idempotent', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - session.dispose() - session.dispose() - - expect(session.getState()).toBe('disposed') - }) - - it('reconnect on disposed session is a no-op', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - session.dispose() - vi.clearAllMocks() - - await session.reconnect(mockConn) - - expect(deployAndLaunchRelay).not.toHaveBeenCalled() - }) - - it('overlapping reconnects cancel the stale one', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - // Why: make the first reconnect hang so the second one aborts it - let resolveFirst!: () => void - vi.mocked(deployAndLaunchRelay).mockReturnValueOnce( - new Promise((resolve) => { - resolveFirst = () => - resolve({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64' as const - }) - }) - ) - mockDeploySuccess() - - const firstReconnect = session.reconnect(mockConn) - const secondReconnect = session.reconnect(mockConn) - - resolveFirst() - await Promise.all([firstReconnect, secondReconnect]) - - expect(session.getState()).toBe('ready') - }) - - it('passes grace time to deployAndLaunchRelay', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn, 600) - - expect(deployAndLaunchRelay).toHaveBeenCalledWith(mockConn, undefined, 600, 'target-1') - }) - - it('restores the configured relay grace after establish', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn, 600) - - expect(session.getMux()?.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: 600 - }) - }) - - it('sets relay grace to unlimited before host sleep', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.mocked(session.getMux()!.notify).mockClear() - - session.prepareForHostSleep() - - expect(session.getMux()?.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: 0 - }) - }) - - it('cleans up port forwards on dispose', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - session.dispose() - - expect(mockPortForward.removeAllForwards).toHaveBeenCalledWith('target-1') - }) - - it('cleans up port forwards on reconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - vi.clearAllMocks() - mockDeploySuccess() - - await session.reconnect(mockConn) - - expect(mockPortForward.removeAllForwards).toHaveBeenCalledWith('target-1') - }) - - it('establish cleans up mux and providers on partial registration failure', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - // Why: simulate registerRelayRoots failing after mux is created but - // before providers are fully registered. - mockStore.getRepos = vi.fn().mockImplementation(() => { - throw new Error('store error during root registration') - }) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await expect(session.establish(mockConn)).rejects.toThrow('store error') - expect(session.getState()).toBe('idle') - expect(session.getMux()).toBeNull() - expect(unregisterSshPtyProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') - expect(unregisterSshGitProvider).toHaveBeenCalledWith('target-1') - }) - - it('reconnect on idle session is a no-op', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.reconnect(mockConn) - - expect(session.getState()).toBe('idle') - expect(deployAndLaunchRelay).not.toHaveBeenCalled() - }) - - it('reconnect failure still allows retry from onStateChange', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - await session.establish(mockConn) - - // Fail the first reconnect - vi.mocked(deployAndLaunchRelay).mockRejectedValueOnce(new Error('deploy failed')) - await session.reconnect(mockConn) - expect(session.getState()).toBe('reconnecting') - - // Retry should work — reconnect accepts 'reconnecting' state - mockDeploySuccess() - await session.reconnect(mockConn) - expect(session.getState()).toBe('ready') - }) -}) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts deleted file mode 100644 index 4f13b47b1d2f..000000000000 --- a/src/main/ssh/ssh-relay-session.ts +++ /dev/null @@ -1,3578 +0,0 @@ -/* oxlint-disable max-lines */ -// Why: single authority for all relay lifecycle state per SSH target (previously scattered across module Maps/Sets with duplicated paths). - -import { randomUUID } from 'node:crypto' -import type { BrowserWindow } from 'electron' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' -import { SshPlainSshModeSession } from './ssh-plain-ssh-session' -import type { RemoteOpenCodeRuntimePreparation } from './ssh-relay-opencode-runtime-retry' -import { execCommand } from './ssh-relay-deploy-helpers' -import { writeStringsViaSftp } from './sftp-upload' -import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' -import { isRelayEndpointHeldError } from './ssh-relay-endpoint-incumbent' -import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-relay-node-pty-repair' -import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' -import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay' -import { sweepOrphanedRelayPtys } from './ssh-orphan-relay-pty-sweep' -import { - clearPreviousRelayCensus, - isReattachHeldByPreviousRelay, - startPreviousRelayCensus -} from './ssh-previous-relay-terminals' -import { attachHeldPtyThroughPreviousRelay } from '../providers/ssh-pty-legacy-relay-delegation' -import { createSshLegacyRelayRouter } from './ssh-legacy-relay-routing' -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshPtyProvider } from '../providers/ssh-pty-provider' -import type { SshPtyAttachResult } from '../providers/ssh-pty-session-reattach' -import type { SshPtyDataCallback, SshPtyExitCallback } from '../providers/ssh-pty-provider-contract' -import type { SshPtyRecoveryActivationLease } from '../providers/ssh-pty-notification-routing' -import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' -import { isMethodNotFoundError } from './ssh-filesystem-stream-reader' -import { SshGitProvider } from '../providers/ssh-git-provider' -import { selectOpenCodePluginSources } from '../agent-hooks/opencode-plugin-settings' -import { bindRemoteClaudeInterruptReconciliation } from './ssh-agent-hook-interrupt-reconciliation' -import { agentHookServer } from '../agent-hooks/server' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { - buildManagedHookDetectionCommands, - readManagedHookDetectionResult -} from '../agent-hooks/managed-hook-detection-commands' -import { - AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_NOTIFICATION_METHOD, - AGENT_HOOK_REQUEST_REPLAY_METHOD, - isRemoteAgentHooksEnabled -} from '../../shared/agent-hook-relay' -import { AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES } from '../../shared/agent-status-legacy-adapter' -import { _internals as openCodeInternals } from '../opencode/hook-service' -import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source' -import { - getSshPtyProvider, - getPtyIdsForConnection, - clearPtyOwnershipForConnection, - clearProviderPtyState, - deletePtyOwnership, - setPtyOwnership, - restorePtyIncarnation, - isCurrentPtyExit -} from '../ipc/pty' -import { - acceptSshPtyOutputData, - acceptSshPtyOutputExit, - allocateSshPtyProviderGeneration, - applySshPtySourceCancellationProof, - applySshPtySourceRecoveryCancellationProof, - beginSshPtyOutputGenerationMigration, - closeSshPtyOutputGeneration, - getSshPtyAcceptedSourceCheckpoints, - installSshPtySourceAckPublisher, - installSshPtySourceCancellationPublisher -} from '../ipc/ssh-pty-output-intake-registry' -import { registerSshHostProviders, retireSshHostProviders } from './ssh-host-provider-set' -import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' -import { PortScanner } from './ssh-port-scanner' -import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' -import type { SshPortForwardManager } from './ssh-port-forward' -import type { SshConnection } from './ssh-connection' -import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { makeRemoteDirectoryCommand } from './ssh-remote-commands' -import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - type DetectedPort, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, - SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, - sshRemotePtyLeaseAllowsReattach -} from '../../shared/ssh-types' -import { normalizeRemoteArtifactInput } from '../../shared/artifact-cli-bridge' -import type { Store } from '../persistence' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { - findTerminalTabIdForLeaf, - hasHostAuthoritativeTerminalMembership -} from '../persistence/terminal-topology/terminal-topology-membership' -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' -import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' -import { - isSshOwnerAdmissionBlocked, - retrySshOwnerRecoveryWhileBlocked -} from './ssh-owner-recovery-retry' -import { - isSshOwnerAdmissionBlockedError, - SshOwnerAdmissionBlockedError -} from './ssh-owner-admission-blocked-error' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' -import { - acknowledgeRemoteOrcaCliPostOutput, - parseRemoteOrcaCliPostOutput -} from './ssh-remote-orchestration-post-output' -import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import { - SSH_AI_VAULT_LIST_SESSIONS_METHOD, - SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS, - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS, - type SshAiVaultRelayListParams, - type SshAiVaultRelayTitleParams -} from '../../shared/ssh-ai-vault-relay' -import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' -import { isValidTerminalTabId } from '../../shared/terminal-tab-id' -import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones' -import { - openSshPtyConsumerSession, - type OpenSshPtyConsumerSessionOptions, - type SshPtyConsumerAdmission, - type SshPtyConsumerOwnerState, - type SshPtyConsumerSessionState -} from './ssh-pty-consumer-session' -import type { - PtySourceRecoveryComplete, - PtySourceRecoveryPending, - PtySourceRecoveryRequest -} from '../../shared/pty-source-recovery-contract' -import { SshPtyRecoveryRetentionBudget } from './ssh-pty-recovery-retention-budget' -import { SshPtyRetiredSourceDeliveries } from './ssh-pty-retired-source-deliveries' -import { - claimSshPtyConsumerRecovery, - detachSshPtyConsumerRecovery, - forgetSshPtyConsumerRecovery, - getSshPtyConsumerRecovery, - rememberSshPtyConsumerRecovery -} from './ssh-pty-consumer-recovery' -import { classifySshPtyFrameRejection, SshPtyFrameRejectionLog } from './ssh-pty-frame-rejection' -import { SshPtyTargetedReattachQueue } from './ssh-pty-targeted-reattach-queue' - -export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' - -type SshPtyExitPayload = Parameters[0] -type SshPtyDataPayload = Parameters[0] -type SshPtyLease = ReturnType[number] -type ReattachedPtyRuntimeRestore = 'restored' | 'missing-surface' -const SSH_PTY_REATTACH_MAX_CONCURRENCY = 8 -const SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS = 10_000 -const SSH_PTY_REATTACH_RETRY_MIN_DELAY_MS = 50 -const SSH_PTY_REATTACH_RETRY_JITTER_MS = 200 -const SSH_REJECTED_PTY_RECOVERY_MAX_ATTEMPTS = 2 -// Why a second ceiling: the consecutive budget resets whenever a reattach succeeds, so a PTY that -// alternates recovered and rejected frames would otherwise reattach forever — each one costs a -// store read, an attach round trip and a store write. -const SSH_REJECTED_PTY_RECOVERY_MAX_GENERATION_ATTEMPTS = 12 -const SSH_REJECTED_PTY_RECOVERY_RETRY_DELAY_MS = 150 -const SSH_PLUGIN_INSTALL_RETRY_DELAY_MS = 5_000 -const SSH_PLUGIN_INSTALL_MAX_RETRIES = 3 -const SSH_SOURCE_RECOVERY_CANCELLATION_FAILED = 'ssh_source_recovery_cancellation_failed' - -// Why: superseded attempts stop quietly; a dead mux still owned by this attempt must enter recovery. -function verifyRelayAttempt( - mux: SshChannelMultiplexer, - isAttemptCurrent: () => boolean, - phase: string -): boolean { - if (!isAttemptCurrent()) { - return false - } - if (mux.isDisposed()) { - throw new Error(`Relay connection lost during ${phase}`) - } - return true -} - -type PendingPtyReattach = { - mux: SshChannelMultiplexer - providerGeneration: number - retentionKey: string - exits: SshPtyExitPayload[] - queuedData: SshPtyDataPayload[] - recoveryData: SshPtyDataPayload[] - liveData: SshPtyDataPayload[] - recovery?: PtySourceRecoveryPending - recoveryComplete?: PtySourceRecoveryComplete - nextRecoverySourceSu?: number - highestRecoverySourceEndSu?: number - replacementDeliveryToken?: string - restoreRequired?: string - recoveryWaiters: Set<() => void> - livePassthrough: boolean - activated: boolean -} - -type RemoteCliBridgeEnv = { - remoteHome: string - binDir: string - relayDir: string - nodePath: string - sockPath: string - credentialFile?: string - hostPlatform: RemoteHostPlatform - pathDelimiter?: ':' | ';' -} - -type ExpectedPtyIdentity = { paneKey?: string; tabId?: string } -type TargetedDeliveryRecovery = 'confirm-existing' | 'fresh-activation' - -function expectedIdentityForLease(lease: { - tabId?: string - leafId?: string -}): ExpectedPtyIdentity | null { - if (typeof lease.tabId !== 'string' || lease.tabId.length === 0) { - return null - } - const paneKey = - isValidTerminalTabId(lease.tabId) && - typeof lease.leafId === 'string' && - isTerminalLeafId(lease.leafId) - ? makePaneKey(lease.tabId, lease.leafId) - : undefined - return { - ...(paneKey ? { paneKey } : {}), - tabId: lease.tabId - } -} - -function parseRecoveryComplete(params: Record): PtySourceRecoveryComplete | null { - if ( - typeof params.id !== 'string' || - typeof params.deliveryToken !== 'string' || - params.deliveryToken.length === 0 || - typeof params.ptyIncarnation !== 'string' || - params.ptyIncarnation.length === 0 || - !positiveSafeInteger(params.clientGeneration) || - !positiveSafeInteger(params.ownerGeneration) || - !nonNegativeSafeInteger(params.checkpointSourceEndSu) || - !nonNegativeSafeInteger(params.recoveryEndSu) || - Number(params.recoveryEndSu) < Number(params.checkpointSourceEndSu) - ) { - return null - } - return Object.freeze({ - id: params.id, - deliveryToken: params.deliveryToken, - ptyIncarnation: params.ptyIncarnation, - clientGeneration: Number(params.clientGeneration), - ownerGeneration: Number(params.ownerGeneration), - checkpointSourceEndSu: Number(params.checkpointSourceEndSu), - recoveryEndSu: Number(params.recoveryEndSu) - }) -} - -function positiveSafeInteger(value: unknown): boolean { - return Number.isSafeInteger(value) && Number(value) > 0 -} - -function nonNegativeSafeInteger(value: unknown): boolean { - return Number.isSafeInteger(value) && Number(value) >= 0 -} - -function sourceRecoveryCancellationError(cause: unknown): Error { - return Object.assign(new Error(SSH_SOURCE_RECOVERY_CANCELLATION_FAILED), { - code: SSH_SOURCE_RECOVERY_CANCELLATION_FAILED, - cause - }) -} - -function isSourceRecoveryCancellationError(error: unknown): boolean { - return (error as { code?: unknown })?.code === SSH_SOURCE_RECOVERY_CANCELLATION_FAILED -} - -export type SshRelayAiVaultHostInfo = { - targetId: string - executionHostId: ExecutionHostId - remoteHome: string - hostPlatform: RemoteHostPlatform -} - -function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): number { - const raw = graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS - const requested = Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS - return requested === 0 - ? 0 - : Math.max( - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, - Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requested) - ) -} - -// Why: teardown barriers are independent, so one failing store write must not hide the others — -// settle them all and aggregate, rather than rethrowing only whichever rejected first. -async function settleSshSessionTeardown( - barriers: (Promise | null | undefined)[] -): Promise { - const results = await Promise.allSettled(barriers.map((barrier) => barrier ?? Promise.resolve())) - const errors = results.flatMap((result) => - result.status === 'rejected' ? [result.reason as unknown] : [] - ) - if (errors.length === 1) { - throw errors[0] - } - if (errors.length > 1) { - throw new AggregateError(errors, 'SSH relay session teardown failed') - } -} - -// Why: dispose is strictly more destructive than detach, so the mode records which teardown a -// session has already committed to and lets dispose supersede an in-flight detach. -type SshRelaySessionTeardownMode = 'detach' | 'dispose' - -export class SshRelaySession { - private _state: RelaySessionState = 'idle' - private mux: SshChannelMultiplexer | null = null - private abortController: AbortController | null = null - private muxDisposeCleanup: (() => void) | null = null - // Why: hold the notification-handler disposer so teardownProviders can release it on reconnect/shutdown (symmetric with muxDisposeCleanup). - private remoteInterruptCleanup: (() => void) | null = null - private muxNotificationCleanup: (() => void) | null = null - private pluginSettingsCleanup: (() => void) | null = null - private pluginInstallRetryTimer: ReturnType | null = null - private pluginInstallGeneration = 0 - // Why: onStateChange never fires when the relay channel closes but SSH stays up; this callback lets ssh.ts drive relay-level reconnect. - private _onRelayLost: ((targetId: string) => void) | null = null - // Why: a version mismatch or a blocked owner admission is terminal, so it needs a separate callback - // from _onRelayLost (which expects a recoverable transport drop). - private _onTerminalRelayError: ((targetId: string, err: Error) => void) | null = null - private _onReady: ((targetId: string) => void) | null = null - private portScanner: PortScanner | null = null - private currentConnection: SshConnection | null = null - private previousRelayCensus: ReturnType | undefined - // Why: a self-driven repair reconnect must not silently re-negotiate the target's grace window. - private lastGraceTimeSeconds: number | undefined = undefined - private hostPlatform: RemoteHostPlatform | null = null - private plainSsh: SshPlainSshModeSession | null = null - private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null - private openCodeRuntimePreparation: { - run: RemoteOpenCodeRuntimePreparation - controller: AbortController - } | null = null - private readonly unsupportedRelayMethods = new Set() - private pendingPtyReattaches = new Map() - private readonly ptyRecoveryRetention = new SshPtyRecoveryRetentionBudget() - private activePtyProviderGeneration: number | null = null - private sourceAckPublisherCleanup: (() => void) | null = null - private sourceCancellationPublisherCleanup: (() => void) | null = null - private teardownMode: SshRelaySessionTeardownMode | null = null - private teardownCompletion: Promise | null = null - // Why: detach's in-memory half is one-shot but its lease write is retryable, so they are tracked - // apart — a rejected write can be re-issued without re-running provider teardown. - private detachedInMemory = false - private detachFlushRejected = false - private ptyRecoveryNotificationCleanups: (() => void)[] = [] - private readonly sourceIdentityByRelayPtyId = new Map< - string, - Readonly<{ - deliveryToken: string - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - nextSourceSu?: number - }> - >() - private readonly retiredSourceDeliveries = new SshPtyRetiredSourceDeliveries() - private readonly rejectedPtyRecoveryAttempts = new Map< - string, - { - providerGeneration: number - attempts: number - generationAttempts: number - reported: boolean - } - >() - private readonly rejectedPtyRecoveryRetries = new Set>() - private readonly rejectedPtyReattaches = new SshPtyTargetedReattachQueue( - SSH_PTY_REATTACH_MAX_CONCURRENCY - ) - private readonly ptyFrameRejectionLog = new SshPtyFrameRejectionLog() - private readonly ptyConsumerClientInstanceId: string - private ptyConsumerSessionState: SshPtyConsumerSessionState | null = null - private activeCompatibilityAttachmentIds = new Set() - - constructor( - readonly targetId: string, - private getMainWindow: () => BrowserWindow | null, - private store: Store, - private portForwardManager: SshPortForwardManager, - private runtime?: OrcaRuntimeService, - private onDetectedPortsChanged?: ( - targetId: string, - ports: DetectedPort[], - platform: string - ) => void - ) { - this.ptyConsumerClientInstanceId = claimSshPtyConsumerRecovery(targetId, store).clientInstanceId - } - - refreshEnvironment( - getMainWindow: () => BrowserWindow | null, - store: Store, - portForwardManager: SshPortForwardManager, - runtime?: OrcaRuntimeService, - onDetectedPortsChanged?: (targetId: string, ports: DetectedPort[], platform: string) => void - ): void { - this.getMainWindow = getMainWindow - this.store = store - this.portForwardManager = portForwardManager - this.runtime = runtime - this.onDetectedPortsChanged = onDetectedPortsChanged - } - - setOnRelayLost(cb: (targetId: string) => void): void { - this._onRelayLost = cb - } - - setOnTerminalRelayError(cb: (targetId: string, err: Error) => void): void { - this._onTerminalRelayError = cb - } - - setOnReady(cb: (targetId: string) => void): void { - this._onReady = cb - } - - getState(): RelaySessionState { - return this._state - } - - // Why: dispose() can mutate _state across await points, so defeat TS's control-flow narrowing that would otherwise reject the 'disposed' check. - private isDisposed(): boolean { - return (this._state as RelaySessionState) === 'disposed' - } - - private requireReadyConnection(): SshConnection { - if (!this.currentConnection) { - throw new Error('SSH connection is not active') - } - return this.currentConnection - } - - getMux(): SshChannelMultiplexer | null { - return this.mux - } - - getHostPlatform(): RemoteHostPlatform | null { - return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform - } - - /** The host's own `$HOME`, read on the host during relay deploy — never this client's. */ - getRemoteHomeDirectory(): string | null { - return this.remoteCliBridgeEnv?.remoteHome ?? null - } - - getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null { - const env = this.remoteCliBridgeEnv - if (!env) { - return null - } - return { - targetId: this.targetId, - executionHostId: toSshExecutionHostId(this.targetId), - remoteHome: env.remoteHome, - hostPlatform: env.hostPlatform - } - } - - async requestSessionSearch(method: string, params: Record): Promise { - const mux = this.mux - if (!mux || mux.isDisposed() || this._state !== 'ready') { - throw new Error('SSH relay is not ready') - } - this.prepareOpenCodeRuntimeForScan() - return mux.request(method, params, { timeoutMs: 15_000 }) - } - - async requestAiVaultSessionList( - params: SshAiVaultRelayListParams, - options: { signal?: AbortSignal; timeoutMs?: number } = {} - ): Promise { - return this.requestOptionalRelayMethod( - SSH_AI_VAULT_LIST_SESSIONS_METHOD, - params, - options, - SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS, - () => this.prepareOpenCodeRuntimeForScan() - ) - } - - async requestAiVaultSessionTitles( - params: SshAiVaultRelayTitleParams, - options: { signal?: AbortSignal; timeoutMs?: number } = {} - ): Promise { - return this.requestOptionalRelayMethod( - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, - params, - options, - SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS - ) - } - - private async requestOptionalRelayMethod( - method: string, - params: Record, - options: { signal?: AbortSignal; timeoutMs?: number }, - defaultTimeoutMs: number, - before?: () => void - ): Promise { - if (this.unsupportedRelayMethods.has(method)) { - return null - } - const mux = this.mux - if (!mux || mux.isDisposed() || this._state !== 'ready') { - throw new Error('SSH relay is not ready') - } - before?.() - try { - return await mux.request(method, params, { - signal: options.signal, - timeoutMs: options.timeoutMs ?? defaultTimeoutMs - }) - } catch (error) { - // Why: older relays lack optional methods; remember per session so callers degrade to null. - if (isMethodNotFoundError(error)) { - this.unsupportedRelayMethods.add(method) - return null - } - throw error - } - } - - getPortScanner(): PortScanner | null { - return this.portScanner - } - - prepareForHostSleep(): void { - const mux = this.mux - if (!mux || mux.isDisposed() || this.isDisposed()) { - return - } - mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 0 }) - } - - // Why: single entry point for relay setup (initial connect + app-restart reconnect) so no path forgets a registration step. - async establish(conn: SshConnection, graceTimeSeconds?: number): Promise { - if (this._state !== 'idle') { - throw new Error(`Cannot establish relay session in state: ${this._state}`) - } - this._state = 'deploying' - this.unsupportedRelayMethods.clear() - this.currentConnection = conn - this.lastGraceTimeSeconds = graceTimeSeconds - - try { - const deployed = await this.deployRelayOrEnterPlainSsh( - conn, - graceTimeSeconds, - () => this._state === 'deploying' - ) - if (!deployed) { - return - } - const { - transport, - serverBuildId, - remoteHome, - remoteRelayDir, - nodePath, - sockPath, - credentialFile, - hostPlatform, - prepareOpenCodeRuntime - } = deployed - this.hostPlatform = hostPlatform ?? null - this.remoteCliBridgeEnv = - remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform - ? { - remoteHome, - binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), - relayDir: remoteRelayDir, - nodePath, - sockPath, - ...(credentialFile ? { credentialFile } : {}), - hostPlatform, - pathDelimiter: hostPlatform.pathDelimiter - } - : null - - // Why: dispose() can fire during the await above; if it did, creating a mux/providers now would leak with no owner to dispose them. - if (this.isDisposed()) { - const orphanMux = new SshChannelMultiplexer(transport) - orphanMux.dispose() - throw new Error('Session disposed during establish') - } - - const mux = new SshChannelMultiplexer(transport) - this.openCodeRuntimePreparation = prepareOpenCodeRuntime - ? { run: prepareOpenCodeRuntime, controller: new AbortController() } - : null - this.mux = mux - const isAttemptCurrent = (): boolean => this.mux === mux && !this.isDisposed() - const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() - - const ptyConsumerSessionState = await this.openPtyConsumerSession( - mux, - serverBuildId, - shouldContinue - ) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer session setup')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - this.ptyConsumerSessionState = ptyConsumerSessionState - await this.rememberPtyConsumerRecovery(serverBuildId) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - - await mux.request('session.resolveHome', { path: '~' }) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'home resolution')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - const connectionIncarnation = randomUUID() - - const registered = await this.registerProviders(mux, shouldContinue, connectionIncarnation) - if (!registered) { - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - throw new Error('Relay provider registration stopped unexpectedly') - } - - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { - if (!mux.isDisposed()) { - mux.dispose() - } - throw new Error('Session disposed during establish') - } - - // Why: explicit disconnect keeps PTY ownership, so a later manual connect must reattach those remote PTYs. - await this.reattachKnownPtys(mux, shouldContinue) - - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { - throw new Error('Session disposed during establish') - } - - this.configureRelayGraceTime(mux, graceTimeSeconds) - verifyRelayAttempt(mux, isAttemptCurrent, 'establish') - this.watchMuxForRelayLoss(mux) - verifyRelayAttempt(mux, isAttemptCurrent, 'establish') - this._state = 'ready' - this.startPortScanning() - this._onReady?.(this.targetId) - } catch (err) { - // Why: registerProviders can throw with a live mux and partial registration — tear everything down so a retry starts clean. - if (!this.isDisposed()) { - this.teardownProviders( - 'connection_lost', - isSourceRecoveryCancellationError(err) - ? SSH_SOURCE_RECOVERY_CANCELLATION_FAILED - : 'connection_lost' - ) - this._state = 'idle' - } - // Why: terminal on first connect — a deployed binary against a still-running legacy daemon, or a - // claim another connection holds. Notify the callback but still rethrow. - // RelayEndpointHeldError is terminal for the same reason: a live incumbent owns the - // socket path, and backoff cannot make it hand it over. The user resolves it. - // RelayEndpointUnresponsiveError is deliberately NOT here: a relay that never answered - // may be stalled, and silence is not a decision — it falls through to retry. - if ( - isRelayVersionMismatchError(err) || - isRelayEndpointHeldError(err) || - isSshOwnerAdmissionBlockedError(err) - ) { - console.warn( - `[ssh-relay-session] Terminal relay error on initial connect for ${this.targetId}: ${err.message}` - ) - this._onTerminalRelayError?.(this.targetId, err) - } - throw err - } - } - - // Why: network-blip reconnect; AbortController-guarded so overlapping attempts from fast flaps cancel the stale one. - async reconnect(conn: SshConnection, graceTimeSeconds?: number): Promise { - // Why: reconnect only from 'ready'/'reconnecting' — from 'deploying' it would tear down a mux establish() is still using; 'idle' has no session yet. - if (this._state !== 'ready' && this._state !== 'reconnecting') { - return - } - - this.releaseRelayLossWatcher() - // Cancel any in-flight reconnect - this.abortController?.abort() - const abortController = new AbortController() - this.abortController = abortController - - this._state = 'reconnecting' - this.unsupportedRelayMethods.clear() - this.currentConnection = conn - this.lastGraceTimeSeconds = graceTimeSeconds - - // Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer. - this.stopPortScanning() - await this.portForwardManager.removeAllForwards(this.targetId) - this.broadcastEmptyLists() - this.teardownProviders('connection_lost') - - try { - const deployed = await this.deployRelayOrEnterPlainSsh( - conn, - graceTimeSeconds, - () => this.abortController === abortController && !abortController.signal.aborted - ) - if (!deployed) { - return - } - const { - transport, - serverBuildId, - remoteHome, - remoteRelayDir, - nodePath, - sockPath, - credentialFile, - hostPlatform, - prepareOpenCodeRuntime - } = deployed - this.hostPlatform = hostPlatform ?? null - this.remoteCliBridgeEnv = - remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform - ? { - remoteHome, - binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), - relayDir: remoteRelayDir, - nodePath, - sockPath, - ...(credentialFile ? { credentialFile } : {}), - hostPlatform, - pathDelimiter: hostPlatform.pathDelimiter - } - : null - - if (abortController.signal.aborted || this.isDisposed()) { - // Why: relay is already running remotely — a throwaway mux we immediately dispose sends a clean shutdown so it doesn't linger until grace expires. - const orphanMux = new SshChannelMultiplexer(transport) - orphanMux.dispose() - return - } - - const mux = new SshChannelMultiplexer(transport) - this.openCodeRuntimePreparation = prepareOpenCodeRuntime - ? { run: prepareOpenCodeRuntime, controller: new AbortController() } - : null - this.mux = mux - - const isAttemptCurrent = (): boolean => - this.mux === mux && - this.abortController === abortController && - !abortController.signal.aborted && - !this.isDisposed() - const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() - - const ptyConsumerSessionState = await this.openPtyConsumerSession( - mux, - serverBuildId, - shouldContinue - ) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer session setup')) { - if (!mux.isDisposed()) { - mux.dispose() - } - return - } - this.ptyConsumerSessionState = ptyConsumerSessionState - await this.rememberPtyConsumerRecovery(serverBuildId) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { - if (!mux.isDisposed()) { - mux.dispose() - } - return - } - - await mux.request('session.resolveHome', { path: '~' }) - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'home resolution')) { - if (!mux.isDisposed()) { - mux.dispose() - } - return - } - const connectionIncarnation = randomUUID() - - const registered = await this.registerProviders(mux, shouldContinue, connectionIncarnation) - if (!registered) { - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { - if (this.mux === mux) { - this.teardownProviders('shutdown') - } else if (!mux.isDisposed()) { - mux.dispose() - } - return - } - throw new Error('Relay provider registration stopped unexpectedly') - } - - // Why: dispose() during registration/attach already cleaned up, but this.mux was reassigned above — clean up the new mux so it doesn't leak. - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'provider registration')) { - if (this.mux === mux) { - this.teardownProviders('shutdown') - } else if (!mux.isDisposed()) { - mux.dispose() - } - return - } - - await this.reattachKnownPtys(mux, shouldContinue) - - if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { - return - } - - this.configureRelayGraceTime(mux, graceTimeSeconds) - verifyRelayAttempt(mux, isAttemptCurrent, 'reconnect') - this.watchMuxForRelayLoss(mux) - verifyRelayAttempt(mux, isAttemptCurrent, 'reconnect') - this._state = 'ready' - this.startPortScanning() - this._onReady?.(this.targetId) - } catch (err) { - // Why: tear down a partially-registered mux so its keepalive/timeout timers don't keep running on a half-initialized session. - if (this.abortController === abortController && !this.isDisposed()) { - this.teardownProviders( - 'connection_lost', - isSourceRecoveryCancellationError(err) - ? SSH_SOURCE_RECOVERY_CANCELLATION_FAILED - : 'connection_lost' - ) - } - // Why terminal: neither a version mismatch nor a blocked owner claim is reconcilable by backoff - // retry, so fire the typed callback and drop out of 'reconnecting'. - // RelayEndpointHeldError is terminal for the same reason: a live incumbent owns the - // socket path, and backoff cannot make it hand it over. The user resolves it. - if ( - isRelayVersionMismatchError(err) || - isRelayEndpointHeldError(err) || - isSshOwnerAdmissionBlockedError(err) - ) { - console.warn( - `[ssh-relay-session] Terminal relay error for ${this.targetId}: ${err.message}` - ) - if (this.abortController === abortController && !this.isDisposed()) { - this._state = 'idle' - } - this._onTerminalRelayError?.(this.targetId, err) - return - } - // Why: stay in 'reconnecting' (not 'ready') since the provider stack is torn down; the SSH manager will fire another onStateChange to retry. - console.warn( - `[ssh-relay-session] Failed to re-establish relay for ${this.targetId}: ${err instanceof Error ? err.message : String(err)}` - ) - if (this.abortController === abortController && !this.isDisposed()) { - // Why: treat non-not-found attach failures as relay loss so ssh.ts's bounded backoff retries instead of stranding the session in 'reconnecting'. - this._onRelayLost?.(this.targetId) - } - } finally { - if (this.abortController === abortController) { - this.abortController = null - } - } - } - - /** Fire-and-forget disposal; prefer {@link disposeAndPersist} when the caller can await durability. */ - dispose(): void { - void this.disposeAndPersist().catch((error) => { - console.warn( - `[ssh-relay-session] Failed to persist disposal for ${this.targetId}: ${error instanceof Error ? error.message : String(error)}` - ) - }) - } - - /** - * Destructive teardown: forgets the consumer recovery record and terminates the leases. - * Repeat calls share one completion, and a dispose requested after a detach supersedes it — - * the destructive half re-runs so detach-then-dispose still forgets recovery and terminates - * leases. (The reverse, detach after dispose, is a no-op.) - */ - disposeAndPersist(): Promise { - if (this.teardownMode === 'dispose') { - return this.teardownCompletion ?? Promise.resolve() - } - const pendingDetach = this.teardownCompletion - this.teardownMode = 'dispose' - try { - this.teardownCompletion = this.runDisposal(pendingDetach) - } catch (error) { - // Why: a synchronous failure in the in-memory half must ride the completion promise, or a later - // disposeAndPersist reports success on a null completion and detachAndPersist re-runs runDetach. - this.teardownCompletion = Promise.reject(error) - } - return this.teardownCompletion - } - - private runDisposal(pendingDetach: Promise | null): Promise { - // Why: the whole in-memory half runs before any await so a concurrent connect can never observe - // a half-torn session; only the durability barriers below are deferred onto the returned promise. - this.releaseRelayLossWatcher() - this.abortController?.abort() - this.stopPortScanning() - this.broadcastEmptyLists() - this.teardownProviders('shutdown') - this.currentConnection = null - this._state = 'disposed' - // Why here and not on reconnect: an explicit disconnect is user action, so the host earns a - // fresh node-pty repair attempt. A reconnect must not, or the repair becomes a loop. - forgetRelayNodePtyRepairs(this.targetId) - clearPreviousRelayCensus(this.targetId, this.previousRelayCensus) - const recoveryRemoval = forgetSshPtyConsumerRecovery( - this.targetId, - this.ptyConsumerClientInstanceId, - this.store - ) - const leaseTermination = this.store.markSshRemotePtyLeasesAsync(this.targetId, 'terminated') - return settleSshSessionTeardown([ - // Why: a superseded detach keeps its own rejection for its own caller; swallow it here so a - // failed detach write cannot fail the disposal that replaced it. - pendingDetach?.catch(() => undefined), - // Why: nothing rebinds after dispose, but direct callers (no IPC-side teardown) still need - // this session's local listeners released. - this.portForwardManager.removeAllForwards(this.targetId), - recoveryRemoval, - leaseTermination - ]) - } - - /** Fire-and-forget detach; prefer {@link detachAndPersist} when the caller can await durability. */ - detach(): void { - void this.detachAndPersist().catch((error) => { - console.warn( - `[ssh-relay-session] Failed to persist detach for ${this.targetId}: ${error instanceof Error ? error.message : String(error)}` - ) - }) - } - - /** - * Non-destructive teardown: keeps PTY ownership so a later connect reclaims this consumer - * identity. Once dispose has been requested this is a no-op — see {@link disposeAndPersist}. - */ - async detachAndPersist(): Promise { - // Why: a rejected lease write must not latch forever — re-issue just the write so the caller can - // retry. Never under 'dispose': that supersedes detach for good and re-issuing would resurrect - // the 'detached' state the disposal already replaced with 'terminated'. - if (!this.teardownCompletion || (this.teardownMode === 'detach' && this.detachFlushRejected)) { - this.teardownCompletion = this.runDetach() - } - this.teardownMode ??= 'detach' - let completion = this.teardownCompletion - while (completion) { - try { - await completion - } catch (error) { - if (completion === this.teardownCompletion) { - throw error - } - } - if (completion === this.teardownCompletion) { - return - } - completion = this.teardownCompletion - } - } - - // Why a separate transition from detachAndPersist: on the committed quit path every in-memory - // change has to land before the final store flush snapshots, and that flush — not a per-session - // durable write — is what persists it. Idempotent, and it schedules no persistence of its own, so - // nothing the async drain finishes later can write recovery state after the snapshot. - // - // 'detached' says this app let go of the lease, not that the remote shell died. The remote PTYs are - // left running for the next attach, exactly as an ordinary detach leaves them. - beginShutdownDetach(): void { - if (this.detachedInMemory || this._state === 'disposed') { - return - } - detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) - this.releaseRelayLossWatcher() - this.abortController?.abort() - this.stopPortScanning() - this.broadcastEmptyLists() - this.teardownProviders('connection_lost') - this.currentConnection = null - this._state = 'disposed' - this.detachedInMemory = true - this.store.markSshRemotePtyLeasesForShutdown(this.targetId, 'detached') - } - - private runDetach(): Promise { - if (!this.detachedInMemory) { - if (this._state === 'disposed') { - return Promise.resolve() - } - // Why first: same synchronous-half-first rule as runDisposal, and this is the highest-value - // step — a fast reconnect must reclaim this identity instead of minting one, even if a - // teardown call below throws unexpectedly. - detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) - this.releaseRelayLossWatcher() - this.abortController?.abort() - this.stopPortScanning() - this.broadcastEmptyLists() - // Why: disconnect keeps PTY ownership so a later manual connect can reattach. - this.teardownProviders('connection_lost') - this.currentConnection = null - this._state = 'disposed' - this.detachedInMemory = true - } - this.detachFlushRejected = false - return settleSshSessionTeardown([ - this.store.markSshRemotePtyLeasesAsync(this.targetId, 'detached') - ]).catch((error: unknown) => { - this.detachFlushRejected = true - throw error - }) - } - - /** Rung D connects with plain SSH providers, so the ladder's reason reaches the user connected. */ - getPlainSshSession(): SshPlainSshModeSession | null { - return this.plainSsh - } - - // ── Private ─────────────────────────────────────────────────────── - - private async deployRelayOrEnterPlainSsh( - conn: SshConnection, - graceTimeSeconds: number | undefined, - isAttemptCurrent: () => boolean - ): Promise> | null> { - try { - const deployed = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) - // Why gated: a superseded or disposed attempt would replace the current attempt's census. - if (isAttemptCurrent() && !this.isDisposed()) { - this.previousRelayCensus = startPreviousRelayCensus(conn, this.targetId, deployed) - } - return deployed - } catch (err) { - // Why system SSH is excluded: it has no ssh2 shell or SFTP channel to degrade onto. - if ( - !(err instanceof RemoteRuntimeUnavailableError) || - conn.usesSystemSshTransport?.() === true || - this.isDisposed() || - !isAttemptCurrent() - ) { - throw err - } - // Why: a superseded attempt's session must not stay registered beside this one. - this.leavePlainSshMode() - this.plainSsh = SshPlainSshModeSession.enter({ - targetId: this.targetId, - connection: conn, - error: err, - onExitAccepted: (payload) => this.retireExitedPty(payload, true) - }) - console.warn( - `[ssh-relay-session] ${this.targetId} connected in plain SSH mode: ${err.reason}` - ) - this._state = 'ready' - this._onReady?.(this.targetId) - return null - } - } - - private leavePlainSshMode(): void { - this.plainSsh?.leave() - this.plainSsh = null - } - - // Why: teardown itself can kill the mux — an aborted request emits rpc.cancel, and a saturated - // control lane turns that admission failure into mux.dispose('connection_lost'). Every teardown - // path must release the watcher before its first mux write, or our own shutdown re-enters - // recovery as a spurious relay loss. teardownProviders is not early enough: stopPortScanning - // runs ahead of it and is what emits that frame. Call this ahead of abortController.abort() - // too — that signal reaches no mux request today, but plumbing it into one would otherwise - // reopen the same hole silently. - private releaseRelayLossWatcher(): void { - this.muxDisposeCleanup?.() - this.muxDisposeCleanup = null - } - - // Why: onStateChange only fires on SSH-level reconnects, so watch for relay-channel loss while SSH stays up and fire onRelayLost. - private watchMuxForRelayLoss(mux: SshChannelMultiplexer): void { - this.releaseRelayLossWatcher() - this.muxDisposeCleanup = mux.onDispose((reason) => { - if (reason === 'connection_lost' && this.mux === mux && !this.isDisposed()) { - console.warn( - `[ssh-relay-session] Relay channel lost for ${this.targetId}, triggering reconnect` - ) - this._onRelayLost?.(this.targetId) - } - }) - } - - /** - * A spawn was refused because the relay cannot load node-pty. Reconnect once so the deploy - * path's `repairInstalledNativeDeps` rebuilds it under `tryAcquireRelayRepairLock`, then hand - * back the provider registered by that reconnect for a single retry. - * - * Nothing here mutates the remote directly — a lock-less rebuild could collide with a - * concurrent reconnect's repair, so the locked deploy path stays the only writer. If the lock - * is busy it launches degraded, the retry hits the same rejection, and the user sees the - * relay's message. The attempt is spent either way. - */ - private async recoverRemoteTerminalRuntime( - requestingProvider: SshPtyProvider, - cause: TerminalUnavailableCause - ): Promise { - const { provider } = await recoverRelayNodePtyForSpawn({ - targetId: this.targetId, - cause, - hasLivePtys: () => requestingProvider.hasLivePtys(), - reconnect: async () => { - const conn = this.currentConnection - if (!conn || this.isDisposed()) { - throw new Error('no_live_ssh_connection') - } - await this.reconnect(conn, this.lastGraceTimeSeconds) - }, - resolveProvider: () => { - if (this._state !== 'ready' || this.isDisposed()) { - return null - } - const current = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - // Why identity-checked: a reconnect that fell back to the same provider would retry - // against the same unrepaired relay. - return current && current !== requestingProvider ? current : null - } - }) - return provider - } - - // Why: shared by establish() and reconnect() so both use the exact same registration sequence. - private async registerProviders( - mux: SshChannelMultiplexer, - shouldContinue: (() => boolean) | undefined, - connectionIncarnation: string - ): Promise { - await this.registerRelayRoots(mux) - if (shouldContinue && !shouldContinue()) { - return false - } - - this.pluginSettingsCleanup?.() - this.pluginSettingsCleanup = - this.store.onSettingsChanged?.((updates) => { - if ('disabledTuiAgents' in updates || 'agentStatusHooksEnabled' in updates) { - void this.installPluginsOnRelay(mux) - } - }) ?? null - await this.installPluginsOnRelay(mux) - if (shouldContinue && !shouldContinue()) { - return false - } - - try { - await this.installRemoteOrcaCliLauncher() - } catch (error) { - // Why: on MaxSessions=1 remotes the relay holds the only slot, so this raw-connection install can fail — don't fail the whole connection. - console.warn( - `[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - } - if (shouldContinue && !shouldContinue()) { - return false - } - - this.wireUpRemoteOrcaCli(mux, connectionIncarnation) - - const providerGeneration = allocateSshPtyProviderGeneration() - const ptyProvider = new SshPtyProvider( - this.targetId, - mux, - this.remoteCliBridgeEnv ?? undefined, - providerGeneration - ) - // Why optional-call: session tests register partial provider stubs, same as the pause adapter below. - ptyProvider.setTerminalUnavailableRecovery?.((cause) => - this.recoverRemoteTerminalRuntime(ptyProvider, cause) - ) - ptyProvider.setLegacyRelayRouting?.(this.createLegacyRelayRouter()) - const consumerOwnerState = this.activePtyConsumerOwner() - if (consumerOwnerState) { - ptyProvider.setPtyDeliveryPauseAdapter?.(({ id, providerGeneration: generation, paused }) => { - if ( - generation !== providerGeneration || - this.activePtyProviderGeneration !== providerGeneration || - this.mux !== mux - ) { - return - } - const sourceIdentity = this.sourceIdentityByRelayPtyId.get(id) - if (consumerOwnerState.outputFlowControl && !sourceIdentity) { - return - } - mux.notify('pty.setDeliveryPaused', { - id, - paused, - clientGeneration: consumerOwnerState.clientGeneration, - ownerGeneration: consumerOwnerState.ownerGeneration, - ...(sourceIdentity ? { deliveryToken: sourceIdentity.deliveryToken } : {}) - }) - }) - } - this.sourceAckPublisherCleanup?.() - this.sourceAckPublisherCleanup = null - this.sourceCancellationPublisherCleanup?.() - this.sourceCancellationPublisherCleanup = null - if (consumerOwnerState?.outputFlowControl) { - this.sourceAckPublisherCleanup = installSshPtySourceAckPublisher( - providerGeneration, - // ACK delivery is idempotent and re-derived from credit state, so it consumes - // the two-valued projection of the write settlement rather than the three arms. - (batch, onSettled) => - mux.notifyWithSettlement( - 'pty.ackData', - batch as unknown as Record, - (settlement) => - onSettled( - settlement.outcome === 'accepted' - ? { ok: true } - : { ok: false, error: settlement.error } - ) - ) - ) - this.sourceCancellationPublisherCleanup = installSshPtySourceCancellationPublisher( - providerGeneration, - async (request) => { - const result = (await mux.request('pty.cancelDelivery', { - ...request, - id: toRelaySshPtyId(this.targetId, request.id) - })) as Record - if ( - result.canceled !== true || - !Number.isSafeInteger(result.sentEndSu) || - !Number.isSafeInteger(result.creditedEndSu) - ) { - throw new Error('ssh_source_cancellation_proof_invalid') - } - return { - sentEndSu: result.sentEndSu as number, - creditedEndSu: result.creditedEndSu as number - } - } - ) - } - this.activePtyProviderGeneration = providerGeneration - const connection = this.requireReadyConnection() - const createSftp = - connection.usesSystemSshTransport?.() === true - ? undefined - : (options?: { signal?: AbortSignal }) => this.requireReadyConnection().sftp(options) - // Why: getHostPlatform() falls back to this.hostPlatform when bridge env is incomplete, so path rules still match the host. - const hostPlatform = this.getHostPlatform() ?? undefined - const fsProvider = new SshFilesystemProvider( - this.targetId, - mux, - createSftp, - { - downloadFile: (sourcePath, destinationPath) => - this.requireReadyConnection().downloadFile(sourcePath, destinationPath, { - hostPlatform - }), - openFileUploadSession: () => - this.requireReadyConnection().openFileUploadSession({ - hostPlatform - }), - writeBuffer: (remotePath, contents, options) => - this.requireReadyConnection().writeBuffer(remotePath, contents, { - hostPlatform, - append: options.append, - exclusive: options.exclusive - }) - }, - hostPlatform - ) - const gitProvider = new SshGitProvider( - this.targetId, - mux, - this.remoteCliBridgeEnv?.hostPlatform ?? null - ) - // Why built first: a throw while building one must not leave the host half-registered. - registerSshHostProviders(this.targetId, { pty: ptyProvider, fs: fsProvider, git: gitProvider }) - this.installPtyRecoveryNotifications(mux) - - this.wireUpPtyEvents(ptyProvider, mux, providerGeneration) - this.wireUpAgentHookEvents(mux) - this.wireUpRemoteWorkspaceEvents(mux) - void this.installManagedHooksOnRemote(mux, shouldContinue) - return true - } - - // Why not acceptPtyData: an earlier build's relay serves these panes without flow control. - private createLegacyRelayRouter(): ReturnType { - return createSshLegacyRelayRouter({ - targetId: this.targetId, - connection: () => this.currentConnection, - clientInstanceId: this.ptyConsumerClientInstanceId, - sink: { - data: (payload) => - void acceptSshPtyOutputData({ - id: payload.id, - data: payload.data, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation, - rawLength: payload.sequenceChars ?? payload.data.length, - transformed: payload.transformed === true, - ...(typeof payload.seq === 'number' ? { sequence: payload.seq } : {}) - }).catch(() => {}), - exit: (payload) => void this.acceptPtyExit(payload).catch(() => {}), - replay: (payload) => this.forwardReattachReplay(payload.id, payload.data) - } - }) - } - - private activePtyConsumerOwner(): SshPtyConsumerOwnerState | null { - const state = this.ptyConsumerSessionState - return state && state.mode !== 'legacy-fallback' ? state : null - } - - private recoverablePtyConsumerOwner( - serverBuildId: string | undefined - ): SshPtyConsumerOwnerState | null { - const active = this.activePtyConsumerOwner() - if (active) { - return active - } - const recovery = getSshPtyConsumerRecovery(this.targetId) - return serverBuildId && recovery?.serverBuildId === serverBuildId - ? (recovery?.owner ?? null) - : null - } - - private async openPtyConsumerSession( - mux: SshChannelMultiplexer, - serverBuildId: string | undefined, - ownsAttempt: () => boolean - ): Promise { - const previousOwner = this.recoverablePtyConsumerOwner(serverBuildId) - const options: OpenSshPtyConsumerSessionOptions = { - clientInstanceId: this.ptyConsumerClientInstanceId, - expectedServerBuildId: serverBuildId, - allowSameBuildLegacyFallback: true, - outputFlowControl: { requestedWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } - } - let admission: SshPtyConsumerAdmission - try { - admission = await this.admitPtyConsumerOwner(mux, previousOwner, options, ownsAttempt) - } catch (error) { - if ( - !previousOwner || - (error as { code?: unknown }).code !== PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR - ) { - throw error - } - this.voidPtyConsumerCheckpoints(previousOwner, ownsAttempt) - if (!ownsAttempt()) { - throw new Error('Session disposed during owner recovery') - } - admission = await openSshPtyConsumerSession(mux, options) - } - if (previousOwner && !admission.resumed) { - this.voidPtyConsumerCheckpoints(previousOwner, ownsAttempt) - } - return admission.state - } - - private async admitPtyConsumerOwner( - mux: SshChannelMultiplexer, - previousOwner: SshPtyConsumerOwnerState | null, - options: OpenSshPtyConsumerSessionOptions, - ownsAttempt: () => boolean - ): Promise { - try { - return await retrySshOwnerRecoveryWhileBlocked( - () => - openSshPtyConsumerSession(mux, { - ...options, - ...(previousOwner - ? { - resume: { - ownerGeneration: previousOwner.ownerGeneration, - ownerLease: previousOwner.ownerLease - } - } - : {}) - }), - { - isCurrent: () => ownsAttempt() && !mux.isDisposed(), - onClosed: (listener) => mux.onDispose(listener) - } - ) - } catch (error) { - // Why converted here: past this point the failure travels the same path as a dropped transport, - // where backoff would keep redeploying a relay that is working fine and refusing on purpose. - if (isSshOwnerAdmissionBlocked(error)) { - throw new SshOwnerAdmissionBlockedError(this.targetId, { cause: error }) - } - throw error - } - } - - // Why the recovery row and clientInstanceId survive: the relay minted a fresh claim, which voids the - // checkpoints taken under the old one but says nothing about our identity for this target. The caller - // durably records the new lease before ready, so removal here would only lose the identity. - private voidPtyConsumerCheckpoints( - previousOwner: SshPtyConsumerOwnerState, - ownsAttempt: () => boolean - ): void { - const recovery = getSshPtyConsumerRecovery(this.targetId) - // Why identity-guarded: the record is target-scoped and its clientInstanceId is shared by every - // session for that target, so only a record still describing the owner this attempt tried to - // resume is ours to void — otherwise a loser wipes the winner's checkpoints. - if ( - !recovery || - !ownsAttempt() || - (recovery.owner && - (recovery.owner.ownerGeneration !== previousOwner.ownerGeneration || - recovery.owner.ownerLease !== previousOwner.ownerLease)) - ) { - return - } - delete recovery.owner - recovery.checkpointsByAppPtyId.clear() - for (const [ptyId, migration] of recovery.modelMigrationsByAppPtyId) { - recovery.modelMigrationsByAppPtyId.set( - ptyId, - migration.then(() => - Object.freeze({ - status: 'checkpoint-unavailable' as const, - reason: 'completion-failed' as const - }) - ) - ) - } - } - - private async rememberPtyConsumerRecovery(serverBuildId: string | undefined): Promise { - const owner = this.activePtyConsumerOwner() - if (!owner || !serverBuildId) { - return - } - await rememberSshPtyConsumerRecovery({ - targetId: this.targetId, - clientInstanceId: this.ptyConsumerClientInstanceId, - serverBuildId, - owner, - store: this.store - }) - } - - private configureRelayGraceTime( - mux: SshChannelMultiplexer, - graceTimeSeconds: number | undefined - ): void { - mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: normalizeRelayGracePeriodSeconds(graceTimeSeconds) - }) - } - - private async installManagedHooksOnRemote( - mux: SshChannelMultiplexer, - shouldContinue?: () => boolean - ): Promise { - if ( - !isRemoteAgentHooksEnabled() || - !this.areAgentStatusHooksEnabled() || - (shouldContinue && !shouldContinue()) - ) { - return - } - if ( - this.remoteCliBridgeEnv?.hostPlatform && - isWindowsRemoteHost(this.remoteCliBridgeEnv.hostPlatform) - ) { - // Why: managed hook installers emit POSIX-only scripts/paths; Windows remotes rely on relay-injected env + plugin overlays instead. - return - } - - try { - const store = this.store as { getSettings?: Store['getSettings'] } - const detected = readManagedHookDetectionResult( - await mux.request('preflight.detectAgents', { - commands: buildManagedHookDetectionCommands(store.getSettings?.() ?? null, 'linux') - }) - ) - const agents = detected.agents - if (agents.length === 0 || (shouldContinue && !shouldContinue())) { - return - } - const hostKeyFingerprint = this.requireReadyConnection().getHostKeyFingerprint?.() - const params = { - ...(hostKeyFingerprint ? { hostKeyFingerprint } : {}), - agents, - ...(detected.claudeVersion ? { claudeVersion: detected.claudeVersion } : {}) - } - const result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as { - errors?: unknown - } - if (typeof result.errors === 'number' && result.errors > 0) { - console.warn( - `[ssh-relay-session] ${result.errors} remote managed hook installers failed for ${this.targetId}` - ) - } - } catch (error) { - // Why: teardown routinely cancels this best-effort request; only warn for - // installer failures that survive the connection lifecycle. - const code = (error as { code?: unknown })?.code - if ( - code === -32601 || - code === 'CONNECTION_LOST' || - code === 'DISPOSED' || - mux.isDisposed() - ) { - return - } - console.warn( - `[ssh-relay-session] relay managed hook install failed for ${this.targetId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - } - } - - private async installRemoteOrcaCliLauncher(): Promise { - if (!this.remoteCliBridgeEnv) { - return - } - const { binDir, hostPlatform } = this.remoteCliBridgeEnv - const plan = createRemoteCliInstallPlan(this.remoteCliBridgeEnv) - const conn = this.requireReadyConnection() - await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), { - wrapCommand: !isWindowsRemoteHost(hostPlatform) - }) - if (typeof conn.writeFile === 'function') { - for (const file of plan.files) { - await conn.writeFile(file.path, file.contents, { hostPlatform }) - } - } else { - await writeStringsViaSftp(conn, plan.files) - } - for (const command of plan.postWriteCommands) { - await execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) - } - } - - // Why: read per invocation so turning the per-host opt-in off takes effect on the next command. - private remoteCliCallerScope(): SshBridgeCallerScope { - return { - kind: 'ssh-bridge', - targetId: this.targetId, - remoteCliControl: this.store.getSshTarget(this.targetId)?.allowRemoteCliControl === true - } - } - - private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer, connectionIncarnation: string): void { - mux.onRequest('orca.cli', async (params) => { - if (!this.runtime) { - throw new Error('Orca runtime is unavailable') - } - const argv = Array.isArray(params.argv) - ? params.argv.filter((item): item is string => typeof item === 'string') - : [] - const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : '/' - const rawEnv = params.env - const env = - rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv) - ? Object.fromEntries( - Object.entries(rawEnv).filter( - (entry): entry is [string, string] => - typeof entry[0] === 'string' && typeof entry[1] === 'string' - ) - ) - : {} - const stdin = typeof params.stdin === 'string' ? params.stdin : undefined - const artifactInput = normalizeRemoteArtifactInput(params.artifactInput) - const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment( - this.targetId, - connectionIncarnation - ) - this.activeCompatibilityAttachmentIds.add(runtimeAuthority.attachmentId) - try { - return await runRemoteOrcaCli(this.runtime, { - argv, - cwd, - env, - ...(stdin !== undefined ? { stdin } : {}), - ...(artifactInput ? { artifactInput } : {}), - runtimeAuthority, - callerScope: this.remoteCliCallerScope() - }) - } finally { - this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId) - this.runtime.releaseOrchestrationCompatibilitySshAttachment(runtimeAuthority.attachmentId) - } - }) - mux.onRequest('orca.cli.postOutput', async (params) => { - if (!this.runtime) { - throw new Error('Orca runtime is unavailable') - } - const rawEnv = params.env - const env = - rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv) - ? Object.fromEntries( - Object.entries(rawEnv).filter( - (entry): entry is [string, string] => - typeof entry[0] === 'string' && typeof entry[1] === 'string' - ) - ) - : {} - const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment( - this.targetId, - connectionIncarnation - ) - this.activeCompatibilityAttachmentIds.add(runtimeAuthority.attachmentId) - try { - await acknowledgeRemoteOrcaCliPostOutput(this.runtime, { - postOutput: parseRemoteOrcaCliPostOutput(params.postOutput), - env, - runtimeAuthority, - callerScope: this.remoteCliCallerScope() - }) - return { acknowledged: true } - } finally { - this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId) - this.runtime.releaseOrchestrationCompatibilitySshAttachment(runtimeAuthority.attachmentId) - } - }) - } - - // Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy — the relay is versioned independently. Best-effort: failure only costs agent status on this host. - private async installPluginsOnRelay( - mux: SshChannelMultiplexer, - attempt = 0, - generation = ++this.pluginInstallGeneration - ): Promise { - if (!isRemoteAgentHooksEnabled()) { - return - } - try { - this.clearPluginInstallRetry() - const hooksEnabled = this.areAgentStatusHooksEnabled() - await mux.request( - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - selectOpenCodePluginSources( - { - opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), - opencode2PluginSource: openCodeInternals.getOpenCode2PluginSource(), - ...(hooksEnabled - ? { - piExtensionSource: getPiAgentStatusExtensionSource('pi'), - ompExtensionSource: getPiAgentStatusExtensionSource('omp'), - primeAgentExtensionSource: getPiAgentStatusExtensionSource('prime-agent') - } - : {}) - }, - this.store.getSettings?.() ?? null - ) - ) - } catch (err) { - // Why: -32601 = older relay without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both. - const code = err instanceof Error && 'code' in err ? err.code : undefined - if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { - return - } - if (mux.isDisposed()) { - return - } - console.warn( - `[ssh-relay-session] agent_hook.installPlugins failed for ${this.targetId}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - if (code === 'SSH_MUX_REQUEST_TIMEOUT') { - this.schedulePluginInstallRetry(mux, attempt, generation) - } - } - } - - private schedulePluginInstallRetry( - mux: SshChannelMultiplexer, - attempt: number, - generation: number - ): void { - if ( - attempt >= SSH_PLUGIN_INSTALL_MAX_RETRIES || - this.mux !== mux || - generation !== this.pluginInstallGeneration - ) { - return - } - this.pluginInstallRetryTimer = setTimeout(() => { - this.pluginInstallRetryTimer = null - if (this.mux === mux && !mux.isDisposed()) { - void this.installPluginsOnRelay(mux, attempt + 1, generation) - } - }, SSH_PLUGIN_INSTALL_RETRY_DELAY_MS) - this.pluginInstallRetryTimer.unref?.() - } - - private clearPluginInstallRetry(): void { - if (this.pluginInstallRetryTimer !== null) { - clearTimeout(this.pluginInstallRetryTimer) - this.pluginInstallRetryTimer = null - } - } - - private areAgentStatusHooksEnabled(): boolean { - const store = this.store as { getSettings?: Store['getSettings'] } - return isAgentStatusHooksEnabled(store.getSettings?.()) - } - - private wireUpRemoteWorkspaceEvents(mux: SshChannelMultiplexer): void { - mux.onNotification((method, params) => { - notifyRemoteWorkspaceHandlers(this.targetId, method, params) - }) - } - - // Why: relay sends connectionId:null, so stamp this.targetId here so the renderer can drop events from torn-down connections. - private wireUpAgentHookEvents(mux: SshChannelMultiplexer): void { - if (!isRemoteAgentHooksEnabled()) { - return - } - // Why: capture the disposer so teardownProviders can release this handler and re-wiring can't double-register it. - this.muxNotificationCleanup?.() - this.remoteInterruptCleanup?.() - this.remoteInterruptCleanup = bindRemoteClaudeInterruptReconciliation( - agentHookServer, - mux, - this.targetId, - () => this.mux === mux - ) - this.muxNotificationCleanup = mux.onNotification((method, params) => { - if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { - return - } - const envelope = params - if ( - typeof envelope.paneKey !== 'string' || - (envelope.isReplay !== undefined && typeof envelope.isReplay !== 'boolean') || - (envelope.launchToken !== undefined && typeof envelope.launchToken !== 'string') - ) { - return - } - // Why: forward the agent CLI's env/version verbatim (not the relay's) so warn-once protocol-mismatch diagnostics fire for remote events too. - agentHookServer.ingestRemote( - { - paneKey: envelope.paneKey, - hostTurnRevision: envelope.hostTurnRevision, - launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined, - tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined, - worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined, - env: typeof envelope.env === 'string' ? envelope.env : undefined, - version: typeof envelope.version === 'string' ? envelope.version : undefined, - hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, - promptInteractionKey: - typeof envelope.promptInteractionKey === 'string' - ? envelope.promptInteractionKey - : undefined, - hookEventName: - typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined, - source: envelope.source, - providerPromptId: envelope.providerPromptId, - grokPromptBoundary: envelope.grokPromptBoundary === true ? true : undefined, - compactTrigger: envelope.compactTrigger, - toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined, - toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined, - teammateName: - typeof envelope.teammateName === 'string' ? envelope.teammateName : undefined, - toolAgentType: - typeof envelope.toolAgentType === 'string' ? envelope.toolAgentType : undefined, - isReplay: envelope.isReplay === true ? true : undefined, - providerSession: envelope.providerSession, - providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, - // Why: names the fields the relay dropped to fit the frame; ingestRemote restores them. - shedFields: envelope.shedFields, - claudeRunningNonAgentTask: - typeof envelope.claudeRunningNonAgentTask === 'boolean' - ? envelope.claudeRunningNonAgentTask - : undefined, - // Why: the SSH relay protocol advertises no run-serving capability. - advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES, - evidenceAgeMs: envelope.evidenceAgeMs, - statusUnavailable: envelope.statusUnavailable, - payload: envelope.payload - }, - this.targetId - ) - }) - - // Why: request replay of cached paneKeys only after the handler is wired, so replayed events can't arrive before we subscribe. Best-effort. - void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => { - const code = (err as { code?: unknown })?.code - if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') { - return - } - if (mux.isDisposed()) { - return - } - // Why: suppress the warn when a normal teardown rejects the in-flight request, so reconnect cycles aren't noisy. - if (mux.isDisposed()) { - return - } - console.warn( - `[ssh-relay-session] agent_hook.requestReplay failed for ${this.targetId}: ${ - err instanceof Error ? err.message : String(err) - }` - ) - }) - } - - private prepareOpenCodeRuntimeForScan(): void { - const preparation = this.openCodeRuntimePreparation - if (preparation) { - void preparation.run(preparation.controller.signal) - } - } - - private teardownProviders( - reason: 'shutdown' | 'connection_lost', - outputGenerationReason: string = reason - ): void { - this.openCodeRuntimePreparation?.controller.abort() - this.openCodeRuntimePreparation = null - this.releaseRelayLossWatcher() - this.pluginSettingsCleanup?.() - this.pluginSettingsCleanup = null - this.pluginInstallGeneration += 1 - this.clearPluginInstallRetry() - this.leavePlainSshMode() - this.muxNotificationCleanup?.() - this.muxNotificationCleanup = null - this.remoteInterruptCleanup?.() - this.remoteInterruptCleanup = null - for (const cleanup of this.ptyRecoveryNotificationCleanups) { - cleanup() - } - this.ptyRecoveryNotificationCleanups = [] - if (this.activePtyProviderGeneration !== null) { - const providerGeneration = this.activePtyProviderGeneration - if (reason === 'connection_lost' && this.activePtyConsumerOwner()?.outputFlowControl) { - this.beginPtyModelMigration(providerGeneration, outputGenerationReason) - } else { - closeSshPtyOutputGeneration(providerGeneration, outputGenerationReason) - } - this.activePtyProviderGeneration = null - } - this.sourceAckPublisherCleanup?.() - this.sourceAckPublisherCleanup = null - this.sourceCancellationPublisherCleanup?.() - this.sourceCancellationPublisherCleanup = null - if (this.mux && !this.mux.isDisposed()) { - this.mux.dispose(reason) - } - this.mux = null - for (const attachmentId of this.activeCompatibilityAttachmentIds) { - this.runtime?.releaseOrchestrationCompatibilitySshAttachment(attachmentId) - } - this.activeCompatibilityAttachmentIds.clear() - - if (reason === 'shutdown') { - clearPtyOwnershipForConnection(this.targetId) - } - // Connection loss makes remote status unverifiable, not exited. Keep the last observation; - // replay or certified process teardown will update or remove it on the execution host. - - retireSshHostProviders(this.targetId) - this.sourceIdentityByRelayPtyId.clear() - this.retiredSourceDeliveries.clear() - this.rejectedPtyRecoveryAttempts.clear() - for (const timer of this.rejectedPtyRecoveryRetries) { - clearTimeout(timer) - } - this.rejectedPtyRecoveryRetries.clear() - this.rejectedPtyReattaches.clear() - this.ptyFrameRejectionLog.clear() - for (const pending of this.pendingPtyReattaches.values()) { - for (const resolve of pending.recoveryWaiters) { - resolve() - } - } - this.pendingPtyReattaches.clear() - this.ptyRecoveryRetention.clear() - } - - // Why: back-compat for old relays that gate FS ops on registered roots; removable post-cutover (docs/relay-fs-allowlist-removal.md). - private async registerRelayRoots(mux: SshChannelMultiplexer): Promise { - const remoteRepos = this.store.getRepos().filter((r) => r.connectionId === this.targetId) - - for (const repo of remoteRepos) { - mux.notify('session.registerRoot', { rootPath: repo.path }) - } - - // Why: git.listWorktrees requires the repo root to be registered first. - await Promise.all( - remoteRepos.map(async (repo) => { - try { - const worktrees = (await mux.request('git.listWorktrees', { - repoPath: repo.path - })) as { path: string }[] - for (const wt of worktrees) { - if (wt.path !== repo.path) { - mux.notify('session.registerRoot', { rootPath: wt.path }) - } - } - } catch { - // git worktree list may fail for folder-mode repos — not fatal - } - }) - ) - } - - // Why: shared by establish()/reconnect() so both paths reset renderer lists the same way. - private broadcastEmptyLists(): void { - const win = this.getMainWindow() - if (!win || win.isDestroyed()) { - return - } - win.webContents.send('ssh:port-forwards-changed', { - targetId: this.targetId, - forwards: [] - }) - win.webContents.send('ssh:detected-ports-changed', { - targetId: this.targetId, - ports: [] - }) - } - - private startPortScanning(): void { - if (!this.mux || this.isDisposed()) { - return - } - // Why: each scan walks /proc/*/fd remotely, so skip ticks while the window is hidden and rescan when it returns. - const scanner = new PortScanner({ - isWindowVisible: () => isMainWindowVisible(this.getMainWindow()), - onWindowBecameVisible: onMainWindowBecameVisible - }) - this.portScanner = scanner - // Why: guard against a late ports.detect callback from a pre-reconnect scanner publishing stale results into the new session. - scanner.startScanning(this.targetId, this.mux, (targetId, ports, platform) => { - if (this.portScanner !== scanner) { - return - } - this.onDetectedPortsChanged?.(targetId, ports, platform) - }) - } - - private stopPortScanning(): void { - if (this.portScanner) { - this.portScanner.stopScanning(this.targetId) - this.portScanner = null - } - } - - private wireUpPtyEvents( - ptyProvider: SshPtyProvider, - mux: SshChannelMultiplexer, - providerGeneration: number - ): void { - ptyProvider.onData((payload) => { - if ( - this.mux !== mux || - this.activePtyProviderGeneration !== providerGeneration || - payload.providerGeneration !== providerGeneration - ) { - return - } - const pending = this.pendingPtyReattaches.get(payload.id) - if (pending && this.activePtyConsumerOwner()?.outputFlowControl) { - this.quarantineReattachData(pending, payload) - return - } - void this.acceptPtyData(payload).catch(() => {}) - }) - ptyProvider.onRejectedData?.((payload) => { - if ( - this.mux !== mux || - this.activePtyProviderGeneration !== providerGeneration || - payload.providerGeneration !== providerGeneration - ) { - return - } - const pending = this.pendingPtyReattaches.get(payload.id) - if (pending) { - pending.restoreRequired = payload.sourceMalformed - ? 'recoverySourceMalformed' - : 'recoverySourceUnadmitted' - this.wakeRecovery(pending) - return - } - void this.acceptPtyData(payload).catch(() => {}) - }) - ptyProvider.onReplay((payload) => { - if (this.mux !== mux || this.activePtyProviderGeneration !== providerGeneration) { - return - } - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:replay', payload) - } - }) - ptyProvider.onExit((payload) => { - if ( - this.mux !== mux || - this.activePtyProviderGeneration !== providerGeneration || - payload.providerGeneration !== providerGeneration - ) { - return - } - const pendingReattach = this.pendingPtyReattaches.get(payload.id) - if (pendingReattach && !pendingReattach.activated) { - // Why: attach response and exit can share one transport batch, before incarnation restoration runs. - pendingReattach.exits.push(payload) - this.wakeRecovery(pendingReattach) - return - } - if (!isCurrentPtyExit(payload)) { - return - } - void this.acceptPtyExit(payload).catch(() => {}) - }) - } - - private acceptPtyData(payload: SshPtyDataPayload): Promise { - const consumerOwner = this.activePtyConsumerOwner() - const offeredSource = payload.source - if ( - offeredSource && - this.retiredSourceDeliveries.has(payload.providerGeneration, offeredSource) - ) { - return Promise.resolve() - } - const rejection = classifySshPtyFrameRejection(payload, consumerOwner) - if (rejection) { - if (offeredSource) { - this.retiredSourceDeliveries.retire(payload.providerGeneration, offeredSource) - } - this.ptyFrameRejectionLog.record(payload, consumerOwner, rejection) - if (rejection.action === 'retire-and-reattach-delivery') { - this.recoverRejectedPtyDelivery(payload, offeredSource) - } - return Promise.resolve() - } - const source = consumerOwner?.outputFlowControl ? offeredSource : undefined - if (source && consumerOwner) { - const current = this.sourceIdentityByRelayPtyId.get(source.relayPtyId) - if ( - source.sourceEndSu <= source.sourceStartSu || - (current && - (current.deliveryToken !== source.deliveryToken || - current.clientGeneration !== source.clientGeneration || - current.ownerGeneration !== source.ownerGeneration || - current.ptyIncarnation !== payload.ptyIncarnation || - (current.nextSourceSu !== undefined && current.nextSourceSu !== source.sourceStartSu))) - ) { - const rejection = { - reason: 'source-range-invalid', - action: 'retire-and-reattach-delivery' - } as const - this.retiredSourceDeliveries.retire(payload.providerGeneration, source) - this.ptyFrameRejectionLog.record(payload, consumerOwner, rejection) - this.recoverRejectedPtyDelivery(payload, source) - return Promise.resolve() - } - this.sourceIdentityByRelayPtyId.set(source.relayPtyId, { - deliveryToken: source.deliveryToken, - clientGeneration: source.clientGeneration, - ownerGeneration: source.ownerGeneration, - ptyIncarnation: payload.ptyIncarnation, - nextSourceSu: source.sourceEndSu - }) - } - const rawLength = payload.sequenceChars ?? payload.data.length - return acceptSshPtyOutputData({ - id: payload.id, - data: payload.data, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation, - rawLength, - transformed: payload.transformed === true, - ...(typeof payload.seq === 'number' ? { sequence: payload.seq } : {}), - ...(source ? { source } : {}) - }) - } - - private recoverRejectedPtyDelivery( - payload: SshPtyDataPayload, - source: SshPtyDataPayload['source'] - ): void { - const mux = this.mux - const providerGeneration = this.activePtyProviderGeneration - let relayPtyId: string - try { - relayPtyId = source?.relayPtyId ?? toRelaySshPtyId(this.targetId, payload.id) - } catch { - return - } - const appPtyId = toAppSshPtyId(this.targetId, relayPtyId) - if ( - payload.id !== appPtyId || - !mux || - mux.isDisposed() || - providerGeneration !== payload.providerGeneration || - this.pendingPtyReattaches.has(appPtyId) || - this.rejectedPtyReattaches.has(appPtyId) - ) { - return - } - if (payload.rejectedSourceRecovery === 'reconnect-channel') { - console.warn( - `[ssh-relay-session] PTY ${relayPtyId} delivery identity could not be retired safely for ${this.targetId}; dropping the relay channel to reconnect` - ) - mux.dispose('connection_lost') - return - } - const previous = this.rejectedPtyRecoveryAttempts.get(appPtyId) - const attempt = - previous?.providerGeneration === providerGeneration - ? previous - : { providerGeneration, attempts: 0, generationAttempts: 0, reported: false } - if ( - attempt.attempts >= SSH_REJECTED_PTY_RECOVERY_MAX_ATTEMPTS || - attempt.generationAttempts >= SSH_REJECTED_PTY_RECOVERY_MAX_GENERATION_ATTEMPTS - ) { - if (!attempt.reported) { - attempt.reported = true - console.warn( - `[ssh-relay-session] PTY ${relayPtyId} delivery recovery exhausted for ${this.targetId}; dropping the relay channel to reconnect` - ) - // Why a channel drop and not a terminal relay error: a terminal error clears the reconnect - // backoff, rotates provider authority (aborting every in-flight fs and git request on the - // target) and parks the target in a manual-recovery state — over one PTY's delivery. Losing - // the channel is the recoverable escalation, and it is what this path did before targeted - // recovery existed. - mux.dispose('connection_lost') - } - return - } - attempt.attempts++ - attempt.generationAttempts++ - this.rejectedPtyRecoveryAttempts.set(appPtyId, attempt) - void this.rejectedPtyReattaches - .run(appPtyId, () => - this.reattachRejectedPty( - relayPtyId, - mux, - providerGeneration, - payload.rejectedSourceRecovery === 'fresh-activation' - ? 'fresh-activation' - : 'confirm-existing' - ) - ) - .then( - (recovered) => { - if (recovered) { - // Why only a completed reattach clears this: an accepted frame proves nothing about the - // delivery that was rejected, and resetting on one lets a flapping PTY reattach forever. - attempt.attempts = 0 - return - } - this.retryRejectedPtyDelivery(payload, source, appPtyId) - }, - (error: unknown) => { - console.warn(`[ssh-relay-session] PTY ${relayPtyId} targeted delivery recovery failed`, { - providerGeneration, - error: error instanceof Error ? error.message : String(error) - }) - this.retryRejectedPtyDelivery(payload, source, appPtyId) - } - ) - } - - // Why liveness is checked before retrying: reattachKnownPty resolves without claiming the lease - // when the PTY exited mid-attach, which is indistinguishable from a failed reattach at the call - // site. Retrying that race twice would drop the relay channel over an ordinary PTY exit. - private retryRejectedPtyDelivery( - payload: SshPtyDataPayload, - source: SshPtyDataPayload['source'], - appPtyId: string - ): void { - const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - if (!ptyProvider || typeof ptyProvider.hasPty !== 'function' || !ptyProvider.hasPty(appPtyId)) { - this.rejectedPtyRecoveryAttempts.delete(appPtyId) - return - } - const timer = setTimeout(() => { - this.rejectedPtyRecoveryRetries.delete(timer) - this.recoverRejectedPtyDelivery(payload, source) - }, SSH_REJECTED_PTY_RECOVERY_RETRY_DELAY_MS) - timer.unref?.() - this.rejectedPtyRecoveryRetries.add(timer) - } - - private async reattachRejectedPty( - relayPtyId: string, - mux: SshChannelMultiplexer, - providerGeneration: number, - targetedDeliveryRecovery: TargetedDeliveryRecovery - ): Promise { - const shouldContinue = () => - this.mux === mux && - !mux.isDisposed() && - this.activePtyProviderGeneration === providerGeneration - const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - // Why re-checked here: this can have waited for a queue slot, and a superseded generation must - // not pay for a lease read or an attach round trip. - if (!ptyProvider || !shouldContinue()) { - return false - } - const activeLease = this.store - .getSshRemotePtyLeases(this.targetId) - .find((lease) => lease.ptyId === relayPtyId && sshRemotePtyLeaseAllowsReattach(lease)) - const activeLeaseByPtyId = activeLease - ? new Map([[relayPtyId, activeLease]]) - : new Map() - const expectedIdentity = activeLease ? expectedIdentityForLease(activeLease) : undefined - const attachedLeaseIds = new Set() - await this.reattachKnownPty({ - ptyProvider, - ptyId: relayPtyId, - activeLeaseByPtyId, - expectedIdentityByPtyId: expectedIdentity - ? new Map([[relayPtyId, expectedIdentity]]) - : new Map(), - attachedLeaseIds, - mux, - providerGeneration, - shouldContinue, - targetedDeliveryRecovery - }) - if (attachedLeaseIds.size > 0 && shouldContinue()) { - await this.store.markSshRemotePtyLeasesAttachedAsync( - this.targetId, - Array.from(attachedLeaseIds) - ) - } - return attachedLeaseIds.has(relayPtyId) - } - - private quarantineReattachData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void { - if (pending.livePassthrough) { - if (this.ownsPtyRecoveryAttempt(payload.id, pending)) { - void this.acceptPtyData(payload).catch(() => {}) - } - return - } - this.observePrivateRecoveryFrame(pending, payload) - if (pending.restoreRequired) { - return - } - const sourceSu = payload.source - ? payload.source.sourceEndSu - payload.source.sourceStartSu - : (payload.sequenceChars ?? payload.data.length) - if (!this.ptyRecoveryRetention.tryRetain(pending.retentionKey, payload.data, sourceSu)) { - pending.restoreRequired = 'recoveryQuarantineCapacityExceeded' - this.wakeRecovery(pending) - return - } - this.routeQuarantinedReattachData(pending, payload) - } - - private routeQuarantinedReattachData( - pending: PendingPtyReattach, - payload: SshPtyDataPayload - ): void { - this.observePrivateRecoveryFrame(pending, payload) - if (!pending.recovery) { - pending.queuedData.push(payload) - return - } - if ( - pending.recoveryComplete && - pending.nextRecoverySourceSu === pending.recovery.recoveryEndSu - ) { - pending.liveData.push(payload) - return - } - this.admitRecoveryData(pending, payload) - } - - private observePrivateRecoveryFrame( - pending: PendingPtyReattach, - payload: SshPtyDataPayload - ): void { - const recovery = pending.recovery - if ( - recovery && - payload.source?.deliveryToken === recovery.deliveryToken && - payload.source.clientGeneration === recovery.clientGeneration && - payload.source.ownerGeneration === recovery.ownerGeneration && - payload.ptyIncarnation === recovery.ptyIncarnation - ) { - pending.highestRecoverySourceEndSu = Math.max( - pending.highestRecoverySourceEndSu ?? recovery.checkpointSourceEndSu, - payload.source.sourceEndSu - ) - } - } - - private admitRecoveryData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void { - if (pending.restoreRequired) { - return - } - const recovery = pending.recovery - const nextSourceSu = pending.nextRecoverySourceSu - if ( - !recovery || - !payload.source || - nextSourceSu === undefined || - payload.source.deliveryToken !== recovery.deliveryToken || - payload.source.clientGeneration !== recovery.clientGeneration || - payload.source.ownerGeneration !== recovery.ownerGeneration || - payload.source.sourceStartSu !== nextSourceSu || - payload.source.sourceEndSu <= payload.source.sourceStartSu || - payload.source.sourceEndSu > recovery.recoveryEndSu || - payload.ptyIncarnation !== recovery.ptyIncarnation - ) { - pending.restoreRequired = 'recoveryFrameIdentityMismatch' - this.wakeRecovery(pending) - return - } - pending.nextRecoverySourceSu = payload.source.sourceEndSu - pending.recoveryData.push(payload) - } - - private installPtyRecoveryNotifications(mux: SshChannelMultiplexer): void { - for (const cleanup of this.ptyRecoveryNotificationCleanups) { - cleanup() - } - this.ptyRecoveryNotificationCleanups = [ - mux.onNotificationByMethod('pty.recoveryComplete', (params) => { - if (this.mux !== mux) { - return - } - const id = typeof params.id === 'string' ? toAppSshPtyId(this.targetId, params.id) : '' - const pending = this.pendingPtyReattaches.get(id) - if (!pending || pending.mux !== mux) { - return - } - const complete = parseRecoveryComplete(params) - if (!complete) { - pending.restoreRequired = 'invalidRecoveryComplete' - } else { - pending.recoveryComplete = complete - } - this.wakeRecovery(pending) - }), - mux.onNotificationByMethod('pty.restoreRequired', (params) => { - if (this.mux !== mux) { - return - } - const id = typeof params.id === 'string' ? toAppSshPtyId(this.targetId, params.id) : '' - const pending = this.pendingPtyReattaches.get(id) - if (!pending || pending.mux !== mux) { - return - } - pending.restoreRequired = - typeof params.reason === 'string' ? params.reason : 'relayRestoreRequired' - this.wakeRecovery(pending) - }), - mux.onNotificationByMethod('pty.deliveryCanceled', (params) => { - if (this.mux !== mux) { - return - } - const id = typeof params.id === 'string' ? params.id : '' - const identity = this.sourceIdentityByRelayPtyId.get(id) - if ( - !identity || - params.deliveryToken !== identity.deliveryToken || - params.clientGeneration !== identity.clientGeneration || - params.ownerGeneration !== identity.ownerGeneration || - params.ptyIncarnation !== identity.ptyIncarnation - ) { - return - } - const replacementDeliveryToken = - typeof params.replacementDeliveryToken === 'string' ? params.replacementDeliveryToken : '' - const pending = this.pendingPtyReattaches.get(toAppSshPtyId(this.targetId, id)) - if (pending?.mux === mux) { - if ( - replacementDeliveryToken.length === 0 || - replacementDeliveryToken === identity.deliveryToken - ) { - pending.restoreRequired = - typeof params.reason === 'string' - ? `relayDeliveryCanceled:${params.reason}` - : 'relayDeliveryCanceled' - this.wakeRecovery(pending) - return - } - if ( - pending.replacementDeliveryToken && - pending.replacementDeliveryToken !== replacementDeliveryToken - ) { - pending.restoreRequired = 'recoveryReplacementTokenMismatch' - this.wakeRecovery(pending) - return - } - pending.replacementDeliveryToken = replacementDeliveryToken - return - } - const generation = this.activePtyProviderGeneration - if ( - generation !== null && - Number.isSafeInteger(params.sentEndSu) && - Number.isSafeInteger(params.creditedEndSu) - ) { - try { - applySshPtySourceCancellationProof( - { - id: toAppSshPtyId(this.targetId, id), - code: -1, - providerGeneration: generation, - ptyIncarnation: identity.ptyIncarnation - }, - { - sentEndSu: params.sentEndSu as number, - creditedEndSu: params.creditedEndSu as number - } - ) - this.retiredSourceDeliveries.retire(generation, { - relayPtyId: id, - ...identity - }) - this.sourceIdentityByRelayPtyId.delete(id) - } catch { - /* Invalid proof retains the active token identity. */ - } - } - }) - ] - } - - private wakeRecovery(pending: PendingPtyReattach): void { - for (const resolve of pending.recoveryWaiters) { - resolve() - } - pending.recoveryWaiters.clear() - } - - private async acceptPtyExit(payload: SshPtyExitPayload): Promise { - await acceptSshPtyOutputExit({ - id: payload.id, - code: payload.code, - providerGeneration: payload.providerGeneration, - ptyIncarnation: payload.ptyIncarnation - }) - if (isCurrentPtyExit(payload)) { - this.retireExitedPty(payload, true) - } - } - - private retireExitedPty(payload: SshPtyExitPayload, deliveryHandled = false): void { - const relayPtyId = toRelaySshPtyId(this.targetId, payload.id) - this.retiredSourceDeliveries.activate(relayPtyId) - clearProviderPtyState(payload.id) - deletePtyOwnership(payload.id) - this.rejectedPtyRecoveryAttempts.delete(payload.id) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(payload.id) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete( - toRelaySshPtyId(this.targetId, payload.id) - ) - this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') - if (deliveryHandled) { - return - } - this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId) - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:exit', payload) - } - } - - private forwardReattachReplay(appPtyId: string, data: string): void { - if (!data) { - return - } - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('pty:replay', { id: appPtyId, data }) - } - } - - private async reattachKnownPtys( - mux: SshChannelMultiplexer, - shouldContinue: () => boolean - ): Promise { - const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined - const providerGeneration = this.activePtyProviderGeneration - if (!ptyProvider || providerGeneration === null || this.mux !== mux) { - return - } - // Why before the lease read: a stop the user asked for and this client could not deliver is - // replayed here, and a confirmed one tombstones its lease — so it must land before the filter - // below decides what to reattach, or the reattach revives a PTY that is about to die. - await replayPendingSshPtyKills({ - targetId: this.targetId, - store: this.store, - provider: ptyProvider, - shouldContinue - }) - if (!shouldContinue()) { - return - } - // Why immediately before the read: a pane's binding is written by several writers, and the - // renderer's debounced layout publish lands long after the spawn commit that leased the pty — - // so a predecessor that was still bound at spawn time never gets marked by a spawn-side - // trigger. Re-deriving from each pane's CURRENT binding here is what actually bounds this set, - // and it repairs stores that already accumulated these rows. - this.store.reconcileSshRemotePtyLeasesForTarget(this.targetId) - // Why not `state !== 'expired'`: that state covers both a superseded sibling (re-adopting it is - // the 2 -> 19 -> 20 fan-out) and an orphan whose reattach merely lost contact. Only the first - // carries a retirement mark, and only it has to be skipped. - const activeLeases = this.store - .getSshRemotePtyLeases(this.targetId) - .filter((lease) => sshRemotePtyLeaseAllowsReattach(lease)) - const activeLeaseByPtyId = new Map(activeLeases.map((lease) => [lease.ptyId, lease])) - const leasedPtyIds = activeLeases.map((lease) => lease.ptyId) - // Why: pass pane identity so the relay can reject cross-generation id collisions; tabId falls back for pre-leafId leases. - const expectedIdentityByPtyId = new Map( - activeLeases - .map((lease): [string, ExpectedPtyIdentity] | null => { - const expected = expectedIdentityForLease(lease) - return expected ? [lease.ptyId, expected] : null - }) - .filter((entry): entry is [string, ExpectedPtyIdentity] => entry !== null) - ) - const attachedLeaseIds = new Set() - // Why: after app restart ptyOwnership is empty, but durable SSH leases still describe grace-window survivors. - const ptyIds = Array.from( - new Set([ - ...getPtyIdsForConnection(this.targetId).map((ptyId) => - toRelaySshPtyId(this.targetId, ptyId) - ), - ...leasedPtyIds - ]) - ) - let nextPtyIndex = 0 - const worker = async (): Promise => { - while (shouldContinue()) { - const ptyId = ptyIds[nextPtyIndex++] - if (ptyId === undefined) { - return - } - try { - await this.reattachKnownPty({ - ptyProvider, - ptyId, - activeLeaseByPtyId, - expectedIdentityByPtyId, - attachedLeaseIds, - mux, - providerGeneration, - shouldContinue - }) - } catch (error) { - if (isSourceRecoveryCancellationError(error)) { - throw error - } - console.warn( - `[ssh-relay-session] PTY ${ptyId} reattach processing failed for ${this.targetId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - } - } - } - await Promise.all( - Array.from({ length: Math.min(SSH_PTY_REATTACH_MAX_CONCURRENCY, ptyIds.length) }, worker) - ) - if (attachedLeaseIds.size > 0 && shouldContinue()) { - await this.store.markSshRemotePtyLeasesAttachedAsync( - this.targetId, - Array.from(attachedLeaseIds) - ) - } - // Why last: reclaiming comes first, so every PTY this connect could route to is routed before - // anything asks which ones are unreachable (#9819). - await sweepOrphanedRelayPtys({ - targetId: this.targetId, - store: this.store, - provider: ptyProvider, - clientInstanceId: this.ptyConsumerClientInstanceId, - isSessionOwner: this.activePtyConsumerOwner() !== null, - routedPtyIds: ptyIds, - shouldContinue - }) - } - - private async reattachKnownPty(args: { - ptyProvider: SshPtyProvider - ptyId: string - activeLeaseByPtyId: Map - expectedIdentityByPtyId: Map - attachedLeaseIds: Set - mux: SshChannelMultiplexer - providerGeneration: number - shouldContinue: () => boolean - targetedDeliveryRecovery?: TargetedDeliveryRecovery - }): Promise { - const { - ptyProvider, - ptyId, - activeLeaseByPtyId, - expectedIdentityByPtyId, - attachedLeaseIds, - mux, - providerGeneration, - shouldContinue, - targetedDeliveryRecovery - } = args - const appPtyId = toAppSshPtyId(this.targetId, ptyId) - const pendingReattach: PendingPtyReattach = { - mux, - providerGeneration, - retentionKey: `${providerGeneration}\0${appPtyId}\0${randomUUID()}`, - exits: [], - queuedData: [], - recoveryData: [], - liveData: [], - recoveryWaiters: new Set(), - livePassthrough: false, - activated: false - } - this.pendingPtyReattaches.set(appPtyId, pendingReattach) - let sourceActivationLease: SshPtyAttachResult['sourceActivationLease'] - let recoveryActivationLease: SshPtyRecoveryActivationLease | undefined - try { - const recoveryRequest = - targetedDeliveryRecovery === 'fresh-activation' - ? undefined - : await this.sourceRecoveryRequest(appPtyId) - const attachResult = await this.attachPtyWithRetry( - ptyProvider, - ptyId, - expectedIdentityByPtyId.get(ptyId), - recoveryRequest, - shouldContinue - ) - sourceActivationLease = attachResult.sourceActivationLease - if (!shouldContinue()) { - return - } - const exitDuringAttach = pendingReattach.exits.find( - (exit) => - !exit.incarnationId || - !attachResult.incarnationId || - exit.incarnationId === attachResult.incarnationId - ) - if (exitDuringAttach && !recoveryRequest) { - if (attachResult.incarnationId) { - restorePtyIncarnation(appPtyId, attachResult.incarnationId) - this.runtime?.acceptPtyIncarnationForExit(appPtyId, attachResult.incarnationId) - } - await this.acceptPtyExit(exitDuringAttach) - return - } - const existingDeliveryConfirmed = - targetedDeliveryRecovery === 'confirm-existing' && - recoveryRequest?.status === 'checkpoint' && - !attachResult.sourceRecovery && - Boolean( - attachResult.sourceActivation && - this.sameSourceDelivery(attachResult.sourceActivation, recoveryRequest) - ) - if (targetedDeliveryRecovery) { - const owner = this.activePtyConsumerOwner() - const activation = attachResult.sourceActivation - if ( - !owner?.outputFlowControl || - !activation || - activation.clientGeneration !== owner.clientGeneration || - activation.ownerGeneration !== owner.ownerGeneration - ) { - return - } - } - if (recoveryRequest && !existingDeliveryConfirmed) { - const recovered = await this.finishSourceRecovery( - ptyId, - appPtyId, - attachResult, - recoveryRequest, - pendingReattach, - shouldContinue, - () => { - const lease = sourceActivationLease - if (!lease) { - return - } - recoveryActivationLease = lease.transferToRecovery((payload) => - this.quarantineReattachData(pendingReattach, payload) - ) - sourceActivationLease = undefined - } - ) - if (!recovered) { - const recoveryExit = this.findExactPendingExit( - pendingReattach, - attachResult.incarnationId - ) - if ( - recoveryExit && - shouldContinue() && - this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach) - ) { - if (recoveryActivationLease) { - recoveryActivationLease.retire() - recoveryActivationLease = undefined - } else if (sourceActivationLease) { - const canceled = await sourceActivationLease.rollback() - sourceActivationLease = undefined - if (!canceled) { - throw sourceRecoveryCancellationError( - new Error('ssh_source_activation_cancellation_unproven') - ) - } - } - this.preparePtyIncarnationForExit(appPtyId, attachResult.incarnationId) - await this.acceptPtyExit(recoveryExit) - } - return - } - const recoveryExit = this.findExactPendingExit(pendingReattach, attachResult.incarnationId) - if (recoveryExit) { - this.preparePtyIncarnationForExit(appPtyId, attachResult.incarnationId) - pendingReattach.activated = true - recoveryActivationLease?.commit() - recoveryActivationLease = undefined - await this.acceptPtyExit(recoveryExit) - return - } - } - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)) { - return - } - const activeLease = activeLeaseByPtyId.get(ptyId) - if (this.isRetiredReattachedPtySurface(activeLease, appPtyId, attachResult.incarnationId)) { - await this.suppressRetiredReattachedPty( - ptyProvider, - ptyId, - appPtyId, - attachResult.incarnationId - ) - return - } - if (attachResult.incarnationId) { - const restoreResult = await this.restoreReattachedPtyRuntime( - appPtyId, - attachResult.incarnationId, - activeLease, - () => shouldContinue() && this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach) - ) - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)) { - return - } - if (restoreResult !== 'restored') { - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - return - } - } else { - setPtyOwnership(appPtyId, toSshExecutionHostId(this.targetId)) - } - attachedLeaseIds.add(ptyId) - pendingReattach.activated = true - recoveryActivationLease?.commit() - recoveryActivationLease = undefined - if (targetedDeliveryRecovery) { - if (targetedDeliveryRecovery === 'fresh-activation') { - this.retiredSourceDeliveries.activate(ptyId) - this.sourceIdentityByRelayPtyId.delete(ptyId) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(appPtyId) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(ptyId) - } - while (pendingReattach.queuedData.length > 0) { - await this.acceptPtyData(pendingReattach.queuedData.shift()!) - } - pendingReattach.livePassthrough = true - } - const exitAfterActivation = pendingReattach.exits.find( - (exit) => - !exit.incarnationId || - !attachResult.incarnationId || - exit.incarnationId === attachResult.incarnationId - ) - if (exitAfterActivation) { - await this.acceptPtyExit(exitAfterActivation) - return - } - if (!recoveryRequest && !targetedDeliveryRecovery) { - this.forwardReattachReplay(appPtyId, attachResult.replay ?? '') - } - sourceActivationLease?.commit() - sourceActivationLease = undefined - } catch (error) { - if (isSourceRecoveryCancellationError(error)) { - throw error - } - if (!shouldContinue()) { - return - } - if (await isReattachHeldByPreviousRelay(this.targetId, error)) { - const routed = await this.reattachThroughPreviousRelay({ - ptyProvider, - ptyId, - appPtyId, - expected: expectedIdentityByPtyId.get(ptyId), - lease: activeLeaseByPtyId.get(ptyId), - attachedLeaseIds, - shouldContinue - }) - if (!routed) { - // Same as an exhausted reattach: the PTY is unverifiable, kept, and left for recovery. - pendingReattach.restoreRequired = 'reattachAttemptsExhausted' - this.wakeRecovery(pendingReattach) - console.warn( - `[ssh-relay-session] Keeping PTY ${ptyId} for ${this.targetId}: an older Orca relay on this host may still run it` - ) - } - return - } - if (!shouldContinue()) { - return - } - this.handlePtyReattachFailure(ptyId, appPtyId, pendingReattach, error) - } finally { - recoveryActivationLease?.retire() - sourceActivationLease?.rollback() - if (this.pendingPtyReattaches.get(appPtyId) === pendingReattach) { - this.pendingPtyReattaches.delete(appPtyId) - } - this.ptyRecoveryRetention.release(pendingReattach.retentionKey) - } - } - - private findExactPendingExit( - pending: PendingPtyReattach, - ptyIncarnation: string | undefined - ): SshPtyExitPayload | undefined { - if (!ptyIncarnation) { - return undefined - } - return pending.exits.find( - (exit) => - exit.providerGeneration === pending.providerGeneration && - exit.ptyIncarnation === ptyIncarnation - ) - } - - private preparePtyIncarnationForExit(appPtyId: string, ptyIncarnation: string | undefined): void { - if (!ptyIncarnation) { - return - } - restorePtyIncarnation(appPtyId, ptyIncarnation) - this.runtime?.acceptPtyIncarnationForExit(appPtyId, ptyIncarnation) - } - - private isRetiredReattachedPtySurface( - lease: SshPtyLease | undefined, - appPtyId: string, - incarnationId: string | undefined - ): boolean { - if (!lease?.worktreeId || !lease.tabId || !lease.leafId || !isTerminalLeafId(lease.leafId)) { - return false - } - const leafId = lease.leafId - const session = this.store.getWorkspaceSession?.() - const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) - const candidates = [session, hostSession] - const currentTabIds = candidates - .map((candidate) => findTerminalTabIdForLeaf(candidate, leafId)) - .filter((tabId): tabId is string => Boolean(tabId && isValidTerminalTabId(tabId))) - const tombstoneMatches = (tabId: string): boolean => { - const paneKey = makePaneKey(tabId, leafId) - return candidates.some((candidate) => { - const tombstone = candidate?.terminalSurfaceTombstonesByPaneKey?.[paneKey] - return Boolean( - tombstone?.ptyId === appPtyId && - (!incarnationId || tombstone.incarnationId === incarnationId) - ) - }) - } - const hasLiveCurrentBinding = currentTabIds.some((tabId) => { - const paneKey = makePaneKey(tabId, leafId) - return ( - !tombstoneMatches(tabId) && - candidates.some( - (candidate) => - candidate?.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId?.[leafId] === appPtyId && - (!incarnationId || - !candidate.terminalPtyIncarnationsByPaneKey?.[paneKey] || - candidate.terminalPtyIncarnationsByPaneKey[paneKey] === incarnationId) - ) - ) - }) - if (hasLiveCurrentBinding) { - return false - } - // Why only when no tab holds the leaf: a pane moved out of the tab before it closed lives on. - const leaseTabId = lease.tabId - const closedByRecord = - currentTabIds.length === 0 && - candidates.some((candidate) => - hasClosedTerminalTabRecord(candidate?.closedTerminalTabTombstonesByTabId, leaseTabId) - ) - return ( - closedByRecord || - [lease.tabId, ...currentTabIds] - .filter((tabId) => isValidTerminalTabId(tabId)) - .some(tombstoneMatches) - ) - } - - private async suppressRetiredReattachedPty( - ptyProvider: SshPtyProvider, - relayPtyId: string, - appPtyId: string, - incarnationId: string | undefined - ): Promise { - try { - this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'expired') - } catch (error) { - console.error('[ssh-relay-session] Failed to expire retired PTY lease:', error) - } - if (incarnationId) { - try { - this.store.recordSshRemotePtyKillIntent(this.targetId, relayPtyId, { - requestedAt: Date.now(), - incarnationId, - attempts: 0 - }) - } catch (error) { - console.error('[ssh-relay-session] Failed to persist retired PTY stop:', error) - } - try { - await ptyProvider.shutdown(appPtyId, { - immediate: true, - expectedIncarnationId: incarnationId - }) - } catch (error) { - console.warn( - '[ssh-relay-session] Retired PTY stop is unverifiable and remains pending:', - error - ) - } - } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - } - - private async restoreReattachedPtyRuntime( - appPtyId: string, - incarnationId: string, - lease: SshPtyLease | undefined, - shouldContinue: () => boolean - ): Promise { - if (lease?.worktreeId && lease.tabId && lease.leafId) { - const { worktreeId, leafId, tabId: leaseTabId } = lease - let tabId = lease.tabId - // The pane's home is this target's partition, where its spawn bound it. Binding into `local` - // left a copy there that startup then preferred over the home copy (STA-9544). - const hostId = toSshExecutionHostId(this.targetId) - const bound = await this.store.persistPtyBinding(() => { - if (!shouldContinue()) { - return null - } - const session = this.store.getWorkspaceSession?.(hostId) - // Rows an older build left in `local`; the renderer moves them home on its next save. - const legacySession = this.store.getWorkspaceSession?.() - // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so - // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that - // merely moved. Leaf is the identity, the tab is only where it currently sits. - tabId = - findTerminalTabIdForLeaf(session, leafId) ?? - findTerminalTabIdForLeaf(legacySession, leafId) ?? - leaseTabId - // Absence of the pane only means "the user closed it" once the persisted membership - // speaks for this worktree. Before that it means the renderer has not published its - // layout yet, and refusing there drops a tab the user still has — the regression that - // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an - // unauthoritative session still gets the creating write. - // A pane only `local` still holds is not gone either: refusing it would strand a live pane - // behind a binding reattach can no longer reach. - const mayCreate = - !hasHostAuthoritativeTerminalMembership(session, worktreeId) || - findTerminalTabIdForLeaf(legacySession, leafId) !== undefined - return { - worktreeId: worktreeId, - tabId, - leafId: leafId, - ptyId: appPtyId, - incarnationId, - ...(mayCreate ? {} : { mayCreate: false }), - mayReviveRetiredSurface: false, - origin: 'relay_reattach' as const - } - }, hostId) - if (!shouldContinue()) { - return 'missing-surface' - } - if (bound === false) { - // Topology absence alone is not authority to kill a process, but neither refusal may - // publish or replay into a missing pane. - // We only got here because pty.attach succeeded, so the host just proved this PTY alive. - // Record that before the lease write: `expired` reads downstream as "reattach gave up", - // and terminal.recoverPane would otherwise treat this refusal as licence to spawn a - // replacement shell over a process the host attested is still running. - this.runtime?.markPtyLivenessLive(appPtyId) - this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'expired') - return 'missing-surface' - } - setPtyOwnership(appPtyId, toSshExecutionHostId(this.targetId)) - restorePtyIncarnation(appPtyId, incarnationId) - this.runtime?.registerPty(appPtyId, lease.worktreeId, this.targetId, { - tabId, - leafId: lease.leafId, - incarnationId - }) - return 'restored' - } - setPtyOwnership(appPtyId, toSshExecutionHostId(this.targetId)) - restorePtyIncarnation(appPtyId, incarnationId) - this.runtime?.onPtySpawned(appPtyId, incarnationId, { awaitsRegistration: false }) - return 'restored' - } - - /** True when an older relay now serves the PTY, or the attempt stopped being current. */ - private async reattachThroughPreviousRelay(args: { - ptyProvider: SshPtyProvider - ptyId: string - appPtyId: string - expected: ExpectedPtyIdentity | undefined - lease: SshPtyLease | undefined - attachedLeaseIds: Set - shouldContinue: () => boolean - }): Promise { - const { appPtyId, shouldContinue } = args - let result: SshPtyAttachResult | null - try { - result = await attachHeldPtyThroughPreviousRelay(args.ptyProvider, appPtyId, args.expected) - } catch (error) { - console.warn( - `[ssh-relay-session] Previous relay reattach failed for ${args.ptyId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return false - } - if (!result || !shouldContinue()) { - return result !== null - } - if (result.incarnationId) { - const restored = await this.restoreReattachedPtyRuntime( - appPtyId, - result.incarnationId, - args.lease, - shouldContinue - ) - if (restored !== 'restored') { - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - return true - } - } else { - setPtyOwnership(appPtyId, toSshExecutionHostId(this.targetId)) - } - args.attachedLeaseIds.add(args.ptyId) - this.forwardReattachReplay(appPtyId, result.replay ?? '') - return true - } - - private async attachPtyWithRetry( - ptyProvider: SshPtyProvider, - ptyId: string, - expectedIdentity: ExpectedPtyIdentity | undefined, - recoveryRequest: PtySourceRecoveryRequest | undefined, - shouldContinue: () => boolean - ): Promise { - let lastError: unknown - for (let attempt = 0; attempt < 2; attempt++) { - if (!shouldContinue()) { - throw lastError ?? new Error('PTY reattach attempt is no longer current') - } - try { - return await this.attachPtyWithDeadline( - ptyProvider, - ptyId, - expectedIdentity, - recoveryRequest - ) - } catch (error) { - lastError = error - if (!shouldContinue() || isSshPtyNotFoundError(error) || attempt === 1) { - throw error - } - await this.waitForPtyReattachRetry() - } - } - throw lastError - } - - private async attachPtyWithDeadline( - ptyProvider: SshPtyProvider, - ptyId: string, - expectedIdentity: ExpectedPtyIdentity | undefined, - recoveryRequest: PtySourceRecoveryRequest | undefined - ): Promise { - let timer: ReturnType | undefined - let timedOut = false - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - timedOut = true - reject( - new Error(`PTY reattach attempt timed out after ${SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS}ms`) - ) - }, SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS) - timer.unref?.() - }) - try { - const attach = expectedIdentity - ? recoveryRequest - ? ptyProvider.attachForReconnect(ptyId, expectedIdentity, recoveryRequest) - : ptyProvider.attachForReconnect(ptyId, expectedIdentity) - : recoveryRequest - ? ptyProvider.attachForReconnect(ptyId, undefined, recoveryRequest) - : ptyProvider.attachForReconnect(ptyId) - const guardedAttach = attach.then((result) => { - if (timedOut) { - result.sourceActivationLease?.rollback() - } - return result - }) - return (await Promise.race([guardedAttach, timeout])) ?? {} - } finally { - if (timer) { - clearTimeout(timer) - } - } - } - - private async waitForPtyReattachRetry(): Promise { - const delayMs = - SSH_PTY_REATTACH_RETRY_MIN_DELAY_MS + - Math.floor(Math.random() * (SSH_PTY_REATTACH_RETRY_JITTER_MS + 1)) - await new Promise((resolve) => { - const timer = setTimeout(resolve, delayMs) - timer.unref?.() - }) - } - - private handlePtyReattachFailure( - ptyId: string, - appPtyId: string, - pending: PendingPtyReattach, - error: unknown - ): void { - if (!isSshPtyNotFoundError(error)) { - pending.restoreRequired = 'reattachAttemptsExhausted' - this.wakeRecovery(pending) - console.warn( - `[ssh-relay-session] Leaving PTY ${ptyId} detached for ${this.targetId} after bounded reattach attempts failed: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return - } - if (isSshPtyIdentityMismatchError(error)) { - console.warn( - `[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${ - error instanceof Error ? error.message : String(error) - }` - ) - return - } - console.warn( - `[ssh-relay-session] Dropping stale PTY ${ptyId} for ${this.targetId} after relay reattach failed: ${ - error instanceof Error ? error.message : String(error) - }` - ) - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - // Deliberately does NOT call runtime.onPtyExit: pty.attach answers not-found both when it - // verified the pid is dead and when its session map simply has no such id (no liveness check on - // that path at all) — which is every id after a relay restart, since ids carry a per-start - // `ptyIdMintEpoch`. This branch may release the id, but certifying a death from that union - // would orphan a live remote shell (docs/reference/ssh-execution-boundary.md). The renderer - // gets code -1, which every reader treats as unverified loss. - this.store.markSshRemotePtyLease(this.targetId, ptyId, 'expired') - const win = this.getMainWindow() - if (win && !win.isDestroyed()) { - // Why a separate flag and not the code: `-1` is the stop sentinel every reader resolves to - // `stop_unverified`, so this branch — the one place a reachable relay answered for this exact - // id and reported it absent — was indistinguishable from a lost link. It says only that the - // relay disowned the id, which a restarted relay also does for ids it never minted, so it is - // deliberately not the `exited` verdict (docs/reference/ssh-execution-boundary.md). - win.webContents.send('pty:exit', { id: appPtyId, code: -1, ptySourceDisowned: true }) - } - } - - private async sourceRecoveryRequest( - appPtyId: string - ): Promise { - if (!this.activePtyConsumerOwner()?.outputFlowControl) { - return undefined - } - const recovery = getSshPtyConsumerRecovery(this.targetId) - const migration = recovery?.modelMigrationsByAppPtyId.get(appPtyId) - if (migration) { - const outcome = await migration - if (recovery?.modelMigrationsByAppPtyId.get(appPtyId) === migration) { - recovery.modelMigrationsByAppPtyId.delete(appPtyId) - } - if (outcome.status !== 'settled') { - return Object.freeze({ status: 'checkpointUnavailable' }) - } - } - const checkpoints = recovery?.checkpointsByAppPtyId - const relayPtyId = toRelaySshPtyId(this.targetId, appPtyId) - // Why: every checkpoint writer records app-id keys now, so the relay-id - // lookup (and its paired delete below) is a legacy guard only. - const checkpoint = checkpoints?.get(appPtyId) ?? checkpoints?.get(relayPtyId) - if (!checkpoint) { - return Object.freeze({ status: 'checkpointUnavailable' }) - } - return Object.freeze({ - status: 'checkpoint', - clientGeneration: checkpoint.clientGeneration, - ownerGeneration: checkpoint.ownerGeneration, - ptyIncarnation: checkpoint.ptyIncarnation, - deliveryToken: checkpoint.deliveryToken, - acceptedSourceEndSu: checkpoint.acceptedSourceEndSu - }) - } - - private beginPtyModelMigration(providerGeneration: number, closeReason: string): void { - const recovery = getSshPtyConsumerRecovery(this.targetId) - if (!recovery) { - closeSshPtyOutputGeneration(providerGeneration, closeReason) - return - } - for (const checkpoint of getSshPtyAcceptedSourceCheckpoints(providerGeneration)) { - recovery.checkpointsByAppPtyId.set(checkpoint.id, checkpoint) - } - const migration = beginSshPtyOutputGenerationMigration(providerGeneration) - for (const [ptyId, result] of migration.byPty) { - const previous = recovery.modelMigrationsByAppPtyId.get(ptyId) - const fence = previous ? previous.then(() => result) : result - recovery.modelMigrationsByAppPtyId.set(ptyId, fence) - void fence.then((outcome) => { - const current = getSshPtyConsumerRecovery(this.targetId) - if (current?.modelMigrationsByAppPtyId.get(ptyId) !== fence) { - return - } - if (outcome.status === 'settled') { - current.checkpointsByAppPtyId.set(ptyId, outcome.checkpoint) - } else { - current.checkpointsByAppPtyId.delete(ptyId) - current.checkpointsByAppPtyId.delete(toRelaySshPtyId(this.targetId, ptyId)) - } - current.modelMigrationsByAppPtyId.delete(ptyId) - }) - } - void migration.completion.then(() => { - closeSshPtyOutputGeneration(providerGeneration, closeReason) - }) - } - - private async finishSourceRecovery( - relayPtyId: string, - appPtyId: string, - attachResult: SshPtyAttachResult, - request: PtySourceRecoveryRequest, - pending: PendingPtyReattach, - shouldContinue: () => boolean, - activateRecoveryQuarantine: () => void - ): Promise { - const recovery = attachResult.sourceRecovery - const pendingRecovery = recovery?.status === 'pending' ? recovery : undefined - const owner = this.activePtyConsumerOwner() - if ( - !owner?.outputFlowControl || - !pendingRecovery || - request.status !== 'checkpoint' || - pendingRecovery.clientGeneration !== owner.clientGeneration || - pendingRecovery.ownerGeneration !== owner.ownerGeneration || - pendingRecovery.ptyIncarnation !== attachResult.incarnationId || - pendingRecovery.ptyIncarnation !== request.ptyIncarnation || - pendingRecovery.checkpointSourceEndSu !== request.acceptedSourceEndSu || - (pending.replacementDeliveryToken !== undefined && - pending.replacementDeliveryToken !== pendingRecovery.deliveryToken) - ) { - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - await this.abandonPtySourceRecovery(relayPtyId, appPtyId, pending) - return false - } - const acceptedRecovery = pendingRecovery - pending.recovery = acceptedRecovery - pending.nextRecoverySourceSu = acceptedRecovery.checkpointSourceEndSu - this.retiredSourceDeliveries.activate(relayPtyId) - this.sourceIdentityByRelayPtyId.set(relayPtyId, { - deliveryToken: acceptedRecovery.deliveryToken, - clientGeneration: acceptedRecovery.clientGeneration, - ownerGeneration: acceptedRecovery.ownerGeneration, - ptyIncarnation: acceptedRecovery.ptyIncarnation, - nextSourceSu: acceptedRecovery.checkpointSourceEndSu - }) - activateRecoveryQuarantine() - for (const payload of pending.queuedData.splice(0)) { - this.routeQuarantinedReattachData(pending, payload) - } - await this.waitForRecoveryFence(pending, shouldContinue) - const exactExit = this.findExactPendingExit(pending, acceptedRecovery.ptyIncarnation) - const complete = pending.recoveryComplete ?? (exactExit ? acceptedRecovery : undefined) - if ( - !shouldContinue() || - pending.restoreRequired || - !complete || - complete.deliveryToken !== acceptedRecovery.deliveryToken || - complete.clientGeneration !== acceptedRecovery.clientGeneration || - complete.ownerGeneration !== acceptedRecovery.ownerGeneration || - complete.ptyIncarnation !== acceptedRecovery.ptyIncarnation || - complete.checkpointSourceEndSu !== acceptedRecovery.checkpointSourceEndSu || - complete.recoveryEndSu !== acceptedRecovery.recoveryEndSu || - pending.nextRecoverySourceSu !== acceptedRecovery.recoveryEndSu - ) { - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - await this.abandonPtySourceRecovery(relayPtyId, appPtyId, pending) - return false - } - let nextLiveSourceSu = acceptedRecovery.recoveryEndSu - for (const payload of pending.liveData) { - if ( - !payload.source || - payload.source.deliveryToken !== acceptedRecovery.deliveryToken || - payload.source.clientGeneration !== acceptedRecovery.clientGeneration || - payload.source.ownerGeneration !== acceptedRecovery.ownerGeneration || - payload.source.sourceStartSu !== nextLiveSourceSu || - payload.source.sourceEndSu <= payload.source.sourceStartSu || - payload.ptyIncarnation !== acceptedRecovery.ptyIncarnation - ) { - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - await this.abandonPtySourceRecovery(relayPtyId, appPtyId, pending) - return false - } - nextLiveSourceSu = payload.source.sourceEndSu - } - try { - for (const payload of pending.recoveryData) { - await this.acceptPtyData(payload) - } - for (const payload of pending.liveData) { - await this.acceptPtyData(payload) - } - pending.livePassthrough = true - } catch { - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - await this.abandonPtySourceRecovery(relayPtyId, appPtyId, pending) - return false - } - if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return false - } - const acceptedSourceEndSu = pending.liveData.reduce( - (endSu, payload) => Math.max(endSu, payload.source?.sourceEndSu ?? endSu), - acceptedRecovery.recoveryEndSu - ) - // Why: checkpoints are app-id keyed; a relay-id entry here would be shadowed - // by a staler app-id entry on the next sourceRecoveryRequest lookup. - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.set( - appPtyId, - Object.freeze({ - id: appPtyId, - providerGeneration: this.activePtyProviderGeneration!, - clientGeneration: acceptedRecovery.clientGeneration, - ownerGeneration: acceptedRecovery.ownerGeneration, - ptyIncarnation: acceptedRecovery.ptyIncarnation, - deliveryToken: acceptedRecovery.deliveryToken, - acceptedSourceEndSu - }) - ) - return true - } - - private async waitForRecoveryFence( - pending: PendingPtyReattach, - shouldContinue: () => boolean - ): Promise { - const deadline = Date.now() + SSH_PTY_REATTACH_ATTEMPT_TIMEOUT_MS - while ( - shouldContinue() && - !pending.recoveryComplete && - !pending.restoreRequired && - !this.findExactPendingExit(pending, pending.recovery?.ptyIncarnation) && - Date.now() < deadline - ) { - await new Promise((resolve) => { - const timer = setTimeout( - () => { - pending.recoveryWaiters.delete(settle) - resolve() - }, - Math.max(1, deadline - Date.now()) - ) - timer.unref?.() - const settle = (): void => { - clearTimeout(timer) - resolve() - } - pending.recoveryWaiters.add(settle) - }) - } - if ( - !pending.recoveryComplete && - !pending.restoreRequired && - !this.findExactPendingExit(pending, pending.recovery?.ptyIncarnation) - ) { - pending.restoreRequired = 'recoveryFenceTimeout' - } - } - - private async abandonPtySourceRecovery( - relayPtyId: string, - appPtyId: string, - pending: PendingPtyReattach - ): Promise { - if (!this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return - } - const recovery = pending.recovery - const { mux, providerGeneration } = pending - if (recovery && !mux.isDisposed()) { - const cancellationRequest = { - id: relayPtyId, - clientGeneration: recovery.clientGeneration, - ownerGeneration: recovery.ownerGeneration, - deliveryToken: recovery.deliveryToken - } - this.retiredSourceDeliveries.retire(providerGeneration, { - relayPtyId, - deliveryToken: recovery.deliveryToken, - clientGeneration: recovery.clientGeneration, - ownerGeneration: recovery.ownerGeneration - }) - try { - const result = (await mux.request('pty.cancelDelivery', cancellationRequest)) as Record< - string, - unknown - > - const highestPrivateSourceEndSu = pending.liveData.reduce( - (endSu, payload) => Math.max(endSu, payload.source?.sourceEndSu ?? endSu), - Math.max( - pending.nextRecoverySourceSu ?? recovery.checkpointSourceEndSu, - pending.highestRecoverySourceEndSu ?? recovery.checkpointSourceEndSu - ) - ) - if ( - result.canceled !== true || - !Number.isSafeInteger(result.sentEndSu) || - (result.sentEndSu as number) < highestPrivateSourceEndSu || - !Number.isSafeInteger(result.creditedEndSu) || - result.creditedEndSu !== recovery.checkpointSourceEndSu || - (result.creditedEndSu as number) > (result.sentEndSu as number) - ) { - throw new Error('ssh_source_cancellation_proof_invalid') - } - if (!this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return - } - const identity = this.sourceIdentityByRelayPtyId.get(relayPtyId) - if (identity && !this.sameSourceDelivery(identity, recovery)) { - return - } - if (identity) { - const applied = applySshPtySourceRecoveryCancellationProof( - { - id: appPtyId, - code: -1, - providerGeneration, - ptyIncarnation: recovery.ptyIncarnation - }, - { - sentEndSu: result.sentEndSu as number, - creditedEndSu: result.creditedEndSu as number - } - ) - if (!applied) { - throw new Error('ssh_source_cancellation_proof_rejected') - } - } - } catch (error) { - if (!this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return - } - console.warn( - `[ssh-relay-session] Failed to cancel replacement delivery for ${relayPtyId}: ${ - error instanceof Error ? error.message : String(error) - }` - ) - throw sourceRecoveryCancellationError(error) - } - } - if (!this.ownsPtyRecoveryAttempt(appPtyId, pending)) { - return - } - const identity = this.sourceIdentityByRelayPtyId.get(relayPtyId) - if (!identity || !recovery || this.sameSourceDelivery(identity, recovery)) { - this.sourceIdentityByRelayPtyId.delete(relayPtyId) - } - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(appPtyId) - getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(relayPtyId) - this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'detached') - } - - private ownsPtyRecoveryAttempt(appPtyId: string, pending: PendingPtyReattach): boolean { - return ( - this.pendingPtyReattaches.get(appPtyId) === pending && - this.mux === pending.mux && - this.activePtyProviderGeneration === pending.providerGeneration && - !pending.mux.isDisposed() - ) - } - - private sameSourceDelivery( - left: Readonly<{ - deliveryToken: string - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - }>, - right: Readonly<{ - deliveryToken: string - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - }> - ): boolean { - return ( - left.deliveryToken === right.deliveryToken && - left.clientGeneration === right.clientGeneration && - left.ownerGeneration === right.ownerGeneration && - left.ptyIncarnation === right.ptyIncarnation - ) - } -} diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts deleted file mode 100644 index c950176dc705..000000000000 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ /dev/null @@ -1,777 +0,0 @@ -// Why: on a split-namespace host (Synology DSM) the shell path and the SFTP path -// name the same directory differently, so the deploy must keep issuing shell -// commands against the canonical path while every SFTP write is redirected — and -// only when this connection's own install marker proves the candidate is ours. - -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' - -vi.mock('./ssh-relay-opencode-runtime', () => ({ - ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') -})) -vi.mock('./ssh-relay-ripgrep-install', () => ({ - remoteRipgrepLayout: vi.fn().mockReturnValue(null), - recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), - ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - app: { getAppPath: () => '/mock/app' } -})) - -vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true), - readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') -})) - -vi.mock('./relay-protocol', () => ({ - RELAY_VERSION: '0.1.0', - RELAY_REMOTE_DIR: '.orca-remote', - parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), - RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', - RELAY_SENTINEL_TIMEOUT_MS: 10_000 -})) - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - uploadDirectory: vi.fn().mockResolvedValue(undefined), - waitForSentinel: vi.fn().mockResolvedValue({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && - (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, - execCommand: vi.fn() -})) - -vi.mock('./ssh-remote-node-resolution', () => ({ - resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') -})) - -vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ - ...(await importOriginal()), - createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' -})) - -vi.mock('./ssh-relay-versioned-install', () => ({ - readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), - computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, - isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - finalizeInstall: vi.fn().mockResolvedValue(undefined), - abandonInstall: vi.fn().mockResolvedValue(undefined), - gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined), - RELAY_INSTALL_LOCK_NAME: '.install-lock' -})) - -vi.mock('./ssh-relay-repair-lock', () => ({ - tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') -})) - -vi.mock('./ssh-relay-gc-claim', () => ({ - releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), - tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), - waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'`, - createSshOperationAbortError: () => - Object.assign(new Error('SSH operation was cancelled'), { - name: 'AbortError' - }) -})) - -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { - RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, - RELAY_DEPLOY_TIMEOUT_MS -} from './ssh-relay-deploy-timing' -import { parseUnameToRelayPlatform } from './relay-protocol' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { - abandonInstall, - finalizeInstall, - isRelayAlreadyInstalled -} from './ssh-relay-versioned-install' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import { BOTH_NATIVE_DEPS_MISSING_PROBE } from './ssh-relay-native-deps-install-fixture' -import type { SshConnection } from './ssh-connection' -import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' - -// The transfer-option slice these tests inspect; SshConnection keeps its own options type internal. -type TransferOptions = { sftpNamespace?: SftpNamespacePathMapping } - -const SHELL_HOME = '/home/u' -const SFTP_HOME = '/homes/u' -const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash' -const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}` -const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_SUFFIX}` -const MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/ -// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a -// freshly installed node-pty loads (#17915). -const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' -const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' -const STAGE_RESERVED = `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0` -const STAGE_PROMOTED = `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED` - -type ConnectionOptions = { - // '/homes/u' models a DSM host whose SFTP subsystem starts outside the shell home. - sftpStartPath?: string - lstatPresent?: (path: string) => boolean - hangRealpath?: boolean - // Models an SFTP session that never confirms close, so teardown stays unconfirmed. - neverCloses?: boolean - systemSsh?: boolean - // Present only on the shipping path; absent doubles exercise the deploy's own SFTP fallback. - transferMethods?: boolean -} - -type Capture = { - writePaths: string[] - uploadTargets: string[] - realpathCalls: string[] - lstatCalls: string[] - sftpEndCalls: number - uploadOptions: (TransferOptions | undefined)[] - writeOptions: (TransferOptions | undefined)[] -} - -function newCapture(): Capture { - return { - writePaths: [], - uploadTargets: [], - realpathCalls: [], - lstatCalls: [], - sftpEndCalls: 0, - uploadOptions: [], - writeOptions: [] - } -} - -// The marker this run created, read back from the shell command that made it. -function issuedMarkerNames(): string[] { - const markers: string[] = [] - for (const [, command] of vi.mocked(execCommand).mock.calls) { - const match = decodeCommand(command).match(MARKER_PATTERN) - if (match) { - markers.push(match[0]) - } - } - return markers -} - -function decodeCommand(command: string): string { - return decodeRemotePowerShellScript(command) -} - -function execCommands(): string[] { - return vi.mocked(execCommand).mock.calls.map(([, command]) => decodeCommand(command)) -} - -function makeConnection(capture: Capture, options: ConnectionOptions = {}): SshConnection { - const startPath = options.sftpStartPath ?? SFTP_HOME - // Default: the marker is visible only through the SFTP namespace, and only under this install's token. - const lstatPresent = - options.lstatPresent ?? - ((path: string) => - path.startsWith(`${SFTP_HOME}/`) && - issuedMarkerNames().some((marker) => path.includes(marker))) - - const makeSftp = (): unknown => { - const sftp = new EventEmitter() - return Object.assign(sftp, { - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - realpath: vi.fn((path: string, cb: (err: Error | null, resolved?: string) => void) => { - capture.realpathCalls.push(path) - if (options.hangRealpath) { - return - } - cb(null, startPath) - }), - lstat: vi.fn((path: string, cb: (err: Error | null) => void) => { - capture.lstatCalls.push(path) - cb(lstatPresent(path) ? null : Object.assign(new Error('No such file'), { code: 2 })) - }), - createWriteStream: vi.fn().mockImplementation((path: string) => { - capture.writePaths.push(path) - const ws = new EventEmitter() - return Object.assign(ws, { - end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)) - }) - }), - end: vi.fn(() => { - capture.sftpEndCalls += 1 - if (!options.neverCloses) { - setTimeout(() => sftp.emit('close'), 0) - } - }) - }) - } - - const conn: Record = { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - sftp: vi.fn().mockImplementation(() => Promise.resolve(makeSftp())) - } - if (options.systemSsh) { - conn.usesSystemSshTransport = vi.fn().mockReturnValue(true) - } - if (options.transferMethods) { - conn.uploadDirectory = vi - .fn() - .mockImplementation((_local: string, remote: string, opts?: TransferOptions) => { - capture.uploadTargets.push(remote) - capture.uploadOptions.push(opts) - return Promise.resolve() - }) - conn.writeFile = vi - .fn() - .mockImplementation((remote: string, _contents: string, opts?: TransferOptions) => { - capture.writePaths.push(remote) - capture.writeOptions.push(opts) - return Promise.resolve() - }) - } - return conn as unknown as SshConnection -} - -function feed(responses: string[]): void { - for (const response of responses) { - vi.mocked(execCommand).mockResolvedValueOnce(response) - } -} - -// POSIX first install, healthy npm install and node-pty probe. -const POSIX_FIRST_INSTALL = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - '', // bounded stale-stage recovery - STAGE_RESERVED, - '', // chmod staged node - '', // final install namespace marker - STAGE_PROMOTED, - '', // shared native-deps cache probe (miss) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - '', // promote into the shared native-deps cache - '', // clean stage root - 'DEAD', - 'READY' -] - -const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - '', // bounded stale-stage recovery - STAGE_RESERVED, - '', // chmod staged node - STAGE_PROMOTED, - '', // shared native-deps cache probe (miss) - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - '', // promote into the shared native-deps cache - '', // clean stage root - 'DEAD', - 'READY' -] - -// POSIX repair of an installed dir whose native deps are missing. -const POSIX_REPAIR = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - BOTH_NATIVE_DEPS_MISSING_PROBE, // probe before the repair lock: the marker names both deps - BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the lock - '', // install-owner marker - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' -] - -const POSIX_HEALTHY_RECONNECT = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - 'ORCA-NATIVE-DEPS-OK', - '', // per-launch namespace marker - 'DEAD', - 'READY' -] - -describe('relay install writes on a split SFTP namespace', () => { - let capture: Capture - let warnSpy: ReturnType - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockImplementation((_sftp, _local, remote: string) => { - capture.uploadTargets.push(remote) - return Promise.resolve() - }) - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') - capture = newCapture() - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - vi.restoreAllMocks() - }) - - it('redirects every first-install artifact transfer while shell commands stay canonical', async () => { - const conn = makeConnection(capture) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(capture.uploadTargets).toHaveLength(1) - expect(capture.uploadTargets[0]).toBe('/homes/u/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.writePaths).toEqual([ - `${capture.uploadTargets[0]}/.version`, - `${SFTP_RELAY_DIR}/package.json` - ]) - // Every shell command — mkdir, chmod, npm, launch — still names the shell path. - for (const command of execCommands()) { - expect(command).not.toContain(SFTP_RELAY_DIR) - } - expect(execCommands().some((command) => command.includes(SHELL_RELAY_DIR))).toBe(true) - }) - - it('folds the install-owner marker into the first-install mkdir', async () => { - const conn = makeConnection(capture) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - const markerCommands = execCommands().filter((command) => MARKER_PATTERN.test(command)) - const reservation = markerCommands.find((command) => - command.includes('__ORCA_UPLOAD_STAGE_SLOT__') - ) - const installMarker = markerCommands.find((command) => command.includes('.install-lock')) - expect(reservation).toContain('mkdir') - expect(reservation).toContain('/.orca-remote/.upload-stages') - expect(reservation).not.toContain('.install-lock') - expect(installMarker).toContain(`${SHELL_RELAY_DIR}/.install-lock`) - }) - - it('probes one shared marker for every first-install artifact transfer', async () => { - const conn = makeConnection(capture) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - const [stageMarker, installMarker] = issuedMarkerNames() - expect(stageMarker).toMatch(MARKER_PATTERN) - expect(installMarker).toMatch(MARKER_PATTERN) - expect(capture.lstatCalls).toHaveLength(6) - for (const path of capture.lstatCalls.slice(0, 4)) { - expect(path).toContain('.upload-stages') - expect(path).toContain(stageMarker) - } - expect(capture.lstatCalls.slice(4)).toEqual([ - `${SHELL_RELAY_DIR}/.install-lock/${installMarker}`, - `${SFTP_RELAY_DIR}/.install-lock/${installMarker}` - ]) - }) - - it('refuses a same-version candidate dir that carries another install marker', async () => { - const foreign = `.sftp-namespace-${'f'.repeat(32)}` - const conn = makeConnection(capture, { - lstatPresent: (path) => path.startsWith(`${SFTP_HOME}/`) && path.includes(foreign) - }) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - expect(capture.writePaths).toEqual([ - `${capture.uploadTargets[0]}/.version`, - `${SHELL_RELAY_DIR}/package.json` - ]) - }) - - it('keeps the shell path and skips probing when both namespaces agree', async () => { - const conn = makeConnection(capture, { sftpStartPath: SHELL_HOME }) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - expect(capture.lstatCalls).toEqual([]) - }) - - it('resolves against a start directory outside any home', async () => { - const conn = makeConnection(capture, { - sftpStartPath: '/volume1/shared', - lstatPresent: (path) => path.startsWith('/volume1/shared/') - }) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(capture.uploadTargets[0]).toContain( - '/volume1/shared/.orca-remote/.upload-stages/slot-0/payload' - ) - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - }) - - it('passes the same mapping to a connection that owns its own transfer methods', async () => { - const conn = makeConnection(capture, { transferMethods: true }) - feed(POSIX_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - // The shipping path hands over shell paths plus a mapping; resolution happens on the write session. - expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - expect(capture.writePaths).toEqual([ - `${capture.uploadTargets[0]}/.version`, - `${SHELL_RELAY_DIR}/package.json` - ]) - const [stageMarker, installMarker] = issuedMarkerNames() - const mappings = [...capture.uploadOptions, ...capture.writeOptions].map( - (options) => options?.sftpNamespace - ) - expect(mappings).toHaveLength(3) - expect(mappings[0]?.shellProbePath).toContain('/.orca-remote/.upload-stages/slot-0/') - expect(mappings[0]?.shellProbePath).toContain(stageMarker) - expect(mappings[1]?.shellProbePath).toBe(mappings[0]?.shellProbePath) - expect(mappings[2]?.shellProbePath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${installMarker}`) - expect(mappings[0]?.homeRelativePath).toBe('.orca-remote/.upload-stages/slot-0/payload') - expect(mappings[1]?.homeRelativePath).toBe(`${mappings[0]?.homeRelativePath}/.version`) - expect(mappings[2]?.homeRelativePath).toBe(`${RELAY_SUFFIX}/package.json`) - expect(capture.realpathCalls).toEqual([]) - }) - - it('leaves system-SSH connections unmapped and unprobed', async () => { - // transferMethods models the shipping SshConnection path (uploadDirectory/writeFile → system SSH helpers). - const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) - feed(POSIX_SYSTEM_SSH_FIRST_INSTALL) - - await deployAndLaunchRelay(conn) - - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(true) - expect(capture.realpathCalls).toEqual([]) - expect(capture.lstatCalls).toEqual([]) - expect(conn.sftp).not.toHaveBeenCalled() - // System SSH never retargets: shell absolute paths, no mapping, no SFTP session. - expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') - expect(capture.uploadTargets[0]).toMatch(/\/payload$/) - expect(capture.writePaths).toEqual([ - `${capture.uploadTargets[0]}/.version`, - `${SHELL_RELAY_DIR}/package.json` - ]) - const transferOptions = [...capture.uploadOptions, ...capture.writeOptions] - expect(transferOptions).toHaveLength(3) - for (const options of transferOptions) { - expect(options?.sftpNamespace).toBeUndefined() - } - }) - - it('leaves Windows hosts unmapped and unprobed', async () => { - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('win32-x64') - const conn = makeConnection(capture) - vi.mocked(execCommand).mockImplementation((_conn, command) => { - const decoded = decodeCommand(command) - if (decoded.includes('__ORCA_REMOTE_PLATFORM__')) { - return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Windows AMD64') - } - if (decoded.includes('[Environment]::GetFolderPath')) { - return Promise.resolve('C:\\Users\\u') - } - if (decoded.includes('__ORCA_UPLOAD_STAGE_SLOT__')) { - return Promise.resolve(STAGE_RESERVED) - } - if (decoded.includes('__ORCA_UPLOAD_STAGE_PROMOTION__')) { - return Promise.resolve(STAGE_PROMOTED) - } - if (decoded.includes('npm install')) { - return Promise.reject(new Error('npm install failed')) - } - return Promise.resolve('') - }) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow('npm install failed') - - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(true) - expect(capture.realpathCalls).toEqual([]) - expect(capture.writePaths[0]).toBe( - 'C:/Users/u/.orca-remote/.upload-stages/slot-0/payload/.version' - ) - expect(capture.writePaths[1]).toBe('C:/Users/u/.orca-remote/relay-0.1.0+testhash/package.json') - }) - - it('releases the first-install lock when a redirected upload fails', async () => { - const conn = makeConnection(capture) - feed(POSIX_FIRST_INSTALL) - vi.mocked(uploadDirectory).mockRejectedValueOnce(new Error('sftp write failed')) - - await expect(deployAndLaunchRelay(conn)).rejects.toThrow('sftp write failed') - - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - }) - - it('ends the SFTP session once when a deploy abort strands namespace discovery', async () => { - vi.useFakeTimers() - try { - const conn = makeConnection(capture, { hangRealpath: true }) - feed(POSIX_FIRST_INSTALL) - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) - const result = await deploy - - expect((result as Error).message).toContain('Relay deployment timed out') - await vi.advanceTimersByTimeAsync(5_000) - expect(capture.sftpEndCalls).toBe(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('retains the first-install lock when the aborted SFTP session never closes', async () => { - vi.useFakeTimers() - try { - const conn = makeConnection(capture, { hangRealpath: true, neverCloses: true }) - feed(POSIX_FIRST_INSTALL) - - const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) - await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) - await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) - await deploy - - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) -}) - -describe('relay repair writes on a split SFTP namespace', () => { - let capture: Capture - let warnSpy: ReturnType - - afterEach(() => vi.unstubAllEnvs()) - beforeEach(() => { - vi.clearAllMocks() - // The host-npm path is opt-in; these cases cover it. - vi.stubEnv('ORCA_SSH_REMOTE_RUNTIME', 'legacy') - vi.mocked(execCommand).mockReset().mockResolvedValue('') - vi.mocked(uploadDirectory).mockResolvedValue(undefined) - vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') - vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') - capture = newCapture() - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - warnSpy.mockRestore() - vi.restoreAllMocks() - }) - - it('leaves credential publication to the relay on a healthy reconnect', async () => { - const conn = makeConnection(capture, { transferMethods: true }) - feed(POSIX_HEALTHY_RECONNECT) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - }) - - it('writes no credential through fallback SFTP on a healthy reconnect', async () => { - const conn = makeConnection(capture) - feed(POSIX_HEALTHY_RECONNECT) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - }) - - it.each(['busy', 'error'] as const)( - 'launches under a %s repair lock without writing a credential', - async (lockResult) => { - const conn = makeConnection(capture) - vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue(lockResult) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - 'ORCA-NATIVE-DEPS-OK', - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - } - ) - - it('keeps a healthy system-SSH reconnect on its secure shell-path writer', async () => { - const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - 'ORCA-NATIVE-DEPS-OK', - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(conn.sftp).not.toHaveBeenCalled() - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - expect(capture.writePaths).toEqual([]) - }) - - it('launches without a client-side credential when a healthy marker is unavailable', async () => { - const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'ORCA-NATIVE-DEPS-OK']) - vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only marker')) - feed(['DEAD', 'READY']) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - }) - - it('stamps the marker only after the locked recheck, then redirects package.json', async () => { - const conn = makeConnection(capture) - let execCountAtLock = -1 - vi.mocked(tryAcquireRelayRepairLock).mockImplementation(() => { - execCountAtLock = vi.mocked(execCommand).mock.calls.length - return Promise.resolve('acquired') - }) - feed(POSIX_REPAIR) - - await deployAndLaunchRelay(conn) - - const commands = execCommands() - const markerIndex = commands.findIndex((command) => MARKER_PATTERN.test(command)) - expect(markerIndex).toBeGreaterThan(execCountAtLock) - // The re-probe under the lock is the last exec before the marker. - expect(commands[markerIndex - 1]).toContain('loadNativeModule') - expect(capture.writePaths).toEqual([`${SFTP_RELAY_DIR}/package.json`]) - }) - - it('does not stamp a marker when repairing over system SSH', async () => { - const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - BOTH_NATIVE_DEPS_MISSING_PROBE, - BOTH_NATIVE_DEPS_MISSING_PROBE, - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) - expect(conn.sftp).not.toHaveBeenCalled() - expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) - expect(capture.writeOptions).toEqual([expect.objectContaining({ sftpNamespace: undefined })]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - }) - - it('degrades to shell paths when marker creation fails outright', async () => { - const conn = makeConnection(capture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - BOTH_NATIVE_DEPS_MISSING_PROBE, - BOTH_NATIVE_DEPS_MISSING_PROBE - ]) - vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only file system')) - feed([ - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - NPTY_CLOEXEC_PATCHED, - 'DEAD', - 'READY' - ]) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - expect(capture.realpathCalls).toEqual([]) - expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( - expect.stringContaining('SFTP namespace marker unavailable') - ) - }) - - it('keeps the repair lock when marker creation has unconfirmed termination', async () => { - const conn = makeConnection(capture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, - BOTH_NATIVE_DEPS_MISSING_PROBE, - BOTH_NATIVE_DEPS_MISSING_PROBE - ]) - vi.mocked(execCommand).mockRejectedValueOnce( - Object.assign(new Error('marker teardown unconfirmed'), { sshChannelCloseConfirmed: false }) - ) - feed(['DEAD', 'READY']) - - await deployAndLaunchRelay(conn) - - expect(capture.writePaths).toEqual([]) - expect(execCommands().some((command) => command.includes('randomBytes'))).toBe(false) - expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() - expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( - expect.stringContaining('launching degraded') - ) - }) -}) diff --git a/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts b/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts deleted file mode 100644 index fe6907acd8d1..000000000000 --- a/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { execFileSync, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { listRelayBaseDirsCommand } from './ssh-remote-commands' -import { gcOldRelayVersions } from './ssh-relay-versioned-install' -import type { SshTarget } from '../../shared/ssh-types' - -const RUN_REVIEW_ORACLE = process.env.ORCA_REVIEW_SSH_UPLOAD_CANCEL === '1' -const REMOTE_REPO = '/tmp/orca-pr-10207-real-repo' - -type TargetFixture = { - containerName: string - identityFile: string - port: number - tempDir: string -} - -type RemoteInventory = { - installLock: boolean - payloadFiles: number - uploadStages: string[] -} - -function run(command: string, args: string[], timeout = 30_000): string { - return execFileSync(command, args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout - }).trim() -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} - -function dockerExec(fixture: TargetFixture, command: string): string { - return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 60_000) -} - -function startTarget(): TargetFixture { - const image = process.env.ORCA_REVIEW_SSH_IMAGE - if (!image) { - throw new Error('ORCA_REVIEW_SSH_IMAGE is required') - } - const tempDir = mkdtempSync(join(tmpdir(), 'orca-pr10207-ssh-')) - const identityFile = join(tempDir, 'id_ed25519') - run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) - const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() - const containerName = `orca-pr10207-${randomUUID().slice(0, 12)}` - run( - 'docker', - [ - 'run', - '-d', - '--name', - containerName, - '-p', - '0.0.0.0::22', - '-e', - `AUTHORIZED_KEY=${publicKey}`, - image, - 'bash', - '-lc', - 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' - ], - 120_000 - ) - const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) - const fixture = { containerName, identityFile, port, tempDir } - dockerExec( - fixture, - [ - `mkdir -p ${shellQuote(REMOTE_REPO)}`, - `cd ${shellQuote(REMOTE_REPO)}`, - 'git init', - 'git config user.email review@test.local', - 'git config user.name "PR 10207 Review"', - "printf '%s\\n' real-docker-ssh-repository > README.md", - 'git add README.md', - 'git commit -m initial' - ].join(' && ') - ) - return fixture -} - -function stopTarget(fixture: TargetFixture | null): void { - if (!fixture) { - return - } - spawnSync('docker', ['stop', fixture.containerName], { stdio: 'ignore', timeout: 20_000 }) - spawnSync('docker', ['rm', fixture.containerName], { stdio: 'ignore', timeout: 20_000 }) - rmSync(fixture.tempDir, { recursive: true, force: true }) -} - -function createConnection(fixture: TargetFixture): SshConnection { - const host = process.env.ORCA_REVIEW_SSH_TARGET_HOST ?? '' - if (!host || host === 'localhost' || host === '::1' || host.startsWith('127.')) { - throw new Error(`Review SSH target must be non-loopback, received ${JSON.stringify(host)}`) - } - const target: SshTarget = { - id: `pr-10207-${randomUUID()}`, - label: 'PR 10207 Docker SSH target', - source: 'manual', - host, - port: fixture.port, - username: 'root', - identityFile: fixture.identityFile, - identitiesOnly: true - } - return new SshConnection(target, { onStateChange: vi.fn() }) -} - -function readInventory(fixture: TargetFixture, remoteRelayDir: string): RemoteInventory { - const stagePool = '/root/.orca-remote/.upload-stages' - const raw = dockerExec( - fixture, - [ - `lock=0; test -d ${shellQuote(`${remoteRelayDir}/.install-lock`)} && lock=1`, - `files=0; test -d ${shellQuote(remoteRelayDir)} && files=$(find ${shellQuote(remoteRelayDir)} -type f ! -path '*/.install-lock/*' | wc -l | tr -d ' ')`, - `printf 'LOCK=%s\\nFILES=%s\\n' "$lock" "$files"`, - `find ${shellQuote(stagePool)} -mindepth 1 -maxdepth 1 \\( -name 'slot-*' -o -name 'claim-*' -o -name 'delete-*' \\) -print 2>/dev/null | sort || true` - ].join('; ') - ) - const lines = raw.split(/\r?\n/) - return { - installLock: lines[0] === 'LOCK=1', - payloadFiles: Number(lines[1]?.slice('FILES='.length) ?? 0), - uploadStages: lines.slice(2).filter(Boolean) - } -} - -describe.skipIf(!RUN_REVIEW_ORACLE)('SSH relay upload cancellation recovery', () => { - let fixture: TargetFixture | null = null - - beforeAll(() => { - fixture = startTarget() - }) - - afterAll(() => { - stopTarget(fixture) - }) - - it('recovers a post-promotion install lock from a previous execution-host boot', async () => { - const activeFixture = fixture as TargetFixture - const remoteRelayDir = '/root/.orca-remote/relay-reboot-lock-oracle' - const previousBootId = 'linux:00000000-0000-0000-0000-000000000000:0' - dockerExec( - activeFixture, - [ - `rm -rf ${shellQuote(remoteRelayDir)}`, - `mkdir -p ${shellQuote(`${remoteRelayDir}/.install-lock`)} ${shellQuote(`${remoteRelayDir}/node_modules`)}`, - `printf '%s\\n' promoted > ${shellQuote(`${remoteRelayDir}/relay.js`)}`, - `printf '%s\\n' ${shellQuote(previousBootId)} > ${shellQuote(`${remoteRelayDir}/.install-lock/.boot-id`)}` - ].join(' && ') - ) - const connection = createConnection(activeFixture) - await connection.connect() - try { - const startedAt = Date.now() - await acquireInstallLock(connection, remoteRelayDir, getRemoteHostPlatform('linux-arm64')) - const elapsedMs = Date.now() - startedAt - const state = dockerExec( - activeFixture, - [ - `cat ${shellQuote(`${remoteRelayDir}/.install-lock/.boot-id`)}`, - `cat ${shellQuote(`${remoteRelayDir}/relay.js`)}`, - `find ${shellQuote(remoteRelayDir)} -maxdepth 1 -name '.install-lock.tombstone.*' -print | wc -l | tr -d ' '` - ].join('; ') - ).split(/\r?\n/u) - - expect(elapsedMs).toBeLessThan(10_000) - expect(state[0]).toMatch(/^linux:[0-9a-f-]+:[0-9]+$/u) - expect(state[0]).not.toBe(previousBootId) - expect(state[1]).toBe('promoted') - expect(state[2]).toBe('0') - } finally { - await connection.disconnect() - dockerExec(activeFixture, `rm -rf ${shellQuote(remoteRelayDir)}`) - } - }, 60_000) - - it('aborts a live SFTP upload after remote bytes arrive without creating the shared lock', async () => { - const activeFixture = fixture as TargetFixture - const localRelayDir = join(process.cwd(), 'out', 'relay', 'linux-arm64') - const relayVersion = readFileSync(join(localRelayDir, '.version'), 'utf8').trim() - const relayJsSize = statSync(join(localRelayDir, 'relay.js')).size - const remoteRelayDir = `/root/.orca-remote/relay-${relayVersion}` - const connection = createConnection(activeFixture) - await connection.connect() - const sentinelPid = dockerExec(activeFixture, 'sleep 300 /dev/null 2>&1 & echo $!') - let releaseFirstWrite: () => void = () => {} - const firstWrite = new Promise((resolve) => { - releaseFirstWrite = resolve - }) - let acknowledgedBytes = 0 - const openSftp = connection.sftp.bind(connection) - connection.sftp = vi.fn(async (signal) => { - const sftp = await openSftp(signal) - const createWriteStream = sftp.createWriteStream.bind(sftp) - sftp.createWriteStream = ((...args: Parameters) => { - const [remotePath] = args - const stream = createWriteStream(...args) - if (!remotePath.endsWith('/payload/relay.js')) { - return stream - } - const writable = stream as typeof stream & { - _write: ( - chunk: Buffer, - encoding: BufferEncoding, - callback: (error?: Error | null) => void - ) => void - } - const write = writable._write.bind(writable) - writable._write = (chunk, encoding, callback): void => { - write(chunk, encoding, (error) => { - if (error) { - callback(error) - return - } - acknowledgedBytes += chunk.length - releaseFirstWrite() - }) - } - return stream - }) as typeof sftp.createWriteStream - return sftp - }) - - const deployment = deployAndLaunchRelay(connection, undefined, 60) - let barrierTimeout: ReturnType - try { - await Promise.race([ - firstWrite, - new Promise((_resolve, reject) => { - barrierTimeout = setTimeout( - () => reject(new Error('Timed out waiting for first remote SFTP chunk')), - 30_000 - ) - }) - ]) - } finally { - clearTimeout(barrierTimeout!) - } - const partialRemoteBytes = Number( - dockerExec( - activeFixture, - "find /root/.orca-remote/.upload-stages -type f -path '*/slot-*/payload/relay.js' -printf '%s\\n'" - ) - ) - const operationController = ( - connection as unknown as { systemOperationAbortController: AbortController } - ).systemOperationAbortController - operationController.abort() - - await expect(deployment).rejects.toMatchObject({ name: 'AbortError' }) - const inventory = readInventory(activeFixture, remoteRelayDir) - const sentinelCommand = dockerExec( - activeFixture, - `tr '\\0' ' ' < /proc/${shellQuote(sentinelPid)}/cmdline` - ) - await connection.disconnect() - - console.log( - `[pr-10207-live-sftp-abort] ${JSON.stringify({ acknowledgedBytes, partialRemoteBytes, relayJsSize, inventory, sentinelPid, sentinelCommand })}` - ) - expect(acknowledgedBytes).toBeGreaterThan(0) - expect(partialRemoteBytes).toBe(acknowledgedBytes) - expect(partialRemoteBytes).toBeLessThan(relayJsSize) - expect(inventory.installLock).toBe(false) - expect(sentinelCommand).toContain('sleep 300') - }, 180_000) - - it('recovers cancellation with bounded safe reclamation and bounded real version GC', async () => { - const activeFixture = fixture as TargetFixture - const relayVersion = readFileSync( - join(process.cwd(), 'out', 'relay', 'linux-arm64', '.version'), - 'utf8' - ).trim() - const remoteRelayDir = `/root/.orca-remote/relay-${relayVersion}` - const firstConnection = createConnection(activeFixture) - const progress: string[] = [] - await firstConnection.connect() - const unconfirmedCancellation = Object.assign(new Error('injected upload cancellation'), { - sshChannelCloseConfirmed: false - }) - firstConnection.uploadDirectory = vi.fn().mockRejectedValue(unconfirmedCancellation) - - await expect( - deployAndLaunchRelay(firstConnection, (status) => progress.push(status), 60) - ).rejects.toBe(unconfirmedCancellation) - await firstConnection.disconnect() - const firstInventory = readInventory(activeFixture, remoteRelayDir) - const expected = process.env.ORCA_REVIEW_EXPECT_RECOVERY === '1' ? 'recovered' : 'blocked' - if (expected === 'recovered') { - const secondAbandonedConnection = createConnection(activeFixture) - await secondAbandonedConnection.connect() - secondAbandonedConnection.uploadDirectory = vi.fn().mockRejectedValue(unconfirmedCancellation) - await expect(deployAndLaunchRelay(secondAbandonedConnection, undefined, 60)).rejects.toBe( - unconfirmedCancellation - ) - await secondAbandonedConnection.disconnect() - } - - const retryConnection = createConnection(activeFixture) - await retryConnection.connect() - let retryResult: 'blocked' | 'recovered' - let finalInventory: RemoteInventory | undefined - let scaleEvidence: - | { gcDurationMs: number; listingBytes: number; scaleEntries: number } - | undefined - if (firstInventory.installLock) { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), 1_500) - await expect( - acquireInstallLock(retryConnection, remoteRelayDir, getRemoteHostPlatform('linux-arm64'), { - signal: controller.signal - }) - ).rejects.toMatchObject({ name: 'AbortError' }) - clearTimeout(timer) - retryResult = 'blocked' - } else { - const deployed = await deployAndLaunchRelay(retryConnection, undefined, 60) - const mux = new SshChannelMultiplexer(deployed.transport) - await expect(mux.request('session.resolveHome', { path: '~' })).resolves.toEqual({ - resolvedPath: '/root' - }) - mux.dispose() - retryResult = 'recovered' - } - const repoHead = await execCommand( - retryConnection, - `cd ${shellQuote(REMOTE_REPO)} && git rev-parse --verify HEAD` - ) - await retryConnection.disconnect() - - if (expected === 'recovered') { - const replacedStage = firstInventory.uploadStages[0]! - const originalStage = `${replacedStage}.original` - const foreignTarget = '/root/orca-pr10207-foreign-stage-target' - const stagePool = '/root/.orca-remote/.upload-stages' - const symlinkStage = `${stagePool}/slot-2` - const ownerMarker = '.orca-upload-owner' - const identityMarker = '.orca-upload-identity' - dockerExec( - activeFixture, - [ - `mv ${shellQuote(replacedStage)} ${shellQuote(originalStage)}`, - `mkdir -p ${shellQuote(`${replacedStage}/payload`)} ${shellQuote(foreignTarget)}`, - `cp ${shellQuote(`${originalStage}/${ownerMarker}`)} ${shellQuote(`${replacedStage}/${ownerMarker}`)}`, - `cp ${shellQuote(`${originalStage}/${identityMarker}`)} ${shellQuote(`${replacedStage}/${identityMarker}`)}`, - `touch -d '3 hours ago' ${shellQuote(`${replacedStage}/${ownerMarker}`)} ${shellQuote(`${originalStage}/${ownerMarker}`)}`, - `printf foreign > ${shellQuote(`${replacedStage}/payload/foreign`)}`, - `printf alive > ${shellQuote(`${foreignTarget}/sentinel`)}`, - `ln -s ${shellQuote(foreignTarget)} ${shellQuote(symlinkStage)}`, - `i=0; while [ "$i" -lt 15197 ]; do mkdir ${shellQuote(`/root/.orca-remote/relay-${relayVersion}.upload-scale-`)}"$i"; i=$((i + 1)); done` - ].join(' && ') - ) - const adversarialInventory = readInventory(activeFixture, remoteRelayDir) - - const reclaimableStage = `${stagePool}/slot-1` - dockerExec( - activeFixture, - `touch -d '3 hours ago' ${shellQuote(`${reclaimableStage}/${ownerMarker}`)}` - ) - - const cleanupConnection = createConnection(activeFixture) - await cleanupConnection.connect() - const cleanedDeployment = await deployAndLaunchRelay(cleanupConnection, undefined, 60) - const cleanupMux = new SshChannelMultiplexer(cleanedDeployment.transport) - await expect(cleanupMux.request('session.resolveHome', { path: '~' })).resolves.toEqual({ - resolvedPath: '/root' - }) - const reclaimDeadline = Date.now() + 10_000 - while ( - dockerExec( - activeFixture, - `test -e ${shellQuote(reclaimableStage)} && echo PRESENT || true` - ) && - Date.now() < reclaimDeadline - ) { - await new Promise((resolve) => setTimeout(resolve, 50)) - } - expect( - dockerExec(activeFixture, `test ! -e ${shellQuote(reclaimableStage)} && echo RECLAIMED`) - ).toBe('RECLAIMED') - - const listing = await execCommand( - cleanupConnection, - listRelayBaseDirsCommand(getRemoteHostPlatform('linux-arm64'), '/root/.orca-remote') - ) - const gcStartedAt = Date.now() - await gcOldRelayVersions( - cleanupConnection, - '/root', - remoteRelayDir, - getRemoteHostPlatform('linux-arm64') - ) - const gcDurationMs = Date.now() - gcStartedAt - finalInventory = readInventory(activeFixture, remoteRelayDir) - expect( - dockerExec( - activeFixture, - `test -L ${shellQuote(symlinkStage)} && test -d ${shellQuote(foreignTarget)} && test "$(cat ${shellQuote(`${foreignTarget}/sentinel`)})" = alive && echo PRESERVED` - ).trim() - ).toBe('PRESERVED') - expect( - dockerExec( - activeFixture, - `test -d ${shellQuote(replacedStage)} && test -d ${shellQuote(originalStage)} && echo PRESERVED` - ).trim() - ).toBe('PRESERVED') - expect(finalInventory.uploadStages.sort()).toEqual( - adversarialInventory.uploadStages.filter((stage) => stage !== reclaimableStage).sort() - ) - const listingBytes = Buffer.byteLength(listing) - expect(listingBytes).toBeLessThan(1_024) - expect(gcDurationMs).toBeLessThan(10_000) - const scaleEntries = Number( - dockerExec( - activeFixture, - `find /root/.orca-remote -mindepth 1 -maxdepth 1 -type d -name ${shellQuote(`relay-${relayVersion}.upload-scale-*`)} | wc -l` - ) - ) - expect(scaleEntries).toBe(15_197) - scaleEvidence = { gcDurationMs, listingBytes, scaleEntries } - cleanupMux.dispose() - await cleanupConnection.disconnect() - } - console.log( - `[pr-10207-oracle] ${JSON.stringify({ progress, firstInventory, retryResult, finalInventory, scaleEvidence, repoHead: repoHead.trim() })}` - ) - expect(progress).toContain('Uploading relay...') - expect(repoHead.trim()).toMatch(/^[0-9a-f]{40}$/) - expect(retryResult).toBe(expected) - if (expected === 'recovered') { - expect(firstInventory.installLock).toBe(false) - expect(firstInventory.uploadStages.length).toBeGreaterThan(0) - expect(finalInventory?.installLock).toBe(false) - expect(finalInventory!.uploadStages).toEqual([ - '/root/.orca-remote/.upload-stages/slot-0', - '/root/.orca-remote/.upload-stages/slot-0.original', - '/root/.orca-remote/.upload-stages/slot-2' - ]) - } else { - expect(firstInventory.installLock).toBe(true) - expect(firstInventory.payloadFiles).toBe(0) - } - }, 180_000) -}) diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts deleted file mode 100644 index 0b5d0a741493..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - renameSync, - rmSync, - symlinkSync, - utimesSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - promoteOwnedRelayUploadStageCommand, - recoverOneStaleRelayUploadStageCommand, - relayUploadStagePromotionConfirmed, - reserveRelayUploadStageCommand, - RELAY_UPLOAD_STAGE_SLOT_COUNT -} from './ssh-relay-upload-stage-commands' - -const posix = getRemoteHostPlatform('linux-x64') -const windows = getRemoteHostPlatform('win32-x64') -const owner = '.sftp-namespace-123e4567e89b12d3a456426614174000' -const roots: string[] = [] -const configuredPowerShell = process.env.ORCA_POWERSHELL_EXECUTABLE -const powerShellExecutable = [ - configuredPowerShell, - ...(process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh']) -].find( - (candidate) => - candidate && - spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { - stdio: 'ignore' - }).status === 0 -) - -function decodePowerShellCommand(command: string): string { - const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/u)?.[1] ?? '' - return Buffer.from(encoded, 'base64').toString('utf16le') -} - -function createPool(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-upload-stage-pool-')) - roots.push(root) - const pool = join(root, 'pool') - mkdirSync(pool) - return pool -} - -function runCommand(host: RemoteHostPlatform, command: string, prefix = '') { - if (host.commandDialect === 'powershell') { - if (!powerShellExecutable) { - throw new Error('PowerShell is unavailable') - } - return spawnSync( - powerShellExecutable, - [ - '-NoProfile', - '-NonInteractive', - '-Command', - `${prefix}\n${decodePowerShellCommand(command)}` - ], - { encoding: 'utf8' } - ) - } - return spawnSync('/bin/sh', ['-c', `${prefix}\n${command}`], { encoding: 'utf8' }) -} - -function createStage(host: RemoteHostPlatform, pool: string, index: number): string { - const reservation = runCommand(host, reserveRelayUploadStageCommand(host, pool, owner)) - expect(reservation.status, reservation.stderr).toBe(0) - return populateReservedStage(pool, index) -} - -function populateReservedStage( - pool: string, - index: number, - replacementOwner?: string, - stale = false, - state: 'slot' | 'claim' | 'delete' = 'slot' -): string { - const slot = join(pool, `slot-${index}`) - const stage = join(pool, `${state}-${index}`) - if (stage !== slot) { - expect(existsSync(stage)).toBe(false) - renameSync(slot, stage) - } - const marker = join(stage, '.orca-upload-owner') - if (replacementOwner !== undefined) { - writeFileSync(marker, replacementOwner) - } - writeFileSync(join(stage, 'payload', 'relay.js'), `relay-${index}`) - if (stale) { - const old = new Date(Date.now() - 60 * 60_000) - utimesSync(marker, old, old) - } - return stage -} - -function createStages(host: RemoteHostPlatform, pool: string, count: number): void { - if (host.commandDialect !== 'powershell' || count < 2) { - for (let index = 0; index < count; index += 1) { - createStage(host, pool, index) - } - return - } - const command = reserveRelayUploadStageCommand(host, pool, owner) - // Only fixture setup shares a process; each unmodified script gets a fresh local scope. - const batch = powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - `$fixtureScript = ${powerShellLiteral(decodePowerShellCommand(command))}`, - '$reservation = [PowerShell]::Create()', - 'try {', - `foreach ($fixtureIndex in 1..${count}) {`, - '$reservation.Commands.Clear()', - '$reservation.Streams.Error.Clear()', - '$null = $reservation.AddScript($fixtureScript, $true)', - '$reservation.Invoke()', - 'if ($reservation.InvocationStateInfo.State -ne "Completed") { throw $reservation.InvocationStateInfo.Reason }', - '}', - '} finally { $reservation.Dispose() }' - ].join('\n') - ) - const result = runCommand(host, batch) - expect(result.status, result.stderr).toBe(0) - const reservations = result.stdout.trim().split(/\r?\n/u) - expect(reservations).toHaveLength(count) - for (const [index, output] of reservations.entries()) { - expect(parseReservedRelayUploadStage(host, pool, owner, output).slotName).toBe(`slot-${index}`) - populateReservedStage(pool, index) - } -} - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -// Allow for real interpreter startup and file-identity probes under full-suite contention. -const SPAWNED_INTERPRETER_TIMEOUT_MS = 240_000 - -describe.each([ - ['POSIX', posix] as const, - ...((powerShellExecutable ? [['PowerShell', windows] as const] : []) as (readonly [ - string, - RemoteHostPlatform - ])[]) -])( - '%s relay upload stage commands', - { timeout: SPAWNED_INTERPRETER_TIMEOUT_MS }, - (_label, host) => { - it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => { - const pool = createPool() - createStages(host, pool, Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT)) - if (count > RELAY_UPLOAD_STAGE_SLOT_COUNT) { - mkdirSync(join(pool, 'foreign-extra')) - } - - const result = runCommand(host, reserveRelayUploadStageCommand(host, pool, owner)) - if (count < RELAY_UPLOAD_STAGE_SLOT_COUNT) { - expect(result.status, result.stderr).toBe(0) - expect(parseReservedRelayUploadStage(host, pool, owner, result.stdout).slotName).toBe( - `slot-${count}` - ) - } else { - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('staging quota is full') - } - }) - - it('promotes only the post-claim owned payload and removes its fixed stage', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(host, pool, 0) - const stage = parseReservedRelayUploadStage( - host, - pool, - owner, - `noise\n__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0\n` - ) - - const result = runCommand( - host, - promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(true) - expect(readFileSync(join(destination, 'relay.js'), 'utf8')).toBe('relay-0') - expect(existsSync(join(pool, 'slot-0'))).toBe(false) - expect(existsSync(join(pool, 'claim-0'))).toBe(false) - expect(existsSync(join(pool, 'delete-0'))).toBe(false) - }) - - // Why: symlink creation in the fixture needs privileges Windows CI doesn't grant. - it.skipIf(process.platform === 'win32')( - 'rejects a payload reparse point without copying or deleting it', - () => { - const pool = createPool() - const destination = join(pool, 'destination') - const foreign = join(pool, 'foreign.js') - mkdirSync(destination) - const stagePath = createStage(host, pool, 0) - rmSync(join(stagePath, 'payload', 'relay.js')) - writeFileSync(foreign, 'foreign') - symlinkSync(foreign, join(stagePath, 'payload', 'relay.js')) - const stage = parseReservedRelayUploadStage( - host, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - - const result = runCommand( - host, - promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - expect(lstatSync(join(pool, 'slot-0', 'payload', 'relay.js')).isSymbolicLink()).toBe(true) - expect(readFileSync(foreign, 'utf8')).toBe('foreign') - } - ) - - it('reclaims one stale owned stage but preserves fresh and foreign stages', () => { - const pool = createPool() - createStages(host, pool, 3) - populateReservedStage(pool, 1, '.foreign-owner', true) - populateReservedStage(pool, 2, undefined, true) - - const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) - - expect(result.status, result.stderr).toBe(0) - expect(existsSync(join(pool, 'slot-0'))).toBe(true) - expect(existsSync(join(pool, 'slot-1'))).toBe(true) - expect(existsSync(join(pool, 'slot-2'))).toBe(false) - }) - - it('drains fixed stale claim and delete states across repeated deployments', () => { - const pool = createPool() - createStages(host, pool, 2) - populateReservedStage(pool, 0, undefined, true, 'claim') - populateReservedStage(pool, 1, undefined, true, 'delete') - - for (let attempt = 0; attempt < 2; attempt += 1) { - const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) - expect(result.status, result.stderr).toBe(0) - } - - expect(existsSync(join(pool, 'claim-0'))).toBe(false) - expect(existsSync(join(pool, 'delete-1'))).toBe(false) - }) - } -) - -describe('POSIX ownership race fencing', () => { - it('restores a replacement directory and preserves the original moved aside before claim', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(posix, pool, 0) - const stage = parseReservedRelayUploadStage( - posix, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - const prefix = [ - 'raced=0', - 'mv() {', - 'if [ "$raced" -eq 0 ]; then', - 'raced=1; command mv "$1" "$1.original"; mkdir "$1"; mkdir "$1/payload"; cp "$1.original/.orca-upload-owner" "$1/.orca-upload-owner"; cp "$1.original/.orca-upload-identity" "$1/.orca-upload-identity"; touch -t 202001010000 "$1/.orca-upload-owner"; printf foreign > "$1/foreign";', - 'fi;', - 'command mv "$@";', - '}' - ].join('\n') - - const result = runCommand( - posix, - promoteOwnedRelayUploadStageCommand(posix, stage, owner, destination), - prefix - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) - expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - }) - - it('restores a replacement symlink without touching its target', () => { - const pool = createPool() - const destination = join(pool, 'destination') - const foreign = join(pool, 'foreign') - mkdirSync(destination) - mkdirSync(foreign) - writeFileSync(join(foreign, 'sentinel'), 'alive') - createStage(posix, pool, 0) - const stage = parseReservedRelayUploadStage( - posix, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - const prefix = [ - 'raced=0', - 'mv() {', - 'if [ "$raced" -eq 0 ]; then raced=1; command mv "$1" "$1.original"; ln -s ' + - `'${foreign}' "$1"; fi;`, - 'command mv "$@";', - '}' - ].join('\n') - - const result = runCommand( - posix, - cleanupOwnedRelayUploadStageCommand(posix, stage, owner), - prefix - ) - - expect(result.status, result.stderr).toBe(0) - expect(lstatSync(join(pool, 'slot-0')).isSymbolicLink()).toBe(true) - expect(readFileSync(join(foreign, 'sentinel'), 'utf8')).toBe('alive') - expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) - }) -}) - -describe.runIf(powerShellExecutable)( - 'PowerShell ownership race fencing', - { timeout: SPAWNED_INTERPRETER_TIMEOUT_MS }, - () => { - it('restores an old same-owner replacement whose persisted file ID does not match', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(windows, pool, 0) - const stage = parseReservedRelayUploadStage( - windows, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - const prefix = [ - '$script:raced = $false', - 'function Move-Item {', - 'param($LiteralPath, $Destination, $ErrorAction)', - 'if (-not $script:raced) {', - '$script:raced = $true', - 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination ($LiteralPath + ".original") -ErrorAction Stop', - '$null = New-Item -ItemType Directory -Path $LiteralPath', - '$null = New-Item -ItemType Directory -Path (Join-Path $LiteralPath "payload")', - '$newPath = (Get-Item -LiteralPath $LiteralPath).FullName', - '$originalPath = (Get-Item -LiteralPath ($LiteralPath + ".original")).FullName', - '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-owner"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-owner")))', - '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-identity"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-identity")))', - '(Get-Item -LiteralPath (Join-Path $newPath ".orca-upload-owner") -Force).LastWriteTimeUtc = [DateTime]::UtcNow.AddHours(-2)', - '[System.IO.File]::WriteAllText((Join-Path $newPath "foreign"), "foreign")', - '}', - 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination $Destination -ErrorAction $ErrorAction', - '}' - ].join('\n') - - const result = runCommand( - windows, - promoteOwnedRelayUploadStageCommand(windows, stage, owner, destination), - prefix - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) - expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - }) - } -) diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.ts b/src/main/ssh/ssh-relay-upload-stage-commands.ts deleted file mode 100644 index e363a574a574..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-commands.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { shellEscape } from './ssh-connection-utils' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - RELAY_UPLOAD_IDENTITY_FILE_NAME as IDENTITY_FILE_NAME, - RELAY_UPLOAD_OWNER_FILE_NAME as OWNER_FILE_NAME, - RELAY_UPLOAD_PROMOTION_RESULT_PREFIX as PROMOTION_RESULT_PREFIX, - RELAY_UPLOAD_SLOT_RESULT_PREFIX as SLOT_RESULT_PREFIX, - RELAY_UPLOAD_STAGE_POOL_NAME, - RELAY_UPLOAD_STAGE_SLOT_COUNT, - RELAY_UPLOAD_STAGE_STALE_SECONDS, - type RelayUploadStageSlot -} from './ssh-relay-upload-stage-contract' -import { - cleanupWindowsRelayUploadStageCommand, - promoteWindowsRelayUploadStageCommand, - recoverWindowsRelayUploadStageCommand, - reserveWindowsRelayUploadStageCommand, - type WindowsUploadStageIdentity -} from './ssh-relay-upload-stage-windows-commands' - -export type { WindowsUploadStageIdentity } from './ssh-relay-upload-stage-windows-commands' -export { - RELAY_UPLOAD_STAGE_POOL_NAME, - RELAY_UPLOAD_STAGE_SLOT_COUNT, - RELAY_UPLOAD_STAGE_STALE_SECONDS, - type RelayUploadStageSlot -} from './ssh-relay-upload-stage-contract' - -const OWNER_PATTERN = /^\.sftp-namespace-[0-9a-f]{32}$/u - -function assertOwner(owner: string): void { - if (!OWNER_PATTERN.test(owner)) { - throw new Error('Invalid relay upload stage owner') - } -} - -function slotPaths( - host: RemoteHostPlatform, - poolDir: string, - slotName: string -): RelayUploadStageSlot { - if (!/^slot-[0-7]$/u.test(slotName)) { - throw new Error('Invalid relay upload stage slot') - } - return { - poolDir, - slotName, - slotDir: joinRemotePath(host, poolDir, slotName), - claimDir: joinRemotePath(host, poolDir, `claim-${slotName.slice(5)}`), - deleteDir: joinRemotePath(host, poolDir, `delete-${slotName.slice(5)}`) - } -} - -export function reserveRelayUploadStageCommand( - host: RemoteHostPlatform, - poolDir: string, - owner: string, - identity?: WindowsUploadStageIdentity -): string { - assertOwner(owner) - return isWindowsRemoteHost(host) - ? reserveWindowsRelayUploadStageCommand(poolDir, owner, identity) - : reservePosixStageCommand(poolDir, owner) -} - -export function parseReservedRelayUploadStage( - host: RemoteHostPlatform, - poolDir: string, - owner: string, - output: string -): RelayUploadStageSlot { - assertOwner(owner) - const authenticatedPrefix = `${SLOT_RESULT_PREFIX}${owner}:` - const line = output - .split(/\r?\n/u) - .map((entry) => entry.trim()) - .find((entry) => entry.startsWith(authenticatedPrefix)) - if (!line) { - throw new Error('Remote relay upload stage reservation returned no authenticated slot') - } - return slotPaths(host, poolDir, line.slice(authenticatedPrefix.length)) -} - -export function promoteOwnedRelayUploadStageCommand( - host: RemoteHostPlatform, - stage: RelayUploadStageSlot, - owner: string, - destinationDir: string, - identity?: WindowsUploadStageIdentity -): string { - assertOwner(owner) - return isWindowsRemoteHost(host) - ? promoteWindowsRelayUploadStageCommand(stage, owner, destinationDir, identity) - : promotePosixStageCommand(stage, owner, destinationDir) -} - -export function relayUploadStagePromotionConfirmed(owner: string, output: string): boolean { - assertOwner(owner) - return output - .split(/\r?\n/u) - .some((line) => line.trim() === `${PROMOTION_RESULT_PREFIX}${owner}:PROMOTED`) -} - -export function cleanupOwnedRelayUploadStageCommand( - host: RemoteHostPlatform, - stage: RelayUploadStageSlot, - owner: string, - identity?: WindowsUploadStageIdentity -): string { - assertOwner(owner) - return isWindowsRemoteHost(host) - ? cleanupWindowsRelayUploadStageCommand(stage, owner, identity) - : cleanupPosixStageCommand(stage, owner) -} - -export function recoverOneStaleRelayUploadStageCommand( - host: RemoteHostPlatform, - poolDir: string, - staleSeconds = RELAY_UPLOAD_STAGE_STALE_SECONDS, - identity?: WindowsUploadStageIdentity -): string { - const cutoffSeconds = Math.max(1, Math.ceil(staleSeconds)) - return isWindowsRemoteHost(host) - ? recoverWindowsRelayUploadStageCommand(poolDir, cutoffSeconds, identity) - : recoverPosixStageCommand(poolDir, Math.ceil(cutoffSeconds / 60)) -} - -function reservePosixStageCommand(poolDir: string, owner: string): string { - const pool = shellEscape(poolDir) - const slots = Array.from({ length: RELAY_UPLOAD_STAGE_SLOT_COUNT }, (_, index) => index).join(' ') - return [ - 'umask 077;', - `pool=${pool};`, - 'mkdir -p "$pool" || exit 1;', - '[ -d "$pool" ] && [ ! -L "$pool" ] || exit 1;', - `for n in ${slots}; do`, - 'slot="$pool/slot-$n"; claim="$pool/claim-$n"; deleting="$pool/delete-$n";', - 'if [ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] && mkdir "$slot" 2>/dev/null; then', - `identity=$(ls -id "$slot" 2>/dev/null | awk '{print $1}') || identity=;`, - `if [ -n "$identity" ] && mkdir "$slot/payload" && printf '%s' ${shellEscape(owner)} > "$slot/${OWNER_FILE_NAME}" && printf '%s' "$identity" > "$slot/${IDENTITY_FILE_NAME}" && : > "$slot/${owner}"; then`, - `printf '%s%s:%s\\n' ${shellEscape(SLOT_RESULT_PREFIX)} ${shellEscape(owner)} "slot-$n"; exit 0;`, - 'fi;', - 'fi;', - 'done;', - `printf '%s\\n' 'Orca relay upload staging quota is full; reconnect after 40 minutes or inspect .orca-remote/${RELAY_UPLOAD_STAGE_POOL_NAME}' >&2;`, - 'exit 75' - ].join(' ') -} - -function posixClaimPrelude(stage: RelayUploadStageSlot): string { - return [ - `pool=${shellEscape(stage.poolDir)}; slot=${shellEscape(stage.slotDir)}; claim=${shellEscape(stage.claimDir)}; deleting=${shellEscape(stage.deleteDir)};`, - '[ -d "$pool" ] && [ ! -L "$pool" ] || exit 0;', - '[ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] || exit 0;', - 'mv "$slot" "$claim" 2>/dev/null || exit 0;' - ].join(' ') -} - -function posixRestoreClaim(): string { - return 'if [ ! -e "$slot" ] && [ ! -L "$slot" ]; then mv "$claim" "$slot" 2>/dev/null || true; fi;' -} - -function posixOwnedDirectoryCheck( - pathVariable: string, - owner: string, - requirePayload: boolean -): string { - const path = `$${pathVariable}` - const checks = [ - `owner_file="${path}/${OWNER_FILE_NAME}";`, - `identity_file="${path}/${IDENTITY_FILE_NAME}";`, - `actual=$(cat "$owner_file" 2>/dev/null) || actual=;`, - `expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=;`, - `identity=$(ls -id "${path}" 2>/dev/null | awk '{print $1}') || identity=;`, - `[ -n "$identity" ] && [ "$identity" = "$expected_identity" ] && [ -d "${path}" ] && [ ! -L "${path}" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && [ "$actual" = ${shellEscape(owner)} ]` - ] - if (requirePayload) { - checks.push( - `&& payload="${path}/payload" && [ -d "$payload" ] && [ ! -L "$payload" ]`, - `&& payload_identity=$(ls -id "$payload" 2>/dev/null | awk '{print $1}') && [ -n "$payload_identity" ]`, - '&& links=$(find "$payload" -type l -print -quit 2>/dev/null) && [ -z "$links" ]' - ) - } - return checks.join(' ') -} - -function posixMoveOwnedClaimToDelete(owner: string): string { - return [ - '[ ! -e "$deleting" ] && [ ! -L "$deleting" ] || exit 0;', - 'mv "$claim" "$deleting" 2>/dev/null || exit 0;', - `if ${posixOwnedDirectoryCheck('deleting', owner, true)} && [ "$identity" = "$claim_identity" ]; then`, - 'rm -rf "$deleting";', - 'else', - 'if [ ! -e "$claim" ] && [ ! -L "$claim" ]; then mv "$deleting" "$claim" 2>/dev/null || true; fi;', - 'fi;' - ].join(' ') -} - -function promotePosixStageCommand( - stage: RelayUploadStageSlot, - owner: string, - destinationDir: string -): string { - const expected = shellEscape(owner) - return [ - posixClaimPrelude(stage), - `if ! { ${posixOwnedDirectoryCheck('claim', owner, true)}; }; then`, - posixRestoreClaim(), - `printf '%s%s:OWNERSHIP_LOST\\n' ${shellEscape(PROMOTION_RESULT_PREFIX)} ${expected}; exit 0;`, - 'fi;', - 'claim_identity="$identity";', - `if (cd "$payload" && [ "$(ls -id . 2>/dev/null | awk '{print $1}')" = "$payload_identity" ] && cp -a . ${shellEscape(destinationDir)}/); then`, - posixMoveOwnedClaimToDelete(owner), - `printf '%s%s:PROMOTED\\n' ${shellEscape(PROMOTION_RESULT_PREFIX)} ${expected}; exit 0;`, - 'fi;', - 'exit 1' - ].join(' ') -} - -function cleanupPosixStageCommand(stage: RelayUploadStageSlot, owner: string): string { - return [ - posixClaimPrelude(stage), - `if ${posixOwnedDirectoryCheck('claim', owner, true)}; then`, - 'claim_identity="$identity";', - posixMoveOwnedClaimToDelete(owner), - 'else', - posixRestoreClaim(), - 'fi;', - 'exit 0' - ].join(' ') -} - -function recoverPosixStageCommand(poolDir: string, staleMinutes: number): string { - const pool = shellEscape(poolDir) - const slots = Array.from({ length: RELAY_UPLOAD_STAGE_SLOT_COUNT }, (_, index) => index).join(' ') - return [ - `pool=${pool}; [ -d "$pool" ] && [ ! -L "$pool" ] || exit 0;`, - `for n in ${slots}; do`, - 'slot="$pool/slot-$n"; claim="$pool/claim-$n"; deleting="$pool/delete-$n";', - `if [ -d "$deleting" ] && [ ! -L "$deleting" ]; then owner_file="$deleting/${OWNER_FILE_NAME}"; identity_file="$deleting/${IDENTITY_FILE_NAME}"; deleting_actual=$(cat "$owner_file" 2>/dev/null) || deleting_actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; deleting_identity=$(ls -id "$deleting" 2>/dev/null | awk '{print $1}') || deleting_identity=; deleting_old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || deleting_old=; if [ -n "$deleting_identity" ] && [ "$deleting_identity" = "$expected_identity" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$deleting_actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$deleting_old" ] && links=$(find "$deleting" -type l -print -quit 2>/dev/null) && [ -z "$links" ]; then rm -rf "$deleting"; exit 0; fi; fi;`, - 'if [ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] && [ -d "$slot" ] && [ ! -L "$slot" ] &&', - `old=$(find "$slot/${OWNER_FILE_NAME}" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) && [ -n "$old" ]; then`, - 'mv "$slot" "$claim" 2>/dev/null || continue;', - 'fi;', - `owner_file="$claim/${OWNER_FILE_NAME}"; identity_file="$claim/${IDENTITY_FILE_NAME}"; actual=$(cat "$owner_file" 2>/dev/null) || actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; identity=$(ls -id "$claim" 2>/dev/null | awk '{print $1}') || identity=; old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || old=;`, - `if [ -n "$identity" ] && [ "$identity" = "$expected_identity" ] && [ -d "$claim" ] && [ ! -L "$claim" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$old" ]; then`, - 'claim_identity="$identity";', - '[ ! -e "$deleting" ] && [ ! -L "$deleting" ] && mv "$claim" "$deleting" 2>/dev/null || continue;', - `owner_file="$deleting/${OWNER_FILE_NAME}"; identity_file="$deleting/${IDENTITY_FILE_NAME}"; deleting_actual=$(cat "$owner_file" 2>/dev/null) || deleting_actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; deleting_identity=$(ls -id "$deleting" 2>/dev/null | awk '{print $1}') || deleting_identity=; deleting_old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || deleting_old=;`, - `if [ -n "$deleting_identity" ] && [ "$deleting_identity" = "$claim_identity" ] && [ "$deleting_identity" = "$expected_identity" ] && [ -d "$deleting" ] && [ ! -L "$deleting" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$deleting_actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$deleting_old" ] && links=$(find "$deleting" -type l -print -quit 2>/dev/null) && [ -z "$links" ]; then`, - 'rm -rf "$deleting"; exit 0;', - 'fi;', - 'if [ ! -e "$claim" ] && [ ! -L "$claim" ]; then mv "$deleting" "$claim" 2>/dev/null || true; fi;', - 'continue;', - 'fi;', - posixRestoreClaim(), - 'done;', - 'exit 0' - ].join(' ') -} diff --git a/src/main/ssh/ssh-relay-upload-stage-contract.ts b/src/main/ssh/ssh-relay-upload-stage-contract.ts deleted file mode 100644 index 2fcc20c1381b..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-contract.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const RELAY_UPLOAD_STAGE_POOL_NAME = '.upload-stages' -export const RELAY_UPLOAD_STAGE_SLOT_COUNT = 8 -export const RELAY_UPLOAD_STAGE_STALE_SECONDS = 40 * 60 - -export const RELAY_UPLOAD_OWNER_FILE_NAME = '.orca-upload-owner' -export const RELAY_UPLOAD_IDENTITY_FILE_NAME = '.orca-upload-identity' -export const RELAY_UPLOAD_SLOT_RESULT_PREFIX = '__ORCA_UPLOAD_STAGE_SLOT__' -export const RELAY_UPLOAD_PROMOTION_RESULT_PREFIX = '__ORCA_UPLOAD_STAGE_PROMOTION__' - -export type RelayUploadStageSlot = { - claimDir: string - deleteDir: string - poolDir: string - slotDir: string - slotName: string -} diff --git a/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts b/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts deleted file mode 100644 index 811b29de5f4f..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' -import { - RELAY_UPLOAD_IDENTITY_FILE_NAME, - RELAY_UPLOAD_OWNER_FILE_NAME, - RELAY_UPLOAD_PROMOTION_RESULT_PREFIX, - RELAY_UPLOAD_SLOT_RESULT_PREFIX, - RELAY_UPLOAD_STAGE_POOL_NAME, - RELAY_UPLOAD_STAGE_SLOT_COUNT, - type RelayUploadStageSlot -} from './ssh-relay-upload-stage-contract' - -export function reserveWindowsRelayUploadStageCommand( - poolDir: string, - owner: string, - identity: WindowsUploadStageIdentity = {} -): string { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(identity), - `$pool = ${powerShellLiteral(poolDir)}`, - `$owner = ${powerShellLiteral(owner)}`, - '$null = New-Item -ItemType Directory -Force -Path $pool', - '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction Stop', - 'if (-not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw "Unsafe relay upload stage pool" }', - `foreach ($n in 0..${RELAY_UPLOAD_STAGE_SLOT_COUNT - 1}) {`, - '$slot = Join-Path $pool ("slot-" + $n)', - '$claim = Join-Path $pool ("claim-" + $n)', - '$deleting = Join-Path $pool ("delete-" + $n)', - '$slotExisting = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', - '$claimExisting = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', - '$deleteExisting = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', - 'if (($null -ne $claimExisting) -or ($null -ne $slotExisting) -or ($null -ne $deleteExisting)) { continue }', - 'try {', - '$null = New-Item -ItemType Directory -Path $slot -ErrorAction Stop', - '$null = New-Item -ItemType Directory -Path (Join-Path $slot "payload") -ErrorAction Stop', - '$identity = & $getFileIdentity $slot', - `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}), $owner, [System.Text.UTF8Encoding]::new($false))`, - `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}), $identity, [System.Text.UTF8Encoding]::new($false))`, - `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(owner)}), '', [System.Text.UTF8Encoding]::new($false))`, - `Write-Output (${powerShellLiteral(RELAY_UPLOAD_SLOT_RESULT_PREFIX)} + $owner + ':slot-' + $n)`, - 'exit 0', - '} catch { continue }', - '}', - `throw ${powerShellLiteral(`Orca relay upload staging quota is full; reconnect after 40 minutes or inspect .orca-remote/${RELAY_UPLOAD_STAGE_POOL_NAME}`)}` - ].join('\n') - ) -} - -/** Where a Windows stage command reads file identities; `node` is a verified pinned node.exe. */ -export type WindowsUploadStageIdentity = { node?: string } - -/** - * `vol:indexHigh:indexLow` in lowercase hex, the format the legacy Add-Type helper persisted. - * libuv fills `dev`/`ino` from the same volume serial and file index, so either reader accepts - * the other's identity files. Single quotes only: PS 5.1 drops embedded double quotes from argv. - */ -export const WINDOWS_UPLOAD_STAGE_IDENTITY_JS = - "const s=require('fs').lstatSync(process.argv[1],{bigint:true});const m=0xffffffffn;" + - "process.stdout.write((s.dev&m).toString(16)+':'+(s.ino>>32n).toString(16)+':'+(s.ino&m).toString(16))" - -function windowsFileIdentityScript(identity: WindowsUploadStageIdentity = {}): string[] { - // Why normalize: a leading zero or upper-case digit from either reader is not a different file. - const normalize = - "function ConvertTo-OrcaFileIdentity([string]$value) { (($value.Trim().ToLowerInvariant() -split ':') | ForEach-Object { $digits = $_.TrimStart('0'); if ($digits -eq '') { '0' } else { $digits } }) -join ':' }" - if (identity.node) { - // Why node.exe and not Add-Type: runtime-compiled P/Invoke is an EDR signal (design D5). - return [ - normalize, - `$orcaIdentityNode = ${powerShellLiteral(identity.node)}`, - `$getFileIdentity = { param($path) $value = & $orcaIdentityNode -e ${powerShellNativeArg(WINDOWS_UPLOAD_STAGE_IDENTITY_JS)} -- $path; if ($LASTEXITCODE -ne 0) { throw 'Orca could not read a relay upload stage file identity' }; ConvertTo-OrcaFileIdentity ([string]$value) }` - ] - } - // Legacy: host-Node relays have no verified node.exe to run; see windows-edr-posture.md. - return [ - normalize, - "if ($env:OS -eq 'Windows_NT') {", - "if ($null -eq ('OrcaRelayUploadFileIdentity' -as [type])) {", - "Add-Type -TypeDefinition @'", - 'using System;', - 'using System.ComponentModel;', - 'using System.Runtime.InteropServices;', - 'using Microsoft.Win32.SafeHandles;', - 'public static class OrcaRelayUploadFileIdentity {', - '[StructLayout(LayoutKind.Sequential)] struct Info { public uint Attr; public System.Runtime.InteropServices.ComTypes.FILETIME C; public System.Runtime.InteropServices.ComTypes.FILETIME A; public System.Runtime.InteropServices.ComTypes.FILETIME W; public uint Vol; public uint SizeH; public uint SizeL; public uint Links; public uint IndexH; public uint IndexL; }', - '[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr sec, uint creation, uint flags, IntPtr template);', - '[DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle handle, out Info info);', - 'public static string Read(string path) {', - 'using (var handle = CreateFileW(path, 0, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) {', - 'if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());', - 'Info info; if (!GetFileInformationByHandle(handle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error());', - 'return info.Vol.ToString("x") + ":" + info.IndexH.ToString("x") + ":" + info.IndexL.ToString("x");', - '}', - '}', - '}', - "'@", - '}', - '$getFileIdentity = { param($path) ConvertTo-OrcaFileIdentity ([OrcaRelayUploadFileIdentity]::Read($path)) }', - '} else {', - '$getFileIdentity = { param($path) $resolved = (Get-Item -LiteralPath $path -Force -ErrorAction Stop).FullName; $value = & /usr/bin/stat -f "%i" -- $resolved 2>$null; if ($LASTEXITCODE -ne 0) { $value = & /usr/bin/stat -c "%i" -- $resolved }; ConvertTo-OrcaFileIdentity ([string]$value) }', - '}' - ] -} - -function windowsClaimPrelude(stage: RelayUploadStageSlot): string[] { - return [ - `$slot = ${powerShellLiteral(stage.slotDir)}`, - `$claim = ${powerShellLiteral(stage.claimDir)}`, - `$deleting = ${powerShellLiteral(stage.deleteDir)}`, - `$pool = ${powerShellLiteral(stage.poolDir)}`, - '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction SilentlyContinue', - 'if (($null -eq $poolItem) -or -not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { exit 0 }', - '$claimBefore = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', - '$deleteBefore = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', - '$slotBefore = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', - 'if (($null -ne $claimBefore) -or ($null -ne $deleteBefore) -or ($null -eq $slotBefore)) { exit 0 }', - 'Move-Item -LiteralPath $slot -Destination $claim -ErrorAction Stop' - ] -} - -function windowsOwnershipScript( - owner: string, - requirePayload: boolean, - pathExpression = '$claim' -): string[] { - return [ - `$expectedOwner = ${powerShellLiteral(owner)}`, - `$ownedPath = ${pathExpression}`, - `$ownerPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, - `$identityPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}`, - '$claimItem = Get-Item -LiteralPath $ownedPath -Force -ErrorAction SilentlyContinue', - '$ownerItem = Get-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue', - '$identityItem = Get-Item -LiteralPath $identityPath -Force -ErrorAction SilentlyContinue', - '$actualOwner = if ($null -ne $ownerItem) { [System.IO.File]::ReadAllText($ownerPath).Trim() } else { "" }', - '$expectedIdentity = if ($null -ne $identityItem) { ConvertTo-OrcaFileIdentity ([System.IO.File]::ReadAllText($identityPath)) } else { "" }', - '$actualIdentity = if ($null -ne $claimItem) { & $getFileIdentity $ownedPath } else { "" }', - '$owned = ($null -ne $claimItem) -and $claimItem.PSIsContainer -and (($claimItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $ownerItem) -and -not $ownerItem.PSIsContainer -and (($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $identityItem) -and -not $identityItem.PSIsContainer -and (($identityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($actualOwner -ceq $expectedOwner) -and ($actualIdentity -ceq $expectedIdentity)', - ...(requirePayload - ? [ - '$payload = Join-Path $ownedPath "payload"', - '$payloadItem = Get-Item -LiteralPath $payload -Force -ErrorAction SilentlyContinue', - '$reparse = $null', - 'if (($null -ne $payloadItem) -and $payloadItem.PSIsContainer -and (($payloadItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) {', - '$reparse = Get-ChildItem -LiteralPath $payload -Force -Recurse -ErrorAction Stop | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1', - '}', - '$owned = $owned -and ($null -ne $payloadItem) -and $payloadItem.PSIsContainer -and (($payloadItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -eq $reparse)' - ] - : []) - ] -} - -function windowsRestoreClaim(): string { - return 'if (($null -eq (Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue))) { Move-Item -LiteralPath $claim -Destination $slot -ErrorAction SilentlyContinue }' -} - -function windowsStaleOwnershipScript(pathExpression: string): string[] { - return [ - `$ownedPath = ${pathExpression}`, - `$ownerPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, - `$identityPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}`, - '$ownedItem = Get-Item -LiteralPath $ownedPath -Force -ErrorAction SilentlyContinue', - '$ownerItem = Get-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue', - '$identityItem = Get-Item -LiteralPath $identityPath -Force -ErrorAction SilentlyContinue', - '$actualOwner = if ($null -ne $ownerItem) { [System.IO.File]::ReadAllText($ownerPath).Trim() } else { "" }', - '$expectedIdentity = if ($null -ne $identityItem) { ConvertTo-OrcaFileIdentity ([System.IO.File]::ReadAllText($identityPath)) } else { "" }', - '$actualIdentity = if ($null -ne $ownedItem) { & $getFileIdentity $ownedPath } else { "" }', - "$owned = ($null -ne $ownedItem) -and $ownedItem.PSIsContainer -and (($ownedItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $ownerItem) -and -not $ownerItem.PSIsContainer -and (($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $identityItem) -and -not $identityItem.PSIsContainer -and (($identityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($actualIdentity -ceq $expectedIdentity) -and ($ownerItem.LastWriteTimeUtc -lt $cutoff) -and ($actualOwner -match '^\\.sftp-namespace-[0-9a-f]{32}$')", - '$reparse = $null', - 'if ($owned) {', - '$reparse = Get-ChildItem -LiteralPath $ownedPath -Force -Recurse -ErrorAction Stop | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1', - '}', - '$owned = $owned -and ($null -eq $reparse)' - ] -} - -function windowsDeleteOwnedClaimScript(owner: string): string[] { - return [ - '$deleteBefore = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', - 'if ($null -ne $deleteBefore) { exit 0 }', - 'Move-Item -LiteralPath $claim -Destination $deleting -ErrorAction Stop', - ...windowsOwnershipScript(owner, true, '$deleting'), - 'if ($owned) {', - 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', - '} elseif (($null -eq (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) {', - 'Move-Item -LiteralPath $deleting -Destination $claim -ErrorAction SilentlyContinue', - '}' - ] -} - -export function promoteWindowsRelayUploadStageCommand( - stage: RelayUploadStageSlot, - owner: string, - destinationDir: string, - identity: WindowsUploadStageIdentity = {} -): string { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(identity), - ...windowsClaimPrelude(stage), - ...windowsOwnershipScript(owner, true), - 'if (-not $owned) {', - windowsRestoreClaim(), - `Write-Output (${powerShellLiteral(RELAY_UPLOAD_PROMOTION_RESULT_PREFIX)} + $expectedOwner + ':OWNERSHIP_LOST')`, - 'exit 0', - '}', - `Get-ChildItem -LiteralPath $payload -Force -ErrorAction Stop | Copy-Item -Destination ${powerShellLiteral(destinationDir)} -Recurse -Force -ErrorAction Stop`, - ...windowsDeleteOwnedClaimScript(owner), - `Write-Output (${powerShellLiteral(RELAY_UPLOAD_PROMOTION_RESULT_PREFIX)} + $expectedOwner + ':PROMOTED')` - ].join('\n') - ) -} - -export function cleanupWindowsRelayUploadStageCommand( - stage: RelayUploadStageSlot, - owner: string, - identity: WindowsUploadStageIdentity = {} -): string { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(identity), - ...windowsClaimPrelude(stage), - ...windowsOwnershipScript(owner, true), - 'if ($owned) {', - ...windowsDeleteOwnedClaimScript(owner), - '} else {', - windowsRestoreClaim(), - '}' - ].join('\n') - ) -} - -export function recoverWindowsRelayUploadStageCommand( - poolDir: string, - staleSeconds: number, - identity: WindowsUploadStageIdentity = {} -): string { - return powerShellCommand( - [ - "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(identity), - `$pool = ${powerShellLiteral(poolDir)}`, - `$cutoff = [DateTime]::UtcNow.AddSeconds(-${staleSeconds})`, - '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction SilentlyContinue', - 'if (($null -eq $poolItem) -or -not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { exit 0 }', - `foreach ($n in 0..${RELAY_UPLOAD_STAGE_SLOT_COUNT - 1}) {`, - '$slot = Join-Path $pool ("slot-" + $n)', - '$claim = Join-Path $pool ("claim-" + $n)', - '$deleting = Join-Path $pool ("delete-" + $n)', - ...windowsStaleOwnershipScript('$deleting'), - 'if ($owned) {', - 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', - 'exit 0', - '}', - `$slotOwner = Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, - '$slotItem = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', - '$slotOwnerItem = Get-Item -LiteralPath $slotOwner -Force -ErrorAction SilentlyContinue', - '$claimItem = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', - '$deleteItem = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', - 'if (($null -eq $claimItem) -and ($null -eq $deleteItem) -and ($null -ne $slotItem) -and $slotItem.PSIsContainer -and (($slotItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $slotOwnerItem) -and -not $slotOwnerItem.PSIsContainer -and (($slotOwnerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and $slotOwnerItem.LastWriteTimeUtc -lt $cutoff) {', - 'Move-Item -LiteralPath $slot -Destination $claim -ErrorAction SilentlyContinue', - '}', - ...windowsStaleOwnershipScript('$claim'), - 'if ($owned -and ($null -eq (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) {', - 'Move-Item -LiteralPath $claim -Destination $deleting -ErrorAction Stop', - ...windowsStaleOwnershipScript('$deleting'), - 'if (-not $owned) {', - 'if (($null -eq (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) { Move-Item -LiteralPath $deleting -Destination $claim -ErrorAction SilentlyContinue }', - 'continue', - '}', - 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', - 'exit 0', - '}', - windowsRestoreClaim(), - '}' - ].join('\n') - ) -} diff --git a/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts b/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts deleted file mode 100644 index a61081daad34..000000000000 --- a/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Upload-stage file identity on Windows through a verified pinned node.exe instead of the legacy - * Add-Type helper (design D5, docs/reference/windows-edr-posture.md). - */ -import { spawnSync } from 'node:child_process' -import { - cpSync, - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - renameSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' -import { - cleanupOwnedRelayUploadStageCommand, - parseReservedRelayUploadStage, - promoteOwnedRelayUploadStageCommand, - recoverOneStaleRelayUploadStageCommand, - relayUploadStagePromotionConfirmed, - reserveRelayUploadStageCommand, - type WindowsUploadStageIdentity -} from './ssh-relay-upload-stage-commands' -import { WINDOWS_UPLOAD_STAGE_IDENTITY_JS } from './ssh-relay-upload-stage-windows-commands' -import { removeTreeSync } from '../../shared/windows-transient-lock-removal' - -const windows = getRemoteHostPlatform('win32-x64') -const owner = '.sftp-namespace-123e4567e89b12d3a456426614174000' -const pool = 'C:/Users/ada/.orca-remote/.upload-stages' -const pinned: WindowsUploadStageIdentity = { - node: 'C:/Users/ada/.orca-remote/runtimes/node-abc/node.exe' -} -const stage = parseReservedRelayUploadStage( - windows, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-3` -) -const roots: string[] = [] - -function allCommands(identity?: WindowsUploadStageIdentity): string[] { - return [ - reserveRelayUploadStageCommand(windows, pool, owner, identity), - promoteOwnedRelayUploadStageCommand(windows, stage, owner, `${pool}/../relay-x`, identity), - cleanupOwnedRelayUploadStageCommand(windows, stage, owner, identity), - recoverOneStaleRelayUploadStageCommand(windows, pool, undefined, identity) - ].map(decodeRemotePowerShellScript) -} - -function tempDir(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-stage-identity-')) - roots.push(root) - return root -} - -function nodeIdentity(path: string): string { - const result = spawnSync(process.execPath, ['-e', WINDOWS_UPLOAD_STAGE_IDENTITY_JS, '--', path], { - encoding: 'utf8' - }) - expect(result.status, result.stderr).toBe(0) - return result.stdout -} - -afterEach(() => { - for (const root of roots.splice(0)) { - removeTreeSync(root) - } -}) - -describe('Windows upload-stage identity commands', () => { - it('compile nothing once a verified node.exe is known', () => { - for (const script of allCommands(pinned)) { - expect(script).not.toMatch(/Add-Type|DllImport|ExecutionPolicy|\.ps1/) - expect(script).toContain(`$orcaIdentityNode = '${pinned.node}'`) - } - }) - - it('keep the Add-Type helper only for relays with no verified node.exe', () => { - for (const script of allCommands()) { - expect(script).toContain('Add-Type -TypeDefinition') - expect(script).not.toContain('$orcaIdentityNode') - } - }) - - it('run node.exe with the fixed script and the path as an argument', () => { - const script = allCommands(pinned)[0] - const lines = script.split('\n').filter((line) => line.includes('OrcaFileIdentity')) - expect(lines).toMatchInlineSnapshot(` - [ - "function ConvertTo-OrcaFileIdentity([string]$value) { (($value.Trim().ToLowerInvariant() -split ':') | ForEach-Object { $digits = $_.TrimStart('0'); if ($digits -eq '') { '0' } else { $digits } }) -join ':' }", - "$getFileIdentity = { param($path) $value = & $orcaIdentityNode -e 'const s=require(''fs'').lstatSync(process.argv[1],{bigint:true});const m=0xffffffffn;process.stdout.write((s.dev&m).toString(16)+'':''+(s.ino>>32n).toString(16)+'':''+(s.ino&m).toString(16))' -- $path; if ($LASTEXITCODE -ne 0) { throw 'Orca could not read a relay upload stage file identity' }; ConvertTo-OrcaFileIdentity ([string]$value) }", - ] - `) - }) -}) - -describe('the pinned node.exe identity script', () => { - it("prints the legacy helper's vol:indexHigh:indexLow lowercase hex", () => { - const dir = tempDir() - const stats = lstatSync(dir, { bigint: true }) - const mask = 0xffffffffn - expect(nodeIdentity(dir)).toBe( - `${(stats.dev & mask).toString(16)}:${(stats.ino >> 32n).toString(16)}:${(stats.ino & mask).toString(16)}` - ) - expect(nodeIdentity(dir)).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/) - }) - - it('survives the claim rename but not a copy with the same contents', () => { - const root = tempDir() - const slot = join(root, 'slot-0') - mkdirSync(slot) - const before = nodeIdentity(slot) - renameSync(slot, join(root, 'claim-0')) - expect(nodeIdentity(join(root, 'claim-0'))).toBe(before) - cpSync(join(root, 'claim-0'), join(root, 'copy'), { recursive: true }) - expect(nodeIdentity(join(root, 'copy'))).not.toBe(before) - }) -}) - -const powerShell = [ - process.env.ORCA_POWERSHELL_EXECUTABLE, - ...(process.platform === 'win32' ? ['powershell.exe', 'pwsh.exe'] : ['pwsh']) -].find( - (candidate) => - candidate && - spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { - stdio: 'ignore' - }).status === 0 -) - -function runPowerShell(command: string): { status: number | null; stdout: string } { - const result = spawnSync( - powerShell!, - ['-NoProfile', '-NonInteractive', '-Command', decodeRemotePowerShellScript(command)], - { encoding: 'utf8' } - ) - expect(result.status, result.stderr).toBe(0) - return result -} - -function reserve(localPool: string, identity?: WindowsUploadStageIdentity): string { - const out = runPowerShell(reserveRelayUploadStageCommand(windows, localPool, owner, identity)) - const reserved = parseReservedRelayUploadStage(windows, localPool, owner, out.stdout) - writeFileSync(join(reserved.slotDir, 'payload', 'relay.js'), 'relay') - return reserved.slotName -} - -function promote(localPool: string, slotName: string, identity?: WindowsUploadStageIdentity) { - const destination = join(localPool, '..', 'relay-x') - mkdirSync(destination, { recursive: true }) - const reserved = parseReservedRelayUploadStage( - windows, - localPool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:${slotName}` - ) - const out = runPowerShell( - promoteOwnedRelayUploadStageCommand(windows, reserved, owner, destination, identity) - ) - return { - promoted: relayUploadStagePromotionConfirmed(owner, out.stdout), - copied: existsSync(join(destination, 'relay.js')) - } -} - -describe.runIf(powerShell)('Windows upload-stage identity (real PowerShell)', () => { - const node = { node: process.execPath } - - it('reserves and promotes through node.exe alone', { timeout: 120_000 }, () => { - const localPool = join(tempDir(), 'pool') - const slot = reserve(localPool, node) - expect(promote(localPool, slot, node)).toEqual({ promoted: true, copied: true }) - }) - - it('accepts an identity file in any hex spelling of the same file', { timeout: 120_000 }, () => { - const localPool = join(tempDir(), 'pool') - const slot = reserve(localPool, node) - const identityFile = join(localPool, slot, '.orca-upload-identity') - const [vol, high, low] = readFileSync(identityFile, 'utf8').split(':') - writeFileSync(identityFile, `${vol.toUpperCase()}:000${high}:${low.toUpperCase()}\r\n`) - expect(promote(localPool, slot, node)).toEqual({ promoted: true, copied: true }) - }) - - // Why Windows only: off Windows the legacy branch reads `stat %i`, a different format. - it.runIf(process.platform === 'win32')( - 'reads identity files the legacy Add-Type helper wrote, and the reverse', - { timeout: 240_000 }, - () => { - const oldToNew = join(tempDir(), 'pool') - expect(promote(oldToNew, reserve(oldToNew), node)).toEqual({ promoted: true, copied: true }) - const newToOld = join(tempDir(), 'pool') - expect(promote(newToOld, reserve(newToOld, node))).toEqual({ promoted: true, copied: true }) - } - ) -}) diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 0dc2f428df95..a3b697ec083a 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -30,7 +30,6 @@ import { gcOldRelayVersions } from './ssh-relay-versioned-install' import { acquireInstallLock } from './ssh-relay-install-lock' -import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import { isRelayGcClaimed, relayGcClaimPath, @@ -179,84 +178,6 @@ describe('acquireInstallLock', () => { expect(commands[4]).toBe("mkdir -p '/r'") }) - it('returns immediately without deleting a live repair lock', async () => { - // Why: repair can run npm install plus rebuild under the same lock; a - // second reconnect must launch degraded rather than corrupt node_modules. - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('LOCKED') - .mockResolvedValueOnce('0') - - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('busy') - - const commands = mockExec.mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) - expect(commands.filter((command) => command.startsWith('rm -rf'))).toHaveLength(0) - }) - - it('does not report stale or indeterminate contention as a launch fence', async () => { - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('LOCKED') - .mockResolvedValueOnce(`${21 * 60}`) - - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') - - mockExec.mockReset().mockRejectedValueOnce(new Error('claim probe failed')) - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') - }) - - it('recovers a stale best-effort repair lock without polling', async () => { - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OK') - .mockResolvedValueOnce('OPEN') - - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('acquired') - - const commands = mockExec.mock.calls.map(([, command]) => command) - expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) - expect(commands.filter((command) => command.includes('lock_tombstone'))).toHaveLength(1) - }) - - it('backs out when GC claims the sibling path during repair lock acquisition', async () => { - mockExec - .mockResolvedValueOnce('OPEN') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('OK') - .mockResolvedValueOnce('LOCKED') - .mockResolvedValueOnce('') - - await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('gc') - - const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' - expect(lastCommand).toBe("rm -rf '/r/.install-lock'") - }) - - it('propagates cancellation through best-effort repair lock commands', async () => { - const abortController = new AbortController() - const abortError = Object.assign(new Error('cancelled'), { name: 'AbortError' }) - mockExec.mockRejectedValueOnce(abortError) - - const promise = tryAcquireRelayRepairLock(conn, '/r', undefined, { - signal: abortController.signal - }) - abortController.abort() - - await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) - expect(mockExec.mock.calls[0]?.[2]?.signal).toBe(abortController.signal) - }) - it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-windows-host-lane.test.ts b/src/main/ssh/ssh-relay-windows-host-lane.test.ts deleted file mode 100644 index c772b90e0511..000000000000 --- a/src/main/ssh/ssh-relay-windows-host-lane.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// Design D5/D6 Windows SSH-host lane: the real client-side relay deploy against a real -// Win32-OpenSSH server on 127.0.0.1 (inbox capability or the pinned preview release), one cell -// per run. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 provisions the account, -// sets DefaultShell, writes the descriptor and runs this file; ssh-windows-hosts.yml runs that. -// -// Run: ORCA_RUN_SSH_WINDOWS_HOST=1 ORCA_SSH_WINDOWS_HOST_CELL= pnpm test -import { randomUUID } from 'node:crypto' -import { writeFileSync } from 'node:fs' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) - -import { ORCAD_RUNTIMES_DIRNAME } from '../../shared/orcad-artifacts' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { WINDOWS_RELAY_LAUNCH_LOG_PREFIX } from './ssh-relay-windows-launch-command' -import type { SshConnection } from './ssh-connection' -import { localHostObserver } from './ssh-hostile-host-observer' -import { - assertCell, - connectHostileHost, - deployErrorText, - deployOnce, - exerciseLaunchedCell, - installHostileHostAppEnvironment, - TERMINAL_PROBES, - type DeployAttempt -} from './ssh-hostile-host-test-harness' -import { - auditWindowsSessionCommands, - windowsSessionCommandViolations, - type WindowsSessionCommandAudit -} from './ssh-session-command-audit' -import { - isWindowsHostCellId, - readWindowsHostCellDescriptor, - windowsHostCell, - windowsHostSshTarget -} from './ssh-windows-host-cells' - -const RUN = process.env.ORCA_RUN_SSH_WINDOWS_HOST === '1' -const CELL_TIMEOUT_MS = 20 * 60_000 - -describe.runIf(RUN)('SSH relay on a Windows OpenSSH host', () => { - let cleanupAppEnvironment: (() => void) | null = null - - beforeAll(() => { - cleanupAppEnvironment = installHostileHostAppEnvironment() - }) - - afterAll(() => { - cleanupAppEnvironment?.() - }) - - it( - 'lands the cell the descriptor names', - async () => { - const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '') - if (!isWindowsHostCellId(descriptor.cell)) { - throw new Error(`${descriptor.cell} does not run in the relay lane`) - } - const cell = windowsHostCell(descriptor.cell, descriptor.target) - const observer = localHostObserver(descriptor.forbiddenToolLog) - const sshTarget = windowsHostSshTarget(descriptor, cell, randomUUID()) - const audits: (WindowsSessionCommandAudit & { uploaded: boolean })[] = [] - const inspectDeploy = (attempt: DeployAttempt, uploaded: boolean): void => { - const audit = auditWindowsSessionCommands(attempt.commands) - audits.push({ ...audit, uploaded }) - const violations = windowsSessionCommandViolations(audit, { uploaded }) - expect(violations, `${cell.id}: ${violations.join('; ')}`).toEqual([]) - } - const receipt: Record = { - cell: cell.id, - target: descriptor.target, - audits - } - // The deploy logs each relay launch; the cell reads them to prove which route ran. - const launches: unknown[] = [] - const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - const line = args.map(String).join(' ') - if (line.startsWith(WINDOWS_RELAY_LAUNCH_LOG_PREFIX)) { - launches.push(JSON.parse(line.slice(WINDOWS_RELAY_LAUNCH_LOG_PREFIX.length))) - } - process.stdout.write(`${line}\n`) - }) - receipt.launches = launches - let conn: SshConnection | null = null - try { - conn = await connectHostileHost(sshTarget) - const first = await deployOnce(conn) - Object.assign(receipt, { - settledRung: first.settledRung, - refusals: first.refusals, - selfTest: first.run?.selfTest ?? null, - runtimeTransfer: first.run?.runtimeTransfer ?? null, - deployError: deployErrorText(first) - }) - await assertCell(cell, observer, first) - if (cell.expect.outcome === 'launched') { - const evidence = await exerciseLaunchedCell({ - cell, - observer, - sshTarget, - terminal: TERMINAL_PROBES.windows, - first, - firstConn: conn, - inspectDeploy - }) - Object.assign(receipt, evidence, { reused: true, gcKeptInUse: true }) - // One launch, outside sshd's job with no WMI grant; the second connect launched nothing, - // so it adopted the relay that outlived the first SSH connection. - expect(launches).toEqual([{ method: 'breakaway', pid: expect.any(Number), inJob: false }]) - } else { - // Opted out: nothing may enter the pinned runtime store, whatever the host-Node path did. - const store = `${descriptor.home}/${RELAY_REMOTE_DIR}/${ORCAD_RUNTIMES_DIRNAME}` - expect(await observer.exists(store)).toBe(false) - receipt.pinnedStoreUntouched = true - } - receipt.passed = true - } finally { - logSpy.mockRestore() - await conn?.disconnect().catch(() => {}) - writeFileSync(descriptor.receipt, `${JSON.stringify(receipt, null, 2)}\n`) - } - }, - CELL_TIMEOUT_MS - ) -}) diff --git a/src/main/ssh/ssh-remote-cli-args.ts b/src/main/ssh/ssh-remote-cli-args.ts deleted file mode 100644 index 4a2e0779cf34..000000000000 --- a/src/main/ssh/ssh-remote-cli-args.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { CLI_BOOLEAN_FLAGS } from '../../shared/cli-argument-boundary' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import { - isOrchestrationRetryRequestId, - RETRY_REQUEST_ID_GUIDANCE, - VALUELESS_RETRY_REQUEST_GUIDANCE -} from '../../shared/orchestration-retry-request-id' - -const REPEATED_FLAG_SEPARATOR = '\u0000' -const REPEATABLE_REMOTE_STRING_FLAGS = new Set(['label']) - -export function parseRemoteCliArgs(argv: string[]): ParsedRemoteCli { - const commandPath: string[] = [] - const flags = new Map() - for (let i = 0; i < argv.length; i += 1) { - const token = argv[i] - if (!token.startsWith('--')) { - commandPath.push(token) - continue - } - const assignment = token.slice(2) - // Why: the SSH relay-backed shim should accept values beginning with `--` via `--flag=value`. - const equalsIndex = assignment.indexOf('=') - if (equalsIndex !== -1) { - setRemoteFlag(flags, assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1)) - continue - } - - const flag = assignment - const next = argv[i + 1] - if (!isRemoteBooleanFlag(flag, commandPath) && next && !next.startsWith('--')) { - setRemoteFlag(flags, flag, next) - i += 1 - } else { - setRemoteFlag(flags, flag, true) - } - } - return { commandPath, flags } -} - -export function resolveRemoteCliHandle( - flags: Map, - env: Record, - flagName: string -): string { - return optionalRemoteCliString(flags, flagName) ?? env.ORCA_TERMINAL_HANDLE ?? 'unknown' -} - -export function requiredRemoteCliString( - flags: Map, - name: string -): string { - const value = optionalRemoteCliString(flags, name) - if (!value) { - throw new Error(`Missing --${name}`) - } - return value -} - -export function optionalRemoteCliString( - flags: Map, - name: string -): string | undefined { - const value = flags.get(name) - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -/** - * The relay shim parses its own argv, so it cannot inherit the CLI's valued-flag guards. A - * `--retry-request` the shell emptied parses as `true`, and letting that fall through to - * `undefined` mints a fresh mutation identity and re-applies the mutation (#15180). - */ -export function readRemoteRetryRequestFlag( - flags: Map -): string | undefined { - const value = flags.get('retry-request') - if (value === undefined) { - return undefined - } - if (value === true) { - throw new RemoteCliArgumentError('invalid_argument', VALUELESS_RETRY_REQUEST_GUIDANCE) - } - if (!isOrchestrationRetryRequestId(value)) { - throw new RemoteCliArgumentError('invalid_argument', RETRY_REQUEST_ID_GUIDANCE) - } - return value -} - -export function optionalRemoteCliNumber( - flags: Map, - name: string -): number | undefined { - const value = optionalRemoteCliString(flags, name) - if (value === undefined) { - return undefined - } - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new RemoteCliArgumentError('invalid_argument', `Invalid numeric value for --${name}`) - } - return parsed -} - -function isRemoteBooleanFlag(flag: string, commandPath: string[]): boolean { - // Why: Android launch already uses --activity ; only Linear issue reads use it as a boolean. - return ( - CLI_BOOLEAN_FLAGS.has(flag) || - (flag === 'activity' && commandPath[0] === 'linear' && commandPath[1] === 'issue') - ) -} - -function setRemoteFlag( - flags: Map, - name: string, - value: string | boolean -): void { - const previous = flags.get(name) - if ( - typeof previous === 'string' && - typeof value === 'string' && - REPEATABLE_REMOTE_STRING_FLAGS.has(name) - ) { - flags.set(name, `${previous}${REPEATED_FLAG_SEPARATOR}${value}`) - return - } - flags.set(name, value) -} diff --git a/src/main/ssh/ssh-remote-cli-argument-error.ts b/src/main/ssh/ssh-remote-cli-argument-error.ts deleted file mode 100644 index 25d67c2d2749..000000000000 --- a/src/main/ssh/ssh-remote-cli-argument-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type ParsedRemoteCli = { - commandPath: string[] - flags: Map -} - -export class RemoteCliArgumentError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.name = 'RemoteCliArgumentError' - this.code = code - } -} diff --git a/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts b/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts deleted file mode 100644 index a8105f141e82..000000000000 --- a/src/main/ssh/ssh-remote-cli-bridge-credential.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => '/host/app' - } -})) -vi.mock('../persistence', () => ({ - getCanonicalUserDataPath: () => '/host/user-data' -})) - -import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' -import { SshBridgeCredentialRegistry } from '../runtime/rpc/ssh-bridge-credentials' -import { runHostOrcaCliPassthrough } from './ssh-remote-cli-host-passthrough' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { OrcaRuntimeService } from '../runtime/orca-runtime' -import { HOST_BOUND_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' - -class FakeChild extends EventEmitter { - readonly stdout = new EventEmitter() - readonly stderr = new EventEmitter() - readonly stdin = { end: vi.fn(), on: vi.fn() } - readonly kill = vi.fn() -} - -const RUNTIME_AUTHORITY = { - kind: 'ssh', - targetId: 'box-1', - connectionIncarnation: 'incarnation-1', - attachmentId: 'attachment-1' -} as const - -describe('the SSH bridge CLI child', () => { - it('holds a credential scoped to its SSH target, never the owner token, for its lifetime only', async () => { - const credentials = new SshBridgeCredentialRegistry() - const child = new FakeChild() - let spawnedEnv: NodeJS.ProcessEnv = {} - const spawn = vi.fn((_exec: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { - spawnedEnv = options.env - return child - }) - - const pending = runHostOrcaCliPassthrough( - { - argv: ['terminal', 'list', '--json'], - cwd: '/home/alice/wt', - env: { ORCA_ENVIRONMENT: 'prod' }, - runtimeAuthority: RUNTIME_AUTHORITY, - callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE - }, - { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => true, - hostEnv: { - PATH: '/usr/bin', - ORCA_ENVIRONMENT: 'owner-paired-server', - ORCA_PAIRING_CODE: 'owner-pairing-code', - [ORCA_SSH_BRIDGE_CREDENTIAL_ENV]: 'stale-or-forged' - }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every ChildProcess member the passthrough touches. - spawn: spawn as never, - credentials - } - ) - - const token = spawnedEnv[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] - expect(token).toMatch(/^sshb_[0-9a-f]{48}$/) - expect(credentials.resolve(token ?? '')).toEqual(HOST_BOUND_SSH_BRIDGE_SCOPE) - // Why: this machine's paired-server selection would route the guest's command with owner credentials. - expect(spawnedEnv.ORCA_ENVIRONMENT).toBeUndefined() - expect(spawnedEnv.ORCA_PAIRING_CODE).toBeUndefined() - - child.emit('close', 0) - await pending - expect(credentials.resolve(token ?? '')).toBeNull() - }) - - it('revokes the credential when the child cannot be launched', async () => { - const credentials = new SshBridgeCredentialRegistry() - const child = new FakeChild() - let token = '' - const spawn = vi.fn((_exec: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { - token = options.env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] ?? '' - return child - }) - const pending = runHostOrcaCliPassthrough( - { argv: ['status'], cwd: '/', env: {}, callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE }, - { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => true, - hostEnv: {}, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every ChildProcess member the passthrough touches. - spawn: spawn as never, - credentials - } - ) - child.emit('error', new Error('ENOENT')) - await expect(pending).rejects.toThrow('Failed to launch') - expect(credentials.resolve(token)).toBeNull() - }) -}) - -describe('the in-process fallback bridge', () => { - const LEGACY_FALLBACK_OPTIONS = { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => false - } - - it('applies the host-bound scope to orchestration it would otherwise relay as owner', async () => { - const runtime = new OrcaRuntimeService() - const result = await runRemoteOrcaCli( - runtime, - { - argv: ['orchestration', 'send', '--to', 'term_local', '--subject', 'hi', '--json'], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_remote' }, - callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE - }, - LEGACY_FALLBACK_OPTIONS - ) - expect(result.exitCode).toBe(1) - expect(JSON.parse(result.stdout).error.code).toBe('forbidden') - }) - - it("filters the fallback terminal list to the bridged host's terminals", async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals: [ - { - handle: 'term_local', - ptyId: 'pty-1', - worktreeId: 'repo::/Users/me/secret', - worktreePath: '/Users/me/secret', - branch: 'main', - tabId: 'tab-1', - leafId: 'leaf-1', - title: 'owner shell', - connected: true, - writable: true, - lastOutputAt: null, - preview: 'owner secrets', - executionHostId: 'local' - } - ], - totalCount: 1, - truncated: false - }) - const result = await runRemoteOrcaCli( - runtime, - { - argv: ['terminal', 'list', '--json'], - cwd: '/home/alice/repo', - env: {}, - callerScope: HOST_BOUND_SSH_BRIDGE_SCOPE - }, - LEGACY_FALLBACK_OPTIONS - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).not.toContain('owner secrets') - expect(JSON.parse(result.stdout).result.terminals).toEqual([]) - }) -}) diff --git a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts deleted file mode 100644 index b54a855596d8..000000000000 --- a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => '/host/app' - } -})) -vi.mock('../persistence', () => ({ - getCanonicalUserDataPath: () => '/host/user-data' -})) - -import { OrcaRuntimeService } from '../runtime/orca-runtime' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' - -// Why: the SSH bridge captures the host CLI child's stdout and exit code without reparsing; this -// pins that a typed refusal envelope and its nonzero exit reach the remote agent unchanged. -it('relays typed dispatch refusal codes from the host CLI unchanged', async () => { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter - stderr: EventEmitter - stdin: { end: ReturnType; on: ReturnType } - kill: ReturnType - } - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() - child.stdin = { end: vi.fn(), on: vi.fn() } - child.kill = vi.fn() - const spawn = vi.fn(() => child) - const refusal = { - id: 'rpc_1', - ok: false, - error: { - code: 'task_not_startable', - message: 'Task task_1 is pending; only ready tasks can be dispatched', - data: { taskId: 'task_1', status: 'pending', unmetDependencies: ['task_0'] } - }, - _meta: { runtimeId: 'runtime_1' } - } - - const resultPromise = runRemoteOrcaCli( - new OrcaRuntimeService(), - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_w', '--json'], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }, - { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => true, - spawn: spawn as never - } - ) - - const stdout = `${JSON.stringify(refusal, null, 2)}\n` - await Promise.resolve() - child.stdout.emit('data', Buffer.from(stdout)) - child.emit('close', 1) - - expect(await resultPromise).toEqual({ stdout, stderr: '', exitCode: 1 }) -}) diff --git a/src/main/ssh/ssh-remote-cli-error-response.ts b/src/main/ssh/ssh-remote-cli-error-response.ts deleted file mode 100644 index dacf9402a451..000000000000 --- a/src/main/ssh/ssh-remote-cli-error-response.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { RpcResponse } from '../runtime/rpc/core' - -export function buildRemoteCliError(message: string, code = 'runtime_error'): RpcResponse { - return { - id: 'remote-cli-local', - ok: false, - error: { code, message }, - _meta: { runtimeId: 'unknown' } - } -} diff --git a/src/main/ssh/ssh-remote-cli-format.test.ts b/src/main/ssh/ssh-remote-cli-format.test.ts deleted file mode 100644 index 31f71caef274..000000000000 --- a/src/main/ssh/ssh-remote-cli-format.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { RpcResponse } from '../runtime/rpc/core' -import { formatRemoteCli } from './ssh-remote-cli-format' - -const meta = { runtimeId: 'runtime-test' } - -describe('formatRemoteCli', () => { - it('falls back to JSON for malformed Linear issue results', () => { - const response: RpcResponse = { - id: 'rpc-1', - ok: true, - _meta: meta, - result: { - issue: { - identifier: 'ENG-123', - title: 'Fix thing', - url: 'https://linear.app/acme/issue/ENG-123', - labels: [] - }, - meta: { - includeErrors: null, - sections: {} - } - } - } - - expect(formatRemoteCli(response)).toEqual({ - stdout: `${JSON.stringify(response.result)}\n`, - stderr: '' - }) - }) - - it('falls back to JSON for malformed Linear search results', () => { - const response: RpcResponse = { - id: 'rpc-1', - ok: true, - _meta: meta, - result: { - issues: [], - meta: { - query: 'auth', - returned: '0' - } - } - } - - expect(formatRemoteCli(response)).toEqual({ - stdout: `${JSON.stringify(response.result)}\n`, - stderr: '' - }) - }) -}) diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts deleted file mode 100644 index 40cbaf57f27b..000000000000 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { CliStatusResult } from '../../shared/runtime-types' -import type { RpcResponse } from '../runtime/rpc/core' -import { formatRemoteLinearCli } from './ssh-remote-linear-output' - -export function formatRemoteCli(response: RpcResponse): { stdout: string; stderr: string } { - if (!response.ok) { - return { stdout: '', stderr: `${formatRemoteCliError(response.error)}\n` } - } - const result = response.result - if (isRecord(result) && 'app' in result && 'runtime' in result && 'graph' in result) { - const record = result as Record - return formatStatusResult(record as CliStatusResult) - } - const linear = formatRemoteLinearCli(result) - if (linear) { - return linear - } - return { stdout: `${JSON.stringify(result)}\n`, stderr: '' } -} - -function formatRemoteCliError(error: { message: string; data?: unknown }): string { - const nextSteps = - isRecord(error.data) && Array.isArray(error.data.nextSteps) - ? error.data.nextSteps.filter((step): step is string => typeof step === 'string') - : [] - if (nextSteps.length === 0) { - return error.message - } - return `${error.message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}` -} - -function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: string } { - return { - stdout: `${[ - `appRunning: ${status.app.running}`, - `pid: ${status.app.pid ?? 'none'}`, - `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, - `runtimeState: ${status.runtime.state}`, - `runtimeReachable: ${status.runtime.reachable}`, - `runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`, - `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, - `graphState: ${status.graph.state}` - ].join('\n')}\n`, - stderr: '' - } -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' -} diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts deleted file mode 100644 index 8ef28fd18e89..000000000000 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { EventEmitter } from 'node:events' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => '/host/app' - } -})) -vi.mock('../persistence', () => ({ - getCanonicalUserDataPath: () => '/host/user-data' -})) - -import { - HostCliUnavailableError, - buildHostCliEnv, - resolveHostCliEntryPath, - resolveHostCliKillTimeoutMs, - runHostOrcaCliPassthrough -} from './ssh-remote-cli-host-passthrough' -import { resolveOrchestrationAskClientTimeoutMs } from '../../shared/orchestration-ask-timeout' -import { remoteCliRequestTimeoutMs } from '../../relay/remote-cli-timeout' -import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' -import { - ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV -} from '../../shared/orchestration-compatibility-evidence' -import { REMOTE_ARTIFACT_INPUT_ENV } from '../../shared/artifact-cli-bridge' -import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' - -type FakeChild = EventEmitter & { - stdout: EventEmitter - stderr: EventEmitter - stdin: { end: ReturnType; on: ReturnType } - kill: ReturnType -} - -function createFakeChild(): FakeChild { - const child = new EventEmitter() as FakeChild - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() - child.stdin = { end: vi.fn(), on: vi.fn() } - child.kill = vi.fn() - return child -} - -const BASE_OPTIONS = { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => true -} - -describe('resolveHostCliEntryPath', () => { - it('uses the in-repo entry for dev builds and the unpacked asar entry when packaged', () => { - expect( - resolveHostCliEntryPath({ isPackaged: false, resourcesPath: '/r', appPath: '/host/app' }) - ).toBe(join('/host/app', 'out', 'cli', 'index.js')) - expect( - resolveHostCliEntryPath({ isPackaged: true, resourcesPath: '/r', appPath: '/host/app' }) - ).toBe(join('/r', 'app.asar.unpacked', 'out', 'cli', 'index.js')) - }) -}) - -describe('buildHostCliEnv', () => { - it('forwards only Orca terminal-context vars from the remote env', () => { - const env = buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv: { PATH: '/host/bin', NODE_OPTIONS: '--inspect' }, - remoteEnv: { - ORCA_TERMINAL_HANDLE: 'term_remote', - ORCA_WORKTREE_ID: 'repo::/home/alice/wt', - ORCA_PANE_KEY: 'pane-9', - ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret', - ORCA_WORKSPACE_ID: 'ws-1', - [ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]: 'wsl', - [ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]: 'caller-host', - [ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]: 'caller-incarnation', - [ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]: 'caller-attachment', - // Why: these are remote-machine paths and must not leak into the host - // subprocess (PATH would break host binary lookup; user-data would - // retarget the CLI at a different local instance). - PATH: '/remote/bin', - ORCA_USER_DATA_PATH: '/remote/user-data' - }, - userDataPath: '/host/user-data', - remoteCwd: '/home/alice/wt/sub', - runtimeAuthority: { - kind: 'ssh', - targetId: 'saved-target', - connectionIncarnation: 'connection-incarnation', - attachmentId: 'runtime-attachment' - } - }) - - expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') - expect(env.ORCA_WORKTREE_ID).toBe('repo::/home/alice/wt') - expect(env.ORCA_PANE_KEY).toBe('pane-9') - expect(env.ORCA_AGENT_LAUNCH_TOKEN).toBe('launch-secret') - expect(env.ORCA_WORKSPACE_ID).toBe('ws-1') - expect(env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]).toBe('ssh') - expect(env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]).toBe('saved-target') - expect(env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]).toBe('connection-incarnation') - expect(env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]).toBe('runtime-attachment') - expect(env.PATH).toBe('/host/bin') - expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data') - expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub') - expect(env.ORCA_CLI_COMMAND).toBe('orca') - expect(env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(env.NODE_OPTIONS).toBeUndefined() - expect(env.ORCA_NODE_OPTIONS).toBe('--inspect') - }) - - it.each([ - ['dev host', { ORCA_DEV_REPO_ROOT: '/repo', ORCA_CLI_COMMAND: 'orca-dev' }], - ['packaged Linux host', { ORCA_CLI_COMMAND: 'orca-ide' }], - ['local host', {}], - ['WSL host', { WSL_DISTRO_NAME: 'Ubuntu', ORCA_CLI_COMMAND: 'orca-ide' }], - ['Windows host', { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }] - ])('pins %s recovery to the remote shim', (_name, hostEnv) => { - const env = buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv, - remoteEnv: { ORCA_CLI_COMMAND: 'untrusted-remote-command' }, - userDataPath: '/host/user-data', - remoteCwd: '/srv/repo' - }) - - expect(env.ORCA_CLI_COMMAND).toBe('orca') - }) - - it('stamps this runtime as the source and ignores remote or inherited stamps', () => { - const remoteSource = { - ORCA_RUNTIME_SOURCE_ID: 'remote-chosen-source', - ORCA_RUNTIME_SOURCE_INCARNATION: 'remote-chosen-runtime' - } - const build = (runtimeSource?: { sourceId: string; incarnation: string } | null) => - buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv: { - ORCA_RUNTIME_SOURCE_ID: 'parent-source', - ORCA_RUNTIME_SOURCE_INCARNATION: 'parent-runtime' - }, - remoteEnv: remoteSource, - userDataPath: '/host/user-data', - remoteCwd: '/srv/repo', - runtimeSource - }) - - expect(build({ sourceId: 'this-source', incarnation: 'this-runtime' })).toMatchObject({ - ORCA_RUNTIME_SOURCE_ID: 'this-source', - ORCA_RUNTIME_SOURCE_INCARNATION: 'this-runtime', - ORCA_RUNTIME_SOURCE_PROFILE_PATH: '/host/user-data', - ORCA_USER_DATA_PATH: '/host/user-data' - }) - const unstamped = build(null) - expect(unstamped.ORCA_RUNTIME_SOURCE_ID).toBeUndefined() - expect(unstamped.ORCA_RUNTIME_SOURCE_INCARNATION).toBeUndefined() - }) - - it('never lets a remote command claim a local agent session', () => { - // The host's env carries a session id when Orca was launched inside a structured session; the - // remote shell's own is from another machine. Session identity is same-host only. - const env = buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv: { - ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', - ORCA_STRUCTURED_SESSION: '1' - }, - remoteEnv: { - ORCA_TERMINAL_HANDLE: 'term_remote', - ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' - }, - userDataPath: '/host/user-data', - remoteCwd: '/srv/repo' - }) - - expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() - expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() - // The remote command still speaks as its own terminal. - expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') - }) - - it('namespaces identical remote artifact paths by stable SSH target', () => { - const artifactInput = { - sourceKey: '/srv/repo/report.html', - fileName: 'report.html', - contentType: 'text/html' as const - } - const build = (targetId: string) => - buildHostCliEnv({ - bridgeCredential: 'sshb_test', - hostEnv: {}, - remoteEnv: {}, - userDataPath: '/host/user-data', - remoteCwd: '/srv/repo', - runtimeAuthority: { - kind: 'ssh', - targetId, - connectionIncarnation: 'ephemeral-connection', - attachmentId: 'ephemeral-attachment' - }, - artifactInput - })[REMOTE_ARTIFACT_INPUT_ENV] - - const first = JSON.parse(String(build('host-a'))) - const second = JSON.parse(String(build('host-b'))) - expect(first.sourceKey).not.toBe(second.sourceKey) - expect(JSON.parse(first.sourceKey)).toEqual(['ssh', 'host-a', '/srv/repo/report.html']) - expect(JSON.parse(second.sourceKey)).toEqual(['ssh', 'host-b', '/srv/repo/report.html']) - expect(first.fileName).toBe('report.html') - }) -}) - -describe('resolveHostCliKillTimeoutMs', () => { - it.each([ - ['--wait-submit', '3600'], - ['--wait-submit=3600'], - ['--wait-submit=3600.000000000000001'], - ['--wait-submit', '1', '--wait-submit=3600'] - ])('keeps SSH prompt observation inside both outer deadlines: %j', (...waitFlags) => { - const argv = ['terminal', 'send', '--text', 'review', '--enter', ...waitFlags] - const innerTimeout = 3_600_000 + 10_000 - const hostTimeout = resolveHostCliKillTimeoutMs(argv) - const relayTimeout = remoteCliRequestTimeoutMs({ argv })! - expect(hostTimeout).toBeGreaterThan(innerTimeout) - expect(relayTimeout).toBeGreaterThan(hostTimeout) - }) - - it('keeps pre-command Enter flags inside the prompt observation deadline', () => { - const argv = ['--enter', 'terminal', 'send', '--text', 'review', '--wait-submit', '3600'] - const hostTimeout = resolveHostCliKillTimeoutMs(argv) - expect(hostTimeout).toBeGreaterThan(3_610_000) - expect(remoteCliRequestTimeoutMs({ argv })).toBeGreaterThan(hostTimeout) - }) - - it('extends the kill timer past an explicit --timeout-ms budget', () => { - expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', '1800000'])).toBe( - 1_920_000 - ) - expect(resolveHostCliKillTimeoutMs(['orchestration', 'check', '--timeout-ms=5000'])).toBe( - 600_000 - ) - expect(resolveHostCliKillTimeoutMs(['worktree', 'list'])).toBe(600_000) - }) - - it.each([ - [[], 720_000], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER)], 1_920_000], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER + 1)], 720_000], - [['--timeout-ms', '9007199254740991.1'], 720_000], - [['--timeout-ms', '1', '--timeout-ms=1800000'], 1_920_000], - [['--timeout-ms=1800000', '--timeout-ms', '1'], 600_000], - [['--timeout-ms', '1800000', '--timeout-ms'], 720_000], - [['--timeout-ms=1800000', '--timeout-ms='], 720_000], - [['--timeout-ms=1800000', '--timeout-ms', 'bad'], 720_000], - [['--timeout-ms', 'bad', '--timeout-ms=1800000'], 1_920_000], - [['--timeout-ms=bad', '--timeout-ms', '1800000'], 1_920_000] - ])('bounds ask child timers with last-wins flags %#', (timeoutArgs, expected) => { - expect(resolveHostCliKillTimeoutMs(['orchestration', '--json', 'ask', ...timeoutArgs])).toBe( - expected - ) - }) - - it('does not apply the ask maximum to other commands', () => { - expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', '1800001'])).toBe( - 1_920_001 - ) - }) - - it.each(['+1000000', '1000000.0', '1e6'])( - 'extends non-ask child timers using CLI-compatible integer syntax %s', - (raw) => { - expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', raw])).toBe(1_120_000) - } - ) - - it.each([ - 'Infinity', - '1.5', - '-1', - 'bad', - String(Number.MAX_SAFE_INTEGER), - String(MAX_TIMER_DELAY_MS - 120_000 + 1) - ])('falls back to the default kill timer when a non-ask --timeout-ms %s is unusable', (raw) => { - expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', raw])).toBe(600_000) - }) - - it('keeps the largest non-ask kill timer that stays inside the timer range', () => { - expect( - resolveHostCliKillTimeoutMs([ - 'terminal', - 'wait', - '--timeout-ms', - String(MAX_TIMER_DELAY_MS - 120_000) - ]) - ).toBe(MAX_TIMER_DELAY_MS) - }) - - it.each<[string[], number | undefined]>([ - [[], undefined], - [['--timeout-ms', '1'], 1], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER)], Number.MAX_SAFE_INTEGER], - [['--timeout-ms', String(Number.MAX_SAFE_INTEGER + 1)], undefined] - ])('keeps inner, host, and relay ask deadlines ordered %#', (timeoutArgs, parsedTimeout) => { - const argv = ['orchestration', 'ask', '--to', 'term_x', ...timeoutArgs] - const innerTimeout = resolveOrchestrationAskClientTimeoutMs(parsedTimeout) - const hostTimeout = resolveHostCliKillTimeoutMs(argv) - const relayTimeout = remoteCliRequestTimeoutMs({ argv }) - - expect(innerTimeout).toBeLessThan(hostTimeout) - expect(hostTimeout).toBeLessThan(relayTimeout!) - }) -}) - -describe('runHostOrcaCliPassthrough', () => { - it('spawns the bundled CLI entry with the remote argv and returns captured output', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['orchestration', 'task-create', '--spec', 'do the thing', '--json'], - cwd: '/home/alice/wt', - env: { ORCA_TERMINAL_HANDLE: 'term_remote' } - }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - child.stdout.emit('data', Buffer.from('{"ok":true}\n')) - child.stderr.emit('data', Buffer.from('warn\n')) - child.emit('close', 0) - - const result = await resultPromise - expect(result).toEqual({ stdout: '{"ok":true}\n', stderr: 'warn\n', exitCode: 0 }) - - expect(spawn).toHaveBeenCalledTimes(1) - const [execPath, args, options] = spawn.mock.calls[0] as unknown as [ - string, - string[], - { env: NodeJS.ProcessEnv } - ] - expect(execPath).toBe('/host/electron') - expect(args).toEqual([ - '/host/app/out/cli/index.js', - 'orchestration', - 'task-create', - '--spec', - 'do the thing', - '--json' - ]) - expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(options.env.ORCA_CLI_CWD).toBe('/home/alice/wt') - expect(options.env.ORCA_TERMINAL_HANDLE).toBe('term_remote') - // Why: stdin must be closed even without a payload so CLI handlers that - // stream stdin see EOF instead of hanging forever. - expect(child.stdin.end).toHaveBeenCalledWith() - }) - - it('pipes a stdin payload to the CLI subprocess', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['linear', 'comment', 'add', 'ENG-1', '--body-file', '-'], - cwd: '/home/alice/wt', - env: {}, - stdin: 'comment body' - }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - child.emit('close', 0) - await resultPromise - - expect(child.stdin.end).toHaveBeenCalledWith('comment body') - }) - - it('propagates non-zero exit codes', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['worktree', 'show'], - cwd: '/', - env: {} - }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - child.stderr.emit('data', Buffer.from('boom\n')) - child.emit('close', 3) - - await expect(resultPromise).resolves.toEqual({ stdout: '', stderr: 'boom\n', exitCode: 3 }) - }) - - it('throws HostCliUnavailableError when the CLI entry is missing', async () => { - const spawn = vi.fn() - await expect( - runHostOrcaCliPassthrough( - { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, - { ...BASE_OPTIONS, entryExists: () => false, spawn: spawn as never } - ) - ).rejects.toBeInstanceOf(HostCliUnavailableError) - expect(spawn).not.toHaveBeenCalled() - }) - - it('rejects an invalid injected kill timeout before spawning', async () => { - const spawn = vi.fn() - await expect( - runHostOrcaCliPassthrough( - { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, - { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 2_147_483_648 } - ) - ).rejects.toBeInstanceOf(RangeError) - expect(spawn).not.toHaveBeenCalled() - }) - - it('throws HostCliUnavailableError when the subprocess fails to launch', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, argv: ['status'], cwd: '/', env: {} }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - child.emit('error', new Error('spawn ENOENT')) - - await expect(resultPromise).rejects.toBeInstanceOf(HostCliUnavailableError) - }) - - it('kills the subprocess and reports an error when the kill timeout elapses', async () => { - vi.useFakeTimers() - try { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['terminal', 'wait', '--for', 'exit'], - cwd: '/', - env: {} - }, - { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 1000 } - ) - - await vi.advanceTimersByTimeAsync(1001) - const result = await resultPromise - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain('timed out') - } finally { - vi.useRealTimers() - } - }) - - it('caps runaway output instead of buffering it unbounded', async () => { - const child = createFakeChild() - const spawn = vi.fn(() => child) - - const resultPromise = runHostOrcaCliPassthrough( - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['terminal', 'read'], - cwd: '/', - env: {} - }, - { ...BASE_OPTIONS, spawn: spawn as never } - ) - - await Promise.resolve() - const chunk = Buffer.alloc(3 * 1024 * 1024, 97) - for (let i = 0; i < 4; i += 1) { - child.stdout.emit('data', chunk) - } - child.emit('close', 0) - - const result = await resultPromise - expect(result.stdout.length).toBeLessThanOrEqual(8 * 1024 * 1024 + 64) - expect(result.stdout).toContain('output truncated') - }) -}) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts deleted file mode 100644 index 5e5eb59e2ab3..000000000000 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ /dev/null @@ -1,349 +0,0 @@ -// The SSH shim runs the bundled CLI so remote shells get the full command surface. -import { app } from 'electron' -import { spawn as nodeSpawn } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { getCanonicalUserDataPath } from '../persistence' -import { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline' -export { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline' -import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay' -import { - ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV, - ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV -} from '../../shared/orchestration-compatibility-evidence' -import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' -import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' -import { stampRuntimeSourceEnv, type RuntimeSourceStamp } from '../../shared/runtime-source-env' -import { - sshBridgeCredentials, - type SshBridgeCallerScope, - type SshBridgeCredentialRegistry -} from '../runtime/rpc/ssh-bridge-credentials' -import { - REMOTE_ARTIFACT_INPUT_ENV, - sshArtifactSourceKey, - type RemoteArtifactInput -} from '../../shared/artifact-cli-bridge' - -export type SshCliRuntimeAuthority = { - kind: 'ssh' - targetId: string - connectionIncarnation: string - attachmentId: string -} - -export type RemoteOrcaCliRequest = { - argv: string[] - cwd: string - env: Record - stdin?: string - artifactInput?: RemoteArtifactInput - runtimeAuthority?: SshCliRuntimeAuthority - // Why: required so no bridged invocation can run with owner authority over this Orca. - callerScope: SshBridgeCallerScope -} - -export type RemoteOrcaCliResult = { - stdout: string - stderr: string - exitCode: number - postOutput?: RemoteOrcaCliPostOutput -} - -export type RemoteOrcaCliPostOutput = - | { - kind: 'legacy_check_ack' - terminal: string - messageIds: string[] - types?: string[] - } - | { - kind: 'legacy_question_ack' - terminal: string - questionId: string - answerMessageId: string - } - -export type HostCliPassthroughOptions = { - execPath?: string - cliEntryPath?: string - userDataPath?: string - hostEnv?: NodeJS.ProcessEnv - spawn?: typeof nodeSpawn - entryExists?: (path: string) => boolean - killTimeoutMs?: number - credentials?: SshBridgeCredentialRegistry - /** The runtime this bridge belongs to; the host CLI must reach exactly that one. */ - runtimeSource?: RuntimeSourceStamp | null -} - -/** Thrown when the host CLI entry cannot be launched at all; callers fall back - * to the legacy in-process command switch so previously-working commands keep - * working even on broken installs. */ -export class HostCliUnavailableError extends Error {} - -// Only terminal identity may cross hosts; remote paths and Node options cannot. -const REMOTE_CONTEXT_ENV_VARS = [ - 'ORCA_TERMINAL_HANDLE', - 'ORCA_WORKTREE_ID', - 'ORCA_PANE_KEY', - 'ORCA_AGENT_LAUNCH_TOKEN', - 'ORCA_WORKSPACE_ID' -] as const - -// Bound output retained for the relay response. -const MAX_CAPTURED_OUTPUT_BYTES = 8 * 1024 * 1024 - -export function resolveHostCliEntryPath(app: { - isPackaged: boolean - resourcesPath: string - appPath: string -}): string { - // Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev - // launcher in cli-installer.ts — packaged builds ship the CLI entry outside - // app.asar so Electron node mode can execute it directly. - return app.isPackaged - ? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') - : join(app.appPath, 'out', 'cli', 'index.js') -} - -export function buildHostCliEnv(args: { - hostEnv: NodeJS.ProcessEnv - remoteEnv: Record - userDataPath: string - remoteCwd: string - bridgeCredential: string - runtimeAuthority?: SshCliRuntimeAuthority - artifactInput?: RemoteArtifactInput - runtimeSource?: RuntimeSourceStamp | null -}): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...args.hostEnv } - for (const key of REMOTE_CONTEXT_ENV_VARS) { - const value = args.remoteEnv[key] - if (typeof value === 'string' && value.length > 0) { - env[key] = value - } - } - // Why: bind the subprocess to this app instance's runtime metadata (dev and - // parallel instances use non-default userData dirs). - env.ORCA_USER_DATA_PATH = args.userDataPath - // Why: the caller's working directory lives on the remote machine, so the - // subprocess cwd cannot be chdir'd there; ORCA_CLI_CWD carries it for - // cwd-based selectors like `--worktree active`. - env.ORCA_CLI_CWD = args.remoteCwd - // Why: recovery commands run on the SSH execution host through its relay shim. - env.ORCA_CLI_COMMAND = 'orca' - // Why: same node-mode hygiene as the shipped CLI launchers — stash and clear - // NODE_OPTIONS so Electron's node bootstrap does not inherit them. - env.ORCA_NODE_OPTIONS = args.hostEnv.NODE_OPTIONS ?? '' - env.ORCA_NODE_REPL_EXTERNAL_MODULE = args.hostEnv.NODE_REPL_EXTERNAL_MODULE ?? '' - delete env.NODE_OPTIONS - delete env.NODE_REPL_EXTERNAL_MODULE - delete env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] - delete env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] - delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] - delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] - delete env[REMOTE_ARTIFACT_INPUT_ENV] - // Why: a remote command must never claim a local agent session. The host's env carries one only - // when Orca was launched inside a session, and identity by session id is same-host only. - delete env[ORCA_AGENT_SESSION_ID_ENV] - delete env[ORCA_STRUCTURED_SESSION_ENV] - // Why: this machine's paired-server selection would route the remote command with its credentials. - delete env.ORCA_PAIRING_CODE - delete env.ORCA_REMOTE_PAIRING - delete env.ORCA_ENVIRONMENT - env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] = args.bridgeCredential - // Why: stamped from this process, never from the remote env, so remote input cannot pick the runtime. - // The host CLI dials exactly this profile, so the stamp names the same one. - stampRuntimeSourceEnv( - env, - args.runtimeSource ? { ...args.runtimeSource, profilePath: args.userDataPath } : null - ) - if (args.runtimeAuthority) { - env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh' - env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId - env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] = - args.runtimeAuthority.connectionIncarnation - env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] = args.runtimeAuthority.attachmentId - } - if (args.artifactInput) { - const sourceKey = args.runtimeAuthority - ? sshArtifactSourceKey(args.runtimeAuthority.targetId, args.artifactInput.sourceKey) - : args.artifactInput.sourceKey - env[REMOTE_ARTIFACT_INPUT_ENV] = JSON.stringify({ ...args.artifactInput, sourceKey }) - } - env.ELECTRON_RUN_AS_NODE = '1' - return env -} - -export async function runHostOrcaCliPassthrough( - request: RemoteOrcaCliRequest, - options: HostCliPassthroughOptions = {} -): Promise { - // Why: per-field lazy defaults keep the module testable — tests inject all - // three, so no Electron API is touched outside the production path. - const execPath = options.execPath ?? process.execPath - let cliEntryPath: string - let userDataPath: string - try { - cliEntryPath = - options.cliEntryPath ?? - resolveHostCliEntryPath({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath() - }) - // Why: must match the userData dir the runtime RPC server writes metadata - // to (see index.ts OrcaRuntimeRpcServer wiring), or the CLI subprocess - // reports "Orca is not running" against a healthy app. - userDataPath = options.userDataPath ?? getCanonicalUserDataPath() - } catch (err) { - // Why: no Electron app context (or broken install paths) — degrade to the - // caller's legacy in-process fallback instead of failing the command. - throw new HostCliUnavailableError( - `Host CLI environment unavailable: ${err instanceof Error ? err.message : String(err)}` - ) - } - const hostEnv = options.hostEnv ?? process.env - const spawn = options.spawn ?? nodeSpawn - const entryExists = options.entryExists ?? existsSync - const killTimeoutMs = options.killTimeoutMs ?? resolveHostCliKillTimeoutMs(request.argv) - if (!isSafeTimerDelayMs(killTimeoutMs)) { - throw new RangeError( - `Host CLI kill timeout must be an integer between 0 and ${MAX_TIMER_DELAY_MS}ms.` - ) - } - - if (!entryExists(cliEntryPath)) { - throw new HostCliUnavailableError(`Orca CLI entry not found at ${cliEntryPath}`) - } - - const credential = (options.credentials ?? sshBridgeCredentials).mint(request.callerScope) - const env = buildHostCliEnv({ - hostEnv, - remoteEnv: request.env, - userDataPath, - remoteCwd: request.cwd, - bridgeCredential: credential.token, - runtimeAuthority: request.runtimeAuthority, - artifactInput: request.artifactInput, - runtimeSource: options.runtimeSource - }) - - try { - return await spawnHostCli(spawn, execPath, cliEntryPath, request, env, killTimeoutMs) - } finally { - credential.revoke() - } -} - -function spawnHostCli( - spawn: typeof nodeSpawn, - execPath: string, - cliEntryPath: string, - request: RemoteOrcaCliRequest, - env: NodeJS.ProcessEnv, - killTimeoutMs: number -): Promise { - return new Promise((resolve, reject) => { - let settled = false - const child = spawn(execPath, [cliEntryPath, ...request.argv], { - env, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) - - const stdout = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES) - const stderr = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES) - - const killTimer = setTimeout(() => { - if (settled) { - return - } - settled = true - try { - child.kill('SIGKILL') - } catch { - // best effort — process may already be gone - } - resolve({ - stdout: stdout.toString(), - stderr: `${stderr.toString()}Orca CLI bridge timed out after ${killTimeoutMs}ms on the host.\n`, - exitCode: 1 - }) - }, killTimeoutMs) - killTimer.unref?.() - - child.on('error', (err) => { - if (settled) { - return - } - settled = true - clearTimeout(killTimer) - // Why: failure to launch (ENOENT, EACCES) means the host CLI is not - // runnable at all — signal the caller to use the legacy fallback rather - // than reporting a confusing per-command failure. - reject( - new HostCliUnavailableError(`Failed to launch the Orca CLI on the host: ${err.message}`) - ) - }) - - child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk)) - child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk)) - - child.on('close', (code) => { - if (settled) { - return - } - settled = true - clearTimeout(killTimer) - resolve({ - stdout: stdout.toString(), - stderr: stderr.toString(), - exitCode: typeof code === 'number' ? code : 1 - }) - }) - - if (child.stdin) { - child.stdin.on('error', () => { - // Why: the CLI may exit without draining stdin; EPIPE here is routine. - }) - if (request.stdin !== undefined) { - child.stdin.end(request.stdin) - } else { - child.stdin.end() - } - } - }) -} - -class CappedOutputCollector { - private readonly chunks: Buffer[] = [] - private bytes = 0 - private truncated = false - - constructor(private readonly maxBytes: number) {} - - push(chunk: Buffer): void { - if (this.truncated) { - return - } - const remaining = this.maxBytes - this.bytes - if (chunk.length >= remaining) { - this.chunks.push(chunk.subarray(0, remaining)) - this.bytes = this.maxBytes - this.truncated = true - return - } - this.chunks.push(chunk) - this.bytes += chunk.length - } - - toString(): string { - const text = Buffer.concat(this.chunks).toString('utf8') - return this.truncated ? `${text}\n[orca ssh cli] output truncated\n` : text - } -} diff --git a/src/main/ssh/ssh-remote-cli-in-process-result.ts b/src/main/ssh/ssh-remote-cli-in-process-result.ts deleted file mode 100644 index a35512e3451d..000000000000 --- a/src/main/ssh/ssh-remote-cli-in-process-result.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { RpcResponse } from '../runtime/rpc/core' -import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import type { RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' -import { formatRemoteCli } from './ssh-remote-cli-format' -import { - formatRemoteOrchestrationAsk, - getRemoteCliExitCode -} from './ssh-remote-orchestration-ask-output' -import { formatRemoteOrchestrationCheck } from './ssh-remote-orchestration-check-output' -import { getRemoteCliPostOutput } from './ssh-remote-orchestration-post-output' -import { resolveRemoteCliHandle } from './ssh-remote-cli-args' - -export function formatInProcessRemoteCliResult( - parsed: ParsedRemoteCli, - env: Record, - response: RpcResponse, - json: boolean -): RemoteOrcaCliResult { - const command = parsed.commandPath.join(' ') - const formatted = - command === 'orchestration check' - ? formatRemoteOrchestrationCheck( - response, - json, - resolveRemoteCliHandle(parsed.flags, env, 'terminal'), - parsed.flags.has('format') - ) - : command === 'orchestration ask' - ? formatRemoteOrchestrationAsk(response, json) - : json - ? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' } - : formatRemoteCli(response) - const postOutput = getRemoteCliPostOutput(parsed, env, response) - return { - stdout: formatted.stdout, - stderr: formatted.stderr, - exitCode: getRemoteCliExitCode(command, response), - ...(postOutput ? { postOutput } : {}) - } -} diff --git a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts deleted file mode 100644 index 44804c80b8ab..000000000000 --- a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => '/host/app' - } -})) -vi.mock('../persistence', () => ({ - getCanonicalUserDataPath: () => '/host/user-data' -})) - -import { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' -import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { CONTROL_GRANTED_SSH_BRIDGE_SCOPE } from './ssh-bridge-caller-scope.test-fixture' - -// Why: a missing CLI entry forces the legacy in-process bridge, the transport -// an SSH-hosted agent actually reaches `terminal list` through. -const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { - execPath: '/host/electron', - cliEntryPath: '/host/app/out/cli/index.js', - userDataPath: '/host/user-data', - entryExists: () => false -} - -describe('remote CLI bridge terminal list', () => { - it('relays the execution host and scope to an SSH-hosted caller', async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals: [ - { - handle: 'term_remote', - ptyId: 'ssh:box-1@@pty-7', - worktreeId: 'repo-ssh::/remote/wt', - worktreePath: '/remote/wt', - branch: 'main', - tabId: 'tab-1', - leafId: 'leaf-1', - title: 'worker', - connected: true, - writable: true, - lastOutputAt: null, - preview: '', - executionHostId: 'ssh:box-1' - } - ], - totalCount: 1, - truncated: false, - hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } - }) - - const result = await runRemoteOrcaCli( - runtime, - { - callerScope: CONTROL_GRANTED_SSH_BRIDGE_SCOPE, - argv: ['terminal', 'list', '--json'], - cwd: '/home/alice/repo', - env: {} - }, - LEGACY_FALLBACK_OPTIONS - ) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - result: { - terminals: { executionHostId?: string }[] - hostScope?: { hostIds: string[]; omittedHostIds: string[] } - } - } - expect(payload.result.terminals[0]?.executionHostId).toBe('ssh:box-1') - expect(payload.result.hostScope).toEqual({ - hostIds: ['ssh:box-1'], - omittedHostIds: ['local'] - }) - }) -}) diff --git a/src/main/ssh/ssh-remote-linear-activity-output.test.ts b/src/main/ssh/ssh-remote-linear-activity-output.test.ts deleted file mode 100644 index 3cfb70c46867..000000000000 --- a/src/main/ssh/ssh-remote-linear-activity-output.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { LinearIssueContextResult } from '../../shared/linear/agent-access' -import { formatRemoteLinearCli } from './ssh-remote-linear-output' - -describe('SSH Linear activity output', () => { - it('shows requested activity in non-json issue output', () => { - const result: LinearIssueContextResult = { - issue: { - id: 'issue-1', - identifier: 'ENG-123', - title: 'Fix thing', - url: 'https://linear.app/acme/issue/ENG-123', - labels: [] - }, - activity: [{ id: 'history-1', actor: { kind: 'system' }, changes: [] }], - meta: { - requested: { - current: true, - include: { - comments: false, - children: false, - attachments: false, - relations: false, - activity: true - }, - depth: 2 - }, - resolved: { - id: 'issue-1', - identifier: 'ENG-123', - workspaceId: 'workspace-1', - workspaceName: 'Acme' - }, - partial: false, - includeErrors: [], - sections: { - activity: { returned: 1, cap: 250, capReached: false, hasMore: false } - } - } - } - - expect(formatRemoteLinearCli(result)?.stdout).toContain('Activity: 1') - }) -}) diff --git a/src/main/ssh/ssh-remote-linear-cli.test.ts b/src/main/ssh/ssh-remote-linear-cli.test.ts deleted file mode 100644 index 4efe8927cbde..000000000000 --- a/src/main/ssh/ssh-remote-linear-cli.test.ts +++ /dev/null @@ -1,877 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { isLinearProjectListResult } from './ssh-remote-linear-result-guards' -import { runControlGrantedRemoteOrcaCli as runRemoteOrcaCli } from './ssh-bridge-caller-scope.test-fixture' - -function createRuntime() { - const runtime = { - getRuntimeId: () => 'runtime-test', - getStatus: () => ({ - runtimeId: 'runtime-test', - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: 1, - liveTabCount: 1, - liveLeafCount: 1 - }), - linearIssueContext: vi.fn(async (request: unknown) => ({ - request, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - title: 'Fix thing', - url: 'https://linear.app/acme/issue/ENG-123', - labels: [{ id: 'label-1', name: 'Bug' }], - priority: 2, - estimate: 5, - dueDate: '2026-06-30' - }, - meta: { - requested: { - current: true, - depth: 2 - }, - resolved: { - id: 'issue-1', - identifier: 'ENG-123', - workspaceId: 'workspace-1', - workspaceName: 'Acme' - }, - partial: false, - includeErrors: [], - sections: {} - } - })), - linearSearchForAgents: vi.fn(async (request: unknown) => ({ - request, - issues: [], - meta: { - query: 'auth bug', - limit: 5, - returned: 0, - limitReached: false, - partial: false, - workspaceErrors: [] - } - })), - linearTeamListForAgents: vi.fn(async (request: unknown) => ({ - request, - teams: [ - { - id: 'team-1', - key: 'ENG', - name: 'Engineering', - workspace: { id: 'workspace-1', name: 'Acme' } - } - ], - meta: { workspaceId: 'workspace-1', returned: 1, partial: false, workspaceErrors: [] } - })), - linearTeamLabelsForAgents: vi.fn(async (request: unknown) => ({ - request, - team: { id: 'team-1', key: 'ENG', name: 'Engineering' }, - labels: [{ id: 'label-1', name: 'Bug', color: '#ff0000' }], - meta: { workspaceId: 'workspace-1', returned: 1 } - })), - linearProjectListForAgents: vi.fn(async (request: unknown) => ({ - request, - projects: [ - { - id: 'project-1', - name: 'Launch', - workspaceId: 'workspace-1', - workspaceName: 'Acme', - teams: [{ id: 'team-1', name: 'Engineering', key: 'ENG' }] - } - ], - meta: { - query: 'launch', - workspaceId: 'workspace-1', - limit: 5, - returned: 1, - hasMore: false, - partial: false, - workspaceErrors: [] - } - })), - linearIssueListForAgents: vi.fn(async (request: unknown) => ({ - request, - issues: [], - meta: { - filter: 'open', - workspaceId: 'workspace-1', - limit: 5, - returned: 0, - hasMore: false, - partial: false, - workspaceErrors: [] - } - })), - linearIssueSetState: vi.fn(async (request: unknown) => ({ - request, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - url: 'https://linear.app/acme/issue/ENG-123' - }, - state: { id: 'state-review', name: 'In Review', type: 'started' }, - previousState: { id: 'state-started', name: 'In Progress' }, - meta: { workspaceId: 'workspace-1', alreadyInState: false } - })), - linearIssueUpdateTask: vi.fn(async (request: unknown) => ({ - request, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - url: 'https://linear.app/acme/issue/ENG-123' - }, - operation: 'priority', - previous: { - assignee: null, - priority: 0, - estimate: null, - dueDate: null, - labels: [] - }, - current: { - assignee: null, - priority: 2, - estimate: null, - dueDate: null, - labels: [] - }, - meta: { workspaceId: 'workspace-1', alreadySet: false } - })), - linearIssueAddComment: vi.fn(async (request: unknown) => ({ - request, - comment: { id: 'comment-1', url: null, parentId: null }, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - url: 'https://linear.app/acme/issue/ENG-123' - }, - meta: { - workspaceId: 'workspace-1', - bodyChars: 4, - writeId: '123e4567-e89b-12d3-a456-426614174000', - deduplicated: false - } - })), - linearIssueAttachLink: vi.fn(async (request: unknown) => ({ - request, - attachment: { id: 'attachment-1', title: 'PR/MR link', url: 'https://example.com/review/1' }, - issue: { - id: 'issue-1', - identifier: 'ENG-123', - url: 'https://linear.app/acme/issue/ENG-123' - }, - meta: { - workspaceId: 'workspace-1', - writeId: '123e4567-e89b-12d3-a456-426614174000', - deduplicated: false - } - })), - linearIssueCreate: vi.fn(async (request: unknown) => ({ - request, - issue: { - id: 'issue-2', - identifier: 'ENG-456', - title: 'Follow-up', - url: 'https://linear.app/acme/issue/ENG-456', - team: { id: 'team-1', key: 'ENG', name: 'Engineering' }, - state: { id: 'state-triage', name: 'Triage' }, - parent: { id: 'issue-1', identifier: 'ENG-123' }, - project: { id: 'project-1', name: 'Launch' } - }, - meta: { - workspaceId: 'workspace-1', - writeId: '123e4567-e89b-12d3-a456-426614174000', - deduplicated: false - } - })) - } as unknown as OrcaRuntimeService - return runtime -} - -describe('runRemoteOrcaCli Linear commands', () => { - it('dispatches Linear issue reads through the remote runtime with SSH context hints', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', '--current', '--full', '--json'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { current: boolean; context: Record } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - current: true, - include: expect.objectContaining({ activity: true }), - context: { - remote: true, - terminalHandle: 'term_ssh', - worktreeId: 'repo::remote' - } - }) - expect(payload.result.request.context).not.toHaveProperty('cwd') - }) - - it('accepts leading boolean flags before SSH Linear commands', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['--json', 'linear', 'issue', 'ENG-123', '--full'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { input: string; include: Record } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - input: 'ENG-123', - include: expect.objectContaining({ activity: true }) - }) - }) - - it('dispatches Linear search positional queries through the remote runtime', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'search', 'auth bug', '--limit', '5', '--workspace', 'all', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { query: string; limit: number; workspaceId: string } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toEqual({ - query: 'auth bug', - limit: 5, - workspaceId: 'all' - }) - }) - - it('dispatches Linear discovery and list reads through the remote runtime', async () => { - const runtime = createRuntime() - - const teamList = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'team', 'list', '--workspace', 'all', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - const labels = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'team', 'labels', '--team', 'ENG', '--workspace', 'workspace-1', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - const list = await runRemoteOrcaCli(runtime, { - argv: [ - 'linear', - 'list', - '--filter', - 'open', - '--team', - 'ENG', - '--limit', - '5', - '--workspace', - 'workspace-1', - '--json' - ], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - const projects = await runRemoteOrcaCli(runtime, { - argv: [ - 'linear', - 'project', - 'list', - '--query', - 'launch', - '--limit', - '5', - '--workspace', - 'workspace-1', - '--json' - ], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(teamList.exitCode).toBe(0) - expect(labels.exitCode).toBe(0) - expect(list.exitCode).toBe(0) - expect(projects.exitCode).toBe(0) - expect( - (runtime as unknown as { linearTeamListForAgents: ReturnType }) - .linearTeamListForAgents - ).toHaveBeenCalledWith({ workspaceId: 'all' }) - expect( - (runtime as unknown as { linearTeamLabelsForAgents: ReturnType }) - .linearTeamLabelsForAgents - ).toHaveBeenCalledWith({ teamInput: 'ENG', workspaceId: 'workspace-1' }) - expect( - (runtime as unknown as { linearIssueListForAgents: ReturnType }) - .linearIssueListForAgents - ).toHaveBeenCalledWith({ - filter: 'open', - teamInput: 'ENG', - limit: 5, - workspaceId: 'workspace-1' - }) - expect( - (runtime as unknown as { linearProjectListForAgents: ReturnType }) - .linearProjectListForAgents - ).toHaveBeenCalledWith({ - query: 'launch', - limit: 5, - workspaceId: 'workspace-1' - }) - }) - - it('formats SSH Linear project list in non-json mode', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'project', 'list', '--query', 'launch', '--workspace', 'workspace-1'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('Launch') - expect(result.stdout).toContain('project-1') - expect(result.stdout).toContain('ENG') - expect(result.stdout).toContain('Acme') - expect(result.stderr).toBe('') - }) - - it('rejects malformed SSH Linear project list results before formatting', () => { - expect( - isLinearProjectListResult({ - projects: [{ id: 'project-1' }], - meta: { - limit: 5, - returned: 1, - hasMore: false, - partial: false, - workspaceErrors: [] - } - }) - ).toBe(false) - expect( - isLinearProjectListResult({ - projects: [{ id: 'project-1', name: 'Launch', teams: [{ id: 'team-1' }] }], - meta: { - limit: 5, - returned: 1, - hasMore: false, - partial: false, - workspaceErrors: [] - } - }) - ).toBe(false) - }) - - it('dispatches Linear status writes through the remote runtime with SSH context hints', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'status', 'set', 'ENG-123', '--to', 'In Review', '--json'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { input: string; to: string; context: Record } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - input: 'ENG-123', - to: 'In Review', - context: { - remote: true, - terminalHandle: 'term_ssh', - worktreeId: 'repo::remote' - } - }) - }) - - it('dispatches Linear task-field writes through the SSH remote runtime', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'priority', 'set', 'ENG-123', '--to', 'high', '--json'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { input: string; operation: string; priority: number } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - input: 'ENG-123', - operation: 'priority', - priority: 2 - }) - }) - - it('dispatches Linear creates with project input through the SSH remote runtime', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: [ - 'linear', - 'create', - '--title', - 'Follow-up', - '--team', - 'ENG', - '--project', - 'project-1', - '--json' - ], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { title: string; teamInput: string; projectInput: string } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - title: 'Follow-up', - teamInput: 'ENG', - projectInput: 'project-1' - }) - }) - - it('formats SSH Linear creates with project input in non-json mode', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'create', '--title', 'Follow-up', '--team', 'ENG', '--project', 'project-1'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('Created ENG-456 under ENG-123 in Launch: Follow-up.\n') - expect(result.stderr).toBe('') - }) - - it('parses --me as a boolean for SSH Linear assignee writes', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'assignee', 'set', '--me', 'ENG-123', '--json'], - cwd: '/home/alice/remote-repo', - env: { - ORCA_TERMINAL_HANDLE: 'term_ssh', - ORCA_WORKTREE_ID: 'repo::remote' - } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { input: string; operation: string; assigneeMe: boolean } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - input: 'ENG-123', - operation: 'assignee', - assigneeMe: true - }) - }) - - it('preserves repeated labels for SSH Linear label writes', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: [ - 'linear', - 'label', - 'set', - 'ENG-123', - '--label', - 'label-1', - '--label', - 'label-2', - '--json' - ], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { operation: string; labelMode: string; labels: string[] } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request).toMatchObject({ - operation: 'labels', - labelMode: 'set', - labels: ['label-1', 'label-2'] - }) - }) - - it('formats SSH Linear writes in non-json mode', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('Added comment comment-1 to ENG-123.\n') - expect(result.stderr).toBe('') - }) - - it('dispatches body-file stdin writes in the SSH shim', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', '--current', '--body-file', '-', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }, - stdin: 'line one\nline two\n' - }) - - expect(result.exitCode).toBe(0) - const payload = JSON.parse(result.stdout) as { - ok: boolean - result: { request: { body: string } } - } - expect(payload.ok).toBe(true) - expect(payload.result.request.body).toBe('line one\nline two\n') - }) - - it('rejects body-file stdin writes when SSH stdin is unavailable', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', '--current', '--body-file', '-', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'invalid_argument', - message: 'SSH Linear writes require stdin when using --body-file -.' - }) - }) - - it('rejects remote body-file paths in the SSH shim before dispatch', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', '--current', '--body-file', 'body.md', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'invalid_argument', - message: 'SSH Linear writes only support --body-file - for stdin.' - }) - }) - - it('formats SSH Linear issue reads in non-json mode', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', '--current'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('ENG-123 Fix thing') - expect(result.stdout).toContain('URL: https://linear.app/acme/issue/ENG-123') - expect(result.stdout).toContain('Priority: high') - expect(result.stdout).toContain('Estimate: 5') - expect(result.stdout).toContain('Labels: Bug') - expect(result.stdout).toContain('Due: 2026-06-30') - expect(result.stdout).not.toContain('"issue"') - }) - - it('prints SSH Linear search partial warnings to stderr in non-json mode', async () => { - const runtime = createRuntime() - const linearSearchForAgents = ( - runtime as unknown as { linearSearchForAgents: ReturnType } - ).linearSearchForAgents - linearSearchForAgents.mockResolvedValueOnce({ - issues: [], - meta: { - query: 'auth', - limit: 20, - returned: 0, - limitReached: false, - partial: true, - workspaceErrors: [ - { - workspace: { id: 'workspace-stale', name: 'Stale' }, - code: 'linear_network_error', - message: 'fetch failed' - } - ] - } - }) - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'search', 'auth'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('No Linear issues found.\n') - expect(result.stderr).toContain('warning: Stale unavailable for Linear search: fetch failed') - }) - - it('formats older SSH Linear search results without workspaceErrors in non-json mode', async () => { - const runtime = createRuntime() - const linearSearchForAgents = ( - runtime as unknown as { linearSearchForAgents: ReturnType } - ).linearSearchForAgents - linearSearchForAgents.mockResolvedValueOnce({ - issues: [], - meta: { - query: 'auth', - limit: 20, - returned: 0, - limitReached: false, - partial: false - } - }) - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'search', 'auth'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('No Linear issues found.\n') - expect(result.stderr).toBe('') - }) - - it('prints SSH Linear non-json failures to stderr instead of stdout', async () => { - const runtime = createRuntime() - const linearIssueContext = ( - runtime as unknown as { linearIssueContext: ReturnType } - ).linearIssueContext - linearIssueContext.mockRejectedValueOnce(new Error('Linear is not connected.')) - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', '--current'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('Linear is not connected.') - }) - - it('prints SSH Linear non-json next steps from structured errors', async () => { - const runtime = createRuntime() - const linearIssueAddComment = ( - runtime as unknown as { linearIssueAddComment: ReturnType } - ).linearIssueAddComment - linearIssueAddComment.mockRejectedValueOnce( - Object.assign(new Error('Linear may have applied the write.'), { - code: 'linear_write_unconfirmed', - data: { nextSteps: ['Retry once with the pinned command: `orca linear comment add`.'] } - }) - ) - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('Linear may have applied the write.') - expect(result.stderr).toContain('Next step: Retry once with the pinned command') - }) - - it('shows SSH Linear command help without dispatching to the runtime', async () => { - const runtime = createRuntime() - const linearIssueContext = ( - runtime as unknown as { linearIssueContext: ReturnType } - ).linearIssueContext - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', '--help'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('orca linear issue') - expect(result.stdout).toContain('Usage: orca linear issue') - expect(linearIssueContext).not.toHaveBeenCalled() - }) - - it('shows SSH Linear group help without dispatching to the runtime', async () => { - const runtime = createRuntime() - const linearIssueContext = ( - runtime as unknown as { linearIssueContext: ReturnType } - ).linearIssueContext - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', '--help'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('orca linear') - expect(result.stdout).toContain('Usage: orca linear [options]') - expect(result.stdout).toContain('search') - expect(result.stdout).toContain('team list') - expect(result.stdout).toContain('label set') - expect(result.stdout).toContain('comment add') - expect(linearIssueContext).not.toHaveBeenCalled() - }) - - it('shows SSH Linear help through the local help command form', async () => { - const runtime = createRuntime() - const linearIssueContext = ( - runtime as unknown as { linearIssueContext: ReturnType } - ).linearIssueContext - - const group = await runRemoteOrcaCli(runtime, { - argv: ['help', 'linear'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - const issue = await runRemoteOrcaCli(runtime, { - argv: ['help', 'linear', 'issue'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(group.exitCode).toBe(0) - expect(group.stdout).toContain('Usage: orca linear [options]') - expect(issue.exitCode).toBe(0) - expect(issue.stdout).toContain('Usage: orca linear issue') - expect(linearIssueContext).not.toHaveBeenCalled() - }) - - it('rejects ambiguous Linear issue positional and flag ids in the remote shim', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', 'ENG-123', '--id', 'ENG-456', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'invalid_argument', - message: 'Pass --id either positionally or as a flag, not both.' - }) - }) - - it('rejects invalid Linear numeric flags in the remote shim', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'search', 'auth', '--limit', 'bad', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'invalid_argument', - message: 'Invalid numeric value for --limit' - }) - }) - - it('preserves Linear-specific JSON error codes for pre-dispatch remote shim validation', async () => { - const runtime = createRuntime() - - const result = await runRemoteOrcaCli(runtime, { - argv: ['linear', 'issue', 'ENG-123', '--workspace', 'all', '--json'], - cwd: '/home/alice/remote-repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) - - expect(result.exitCode).toBe(1) - const payload = JSON.parse(result.stdout) as { - ok: boolean - error: { code: string; message: string } - } - expect(payload.ok).toBe(false) - expect(payload.error).toMatchObject({ - code: 'linear_invalid_workspace', - message: '--workspace all is not valid for issue' - }) - }) -}) diff --git a/src/main/ssh/ssh-remote-linear-cli.ts b/src/main/ssh/ssh-remote-linear-cli.ts deleted file mode 100644 index 4a5c5c4b4167..000000000000 --- a/src/main/ssh/ssh-remote-linear-cli.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import type { RpcResponse } from '../runtime/rpc/core' -import { getRemoteLinearReadHelp } from './ssh-remote-linear-read-help' -import { tryDispatchRemoteLinearReadCli } from './ssh-remote-linear-read-cli' -import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import { - getRemoteLinearWriteHelp, - tryDispatchRemoteLinearWriteCli -} from './ssh-remote-linear-write-cli' - -export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null { - const helpPath = remoteLinearHelpPath(parsed) - if (!helpPath) { - return null - } - const readHelp = getRemoteLinearReadHelp(helpPath) - if (readHelp) { - return readHelp - } - return getRemoteLinearWriteHelp({ ...parsed, commandPath: helpPath }) -} - -function remoteLinearHelpPath(parsed: ParsedRemoteCli): string[] | null { - if (parsed.commandPath[0] === 'help' && parsed.commandPath[1] === 'linear') { - return parsed.commandPath.slice(1) - } - if (parsed.flags.has('help') && parsed.commandPath[0] === 'linear') { - return parsed.commandPath - } - return null -} - -export async function tryDispatchRemoteLinearCli( - dispatcher: RpcDispatcher, - parsed: ParsedRemoteCli, - env: Record, - stdin?: string -): Promise { - const readResponse = await tryDispatchRemoteLinearReadCli(dispatcher, parsed, env) - if (readResponse) { - return readResponse - } - const writeResponse = await tryDispatchRemoteLinearWriteCli(dispatcher, parsed, env, stdin) - if (writeResponse) { - return writeResponse - } - return null -} diff --git a/src/main/ssh/ssh-remote-linear-list-issues.test.ts b/src/main/ssh/ssh-remote-linear-list-issues.test.ts deleted file mode 100644 index f7c66aee7e6a..000000000000 --- a/src/main/ssh/ssh-remote-linear-list-issues.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import { dispatchRemoteLinearListIssues } from './ssh-remote-linear-list-issues' - -describe('SSH Linear MCP-compatible issue listing', () => { - it('dispatches the same filter and pagination contract as the local CLI', async () => { - const dispatch = vi.fn().mockResolvedValue({ - id: 'response-1', - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - const dispatcher = { dispatch } as unknown as RpcDispatcher - - await dispatchRemoteLinearListIssues(dispatcher, { - commandPath: ['linear', 'list-issues'], - flags: new Map([ - ['team', 'ENG'], - ['cycle', 'Cycle 1'], - ['label', 'Bug'], - ['limit', '100'], - ['query', 'auth'], - ['state', 'In Progress'], - ['cursor', 'next-page'], - ['order-by', 'createdAt'], - ['project', 'Launch'], - ['release', 'v1'], - ['assignee', 'me'], - ['delegate', 'agent-1'], - ['parent-id', 'issue-parent'], - ['priority', '2'], - ['created-at', '-P30D'], - ['updated-at', '-P7D'], - ['include-archived', true], - ['workspace', 'workspace-1'], - ['json', true] - ]) - }) - - expect(dispatch).toHaveBeenCalledWith({ - id: expect.stringMatching(/^remote-cli-/), - authToken: 'remote-cli', - method: 'linear.mcpListIssues', - params: { - team: 'ENG', - cycle: 'Cycle 1', - label: 'Bug', - limit: 100, - query: 'auth', - state: 'In Progress', - cursor: 'next-page', - orderBy: 'createdAt', - project: 'Launch', - release: 'v1', - assignee: 'me', - delegate: 'agent-1', - parentId: 'issue-parent', - priority: 2, - createdAt: '-P30D', - updatedAt: '-P7D', - includeArchived: true, - workspaceId: 'workspace-1' - } - }) - }) - - it('rejects zero limits before RPC dispatch like the local CLI', async () => { - const dispatch = vi.fn() - const dispatcher = { dispatch } as unknown as RpcDispatcher - - await expect( - dispatchRemoteLinearListIssues(dispatcher, { - commandPath: ['linear', 'list-issues'], - flags: new Map([['limit', '0']]) - }) - ).rejects.toMatchObject({ - code: 'invalid_argument', - message: 'Invalid positive integer for --limit' - }) - expect(dispatch).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/ssh-remote-linear-list-issues.ts b/src/main/ssh/ssh-remote-linear-list-issues.ts deleted file mode 100644 index 98f10e8f36c7..000000000000 --- a/src/main/ssh/ssh-remote-linear-list-issues.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { LinearMcpIssueListRequest } from '../../shared/linear/agent-access' -import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import type { RpcResponse } from '../runtime/rpc/core' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import { LINEAR_MCP_ISSUE_LIST_FLAGS } from './ssh-remote-linear-read-flags' - -export async function dispatchRemoteLinearListIssues( - dispatcher: RpcDispatcher, - parsed: ParsedRemoteCli -): Promise { - for (const flag of parsed.flags.keys()) { - if (!LINEAR_MCP_ISSUE_LIST_FLAGS.has(flag)) { - throw new RemoteCliArgumentError('invalid_argument', `Unknown flag: --${flag}`) - } - } - if (parsed.commandPath.length !== 2) { - throw new RemoteCliArgumentError('invalid_argument', 'list-issues does not accept positionals') - } - const priority = optionalInteger(parsed.flags, 'priority') - if (priority !== undefined && (priority < 0 || priority > 4)) { - throw new RemoteCliArgumentError('invalid_argument', '--priority must be between 0 and 4') - } - const limit = optionalPositiveInteger(parsed.flags, 'limit') - const request: LinearMcpIssueListRequest = { - team: optionalString(parsed.flags, 'team'), - cycle: optionalString(parsed.flags, 'cycle'), - label: optionalString(parsed.flags, 'label'), - limit, - query: optionalString(parsed.flags, 'query'), - state: optionalString(parsed.flags, 'state'), - cursor: optionalString(parsed.flags, 'cursor'), - orderBy: orderBy(parsed.flags), - project: optionalString(parsed.flags, 'project'), - release: optionalString(parsed.flags, 'release'), - assignee: optionalString(parsed.flags, 'assignee'), - delegate: optionalString(parsed.flags, 'delegate'), - parentId: optionalString(parsed.flags, 'parent-id'), - priority, - createdAt: optionalString(parsed.flags, 'created-at'), - updatedAt: optionalString(parsed.flags, 'updated-at'), - includeArchived: parsed.flags.get('include-archived') === true, - workspaceId: optionalString(parsed.flags, 'workspace') - } - return await dispatcher.dispatch({ - id: `remote-cli-${Date.now()}`, - authToken: 'remote-cli', - method: 'linear.mcpListIssues', - params: request - }) -} - -function optionalString(flags: Map, name: string): string | undefined { - const value = flags.get(name) - return typeof value === 'string' && value ? value : undefined -} - -function optionalInteger(flags: Map, name: string): number | undefined { - const raw = optionalString(flags, name) - if (raw === undefined) { - return undefined - } - const value = Number(raw) - if (!Number.isInteger(value) || value < 0) { - throw new RemoteCliArgumentError('invalid_argument', `--${name} must be an integer`) - } - return value -} - -function optionalPositiveInteger( - flags: Map, - name: string -): number | undefined { - const raw = optionalString(flags, name) - if (raw === undefined) { - return undefined - } - const value = Number(raw) - if (!Number.isInteger(value) || value <= 0) { - throw new RemoteCliArgumentError('invalid_argument', `Invalid positive integer for --${name}`) - } - return value -} - -function orderBy(flags: Map): LinearMcpIssueListRequest['orderBy'] { - const value = optionalString(flags, 'order-by') - if (value === undefined || value === 'createdAt' || value === 'updatedAt') { - return value - } - throw new RemoteCliArgumentError('invalid_argument', '--order-by must be createdAt or updatedAt') -} diff --git a/src/main/ssh/ssh-remote-linear-output.ts b/src/main/ssh/ssh-remote-linear-output.ts deleted file mode 100644 index 533e743d10ef..000000000000 --- a/src/main/ssh/ssh-remote-linear-output.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { - LinearIssueContextResult, - LinearIssueListResult, - LinearMcpIssueListResult, - LinearIssueTaskUpdateResult, - LinearIssueRelationWriteResult, - LinearProjectListResult, - LinearSearchIssueSummary, - LinearSearchResult, - LinearTeamListResult, - LinearTeamLabelsResult, - LinearTeamMembersResult, - LinearTeamStatesResult, - LinearStatusSetResult, - LinearCommentAddResult, - LinearAttachResult, - LinearCreateResult, - LinearSaveIssueResult -} from '../../shared/linear/agent-access' -import { appendLinearListTruncation } from '../../shared/linear/list-truncation-format' -import { linearPriorityLabel } from '../../shared/linear/priority-label' -import { - formatLinearProjectListRows, - linearProjectListWarningLines -} from '../../shared/linear/project-list-format' -import { - isLinearAttachResult, - isLinearCommentAddResult, - isLinearCreateResult, - isLinearSaveIssueResult, - isLinearIssueContextResult, - isLinearIssueListResult, - isLinearMcpIssueListResult, - isLinearProjectListResult, - isLinearSearchResult, - isLinearStatusSetResult, - isLinearTaskUpdateResult, - isLinearRelationWriteResult, - isLinearTeamLabelsResult, - isLinearTeamListResult, - isLinearTeamMembersResult, - isLinearTeamStatesResult -} from './ssh-remote-linear-result-guards' - -export function formatRemoteLinearCli(result: unknown): { stdout: string; stderr: string } | null { - if (isLinearIssueContextResult(result)) { - return { stdout: `${formatLinearIssue(result)}\n`, stderr: linearIssueWarnings(result) } - } - if (isLinearSearchResult(result)) { - return { - stdout: `${appendLinearListTruncation( - formatLinearIssueRows(result.issues), - result.issues.length, - result.truncated ?? result.meta.limitReached - )}\n`, - stderr: linearListWarnings(result, 'Linear search') - } - } - if (isLinearMcpIssueListResult(result)) { - return { - stdout: `${appendLinearListTruncation( - formatLinearIssueRows(result.issues), - result.issues.length, - result.truncated ?? result.meta.hasMore - )}\n`, - stderr: linearMcpListWarnings(result) - } - } - if (isLinearIssueListResult(result)) { - return { - stdout: `${appendLinearListTruncation( - formatLinearIssueRows(result.issues), - result.issues.length, - result.truncated ?? result.meta.hasMore - )}\n`, - stderr: linearListWarnings(result) - } - } - if (isLinearProjectListResult(result)) { - return { - stdout: `${formatLinearProjectListRows(result)}\n`, - stderr: linearProjectListWarnings(result) - } - } - if (isLinearTeamListResult(result)) { - return { stdout: `${formatLinearTeamList(result)}\n`, stderr: linearListWarnings(result) } - } - if (isLinearTeamMembersResult(result)) { - return { stdout: `${formatLinearTeamMembers(result)}\n`, stderr: '' } - } - if (isLinearTeamStatesResult(result)) { - return { stdout: `${formatLinearTeamStates(result)}\n`, stderr: '' } - } - if (isLinearTeamLabelsResult(result)) { - return { stdout: `${formatLinearTeamLabels(result)}\n`, stderr: '' } - } - if (isLinearStatusSetResult(result)) { - return { stdout: `${formatLinearStatusSet(result)}\n`, stderr: '' } - } - if (isLinearTaskUpdateResult(result)) { - return { stdout: `${formatLinearTaskUpdate(result)}\n`, stderr: '' } - } - if (isLinearRelationWriteResult(result)) { - return { stdout: `${formatLinearRelationWrite(result)}\n`, stderr: '' } - } - if (isLinearCommentAddResult(result)) { - return { stdout: `${formatLinearCommentAdd(result)}\n`, stderr: '' } - } - if (isLinearAttachResult(result)) { - return { stdout: `${formatLinearAttach(result)}\n`, stderr: '' } - } - if (isLinearSaveIssueResult(result)) { - return { stdout: `${formatLinearSaveIssue(result)}\n`, stderr: '' } - } - if (isLinearCreateResult(result)) { - return { stdout: `${formatLinearCreate(result)}\n`, stderr: '' } - } - return null -} - -function formatLinearIssue(result: LinearIssueContextResult): string { - const issue = result.issue - const lines = [ - `${issue.identifier} ${issue.title}`, - `URL: ${issue.url}`, - `State: ${issue.state?.name ?? 'unknown'}`, - `Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`, - `Project: ${issue.project?.name ?? 'none'}` - ] - lines.push(`Priority: ${formatPriority(issue.priority)}`) - lines.push(`Estimate: ${issue.estimate ?? 'none'}`) - if (issue.labels.length > 0) { - lines.push( - `Labels: ${issue.labels - .map((label) => label.name) - .filter(Boolean) - .join(', ')}` - ) - } - if (issue.dueDate) { - lines.push(`Due: ${issue.dueDate}`) - } - for (const section of ['comments', 'children', 'attachments', 'relations', 'activity'] as const) { - const meta = result.meta.sections[section] - if (meta) { - lines.push(`${section[0].toUpperCase()}${section.slice(1)}: ${meta.returned}`) - } - } - if (result.inlineMedia?.length) { - lines.push(`Inline media: ${result.inlineMedia.length} (use --json for URLs)`) - } - return lines.join('\n') -} - -function formatLinearSaveIssue(result: LinearSaveIssueResult): string { - return result.meta.created - ? formatLinearCreate(result) - : `Saved ${result.issue.identifier}: ${result.issue.title}.` -} - -function formatLinearIssueRows(issues: LinearSearchIssueSummary[]): string { - if (issues.length === 0) { - return 'No Linear issues found.' - } - return issues.map(formatLinearIssueRow).join('\n') -} - -function formatLinearIssueRow(issue: LinearSearchIssueSummary): string { - const state = issue.state?.name ?? 'unknown' - const assignee = issue.assignee?.displayName ?? 'unassigned' - return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}` -} - -function formatPriority(priority: number | null | undefined): string { - return linearPriorityLabel(priority) -} - -function formatLinearTeamList(result: LinearTeamListResult): string { - if (result.teams.length === 0) { - return 'No Linear teams found.' - } - return result.teams - .map( - (team) => - `${team.key.padEnd(10)} ${team.name}${team.workspace ? ` ${team.workspace.name}` : ''}` - ) - .join('\n') -} - -function formatLinearTeamMembers(result: LinearTeamMembersResult): string { - if (result.members.length === 0) { - return `No Linear members found for ${result.team.key}.` - } - return result.members - .map((member) => `${(member.displayName ?? 'unknown').padEnd(24)} ${member.id ?? ''}`) - .join('\n') -} - -function formatLinearTeamStates(result: LinearTeamStatesResult): string { - if (result.states.length === 0) { - return `No Linear workflow states found for ${result.team.key}.` - } - return result.states - .map((state) => `${state.name.padEnd(24)} ${(state.type ?? '').padEnd(12)} ${state.id}`) - .join('\n') -} - -function formatLinearTeamLabels(result: LinearTeamLabelsResult): string { - if (result.labels.length === 0) { - return `No Linear labels found for ${result.team.key}.` - } - return result.labels.map((label) => `${label.name.padEnd(24)} ${label.id}`).join('\n') -} - -function formatLinearStatusSet(result: LinearStatusSetResult): string { - const suffix = result.meta.alreadyInState ? ' (already set)' : '' - return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.` -} - -function formatLinearTaskUpdate(result: LinearIssueTaskUpdateResult): string { - const suffix = result.meta.alreadySet ? ' (already set)' : '' - return `Updated ${result.issue.identifier} ${taskOperationLabel(result.operation)}${suffix}.` -} - -function formatLinearRelationWrite(result: LinearIssueRelationWriteResult): string { - const verb = result.operation === 'add' ? 'Added' : 'Removed' - const suffix = result.meta.alreadySet - ? result.operation === 'add' - ? ' (already present)' - : ' (already absent)' - : '' - return `${verb} ${result.issue.identifier} ${result.relation.relationship} ${result.relatedIssue.identifier}${suffix}.` -} - -function formatLinearCommentAdd(result: LinearCommentAddResult): string { - const suffix = result.meta.deduplicated ? ' (already posted)' : '' - return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.` -} - -function formatLinearAttach(result: LinearAttachResult): string { - const suffix = result.meta.deduplicated ? ' (already attached)' : '' - return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.` -} - -function formatLinearCreate(result: LinearCreateResult | LinearSaveIssueResult): string { - const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : '' - const project = result.issue.project?.name ? ` in ${result.issue.project.name}` : '' - const suffix = result.meta.deduplicated ? ' (already created)' : '' - return `Created ${result.issue.identifier}${parent}${project}: ${result.issue.title}${suffix}.` -} - -function taskOperationLabel(operation: LinearIssueTaskUpdateResult['operation']): string { - return operation === 'dueDate' ? 'due date' : operation -} - -function linearIssueWarnings(result: LinearIssueContextResult): string { - const warnings = result.meta.includeErrors.map( - (error) => `warning: ${error.include} unavailable: ${error.message}` - ) - for (const [name, meta] of Object.entries(result.meta.sections)) { - if (meta?.capReached) { - warnings.push(`warning: ${name} capped at ${meta.returned}/${meta.cap}`) - } - } - return warnings.length > 0 ? `${warnings.join('\n')}\n` : '' -} - -function linearListWarnings( - result: LinearSearchResult | LinearIssueListResult | LinearTeamListResult, - label = 'Linear' -): string { - const warnings: string[] = [] - const meta = result.meta - if ('hasMore' in meta && meta.hasMore) { - warnings.push(`warning: showing first ${meta.returned} Linear issues`) - } - if ('limitReached' in meta && meta.limitReached) { - warnings.push(`warning: showing first ${meta.returned} Linear issues`) - } - for (const error of meta.workspaceErrors ?? []) { - warnings.push(`warning: ${error.workspace.name} unavailable for ${label}: ${error.message}`) - } - return warnings.length > 0 ? `${warnings.join('\n')}\n` : '' -} - -function linearMcpListWarnings(result: LinearMcpIssueListResult): string { - const warnings = result.meta.workspaceErrors.map( - (error) => `warning: ${error.workspace.name} unavailable for Linear: ${error.message}` - ) - if (result.meta.hasMore) { - warnings.unshift( - `warning: more results available; next cursor: ${result.meta.nextCursor ?? 'n/a'}` - ) - } - return warnings.length > 0 ? `${warnings.join('\n')}\n` : '' -} - -function linearProjectListWarnings(result: LinearProjectListResult): string { - const warnings = linearProjectListWarningLines(result) - return warnings.length > 0 ? `${warnings.join('\n')}\n` : '' -} diff --git a/src/main/ssh/ssh-remote-linear-read-cli.ts b/src/main/ssh/ssh-remote-linear-read-cli.ts deleted file mode 100644 index d9ecfb273655..000000000000 --- a/src/main/ssh/ssh-remote-linear-read-cli.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { - LINEAR_CHILDREN_MAX_DEPTH, - clampLinearIssueDepth, - clampLinearSearchLimit, - type LinearIssueInclude -} from '../../shared/linear/agent-access' -import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import type { RpcResponse } from '../runtime/rpc/core' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' -import { dispatchRemoteLinearListIssues } from './ssh-remote-linear-list-issues' - -import { - LINEAR_ISSUE_FLAGS, - LINEAR_LIST_FLAGS, - LINEAR_PROJECT_LIST_FLAGS, - LINEAR_SEARCH_FLAGS, - LINEAR_TEAM_LIST_FLAGS, - LINEAR_TEAM_LOOKUP_FLAGS -} from './ssh-remote-linear-read-flags' - -export async function tryDispatchRemoteLinearReadCli( - dispatcher: RpcDispatcher, - parsed: ParsedRemoteCli, - env: Record -): Promise { - if (isRemoteCommand(parsed, 'linear', 'list-issues')) { - return await dispatchRemoteLinearListIssues(dispatcher, parsed) - } - if (isRemoteCommand(parsed, 'linear', 'issue')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'issue'], - allowedFlags: LINEAR_ISSUE_FLAGS, - positionalFlag: 'id', - maxPositionals: 1 - }) - return await call(dispatcher, 'linear.issueContext', buildRemoteLinearIssueRequest(parsed, env)) - } - if (isRemoteCommand(parsed, 'linear', 'search')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'search'], - allowedFlags: LINEAR_SEARCH_FLAGS, - positionalFlag: 'query', - maxPositionals: 1 - }) - return await call(dispatcher, 'linear.agentSearchIssues', { - query: remotePositional(parsed, 2) ?? requiredString(parsed.flags, 'query'), - limit: clampLinearSearchLimit(optionalPositiveInteger(parsed.flags, 'limit')), - workspaceId: optionalString(parsed.flags, 'workspace') - }) - } - if (isRemoteCommand(parsed, 'linear', 'team', 'list')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'team', 'list'], - allowedFlags: LINEAR_TEAM_LIST_FLAGS, - positionalFlag: 'id', - maxPositionals: 0 - }) - return await call(dispatcher, 'linear.agentTeamList', { - workspaceId: optionalString(parsed.flags, 'workspace') - }) - } - if (isRemoteCommand(parsed, 'linear', 'team', 'members')) { - return await dispatchRemoteLinearTeamLookup( - dispatcher, - parsed, - ['linear', 'team', 'members'], - 'linear.agentTeamMembers' - ) - } - if (isRemoteCommand(parsed, 'linear', 'team', 'states')) { - return await dispatchRemoteLinearTeamLookup( - dispatcher, - parsed, - ['linear', 'team', 'states'], - 'linear.agentTeamStates' - ) - } - if (isRemoteCommand(parsed, 'linear', 'team', 'labels')) { - return await dispatchRemoteLinearTeamLookup( - dispatcher, - parsed, - ['linear', 'team', 'labels'], - 'linear.agentTeamLabels' - ) - } - if (isRemoteCommand(parsed, 'linear', 'project', 'list')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'project', 'list'], - allowedFlags: LINEAR_PROJECT_LIST_FLAGS, - positionalFlag: 'id', - maxPositionals: 0 - }) - return await call(dispatcher, 'linear.agentProjectList', { - query: optionalString(parsed.flags, 'query'), - limit: clampLinearSearchLimit(optionalPositiveInteger(parsed.flags, 'limit')), - workspaceId: optionalString(parsed.flags, 'workspace') - }) - } - if (isRemoteCommand(parsed, 'linear', 'list')) { - validateLinearRemoteArgs(parsed, { - command: ['linear', 'list'], - allowedFlags: LINEAR_LIST_FLAGS, - positionalFlag: 'id', - maxPositionals: 0 - }) - return await call(dispatcher, 'linear.agentIssueList', { - filter: linearListFilter(parsed.flags), - teamInput: optionalString(parsed.flags, 'team'), - limit: optionalPositiveInteger(parsed.flags, 'limit'), - workspaceId: optionalString(parsed.flags, 'workspace') - }) - } - return null -} - -async function dispatchRemoteLinearTeamLookup( - dispatcher: RpcDispatcher, - parsed: ParsedRemoteCli, - command: string[], - method: string -): Promise { - validateLinearRemoteArgs(parsed, { - command, - allowedFlags: LINEAR_TEAM_LOOKUP_FLAGS, - positionalFlag: 'team', - maxPositionals: 0 - }) - return await call(dispatcher, method, { - teamInput: requiredString(parsed.flags, 'team'), - workspaceId: optionalString(parsed.flags, 'workspace') - }) -} - -function validateLinearRemoteArgs( - parsed: ParsedRemoteCli, - options: { - command: string[] - allowedFlags: ReadonlySet - positionalFlag: string - maxPositionals: number - } -): void { - for (const flag of parsed.flags.keys()) { - if (!options.allowedFlags.has(flag)) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `Unknown flag --${flag} for command: ${options.command.join(' ')}` - ) - } - } - - const positionals = parsed.commandPath.slice(options.command.length) - if (positionals.length > options.maxPositionals) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `Unknown command: ${parsed.commandPath.join(' ')}` - ) - } - if (positionals.length > 0 && parsed.flags.has(options.positionalFlag)) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `Pass --${options.positionalFlag} either positionally or as a flag, not both.` - ) - } -} - -function isRemoteCommand(parsed: ParsedRemoteCli, ...command: string[]): boolean { - return command.every((part, index) => parsed.commandPath[index] === part) -} - -function remotePositional(parsed: ParsedRemoteCli, startIndex: number): string | undefined { - const value = parsed.commandPath.slice(startIndex).join(' ').trim() - return value || undefined -} - -function buildRemoteLinearIssueRequest( - parsed: ParsedRemoteCli, - env: Record -): Record { - const full = parsed.flags.get('full') === true - const include: Record = { - comments: full || parsed.flags.get('comments') === true, - children: full || parsed.flags.get('children') === true, - attachments: full || parsed.flags.get('attachments') === true, - relations: full || parsed.flags.get('relations') === true, - activity: full || parsed.flags.get('activity') === true - } - if (parsed.flags.has('depth') && !include.children) { - throw new RemoteCliArgumentError('invalid_argument', '--depth requires --children or --full') - } - const requestedDepth = optionalNonNegativeInteger(parsed.flags, 'depth') - if (requestedDepth !== undefined && requestedDepth > LINEAR_CHILDREN_MAX_DEPTH) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `--depth must be at most ${LINEAR_CHILDREN_MAX_DEPTH}` - ) - } - const workspaceId = optionalString(parsed.flags, 'workspace') - if (workspaceId === 'all') { - throw new RemoteCliArgumentError( - 'linear_invalid_workspace', - '--workspace all is not valid for issue' - ) - } - const input = optionalString(parsed.flags, 'id') ?? remotePositional(parsed, 2) - return { - input, - current: input ? false : parsed.flags.get('current') === true, - workspaceId, - include, - depth: clampLinearIssueDepth(requestedDepth), - context: { - remote: true, - ...(env.ORCA_WORKTREE_ID ? { worktreeId: env.ORCA_WORKTREE_ID } : {}), - ...(env.ORCA_TERMINAL_HANDLE ? { terminalHandle: env.ORCA_TERMINAL_HANDLE } : {}) - } - } -} - -async function call( - dispatcher: RpcDispatcher, - method: string, - params?: Record -): Promise { - return await dispatcher.dispatch({ - id: `remote-cli-${Date.now()}`, - authToken: 'remote-cli', - method, - params - }) -} - -function requiredString(flags: Map, name: string): string { - const value = optionalString(flags, name) - if (!value) { - throw new RemoteCliArgumentError('invalid_argument', `Missing --${name}`) - } - return value -} - -function optionalString(flags: Map, name: string): string | undefined { - const value = flags.get(name) - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function optionalNumber(flags: Map, name: string): number | undefined { - const value = optionalString(flags, name) - if (value === undefined) { - return undefined - } - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new RemoteCliArgumentError('invalid_argument', `Invalid numeric value for --${name}`) - } - return parsed -} - -function optionalPositiveInteger( - flags: Map, - name: string -): number | undefined { - const value = optionalNumber(flags, name) - if (value === undefined) { - return undefined - } - if (!Number.isInteger(value) || value <= 0) { - throw new RemoteCliArgumentError('invalid_argument', `Invalid positive integer for --${name}`) - } - return value -} - -function linearListFilter( - flags: Map -): 'assigned' | 'created' | 'all' | 'completed' | 'open' | undefined { - const filter = optionalString(flags, 'filter') - if (filter === undefined) { - return undefined - } - if (['assigned', 'created', 'all', 'completed', 'open'].includes(filter)) { - return filter as 'assigned' | 'created' | 'all' | 'completed' | 'open' - } - throw new RemoteCliArgumentError( - 'invalid_argument', - '--filter must be assigned, created, all, completed, or open' - ) -} - -function optionalNonNegativeInteger( - flags: Map, - name: string -): number | undefined { - const value = optionalNumber(flags, name) - if (value === undefined) { - return undefined - } - if (!Number.isInteger(value) || value < 0) { - throw new RemoteCliArgumentError( - 'invalid_argument', - `Invalid non-negative integer for --${name}` - ) - } - return value -} diff --git a/src/main/ssh/ssh-remote-linear-read-flags.ts b/src/main/ssh/ssh-remote-linear-read-flags.ts deleted file mode 100644 index 08eea74c43d2..000000000000 --- a/src/main/ssh/ssh-remote-linear-read-flags.ts +++ /dev/null @@ -1,83 +0,0 @@ -export const LINEAR_ISSUE_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'current', - 'comments', - 'children', - 'depth', - 'attachments', - 'relations', - 'activity', - 'full', - 'workspace', - 'id' -]) -export const LINEAR_SEARCH_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'limit', - 'workspace', - 'query' -]) -export const LINEAR_TEAM_LIST_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'workspace' -]) -export const LINEAR_TEAM_LOOKUP_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'team', - 'workspace' -]) -export const LINEAR_PROJECT_LIST_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'query', - 'limit', - 'workspace' -]) -export const LINEAR_LIST_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'filter', - 'team', - 'limit', - 'workspace' -]) -export const LINEAR_MCP_ISSUE_LIST_FLAGS = new Set([ - 'help', - 'json', - 'pairing-code', - 'environment', - 'team', - 'cycle', - 'label', - 'limit', - 'query', - 'state', - 'cursor', - 'order-by', - 'project', - 'release', - 'assignee', - 'delegate', - 'parent-id', - 'priority', - 'created-at', - 'updated-at', - 'include-archived', - 'workspace' -]) diff --git a/src/main/ssh/ssh-remote-linear-read-help.ts b/src/main/ssh/ssh-remote-linear-read-help.ts deleted file mode 100644 index da7948f4e9df..000000000000 --- a/src/main/ssh/ssh-remote-linear-read-help.ts +++ /dev/null @@ -1,163 +0,0 @@ -export function getRemoteLinearReadHelp(commandPath: string[]): string | null { - if (commandPath.length === 1 && commandPath[0] === 'linear') { - return LINEAR_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'issue')) { - return LINEAR_ISSUE_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'list-issues')) { - return LINEAR_MCP_ISSUE_LIST_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'search')) { - return LINEAR_SEARCH_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'team', 'list')) { - return LINEAR_TEAM_LIST_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'team', 'members')) { - return LINEAR_TEAM_MEMBERS_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'team', 'states')) { - return LINEAR_TEAM_STATES_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'team', 'labels')) { - return LINEAR_TEAM_LABELS_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'project', 'list')) { - return LINEAR_PROJECT_LIST_HELP - } - if (matchesRemoteCommand(commandPath, 'linear', 'list')) { - return LINEAR_LIST_HELP - } - return null -} - -function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean { - return ( - commandPath.length === command.length && - command.every((part, index) => commandPath[index] === part) - ) -} - -const LINEAR_HELP = `orca linear - -Usage: orca linear [options] - -Commands: - save-issue Create or update a Linear issue - list-issues List Linear issues with MCP-compatible filters - relation add Add a Linear issue relation - relation remove Remove a Linear issue relation - issue Read Linear issue context for agents - search Search connected Linear workspaces - team list List connected Linear teams - team members List Linear team members - team states List Linear team workflow states - team labels List Linear team labels - project list List connected Linear projects - list List Linear issues - assignee set Set a Linear issue assignee - assignee clear Clear a Linear issue assignee - priority set Set a Linear issue priority - priority clear Clear a Linear issue priority - estimate set Set a Linear issue estimate - estimate clear Clear a Linear issue estimate - due-date set Set a Linear issue due date - due-date clear Clear a Linear issue due date - label add Add labels to a Linear issue - label remove Remove labels from a Linear issue - label set Replace labels on a Linear issue - status set Set a Linear issue status - comment add Add a comment to a Linear issue - attach Attach a link to a Linear issue - create Create a Linear issue - -Run \`orca linear --help\` for command-specific usage.` - -const LINEAR_ISSUE_HELP = `orca linear issue - -Usage: orca linear issue [] [--current] [--comments] [--children] [--depth ] [--attachments] [--relations] [--activity] [--full] [--workspace ] [--json] - -Read Linear issue context for agents - -Options: - --help Show this help message - --json Emit machine-readable JSON - --pairing-code - --environment - --current Use the current Orca worktree linked Linear issue - --comments Include threaded Linear comments - --children Include recursive child issues - --depth Child issue depth for --children/--full - --attachments Include attachment metadata and URLs - --relations Include blocking, related, and duplicate links - --activity Include issue field-change history - --full Include all supported V1 issue context within caps - --workspace Connected Linear workspace id - --id Linear issue key, id, or URL - -Examples: - $ orca linear issue ENG-123 - $ orca linear issue --current --comments - $ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json` - -const LINEAR_MCP_ISSUE_LIST_HELP = `orca linear list-issues - -Usage: orca linear list-issues [--team ] [--cycle ] [--label