diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e450ce62b8e8..dd1e0ab2e5df 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -319,6 +319,10 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:runtime-electron-ratchet + - name: Enforce owner-routing ratchet + if: needs.code_paths.outputs.static_analysis == 'true' + run: pnpm run check:owner-routing-ratchet + - name: Check Node runtime pin if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:node-runtime-pin @@ -808,6 +812,7 @@ jobs: # Cancellation and failed prerequisites stop the expensive matrix. if: >- !cancelled() && + needs.code_paths.result == 'success' && needs.code_paths.outputs.test == 'true' && needs.preflight.result == 'success' uses: ./.github/workflows/unit-tests.yml @@ -815,6 +820,7 @@ jobs: node_versions: '["24"]' runner: ubuntu-24.04-arm shards: ${{ needs.preflight.outputs.shards }} + browser_contract: ${{ needs.code_paths.outputs.orcad_browser == 'true' }} # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s @@ -824,48 +830,6 @@ jobs: if: ${{ !cancelled() && (needs.test.result == 'success' || needs.test.result == 'failure') }} uses: ./.github/workflows/unit-selection-evidence.yml - # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix - # would pay for it on every shard to run one file in whichever shard it landed in. - orcad_browser: - name: orcad browser provider - needs: [code_paths, preflight] - if: >- - !cancelled() && - needs.code_paths.result == 'success' && - needs.code_paths.outputs.orcad_browser == 'true' && - needs.preflight.result == 'success' - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - # Why no native-runtime: the provider drives the prebuilt agent-browser binary - # shipped in node_modules and never touches node-pty. - - uses: ./.github/actions/install-node-dependencies - - # Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an - # AppArmor userns profile for the Chrome .deb, so chromium dies with "No usable - # sandbox" and the provider passes no --no-sandbox. Why fail instead of skip: an - # unset ORCA_BROWSER_EXECUTABLE is exactly how this test went uncovered for so long. - - name: Resolve Chrome for the browser provider - run: | - set -euo pipefail - chrome="$(command -v google-chrome || command -v google-chrome-stable || true)" - if [ -z "$chrome" ]; then - echo "::error::No Google Chrome on the runner; the browser provider test would silently skip." - exit 1 - fi - "$chrome" --version - echo "ORCA_BROWSER_EXECUTABLE=$chrome" >> "$GITHUB_ENV" - - - name: Test external Chromium browser provider - run: | - pnpm exec vitest run --config config/vitest.config.ts \ - src/main/orcad/external-chromium-browser-process.integration.test.ts - # Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test` # matrix would pay for both on every shard to run two files. It builds the same bundle the # package job ships, and adds the render and census checks packaging does not run. @@ -1428,7 +1392,6 @@ jobs: - xterm_patch_sync - shell_contracts - test - - orcad_browser - mobile_web_app - cross-version-wire - managed_hook_node18 @@ -1463,8 +1426,6 @@ jobs: SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }} TEST: ${{ needs.test.result }} TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }} - ORCAD_BROWSER: ${{ needs.orcad_browser.result }} - ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }} MOBILE_WEB_APP: ${{ needs.mobile_web_app.result }} MOBILE_WEB_APP_SHOULD_RUN: ${{ needs.code_paths.outputs.mobile_web_app }} CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }} @@ -1504,7 +1465,6 @@ jobs: check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN" check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN" check_job test "$TEST" "$TEST_SHOULD_RUN" - check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN" check_job mobile_web_app "$MOBILE_WEB_APP" "$MOBILE_WEB_APP_SHOULD_RUN" check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN" check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN" diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index 4ab295aa26af..fc319e9f9762 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -17,6 +17,7 @@ on: - 'src/main/ssh/ssh-hostile-host-*' - 'src/main/ssh/sftp-*' - 'src/relay/**' + - 'src/wsl-guest/**' - 'src/shared/node-runtime-pin.ts' - 'src/shared/orcad-artifacts.ts' - 'config/scripts/build-orcad-*.mjs' diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index b37f58207ab7..752f3ff9a2a0 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -18,6 +18,7 @@ on: - 'src/main/ssh/ssh-windows-host-*' - 'src/main/ssh/ssh-session-command-audit.ts' - 'src/relay/**' + - 'src/wsl-guest/**' - 'src/packages/process-host/**' - 'pnpm-workspace.yaml' - 'src/shared/node-runtime-pin.ts' diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index c4ff51dd1e19..3848e542cf4e 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -14,6 +14,12 @@ on: default: ubuntu-latest type: string + browser_contract: + description: Include the PR external Chromium browser contract in relay integration. + required: false + default: false + type: boolean + shards: description: JSON array of shard assignments, produced by unit-plan.yml. required: true @@ -96,6 +102,7 @@ jobs: persist-credentials: false - uses: ./.github/actions/install-node-dependencies + id: root_dependencies with: native-runtime: none node-version: ${{ matrix.node }} @@ -103,15 +110,40 @@ jobs: pnpm-lock.yaml cloud/pnpm-lock.yaml + - name: Resolve Chrome for the browser provider + id: browser_executable + if: inputs.browser_contract && success() + run: | + set -euo pipefail + chrome="$(command -v google-chrome || command -v google-chrome-stable || true)" + if [ -z "$chrome" ]; then + echo "::error::No Google Chrome on the runner; the browser provider test would silently skip." + exit 1 + fi + "$chrome" --version + echo "executable=$chrome" >> "$GITHUB_OUTPUT" + + - name: Test external Chromium browser provider + if: inputs.browser_contract && success() + env: + ORCA_BACKGROUND_LAUNCH: '1' + ORCA_BROWSER_EXECUTABLE: ${{ steps.browser_executable.outputs.executable }} + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/orcad/external-chromium-browser-process.integration.test.ts + # These two tests import the cloud relay workspace directly. Keeping them in one job # avoids installing and building the same workspace once per unit-test shard. - name: Install relay integration dependencies + id: relay_dependencies + if: ${{ !cancelled() && steps.root_dependencies.outcome == 'success' }} working-directory: cloud run: | npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay^...' build - name: Test relay integration contracts + if: ${{ !cancelled() && steps.relay_dependencies.outcome == 'success' }} env: ORCA_BACKGROUND_LAUNCH: '1' run: >- diff --git a/config/knip.json b/config/knip.json index 98891b9e687a..512129707510 100644 --- a/config/knip.json +++ b/config/knip.json @@ -21,7 +21,7 @@ "src/renderer/src/**/*.worker.ts", "src/cli/index.ts", "src/relay/relay.ts", - "src/relay/wsl-agent-hook-relay.ts", + "src/wsl-guest/wsl-agent-hook-relay.ts", "config/scripts/**/*.{mjs,cjs,js,ts}", "config/build-plugins/**/*.ts", "electron.vite.config.ts", diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt new file mode 100644 index 000000000000..7f4a13042581 --- /dev/null +++ b/config/owner-routing-baseline.txt @@ -0,0 +1,131 @@ +# 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/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/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 +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/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 +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/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 +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 +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 +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 +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 +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/editor/actions/markdown-link-action.ts +4 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 +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 +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/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index abbad1d1a476..4b50f1549903 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -130,7 +130,6 @@ src/relay/ai-vault-service-spawn.ts src/relay/external-automations-handler.ts src/relay/fs-handler-git-fallback.ts src/relay/fs-handler-list-files.ts -src/relay/preflight-handler.ts src/relay/pty-shell-utils.ts src/relay/subprocess-tree-termination.ts src/relay/workspace-space-scan.ts @@ -140,3 +139,4 @@ src/shared/fish-binary-requirement.ts src/shared/process-table-snapshot-reader.ts src/shared/pty-slave-line-discipline-echo.ts src/shared/shell-process-readiness.ts +src/wsl-guest/preflight-handler.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index aec0fe91af29..db244cabe768 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -337,7 +337,7 @@ "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/relay/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/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" ], "testFiles": [ "src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts", @@ -346,7 +346,7 @@ "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/relay/agent-hook-interrupt-reconciliation.test.ts", + "src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts", "src/main/ssh/ssh-agent-hook-interrupt-reconciliation.test.ts" ], "assertionRefs": [ @@ -376,7 +376,7 @@ "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/relay/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/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", "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." @@ -9838,7 +9838,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/relay/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 src/main/ssh/ssh-relay-session-agent-hooks.integration.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": [ @@ -9848,7 +9848,7 @@ "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/relay/agent-hook-server.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" @@ -9896,7 +9896,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/relay/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 src/main/ssh/ssh-relay-session-agent-hooks.integration.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,17 +12799,17 @@ "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/relay/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/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/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/relay/dispatcher.test.ts src/relay/pty-handler-source-publication.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/relay/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/relay/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-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", @@ -12846,7 +12846,7 @@ "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/relay/protocol-backpressure.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", "src/relay/pty-source-credit-ledger.test.ts", @@ -12855,7 +12855,7 @@ "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", - "src/relay/dispatcher.test.ts", + "src/wsl-guest/dispatcher.test.ts", "src/relay/pty-handler-source-publication.test.ts", "src/relay/ssh-pty-source-credit-adapter.test.ts", "src/shared/pty-consumer-session.test.ts", @@ -12872,7 +12872,7 @@ "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/relay/dispatcher-client-writer.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" ], @@ -13029,7 +13029,7 @@ ] }, { - "file": "src/relay/dispatcher.test.ts", + "file": "src/wsl-guest/dispatcher.test.ts", "assertions": [ "a saturated legacy primary remains required backpressure and is never detached as an additional subscriber" ] @@ -13063,7 +13063,7 @@ ] }, { - "file": "src/relay/protocol-backpressure.test.ts", + "file": "src/wsl-guest/protocol-backpressure.test.ts", "assertions": [ "relay decoder accepts one maximum frame plus 1 MiB partial input and rejects one extra byte", "a throwing continuation clears retained input, releases one pause epoch, and publishes one typed ownership error" @@ -13196,7 +13196,7 @@ ] }, { - "file": "src/relay/dispatcher-client-writer.test.ts", + "file": "src/wsl-guest/dispatcher-client-writer.test.ts", "assertions": [ "encoded producer frames stay below high-water-minus-reserve capacity", "one fixed-size filesystem compatibility frame is admitted only on an empty sink", @@ -13250,7 +13250,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/relay/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/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", "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." diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index d901024bf8bc..8acbeb5760e1 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -618,9 +618,9 @@ describe('the Phase C budget', () => { // in from 50 with the envelope: it grants the worst swept route to each one past the sweep, // where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out. expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS) - expect(assertAssetCeilingFitsShell(29, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(251) + expect(assertAssetCeilingFitsShell(29, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(256) expect(() => assertAssetCeilingFitsShell(30, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( - /261 .*256/ + /266 .*256/ ) }) }) diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 27e5103e76aa..fcff37a82547 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -289,8 +289,8 @@ for (const platform of RELAY_BUILD_PLATFORMS) { // so a single platform-independent bundle suffices; it ships inside the // Windows app via the same out/relay extraResources mapping. { - const wslHookEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts') - const wslBrowserNetworkEntry = join(ROOT, 'src', 'relay', 'wsl-browser-network-relay.ts') + const wslHookEntry = join(ROOT, 'src', 'wsl-guest', 'wsl-agent-hook-relay.ts') + const wslBrowserNetworkEntry = join(ROOT, 'src', 'wsl-guest', 'wsl-browser-network-relay.ts') const outDir = join(OUT_ROOT, 'wsl') mkdirSync(outDir, { recursive: true }) await build({ diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 9b213031aa7d..484e558f1c57 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -232,11 +232,12 @@ export function collectBaseLineBlocks(root, comparisonBase, files = null) { // Why: in a split, the moved code's base text lives in the ORIGINAL file, which is // often deleted or renamed away. Deleted paths never reach the changed-file list // (it filters to ACMRTUB), so read every path the diff touches, deletions included. + // --no-renames: a rename otherwise lists only its new path, which the base lacks. const paths = files ?? - splitNullDelimited(runGit(root, ['diff', '--name-only', '-z', comparisonBase, '--'])).filter( - (file) => SOURCE_FILE_PATTERN.test(file) - ) + splitNullDelimited( + runGit(root, ['diff', '--name-only', '--no-renames', '-z', comparisonBase, '--']) + ).filter((file) => SOURCE_FILE_PATTERN.test(file)) const blocks = [] for (const file of paths) { const result = spawnSync('git', ['show', `${comparisonBase}:${file}`], { diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index 91d6f64d4cff..c87b80689a11 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -1,10 +1,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' import { runProcessSync } from '@orca/process-host' import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' import { OXLINT_SCANS, + collectBaseLineBlocks, diagnosticTouchesAddedLines, isUnloadedPluginDirectiveUnusedWarning, isMovedCode, @@ -124,6 +126,31 @@ describe('moved-code exemption', () => { it('never exempts an empty highlight', () => { expect(isMovedCode(['', ' '], [['a()']])).toBe(false) }) + + it('reads the base text of a file that was renamed away', () => { + const repo = mkdtempSync(path.join(tmpdir(), 'changed-code-quality-rename-')) + try { + const git = (...args) => { + const result = runProcessSync({ + program: 'git', + args: ['-c', 'commit.gpgsign=false', ...args], + cwd: repo + }) + expect(result.code, result.stderr).toBe(0) + return result.stdout.trim() + } + git('init', '--quiet') + writeFileSync(path.join(repo, 'old.ts'), 'const moved = value as Thing\n') + git('add', 'old.ts') + git('-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-qm', 'base') + const base = git('rev-parse', 'HEAD') + git('mv', 'old.ts', 'new.ts') + + expect(collectBaseLineBlocks(repo, base)).toEqual([['const moved = value as Thing']]) + } finally { + rmSync(repo, { recursive: true, force: true }) + } + }) }) describe('unloaded plugin directive unused warning', () => { diff --git a/config/scripts/check-owner-routing-ratchet.mjs b/config/scripts/check-owner-routing-ratchet.mjs new file mode 100644 index 000000000000..c3304373fa5c --- /dev/null +++ b/config/scripts/check-owner-routing-ratchet.mjs @@ -0,0 +1,157 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +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. + +const BASELINE_PATH = 'config/owner-routing-baseline.txt' +const SCAN_ROOT = 'src/renderer/src' +const HELPERS = '(?:getActiveRuntimeTarget|legacyRouteFromSettings|settingsForRuntimeOwner)' +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(`(? FOCUS_ROUTING_ALIAS.test(list)) +} + +export function isScannedPath(rel) { + return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)\.tsx?$/.test(rel) +} + +/** ` ` lines; `#` comments and blanks ignored. */ +export function parseBaseline(text) { + const counts = new Map() + for (const raw of text.split('\n')) { + const line = raw.trim() + if (!line || line.startsWith('#')) { + continue + } + const [count, file] = line.split(/\s+/, 2) + counts.set(file, Number(count)) + } + 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') + 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` +} + +/** `grown`: above baseline (fails). `shrunk`: below baseline, must be pruned so it cannot regrow. */ +export function diffCounts(current, baseline) { + const grown = [] + const shrunk = [] + for (const file of new Set([...current.keys(), ...baseline.keys()])) { + const now = current.get(file) ?? 0 + const allowed = baseline.get(file) ?? 0 + if (now > allowed) { + grown.push({ file, now, allowed }) + } else if (now < allowed) { + shrunk.push({ file, now, allowed }) + } + } + const byFile = (a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) + return { grown: grown.sort(byFile), shrunk: shrunk.sort(byFile) } +} + +export function collectCurrentCounts(root = process.cwd()) { + const tracked = execFileSync('git', ['ls-files', SCAN_ROOT], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024 + }) + .split('\n') + .filter((rel) => rel && isScannedPath(rel)) + const counts = new Map() + const aliased = [] + for (const rel of tracked) { + let source + try { + source = fs.readFileSync(path.join(root, rel), 'utf8') + } catch { + continue + } + const count = countFocusRoutingCalls(source) + if (count > 0) { + counts.set(rel, count) + } + if (hasFocusRoutingAlias(source)) { + aliased.push(rel) + } + } + return { counts, aliased } +} + +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) + if (!fs.existsSync(baselineFile)) { + console.error(`::error::Missing ${BASELINE_PATH}.`) + return 1 + } + const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) + const { counts: current, aliased } = collectCurrentCounts(root) + const { grown, shrunk } = diffCounts(current, baseline) + for (const file of aliased) { + 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.` + ) + } + for (const { file, now, allowed } of grown) { + 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.` + ) + } + for (const { file, now, allowed } of shrunk) { + console.error( + `::error file=${file}::${now} focus-routed call(s), baseline still allows ${allowed}. Run: pnpm check:owner-routing-ratchet --prune` + ) + } + if (aliased.length > 0 || grown.length > 0 || shrunk.length > 0) { + return 1 + } + console.log(`Owner-routing ratchet OK — ${total(current)} focus-routed call site(s).`) + return 0 +} + +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) + // 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)) + } + process.exit(main(root)) +} diff --git a/config/scripts/check-owner-routing-ratchet.test.mjs b/config/scripts/check-owner-routing-ratchet.test.mjs new file mode 100644 index 000000000000..c33b9304121e --- /dev/null +++ b/config/scripts/check-owner-routing-ratchet.test.mjs @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' + +import { + countFocusRoutingCalls, + diffCounts, + formatBaseline, + hasFocusRoutingAlias, + isScannedPath, + parseBaseline +} from './check-owner-routing-ratchet.mjs' + +describe('countFocusRoutingCalls', () => { + it('counts the three focus-routing helpers together', () => { + const src = [ + 'const a = getActiveRuntimeTarget(settings)', + 'const b = legacyRouteFromSettings(settings)', + 'const c = getActiveRuntimeTarget(settingsForRuntimeOwner(settings, id))' + ].join('\n') + expect(countFocusRoutingCalls(src)).toBe(4) + }) + + it('ignores definitions, type queries and longer names', () => { + const src = [ + 'export function getActiveRuntimeTarget(settings) {}', + 'type T = ReturnType', + 'mygetActiveRuntimeTarget(settings)' + ].join('\n') + expect(countFocusRoutingCalls(src)).toBe(0) + }) + + it('counts spaced calls and value uses but not import or export lists', () => { + const src = [ + "import { callRuntimeRpc, getActiveRuntimeTarget } from './rpc'", + "export {\n settingsForRuntimeOwner,\n type RuntimeClientTarget\n} from './target'", + 'const a = getActiveRuntimeTarget (settings)', + 'const b = list.map(getActiveRuntimeTarget)' + ].join('\n') + expect(countFocusRoutingCalls(src)).toBe(2) + }) +}) + +describe('hasFocusRoutingAlias', () => { + it('refuses an aliased import or re-export, which would hide its calls', () => { + expect(hasFocusRoutingAlias("import { getActiveRuntimeTarget as route } from './rpc'")).toBe( + true + ) + expect( + hasFocusRoutingAlias("export {\n settingsForRuntimeOwner as owner\n} from './target'") + ).toBe(true) + expect(hasFocusRoutingAlias("import { getActiveRuntimeTarget } from './rpc'")).toBe(false) + }) +}) + +describe('isScannedPath', () => { + it('scans renderer sources but not tests', () => { + expect(isScannedPath('src/renderer/src/a.ts')).toBe(true) + expect(isScannedPath('src/renderer/src/a.tsx')).toBe(true) + expect(isScannedPath('src/renderer/src/a.test.ts')).toBe(false) + expect(isScannedPath('src/renderer/src/a.spec.tsx')).toBe(false) + }) +}) + +describe('baseline', () => { + it('round-trips through format and parse, dropping zero rows', () => { + const counts = new Map([ + ['src/b.ts', 2], + ['src/a.ts', 1], + ['src/c.ts', 0] + ]) + expect(parseBaseline(formatBaseline(counts))).toEqual( + new Map([ + ['src/a.ts', 1], + ['src/b.ts', 2] + ]) + ) + }) + + it('fails growth, including a new file, and asks to prune shrinkage', () => { + const { grown, shrunk } = diffCounts( + new Map([ + ['src/a.ts', 3], + ['src/new.ts', 1], + ['src/b.ts', 1] + ]), + new Map([ + ['src/a.ts', 2], + ['src/b.ts', 2], + ['src/gone.ts', 1] + ]) + ) + expect(grown).toEqual([ + { file: 'src/a.ts', now: 3, allowed: 2 }, + { file: 'src/new.ts', now: 1, allowed: 0 } + ]) + expect(shrunk).toEqual([ + { file: 'src/b.ts', now: 1, allowed: 2 }, + { file: 'src/gone.ts', now: 0, allowed: 1 } + ]) + }) +}) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index a9653e554719..ebff3e03a683 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -4697,11 +4697,11 @@ "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/relay/agent-hook-envelope-publication.test.ts": 351, - "src/relay/agent-hook-integration.test.ts": 848, + "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/relay/agent-hook-server-codex-subagent-transcript.test.ts": 1327, - "src/relay/agent-hook-server.test.ts": 1396, + "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, @@ -4710,19 +4710,19 @@ "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/relay/dispatcher-capacity-degradation.test.ts": 194, - "src/relay/dispatcher-client-close-cause.test.ts": 194, - "src/relay/dispatcher-client-writer.test.ts": 92, - "src/relay/dispatcher-frame-guard-regressions.test.ts": 169, - "src/relay/dispatcher-json-payload.test.ts": 122, - "src/relay/dispatcher-notification-ownership.test.ts": 140, - "src/relay/dispatcher-per-connection-state-baseline.test.ts": 146, - "src/relay/dispatcher-rpc-cancel-id-coercion.test.ts": 134, - "src/relay/dispatcher-silent-client-reaper.test.ts": 213, - "src/relay/dispatcher-structured-error.test.ts": 136, - "src/relay/dispatcher-timeout.test.ts": 210, - "src/relay/dispatcher-writer-admission.test.ts": 161, - "src/relay/dispatcher.test.ts": 306, + "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, @@ -4788,20 +4788,20 @@ "src/relay/hermes-run-correlation.test.ts": 44, "src/relay/hermes-run-history.test.ts": 85, "src/relay/integration.test.ts": 1877, - "src/relay/legacy-relay-publication-ledger.test.ts": 36, + "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/relay/omp-config-root.test.ts": 152, + "src/wsl-guest/omp-config-root.test.ts": 152, "src/relay/omp-fresh-launch-environment.test.ts": 600, - "src/relay/plugin-overlay-env.test.ts": 60, - "src/relay/plugin-overlay.test.ts": 164, - "src/relay/plugin-source-limit.test.ts": 41, + "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/relay/preflight-handler.test.ts": 97, - "src/relay/protocol-backpressure.test.ts": 87, - "src/relay/protocol-handshake.test.ts": 63, - "src/relay/protocol-json-payload.test.ts": 188, + "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, @@ -4830,14 +4830,14 @@ "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/relay/relay-command-env.test.ts": 96, + "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/relay/relay-hot-path-operation-counts.test.ts": 211, + "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, @@ -4882,9 +4882,9 @@ "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/relay/wsl-agent-hook-relay.test.ts": 317, - "src/relay/wsl-hook-fs-bridge.test.ts": 39, - "src/relay/wsl-install-plugins-handler.test.ts": 161, + "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, diff --git a/config/scripts/github-opened-issue-repository.test.ts b/config/scripts/github-opened-issue-repository.test.ts index b2d0519c5cc1..25574f2a48b2 100644 --- a/config/scripts/github-opened-issue-repository.test.ts +++ b/config/scripts/github-opened-issue-repository.test.ts @@ -38,9 +38,6 @@ vi.mock('@/store', () => ({ { getState: () => ({ recordFeatureInteraction: vi.fn() }) } ) })) -vi.mock('@/lib/repo-runtime-owner', () => ({ - getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) -})) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({ useGitHubDuplicateIssueCandidates: () => [] diff --git a/config/scripts/github-pr-assignee-repository.test.ts b/config/scripts/github-pr-assignee-repository.test.ts index 0265aa92c414..277502b30f53 100644 --- a/config/scripts/github-pr-assignee-repository.test.ts +++ b/config/scripts/github-pr-assignee-repository.test.ts @@ -52,9 +52,6 @@ vi.mock('@/store', () => ({ { getState: () => ({ recordFeatureInteraction: vi.fn() }) } ) })) -vi.mock('@/lib/repo-runtime-owner', () => ({ - getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) -})) vi.mock('@/components/ui/popover', () => ({ Popover: vi.fn(), PopoverContent: vi.fn(), diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 6f4948712dd7..964de9620bc9 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1784,6 +1784,9 @@ "auto.components.right.sidebar.FileExplorerRow.3161c4e425": { "ko": "폴더" }, + "auto.components.right.sidebar.FileExplorerRow.revealInFinder": { + "ko": "Finder에서 보기" + }, "auto.components.right.sidebar.GitHistoryPanel.8232c8b2f2": { "ko": "{{value0}} commit 열기: {{value1}}" }, diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index 2135da349b53..e9cc5f4bd39d 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -17,7 +17,7 @@ const BUILD_PREFIXES = [ '.github/actions/prepare-orcad-prebuilds/' ] // A remote target's OS does not identify the client platform that builds its commands. -const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/'] +const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/', 'src/wsl-guest/'] const PLATFORM_PREFIXES = [ 'src/main/persistence/', 'src/main/sqlite/', diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index 2dd82ad5d261..94889330895e 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -5,6 +5,10 @@ import { parse } from 'yaml' import { classifyPrJobs } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const unit = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8')) +const relaySteps = unit.jobs.relay_integration.steps +const chrome = relaySteps.find((step) => step.name === 'Resolve Chrome for the browser provider') +const browser = relaySteps.find((step) => step.name === 'Test external Chromium browser provider') const routes = [ ['git_compatibility', 'src/shared/git-capability-cache.ts'], ['codex_index_heal_contract', 'src/main/codex/codex-session-index-heal.ts'], @@ -45,7 +49,9 @@ function routedOutputs(files, reused) { } describe.each(routes)('%s preflight admission', (jobName, changedFile) => { - const job = workflow.jobs[jobName] + const job = workflow.jobs[jobName === 'orcad_browser' ? 'test' : jobName] + const condition = + jobName === 'orcad_browser' ? `(${job.if}) && (${chrome.if}) && (${browser.if})` : job.if it('waits for the detector and physical preflight job', () => { expect(job.needs).toEqual(['code_paths', 'preflight']) @@ -71,12 +77,15 @@ describe.each(routes)('%s preflight admission', (jobName, changedFile) => { { name: 'route is unknown', selected: 'unknown', admitted: false }, { name: 'workflow is cancelled', cancelled: true, admitted: false } ])('evaluates the actual workflow condition: $name', (scenario) => { - const admitted = evaluate(job.if, { + const context = { cancelled: () => scenario.cancelled ?? false, + success: () => true, + inputs: {}, needs: { code_paths: { result: scenario.detector === undefined ? 'success' : (scenario.detector ?? undefined), outputs: { + test: 'true', [jobName]: scenario.selected === undefined ? 'true' : (scenario.selected ?? undefined) } }, @@ -84,8 +93,11 @@ describe.each(routes)('%s preflight admission', (jobName, changedFile) => { result: scenario.preflight === undefined ? 'success' : (scenario.preflight ?? undefined) } } - }) - expect(admitted).toBe(scenario.admitted) + } + if (jobName === 'orcad_browser') { + context.inputs.browser_contract = evaluate(job.with.browser_contract, context) + } + expect(evaluate(condition, context)).toBe(scenario.admitted) }) it.each([ @@ -110,11 +122,16 @@ describe.each(routes)('%s preflight admission', (jobName, changedFile) => { const outputs = routedOutputs([changedFile], reused) expect(outputs[jobName]).toBe(String(reused !== 'true')) expect(outputs.test).toBe(String(reused !== 'true')) - const admitted = evaluate(job.if, { + const context = { cancelled: () => false, + success: () => true, + inputs: {}, needs: { code_paths: { result: 'success', outputs }, preflight: { result: 'success' } } - }) - expect(admitted).toBe(reused !== 'true') + } + if (jobName === 'orcad_browser') { + context.inputs.browser_contract = evaluate(job.with.browser_contract, context) + } + expect(evaluate(condition, context)).toBe(reused !== 'true') } ) }) @@ -141,3 +158,52 @@ it('keeps the mobile-only bundle job in the first wave', () => { mobile_web_app: true }) }) + +it('preserves relay continuation, setup guards and cancellation', () => { + const steps = relaySteps + const install = steps.find((step) => step.id === 'relay_dependencies') + const test = steps.find((step) => step.name === 'Test relay integration contracts') + const context = { + cancelled: () => false, + success: () => false, + steps: { + root_dependencies: { outcome: 'success' }, + relay_dependencies: { outcome: 'success' } + } + } + expect(evaluate(install.if, context)).toBe(true) + expect(evaluate(test.if, context)).toBe(true) + context.steps.root_dependencies.outcome = 'failure' + expect(evaluate(install.if, context)).toBe(false) + context.steps.relay_dependencies.outcome = 'failure' + expect(evaluate(test.if, context)).toBe(false) + context.steps.root_dependencies.outcome = 'success' + context.steps.relay_dependencies.outcome = 'success' + context.cancelled = () => true + expect(evaluate(install.if, context)).toBe(false) + expect(evaluate(test.if, context)).toBe(false) + expect( + steps.findIndex((step) => step.name === 'Test external Chromium browser provider') + ).toBeLessThan(steps.indexOf(install)) + expect(test['continue-on-error']).toBeUndefined() + expect(unit.jobs.relay_integration['continue-on-error']).toBeUndefined() + expect(chrome['continue-on-error']).toBeUndefined() + expect(browser['continue-on-error']).toBeUndefined() + expect(workflow.jobs.verify.needs).toContain('test') +}) + +it('guards both browser steps after setup or discovery failure and for default callers', () => { + const context = { + inputs: { browser_contract: true }, + success: () => true + } + expect(evaluate(chrome.if, context)).toBe(true) + expect(evaluate(browser.if, context)).toBe(true) + context.success = () => false + expect(evaluate(chrome.if, context)).toBe(false) + expect(evaluate(browser.if, context)).toBe(false) + context.success = () => true + context.inputs.browser_contract = unit.on.workflow_call.inputs.browser_contract.default + expect(evaluate(chrome.if, context)).toBe(false) + expect(evaluate(browser.if, context)).toBe(false) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 938e50f6efe2..abb759c69f1b 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -250,6 +250,7 @@ const MANAGED_HOOK_PREFIXES = [ 'src/packages/process-host/', 'config/scripts/build-relay', 'src/relay/', + 'src/wsl-guest/', 'src/shared/agent-hook', 'src/main/agent-hooks/' ] @@ -527,7 +528,11 @@ function jobDetector(job) { return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES)) case 'codex_index_heal_contract': return (files) => - files.some((file) => matchesPrefix(file, CODEX_INDEX_HEAL_CONTRACT_PREFIXES)) + files.some( + (file) => + file !== 'src/main/codex/codex-app-server-client.test.ts' && + matchesPrefix(file, CODEX_INDEX_HEAL_CONTRACT_PREFIXES) + ) case 'xterm_patch_sync': return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES)) case 'shell_contracts': diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 70b1db76ac4c..97a1b0f11e3e 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -625,7 +625,7 @@ describe('PR Checks skip wiring', () => { it('gates each expensive job on its classifier and cache prerequisite', () => { for (const jobName of expensiveJobs.filter( - (jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName) + (jobName) => !['test', 'static_analysis', 'typecheck', 'orcad_browser'].includes(jobName) )) { expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths', 'preflight']) expect(prWorkflow.jobs[jobName].if, jobName).toContain( diff --git a/config/scripts/pr-codex-unit-only-selection.test.mjs b/config/scripts/pr-codex-unit-only-selection.test.mjs new file mode 100644 index 000000000000..518bd4682b12 --- /dev/null +++ b/config/scripts/pr-codex-unit-only-selection.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +describe('per-job path classification', () => { + it('keeps the client-only unit suite out of real Codex binary checks', () => { + const unit = 'src/main/codex/codex-app-server-client.test.ts' + const expected = { + should_run: true, + static_analysis: true, + typecheck: true, + git_compatibility: false, + codex_index_heal_contract: false, + xterm_patch_sync: false, + shell_contracts: false, + test: true, + orcad_browser: false, + mobile_web_app: false, + 'cross-version-wire': false, + managed_hook_node18: false, + package: false, + package_windows: false + } + expect(classifyPrJobs([unit])).toMatchObject(expected) + expect( + classifyPrJobs([ + unit, + 'config/scripts/process-host-build-dist.test.mjs', + 'src/main/ai-vault/session-parse-cache-persistence.test.ts', + 'src/shared/worktree/id.test.ts' + ]) + ).toMatchObject(expected) + for (const file of [ + 'src/main/codex/codex-app-server-client.ts', + 'src/main/codex/codex-app-server-client-other.test.ts', + 'src/main/codex/codex-app-server-client.test-fixture.ts', + 'src/main/codex/codex-index-heal-binary-contract.test.ts', + 'src/main/pty/codex-no-daemon-binary-contract.test.ts', + 'src/main/codex/codex-hook-file-entry-binary-contract.test.ts', + 'src/main/agent-trust-presets.test.ts', + '.github/workflows/pr.yml' + ]) { + expect(classifyPrJobs([unit, file]).codex_index_heal_contract, file).toBe(true) + } + }) +}) diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 44eaffb04d08..33667dff6fba 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -86,6 +86,7 @@ describe('PR E2E gate contract', () => { '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-' diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index cc48e9558302..1db0497bb884 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -189,7 +189,7 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/ssh-localhost.spec.ts'], matches: (file) => isProductSource(file) && - /^src\/(?:relay\/(?:agent-hook|relay-agent-hook-runtime|plugin-overlay)|main\/(?:agent-hooks\/|ssh\/ssh-relay-session\.ts$)|shared\/agent-hook)/.test( + /^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 ) }, @@ -256,7 +256,7 @@ export const PR_E2E_SOURCE_ROUTES = [ // 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\/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( + /^(?: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 ) }, diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 3ec69891b091..f2936f23438f 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -54,7 +54,7 @@ it('requires physical preflight success before publishing shards and admitting c for (const result of ['success', 'failure', 'cancelled', 'skipped']) { const admitted = runInNewContext(workflow.jobs.test.if, { cancelled: () => false, - needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } } + needs: { code_paths: { result: 'success', outputs: { test: 'true' } }, preflight: { result } } }) expect(admitted, result).toBe(result === 'success') } @@ -199,6 +199,7 @@ it('pins every foreground and background step to its selected phase', () => { ['Enforce max-lines ratchet', staticPhase], ['Enforce ts-nocheck ratchet', staticPhase], ['Enforce runtime Electron-import ratchet', staticPhase], + ['Enforce owner-routing ratchet', staticPhase], ['Check Node runtime pin', staticPhase], ['Boot orcad and round-trip a terminal', staticPhase], ['Verify the generated RPC params catalog', staticPhase], diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs index 726706be229b..fae60f56657f 100644 --- a/config/scripts/pr-ready-check-gate.test.mjs +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -8,8 +8,12 @@ const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks') const variable = (job) => job.replaceAll('-', '_').toUpperCase() const requiredJobs = [ - 'preflight', - ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') + ...new Set([ + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck').map((job) => + job === 'orcad_browser' ? 'test' : job + ) + ]) ] function requiredResults(shouldRun) { diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index 11f171a2b257..d3a11b7469d0 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -160,7 +160,11 @@ describe('ready-for-review required check reuse', () => { expect(workflow.jobs.verify.needs).toEqual([ 'code_paths', 'preflight', - ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') + ...new Set( + PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck').map( + (job) => (job === 'orcad_browser' ? 'test' : job) + ) + ) ]) }) diff --git a/config/scripts/process-host-build-dist.test.mjs b/config/scripts/process-host-build-dist.test.mjs index 115f8ccf30c0..3269992a3f64 100644 --- a/config/scripts/process-host-build-dist.test.mjs +++ b/config/scripts/process-host-build-dist.test.mjs @@ -33,9 +33,15 @@ afterEach(() => { } }) -function fixture() { - const packageDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-process-host-build-'))) - temporaryRoots.push(packageDir) +function fixture(onTestFinished) { + const root = mkdtempSync(join(tmpdir(), 'orca-process-host-build-')) + if (onTestFinished) { + onTestFinished(() => rmSync(root, { recursive: true, force: true })) + } + const packageDir = realpathSync(root) + if (!onTestFinished) { + temporaryRoots.push(packageDir) + } mkdirSync(join(packageDir, 'src')) mkdirSync(join(packageDir, 'node_modules')) symlinkSync(realCompilerDir, join(packageDir, 'node_modules', 'typescript'), 'junction') @@ -93,9 +99,9 @@ setTimeout(() => {}, 17_000) ) } -async function build(packageDir) { +async function build(packageDir, expectResult = expect) { const result = await runBuild(packageDir) - expect(result).toMatchObject({ code: 0, stderr: '' }) + expectResult(result).toMatchObject({ code: 0, stderr: '' }) return result } @@ -276,29 +282,31 @@ while (!existsSync(${JSON.stringify(finished)}) || reads === 0) { expect(readFileSync(join(packageDir, 'dist', 'internal.js'), 'utf8')).toContain('value = 9') }) - it.each([ + it.concurrent.for([ ['taken over by another build', true], ['removed', false] ])( 'fails without publishing when the held build lock is %s', - async (_label, recreateLock) => { - const packageDir = fixture() - await build(packageDir) + { timeout: 30_000 }, + async ([_label, recreateLock], { expect: expectResult, onTestFinished }) => { + const packageDir = fixture(onTestFinished) + await build(packageDir, expectResult) const before = distSnapshot(packageDir) const entry = readFileSync(join(packageDir, 'dist', 'entry.js'), 'utf8') installLockStealingCompiler(packageDir, { recreateLock }) const result = await runBuild(packageDir) - expect(result.code).toBe(1) - expect(result.stderr).toContain('build lock was compromised') - expect(distSnapshot(packageDir)).toEqual(before) - expect(readFileSync(join(packageDir, 'dist', 'entry.js'), 'utf8')).toBe(entry) + expectResult(result.code).toBe(1) + expectResult(result.stderr).toContain('build lock was compromised') + expectResult(distSnapshot(packageDir)).toEqual(before) + expectResult(readFileSync(join(packageDir, 'dist', 'entry.js'), 'utf8')).toBe(entry) // The other build's lock is left in place. - expect(existsSync(join(packageDir, '.dist-build.lock'))).toBe(recreateLock) - expect(leftovers(packageDir).filter((name) => name.startsWith('.dist-staging-'))).toEqual([]) - }, - 30_000 + expectResult(existsSync(join(packageDir, '.dist-build.lock'))).toBe(recreateLock) + expectResult( + leftovers(packageDir).filter((name) => name.startsWith('.dist-staging-')) + ).toEqual([]) + } ) it('reclaims staging directories abandoned by a killed build', async () => { diff --git a/config/scripts/process-host-dev-rebuild.test.ts b/config/scripts/process-host-dev-rebuild.test.ts index dd44b254a5a1..b064841c977c 100644 --- a/config/scripts/process-host-dev-rebuild.test.ts +++ b/config/scripts/process-host-dev-rebuild.test.ts @@ -287,11 +287,4 @@ describe('External process-host development rebuilds', () => { await bundle.close() } }) - - it('leaves pnpm dev in no-watch mode unless the caller passes --watch', () => { - // Watch mode restarts Electron on every main-process save, so it stays opt-in. - const launcher = readFileSync(resolve('config/scripts/run-electron-vite-dev.mjs'), 'utf8') - expect(launcher).toContain("const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]") - expect(launcher).not.toMatch(/['"](?:--watch|-w)['"]/) - }) }) diff --git a/config/scripts/relay-json-payload-benchmark.ts b/config/scripts/relay-json-payload-benchmark.ts index 8c05fea0227d..79ea8065e78c 100644 --- a/config/scripts/relay-json-payload-benchmark.ts +++ b/config/scripts/relay-json-payload-benchmark.ts @@ -8,7 +8,7 @@ import { encodePreparedJsonRpcFrame, prepareJsonRpcPayload, type JsonRpcNotification -} from '../../src/relay/protocol' +} from '../../src/wsl-guest/protocol' type BenchmarkCase = { name: string diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index 784abfc48501..1f1f4510afd9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -65,7 +65,7 @@ function waitForExit(proc) { async function loadProtocol(bundleDir) { const outfile = join(bundleDir, 'relay-protocol.cjs') await build({ - entryPoints: [resolve('src/relay/protocol.ts')], + entryPoints: [resolve('src/wsl-guest/protocol.ts')], bundle: true, platform: 'node', format: 'cjs', diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index 96727e837abb..1a67f56cf10c 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -54,6 +54,7 @@ const result = spawnSync( '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', diff --git a/config/scripts/ssh-localhost-e2e-routing.test.mjs b/config/scripts/ssh-localhost-e2e-routing.test.mjs index 48335cd969fb..be149f954763 100644 --- a/config/scripts/ssh-localhost-e2e-routing.test.mjs +++ b/config/scripts/ssh-localhost-e2e-routing.test.mjs @@ -38,8 +38,8 @@ 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/relay/agent-hook-server.ts', - 'src/relay/plugin-overlay.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' diff --git a/config/scripts/ssh-relay-import-boundary.test.mjs b/config/scripts/ssh-relay-import-boundary.test.mjs new file mode 100644 index 000000000000..ad5570ce2f32 --- /dev/null +++ b/config/scripts/ssh-relay-import-boundary.test.mjs @@ -0,0 +1,72 @@ +import { globSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { expect, it } from 'vitest' +import { collectModuleSpecifiers } from './static-module-specifiers.mjs' +import { isTestOnlySourcePath } from './test-only-source-path.mjs' + +const ROOT = path.resolve(import.meta.dirname, '..', '..') +const RELAY_SOURCE = 'src/relay' + +// Why: the SSH relay is being retired; only its own tests may reach into it, and this list only shrinks. +const RELAY_TEST_IMPORTERS = [ + 'src/main/ai-vault-search/session-search-scope-entry-points.test.ts', + 'src/main/ai-vault/antigravity-index-file-admission.test.ts', + 'src/main/ai-vault/remote-session-large-transcripts.test.ts', + 'src/main/git/git-network-safety-real-git.test.ts', + 'src/main/git/source-control/blob-absence-real-git.test.ts', + 'src/main/git/source-control/bulk-pathspec-stdin.test.ts', + 'src/main/git/source-control/staging-discard-index-safety.test.ts', + 'src/main/git/status-branch-compare-real-ref.test.ts', + 'src/main/git/status-branch-line-total-relay-parity.test.ts', + 'src/main/git/status-conflict-overlap.bench.test.ts', + 'src/main/git/worktree-rebase-update-refs-real-git.test.ts', + 'src/main/git/worktree-safety-real-git.test.ts', + 'src/main/ipc/filesystem-quick-open-options.integration.test.ts', + 'src/main/ipc/worktree-push-target-cleanup.test.ts', + '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' +] + +function importsRelay(file, contents) { + // Why: parsing every source file overruns the test timeout on a loaded runner. + if (!contents.includes('relay')) { + return false + } + return collectModuleSpecifiers(file, contents).some((specifier) => { + if (!specifier.startsWith('.')) { + return false + } + const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)) + return target === RELAY_SOURCE || target.startsWith(`${RELAY_SOURCE}/`) + }) +} + +it('keeps everything outside the SSH relay from importing it', () => { + const importers = globSync( + ['src', 'config', 'tests', 'mobile/src', 'mobile/app', 'mobile/scripts'].map( + (directory) => `${directory}/**/*.{ts,tsx,mts,cts,js,mjs,cjs}` + ), + { + cwd: ROOT, + exclude: [`${RELAY_SOURCE}/**`, '**/node_modules/**', '**/dist/**', '**/out/**'] + } + ) + .map((file) => file.replaceAll('\\', '/')) + .filter((file) => importsRelay(file, readFileSync(path.join(ROOT, file), 'utf8'))) + .sort() + + expect(importers.filter((file) => !isTestOnlySourcePath(file))).toEqual([]) + expect(importers).toEqual(RELAY_TEST_IMPORTERS) +}) diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs index 48917d294ce2..9f7b74ec0b06 100644 --- a/config/scripts/verify-mobile-web-app-bundle.mjs +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -38,7 +38,7 @@ export function mobileWebAppBundleMaxAssets(routeCount, imageCount) { * refused asset on a phone. Splitting barely moves it — the same code is emitted in more files — * so shrinking this still means cutting code. * - * This head reads 7,686,714 bytes of the 9,437,184 here, 81.5%, leaving 1,750,470. A reading and + * This head reads 8,089,292 bytes of the 9,437,184 here, 85.7%, leaving 1,347,892. A reading and * not a pin: nothing asserts it, because the number moves with every build. It is here so the * generation that spends the rest can see it was already this close. */ @@ -52,7 +52,7 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024 * `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than * fitted to. The spread it shows is 1 to 10: `pr` and `web` add one script each, `session` adds ten. * The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the - * swept tree reads 74 scripts at 16 routes. + * swept tree reads 79 scripts at 16 routes. * * This table is the fence's only input, so a route added to the tree stales it and the pins beside * the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped. @@ -73,7 +73,7 @@ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [ ['./h/[hostId]/source-control/[worktreeId].tsx', 63], ['./h/[hostId]/tasks.tsx', 71], ['./h/[hostId]/web.tsx', 72], - ['./h/_layout.tsx', 74] + ['./h/_layout.tsx', 79] ] const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts) @@ -134,7 +134,7 @@ export function mobileWebAppBundleMaxChunks(routeCount) { /** * What the browser must parse before the first route can paint: the entry plus every chunk it * reaches by static import. This is the budget splitting exists to hold — it was 8.16 MB as one - * chunk and measures 1,244,312 bytes split on this head, 1.19 of the 3 MiB — so a route + * chunk and measures 1,362,150 bytes split on this head, 1.30 of the 3 MiB — so a route * re-imported statically, or `splitting` dropped, fails the build here instead of arriving as a * slow first open on a phone. * diff --git a/config/scripts/verify-packaged-mobile-web-bundle.test.mjs b/config/scripts/verify-packaged-mobile-web-bundle.test.mjs index 6304670b61f2..54f99afd324f 100644 --- a/config/scripts/verify-packaged-mobile-web-bundle.test.mjs +++ b/config/scripts/verify-packaged-mobile-web-bundle.test.mjs @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,12 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { writeMobileWebBundleFixtureTree } from './mobile-web-bundle-fixture-tree.mjs' const require = createRequire(import.meta.url) -const { - MOBILE_WEB_BUNDLE_DIR, - assertMobileWebBundleBuilt -} = require('./verify-packaged-mobile-web-bundle.cjs') +const { assertMobileWebBundleBuilt } = require('./verify-packaged-mobile-web-bundle.cjs') const electronBuilderConfig = require('../electron-builder.config.cjs') -const REPO_ROOT = join(import.meta.dirname, '..', '..') async function withBundle(run) { const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-')) @@ -61,17 +57,6 @@ describe('assertMobileWebBundleBuilt', () => { }) }) - it('accepts exactly the manifest, the entrypoint and the listed assets', async () => { - await withBundle(async ({ bundleDir, manifest }) => { - const onDisk = (await readdir(bundleDir, { recursive: true, withFileTypes: true })) - .filter((entry) => entry.isFile()) - .map((entry) => join(entry.parentPath, entry.name).slice(bundleDir.length + 1)) - expect(onDisk.toSorted()).toEqual( - ['manifest.json', ...manifest.assets.map((asset) => asset.path)].toSorted() - ) - }) - }) - it('fails when the manifest is missing', async () => { const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-')) try { @@ -174,12 +159,6 @@ describe('assertMobileWebBundleBuilt', () => { }) describe('electron-builder packaging wiring', () => { - it('excludes every repo source tree from app.asar', () => { - // The page is built from mobile/, which this excludes wholesale; out/mobile-web is what ships. - expect(electronBuilderConfig.files).toContain('!src{,/**/*}') - expect(electronBuilderConfig.files).toContain('!mobile{,/**/*}') - }) - it('does not exclude the built bundle, so out/mobile-web ships like out/web', () => { const excludesBuiltBundle = electronBuilderConfig.files.some( (entry) => typeof entry === 'string' && entry.startsWith('!out/mobile-web') @@ -187,16 +166,6 @@ describe('electron-builder packaging wiring', () => { expect(excludesBuiltBundle).toBe(false) }) - it('runs the bundle guard in beforePack', () => { - expect(String(electronBuilderConfig.beforePack)).toContain('assertMobileWebBundleBuilt') - }) - - it('defaults the bundle root to out/mobile-web when electron-builder calls it', () => { - expect(MOBILE_WEB_BUNDLE_DIR).toBe(join(REPO_ROOT, 'out', 'mobile-web')) - // electron-builder passes the context alone, so the default is what ships. - expect(electronBuilderConfig.beforePack.length).toBe(1) - }) - it('verifies the bundle root it is given, not the repo one', async () => { // The seam exists so unit tests need no built out/; it would be worthless if the root were // accepted and then ignored. diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index fc919e253a18..9b79d5241c79 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -46,6 +46,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/claude/claude-subagent-permission-request.test.ts', 'src/main/claude/claude-subagent-resume-after-restart.test.ts', 'src/main/codex/codex-provider-retry-idle-sweep.test.ts', + 'src/main/codex/codex-subagent-roster-retention.test.ts', 'src/main/codex/codex-structured-journal-exit-convergence.test.ts', 'src/main/codex/codex-structured-journal-goal-revision.test.ts', 'src/main/codex/codex-structured-journal-repeated-questions.test.ts', diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index f3ae078cc2dd..2ceb821f8a3e 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -21,6 +21,7 @@ "../src/shared/**/*", "../src/packages/process-host/src/__fixtures__/**/*", "../src/relay/**/*", + "../src/wsl-guest/**/*", "../src/types/**/*" ], // This one test pins the shipped cloud verifier (cloud/apps/relay) whose diff --git a/config/tsconfig.relay.json b/config/tsconfig.relay.json index 242069f761c0..7951c1ada46c 100644 --- a/config/tsconfig.relay.json +++ b/config/tsconfig.relay.json @@ -2,6 +2,7 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "include": [ "../src/relay/**/*", + "../src/wsl-guest/**/*", "../src/main/pty/overlay-mirror.ts", "../src/main/pty/shell-startup-env.ts" ], diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 0a46a953b7ac..302611ab2bc6 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 101m + + downloads: 102m @@ -15,7 +15,7 @@ downloads downloads - 101m - 101m + 102m + 102m diff --git a/docs/audits/ssh-file-metadata-retention/relay-fixture.mjs b/docs/audits/ssh-file-metadata-retention/relay-fixture.mjs index 2c545e5df893..22ea610a5920 100644 --- a/docs/audits/ssh-file-metadata-retention/relay-fixture.mjs +++ b/docs/audits/ssh-file-metadata-retention/relay-fixture.mjs @@ -6,7 +6,7 @@ import { createHash, randomBytes } from 'node:crypto' import { writeFileSync, readFileSync } from 'node:fs' import { SshChannelMultiplexer } from '../../../src/main/ssh/ssh-channel-multiplexer' import { readFileViaStream } from '../../../src/main/ssh/ssh-filesystem-stream-reader' -import { RelayDispatcher } from '../../../src/relay/dispatcher' +import { RelayDispatcher } from '../../../src/wsl-guest/dispatcher' import { RelayStreamRegistry } from '../../../src/relay/fs-stream-registry' import { readRelayFileStreamMetadata } from '../../../src/relay/fs-handler-file-read' diff --git a/docs/audits/ssh-file-metadata-retention/scenario.test.mjs b/docs/audits/ssh-file-metadata-retention/scenario.test.mjs index 5e5c9455200b..8af488e48e43 100644 --- a/docs/audits/ssh-file-metadata-retention/scenario.test.mjs +++ b/docs/audits/ssh-file-metadata-retention/scenario.test.mjs @@ -9,7 +9,7 @@ import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode -} from '../../../src/relay/protocol' +} from '../../../src/wsl-guest/protocol' import { candidate, report, diff --git a/docs/audits/sync-notes-2026-10-11.md b/docs/audits/sync-notes-2026-10-11.md new file mode 100644 index 000000000000..33efae1098fe --- /dev/null +++ b/docs/audits/sync-notes-2026-10-11.md @@ -0,0 +1,125 @@ +# Sync notes: stablyai/orca `main` into `fork-main` (2026-10-11) + +Branch: `sync/upstream-2026-10-11` from `origin/fork-main` (`d8481b1c71`). +Merge: `upstream/main` @ `5c7c493042` (`git merge --no-ff`). +At launch, fork-main was 446 behind / 585 ahead of upstream/main. + +karlorz/orca PR #15 (`sync/upstream-2026-10-10`) already resolved the same +fork-vs-upstream conflicts against upstream `f307391637`. This merge reused +those resolutions and then applied the remaining 46 upstream commits +(`f307391637..upstream/main`) with a 3-way merge (PR15 tree as current, +`f307391637` as base, `upstream/main` as other). + +**Do not push until PR #15 is MERGED.** After it lands: `git fetch origin && +git merge origin/fork-main`, rerun light checks, then push this branch and +open PR "sync: upstream main 2026-10-11" against `fork-main`. Do not merge it. + +## Policy + +- Keep fork features listed in `config/fork-features.yml`. +- Upstream wins for files the fork does not customize. +- Allowlisted auto-resolves (daily `fork-sync-fork-main.mjs`): `.gitignore` + union; `mobile/app.json` did not conflict. + +## Conflict resolutions (55 paths) + +### Taken from PR #15 (inputs unchanged since that merge) + +Relay/startup, automations schema/UI/CLI/RPC, dictation, pairing, i18n +fixtures, sleeping-agent launch, programmatic paste, background-session +contract, and related tests — 38 conflicted files checked out from +`origin/sync/upstream-2026-10-10`. + +Deleted with upstream (same as PR #15): +`src/main/runtime/orchestration/send-agent-turn-boundary.test.ts`. + +### 3-way: PR #15 + remaining upstream (`f307391637..upstream/main`) + +Clean (no leftover markers): + +- `cloud/apps/relay/src/relay-server.ts` — keep fork own-relay harvest bits on + the newer upstream server. +- `mobile/pnpm-lock.yaml` +- `src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx` +- `src/renderer/src/i18n/en-runtime-required.json` +- `src/renderer/src/i18n/locales/en.json` (fork `agentFlags` copy kept) +- `src/shared/rpc-contract/rpc-params-catalog.generated.ts` +- `src/main/startup/main-window-core-services.ts` +- `src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts` +- `src/renderer/src/lib/launch-agent-background-session.ts` + +`.gitignore`: union of ours and theirs, then dropped two orphan comment +lines that were not ignore rules. + +### Automations run-open (fork Resume + upstream paired server) + +These six still conflicted in the 3-way because fork and the extra upstream +rewrote the same helpers: + +- `automation-run-open-target.ts` — keep fork pane-mounted / nullable-PTY + helpers (`selectAutomationRunPaneMounted`, `isAutomationRunPaneMounted`, + `canOpenAutomationRunOpenTarget`) used by live view and resume-target; + add upstream `resolveAutomationRunTerminalTarget` and + `getAutomationRunOwnerEnvironmentId`. +- `automation-run-view-state.ts` — keep fork "leftover paneKey/ptyId is not + a live terminal → Resume"; add upstream "terminal is on the paired + server" View-run state. Drop upstream's leftover-identity "View run / + unavailable" branch. +- `automation-run-workspace-action.ts` — keep fork sleeping-session remount + for a closed tab; add upstream `openRunTerminalOnOwner` / + `activateTerminalTabOnOwner` for a paired-server terminal. +- `use-automation-run-page-state.ts` — keep fork hook (store has no + `unifiedTabsByWorktree` fields; `AutomationsPageSurface` does not read + `selectedAutomationRunPageViewState`). +- `automation-run-open-target.test.ts` — keep fork cases (View run while + the pane is mounted after the original PTY is gone). +- `src/relay/agent-exec-disposal.test.ts` — take upstream comment wrapping. + +### PR #15 fork artifacts copied in + +- `src/main/runtime/relay/relay-auth-coordinator-types.ts` (max-lines split) +- `src/main/startup/main-process-relay-install-retry.test.ts` +- `src/main/automations/headless-automation-agent-launch-fork-args.test.ts` +- `src/main/persistence/scheduling-automations/automation-agent-flags-migration.ts` + (+ test) +- `src/main/ipc/orca-profile-handler-args.ts` and the extract in + `src/main/ipc/orca-profiles.ts` +- `tests/e2e/helpers/paired-web-client-url.unit.test.ts` + +Not copied: `src/relay/dispatcher-bulk-*.ts` (present on PR #15 because it +merged older upstream; deleted on current `upstream/main`). + +`src/shared/terminal-navigation-runtime-capabilities.ts` was not copied. +Upstream now inlines `terminal.session-navigation.v1`, +`terminal.originating-pane-navigation.v1`, and `terminal.question-answer.v1` +in `protocol-version.ts`. Fork `automation.agent-flags.v1` lives in +`src/shared/automation-runtime-capabilities.ts` and is advertised via +`AUTOMATION_RUNTIME_CAPABILITIES`. + +### Auto-merged files that still needed PR #15 semantics + +- `pet.speak` and fork terminal navigation/question RPC methods: restore + `permission: 'workspace'` (required by upstream's RPC define helpers). +- `loaded-state-parsing.ts`: wire `migrateLegacyAutomationAgentFlags` after + context backfill, before owner migration. +- `AutomationEditorSettingsSidebar.test.tsx`: restore fork `model` / + `agentFlags` fixture fields. + +## Follow-up after the merge commit + +Git auto-merged fork-only `automation-model.ts` and launch/CLI helpers from +`origin/fork-main`, which still used the pre-rename `extraArgs` names. Restored +the PR #15 `agentFlags` versions in `d284965952` so they match the +conflict-resolved `automation-params` types. + +## Light local checks (this box, via `heavy-run`) + +- `tsc --noEmit -p config/tsconfig.tc.cli.json`: clean. +- vitest `--maxWorkers=1` on 12 related files (agentFlags, run-open-target, + editor sidebar, relay-auth-coordinator, relay install retry, desktop + installer, session broker, schedule picker, agent-exec-disposal): 104 + passed. +- Full `pnpm tc` (node+web, 7+6 GiB heaps) and mobile tests not run locally. + Native `node-pty` rebuild skipped (no `c++` on this image); install used + `--ignore-scripts`. +- Push is blocked until PR #15 is `MERGED`. diff --git a/mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx b/mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx index 332aa29512b7..a71179df178c 100644 --- a/mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx +++ b/mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx @@ -376,6 +376,37 @@ describe('MobileNativeChatBackgroundTasks', () => { expect(stop).toHaveBeenCalledWith('codex-command:exec-1') }) + it('stops a Grok background command and the Grok subagent beside it by their own ids', () => { + const stop = vi.fn(async () => REJECTED) + const mounted = mount( + tasksFor( + { + state: 'monitoring', + supportsTaskStop: true, + supportsStopAll: false, + children: [ + view('agent', { providerId: 'subagent-1', description: 'Review the diff' }), + view('cmd', { + kind: 'command', + providerId: 'acp-task:01a10366', + description: 'Run the dev server' + }) + ] + }, + { stop } + ) + ) + expand(mounted) + const buttons = stopButtons(mounted) + const labels = buttons.map((button) => button.props.accessibilityLabel) + expect(labels).toEqual( + expect.arrayContaining(['Stop Run the dev server', 'Stop Review the diff']) + ) + expect(labels).not.toContain('Stop background tasks') + act(() => buttons[labels.indexOf('Stop Run the dev server')]!.props.onPress()) + expect(stop).toHaveBeenCalledWith('acp-task:01a10366') + }) + it('offers Stop all only to a host with no per-row stop that still accepts one', async () => { let finish: (result: StopResult) => void = () => {} const stop = vi.fn(() => new Promise((resolve) => (finish = resolve))) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts index b9f5d9e174e7..062e2584b5d0 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.test.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace' -import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader' +import { WORKSPACE_ON_OTHER_RUNTIME } from '../../../src/shared/preflight-other-runtime-refusal-capability' +import { + loadMobileNewTabAgentOptions, + MobileWorkspaceOnOtherRuntimeError +} from './mobile-new-tab-agent-loader' function createClient( handler: (method: string, params?: unknown) => Promise @@ -69,4 +73,94 @@ describe('mobile new-tab agent loading', () => { 'preflight.detectRemoteAgents' ]) }) + + it('#13752: names another runtime instead of listing the paired host for its workspace', async () => { + const client = createClient(async (method) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: {} } } + } + if (method === 'repo.list') { + return { + ok: true, + result: { + repos: [{ id: 'repo-1', executionHostId: 'runtime:env-b', connectionId: 'b-ssh' }] + } + } + } + throw new Error(`unexpected request: ${method}`) + }) + + await expect( + loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' }) + ).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError) + // Neither the paired host nor its SSH targets are probed for a workspace it does not own. + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'repo.list', + 'settings.get' + ]) + }) + + // Rows on two hosts share the id; the SSH row first, so its connection would name this host's target. + function sharedRepoIdClient(detectAgents: () => unknown) { + return createClient(async (method, params) => { + if (method === 'settings.get') { + return { ok: true, result: { settings: {} } } + } + if (method === 'repo.list') { + return { + ok: true, + result: { + repos: [ + { id: 'repo-1', executionHostId: 'ssh:a-target', connectionId: 'a-target' }, + { id: 'repo-1', executionHostId: 'runtime:env-b' } + ] + } + } + } + if (method === 'preflight.detectAgents') { + expect(params).toEqual({ worktreeId: 'repo-1::/srv/worktree' }) + return detectAgents() + } + throw new Error(`unexpected request: ${method}`) + }) + } + + it('#13752: lets a refusing host decide a shared repo id, and reads its refusal', async () => { + const refused = sharedRepoIdClient(() => ({ + ok: false, + error: { code: 'runtime_error', message: WORKSPACE_ON_OTHER_RUNTIME } + })) + await expect( + loadMobileNewTabAgentOptions({ + client: refused, + worktreeId: 'repo-1::/srv/worktree', + hostRefusesOtherRuntime: true + }) + ).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError) + + const answered = sharedRepoIdClient(() => ({ ok: true, result: ['claude'] })) + await expect( + loadMobileNewTabAgentOptions({ + client: answered, + worktreeId: 'repo-1::/srv/worktree', + hostRefusesOtherRuntime: true + }) + ).resolves.toEqual([{ agent: 'claude', label: 'Claude' }]) + expect(answered.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'repo.list', + 'settings.get', + 'preflight.detectAgents' + ]) + }) + + it('#13752: an older host cannot decide a shared repo id, so nothing is probed', async () => { + const client = sharedRepoIdClient(() => ({ ok: true, result: ['codex'] })) + await expect( + loadMobileNewTabAgentOptions({ client, worktreeId: 'repo-1::/srv/worktree' }) + ).rejects.toBeInstanceOf(MobileWorkspaceOnOtherRuntimeError) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'repo.list', + 'settings.get' + ]) + }) }) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index 596ed118f5f2..a5d16e782401 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -1,3 +1,7 @@ +import { + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, + WORKSPACE_ON_OTHER_RUNTIME +} from '../../../src/shared/preflight-other-runtime-refusal-capability' import { newTabSettingsRead } from '../transport/settings-read-operations' import { type MobileRuntimeRepoSummary, @@ -15,6 +19,17 @@ import { type MobileNewTabAgentSettings } from './mobile-new-tab-agent-options' +const WORKSPACE_ON_OTHER_RUNTIME_MESSAGE = + 'This workspace runs on another Orca server. Pair that server directly to see its agents.' + +/** The paired host does not own this workspace, so its agents are unverifiable from here. */ +export class MobileWorkspaceOnOtherRuntimeError extends Error { + constructor() { + super(WORKSPACE_ON_OTHER_RUNTIME_MESSAGE) + this.name = 'MobileWorkspaceOnOtherRuntimeError' + } +} + /** What a launch in this workspace can choose from: the host's settings, the agents detected on * the workspace's execution host, and the workspace's repo (absent for the floating workspace). */ export type MobileAgentLaunchContext = { @@ -23,14 +38,26 @@ export type MobileAgentLaunchContext = { repo: MobileRuntimeRepoSummary | null } -export async function loadMobileAgentLaunchContext(args: { +export function hostRefusesOtherRuntimeWorkspace( + hostCapabilities: readonly string[] | null | undefined +): boolean { + return hostCapabilities?.includes(PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY) === true +} + +type MobileAgentLaunchContextArgs = { client: RpcClient worktreeId: string -}): Promise { + /** Whether the host refuses, rather than answers for, a workspace another runtime owns. */ + hostRefusesOtherRuntime?: boolean +} + +export async function loadMobileAgentLaunchContext( + args: MobileAgentLaunchContextArgs +): Promise { const { client, worktreeId } = args // Started before the settings read, not inside the array: the detection request goes on the wire // first, and the recorded sender order is what says so. - const detectedAgentsRequest = loadDetectedAgents(client, worktreeId) + const detectedAgentsRequest = loadDetectedAgents(client, worktreeId, args.hostRefusesOtherRuntime) const [settingsResponse, detectedAgents] = await Promise.all([ newTabSettingsRead.request(client), detectedAgentsRequest @@ -38,14 +65,13 @@ export async function loadMobileAgentLaunchContext(args: { const readSettings = newTabSettingsRead.interpret(settingsResponse) // Interpreted after the group, not inside it: whichever peer failed first must not decide the // error the sheet shows, and main raised the detection refusal only once settings had settled. - const detected = detectedAgents.interpret(detectedAgents.reply) + const detected = interpretDetectedAgents(detectedAgents) return { settings: readSettings(), detectedAgents: detected, repo: detectedAgents.repo } } -export async function loadMobileNewTabAgentOptions(args: { - client: RpcClient - worktreeId: string -}): Promise { +export async function loadMobileNewTabAgentOptions( + args: MobileAgentLaunchContextArgs +): Promise { const context = await loadMobileAgentLaunchContext(args) return buildMobileNewTabAgentOptions( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. @@ -63,7 +89,8 @@ type DetectedAgentsReply = { async function loadDetectedAgents( client: RpcClient, - worktreeId: string + worktreeId: string, + hostRefusesOtherRuntime = false ): Promise { // Why: the floating workspace runs on the paired host, so it has no repo connection to resolve. if (isFloatingWorkspaceWorktreeId(worktreeId)) { @@ -81,6 +108,22 @@ async function loadDetectedAgents( if (!repo) { throw new Error('worktree_repo_not_found') } + // A prefix test, not parseExecutionHostId: importing execution-host here splits a web bundle chunk. + const runtimeOwned = repos + .filter((candidate) => candidate.id === repoId) + .map((candidate) => candidate.executionHostId?.startsWith('runtime:') === true) + if (runtimeOwned.includes(true)) { + // Why: rows on several hosts can share a repo id, and then only a host that refuses another + // runtime's workspace may decide; the first row's connection could name this host's SSH target. + if (!hostRefusesOtherRuntime || !runtimeOwned.includes(false)) { + throw new MobileWorkspaceOnOtherRuntimeError() + } + return { + reply: await preflightDetectAgentsRead.request(client, { worktreeId }), + interpret: preflightDetectAgentsRead.interpret, + repo + } + } const connectionId = repo.connectionId?.trim() || null return connectionId ? { @@ -96,3 +139,15 @@ async function loadDetectedAgents( repo } } + +function interpretDetectedAgents(detected: DetectedAgentsReply): unknown[] { + try { + return detected.interpret(detected.reply) + } catch (error) { + // A host with the refusal answers it for a workspace another runtime owns. + if (error instanceof Error && error.message === WORKSPACE_ON_OTHER_RUNTIME) { + throw new MobileWorkspaceOnOtherRuntimeError() + } + throw error + } +} diff --git a/mobile/src/session/mobile-session-read-operations.ts b/mobile/src/session/mobile-session-read-operations.ts index ee13f5dbad1d..3e75554fd1ee 100644 --- a/mobile/src/session/mobile-session-read-operations.ts +++ b/mobile/src/session/mobile-session-read-operations.ts @@ -32,7 +32,11 @@ export const sessionTerminalListRead = bindDeferredRpcOperation( }) ) -export type MobileRuntimeRepoSummary = { id: string; connectionId?: string | null } +export type MobileRuntimeRepoSummary = { + id: string + connectionId?: string | null + executionHostId?: string | null +} const repoListReader = rpcResultVariant('runtime-repo-list', runtimeRepoListSchema) diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts index 7a71ee9ab40a..3a0df95cb3cd 100644 --- a/mobile/src/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -144,7 +144,8 @@ export type DirtyMarkdownDraft = { content: string } -export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error' +/** `other-runtime`: another Orca server owns the workspace, so the paired host cannot list its agents. */ +export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error' | 'other-runtime' export type MobileDisplayMode = 'auto' | 'phone' | 'desktop' diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index c425cb255cda..9496084397b2 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -6,7 +6,10 @@ import { launchAgentInExistingWorkspace, supportsMobileExistingAgentLaunch } from './mobile-existing-agent-launch' -import { loadMobileAgentLaunchContext } from './mobile-new-tab-agent-loader' +import { + hostRefusesOtherRuntimeWorkspace, + loadMobileAgentLaunchContext +} from './mobile-new-tab-agent-loader' import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-launch-agent' // Launch path for the phone's AI buttons ("Fix checks with AI", "Resolve conflicts with AI", commit @@ -38,7 +41,11 @@ export async function launchAgentWithPrompt(args: { let resolved try { resolved = resolveMobileSourceControlLaunchAgent( - await loadMobileAgentLaunchContext({ client: args.client, worktreeId: args.worktreeId }), + await loadMobileAgentLaunchContext({ + client: args.client, + worktreeId: args.worktreeId, + hostRefusesOtherRuntime: hostRefusesOtherRuntimeWorkspace(args.hostCapabilities) + }), args.actionId ) } catch (error) { diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index 86d351c1c5d8..0abf3c97f4d9 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -2,7 +2,11 @@ import { useEffect } from 'react' import { AppState, type AppStateStatus } from 'react-native' import { useClipboardReader } from '../platform/clipboard' import { triggerSelection, triggerError } from '../platform/haptics' -import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader' +import { + hostRefusesOtherRuntimeWorkspace, + loadMobileNewTabAgentOptions, + MobileWorkspaceOnOtherRuntimeError +} from './mobile-new-tab-agent-loader' import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments' import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate' import { useMobileTerminalPaste } from './use-mobile-terminal-paste' @@ -13,6 +17,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti worktreeId, client, connState, + hostCapabilities, activeHandle, pendingDiffNotesDelivery, showCreateTabDrawer, @@ -120,6 +125,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti } }, [clipboardContents, selectModeActive, setCanPaste]) + const hostRefusesOtherRuntime = hostRefusesOtherRuntimeWorkspace(hostCapabilities) useEffect(() => { const shouldLoadAgentOptions = showCreateTabDrawer || pendingDiffNotesDelivery !== null if (!shouldLoadAgentOptions) { @@ -140,24 +146,34 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti void (async () => { const options = await loadMobileNewTabAgentOptions({ client, - worktreeId + worktreeId, + hostRefusesOtherRuntime }) if (stale) { return } setCreateTabAgentOptions(options) setCreateTabAgentLoadState('loaded') - })().catch(() => { + })().catch((error: unknown) => { if (!stale) { setCreateTabAgentOptions([]) - setCreateTabAgentLoadState('error') + setCreateTabAgentLoadState( + error instanceof MobileWorkspaceOnOtherRuntimeError ? 'other-runtime' : 'error' + ) } }) return () => { stale = true } - }, [client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId]) + }, [ + client, + connState, + hostRefusesOtherRuntime, + pendingDiffNotesDelivery, + showCreateTabDrawer, + worktreeId + ]) return { handlePaste, flushPendingLiveInputBeforeAttachmentSend, diff --git a/mobile/src/session/use-mobile-session-panel-route-actions.tsx b/mobile/src/session/use-mobile-session-panel-route-actions.tsx index bfdaa017cb8d..1c39b0c849ee 100644 --- a/mobile/src/session/use-mobile-session-panel-route-actions.tsx +++ b/mobile/src/session/use-mobile-session-panel-route-actions.tsx @@ -37,6 +37,13 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati sendDiffNotesToNewAgent, handleCreateTerminal } = scope + const otherRuntimeAgentRow = { + label: 'Agents on Another Server', + hint: 'Pair that server directly', + icon: Bot, + disabled: true, + onPress: () => {} + } const createTabAgentActions = createTabAgentLoadState === 'loading' ? [ @@ -66,17 +73,19 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati onPress: () => {} } ] - : createTabAgentLoadState === 'error' - ? [ - { - label: 'Agent Presets Unavailable', - hint: 'Check the host connection', - icon: Bot, - disabled: true, - onPress: () => {} - } - ] - : [] + : createTabAgentLoadState === 'other-runtime' + ? [otherRuntimeAgentRow] + : createTabAgentLoadState === 'error' + ? [ + { + label: 'Agent Presets Unavailable', + hint: 'Check the host connection', + icon: Bot, + disabled: true, + onPress: () => {} + } + ] + : [] const sendDiffNotesAgentActions = pendingDiffNotesDelivery === null ? [] @@ -118,17 +127,19 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati onPress: () => {} } ] - : createTabAgentLoadState === 'error' - ? [ - { - label: 'Agent Presets Unavailable', - hint: 'Copy notes instead', - icon: Bot, - disabled: true, - onPress: () => {} - } - ] - : [] + : createTabAgentLoadState === 'other-runtime' + ? [otherRuntimeAgentRow] + : createTabAgentLoadState === 'error' + ? [ + { + label: 'Agent Presets Unavailable', + hint: 'Copy notes instead', + icon: Bot, + disabled: true, + onPress: () => {} + } + ] + : [] // Panel-icon taps route through the dock-vs-push decision (U1): dock-capable rows dock, constrained rows push. const handleSessionContentRowLayout = useCallback((event: LayoutChangeEvent) => { diff --git a/package.json b/package.json index e44d1f849231..f4873e27f119 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:owner-routing-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -47,6 +47,7 @@ "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", + "check:owner-routing-ratchet": "node config/scripts/check-owner-routing-ratchet.mjs", "check:readme-local-links": "node config/scripts/check-readme-local-links.mjs", "check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs", "check:runtime-launcher-protocol-ratchet": "node config/scripts/check-runtime-launcher-protocol-ratchet.mjs", diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index a7bf9ee2abe8..7b17e9632dd0 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -208,7 +208,8 @@ export const PROJECT_HANDLERS: Record = { client, 'projectHostSetup.delete', { - setupId: getRequiredStringFlag(flags, 'setup') + setupId: getRequiredStringFlag(flags, 'setup'), + force: flags.get('force') === true } ) printResult(result, json, formatProjectHostSetupDeleteResult) diff --git a/src/cli/index-project-setup.test.ts b/src/cli/index-project-setup.test.ts index f104e45c39a1..abc668e5fb9b 100644 --- a/src/cli/index-project-setup.test.ts +++ b/src/cli/index-project-setup.test.ts @@ -776,7 +776,10 @@ describe('orca cli worktree awareness', () => { }) }) - it('deletes project host setup metadata through the project-first runtime API', async () => { + it.each([ + { flags: [], force: false }, + { flags: ['--force'], force: true } + ])('deletes project host setup metadata with force=$force', async ({ flags, force }) => { queueFixtures( callMock, okFixture('req_project_setup_delete', { @@ -806,10 +809,11 @@ describe('orca cli worktree awareness', () => { ) vi.spyOn(console, 'log').mockImplementation(() => {}) - await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo') + await main(['project', 'setup-delete', '--setup', 'setup-gpu', ...flags, '--json'], '/tmp/repo') expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', { - setupId: 'setup-gpu' + setupId: 'setup-gpu', + force }) }) }) diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 4b61499ffc10..c34ab0884af1 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -74,7 +74,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' orca project setup-clone --project --host --url --destination [--display-name ] [--json]', ' orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', ' orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', - ' orca project setup-delete --setup [--json]', + ' orca project setup-delete --setup [--force] [--json]', ' orca repo list [--json]', ' orca repo add --path [--json]', ' orca repo show --repo [--json]', diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 2fa6130ce3d7..7037ccb57f6c 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -1,8 +1,8 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../shared/protocol-version' import { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' import { launchOrcaApp } from './runtime/launch' @@ -31,6 +31,12 @@ afterEach(async () => { servers.clear() }) +function makeRuntimeClientFixture(): string { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + onTestFinished(() => rmSync(userDataPath, { recursive: true, force: true })) + return userDataPath +} + function writeMetadata( userDataPath: string, endpoint: string, @@ -76,7 +82,7 @@ function findUnusedPid(seed = 200_000): number { // EACCES errors on listen(), so the suite is skipped on that platform. describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { it('adds an opaque durable request ID only to orchestration mutations', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const requests: Record[] = [] const server = createServer((socket) => { @@ -140,7 +146,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('rejects an old local runtime before sending an orchestration mutation', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const requests: Record[] = [] const server = createServer((socket) => { @@ -177,7 +183,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('returns the full RPC envelope for successful calls', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) @@ -210,7 +216,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('reports not_running when no runtime metadata exists', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const client = new RuntimeClient(userDataPath, 100) const status = await client.getCliStatus() @@ -233,7 +239,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('reports stale_bootstrap when bootstrap artifacts exist but no runtime is reachable', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() writeMetadata(userDataPath, join(userDataPath, 'missing.sock'), 'token', findUnusedPid()) const client = new RuntimeClient(userDataPath, 100) @@ -244,7 +250,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('reports graph_not_ready when the runtime is reachable but graph is unavailable', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) @@ -280,7 +286,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('openOrca activates the app even when a desktop runtime is already reachable', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) @@ -317,7 +323,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') let statusRequests = 0 const server = createServer((socket) => { @@ -358,7 +364,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('openOrca fails explicitly when the serve owner cannot promote safely', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) @@ -397,7 +403,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('times out if the runtime never responds', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) @@ -417,7 +423,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('preserves a dropped read-only orchestration failure exactly', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') let request: Record | undefined const server = createServer((socket) => { @@ -447,7 +453,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('allows a per-call timeout override for long runtime requests', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) @@ -479,7 +485,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('preserves structured runtime failures', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) @@ -512,7 +518,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('rejects invalid runtime response frames', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) @@ -533,7 +539,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) it('rejects mismatched response ids from the runtime', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { sockets.add(socket) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 5efe54c7ba35..40f8affd4769 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -21,6 +21,7 @@ import { didAnotherRuntimeHandleTerminalPrompt } from './terminal-prompt-mutation-recovery' import { markEnvironmentUsed } from './environments' +import { createRuntimeSourceFence, type RuntimeSourceFence } from './runtime-source-fence' import { resolveRemotePairing } from './runtime-remote-pairing' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, @@ -56,6 +57,7 @@ export class RuntimeClient { private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope( process.env ) + private readonly runtimeSourceFence: RuntimeSourceFence // Why: browser commands trigger first-time session init (agent-browser connect + // CDP proxy setup) which can take 15-30s. 60s accommodates cold start without @@ -75,6 +77,7 @@ export class RuntimeClient { this.originalArgs = originalArgs ? [...originalArgs] : undefined this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector) this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector) + this.runtimeSourceFence = createRuntimeSourceFence(process.env, userDataPath, requestTimeoutMs) } get isRemote(): boolean { @@ -162,9 +165,18 @@ export class RuntimeClient { return response } const metadata = readMetadata(this.userDataPath) + // Why outside recover(): a refusal here happens before anything is sent, so there is nothing to retry. + const expectedRuntimeSource = await this.runtimeSourceFence.check(metadata, method) let response try { - response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope) + response = await sendRequest( + metadata, + method, + params, + effectiveTimeoutMs, + envelope, + expectedRuntimeSource + ) } catch (error) { throw recover(error, metadata.runtimeId ?? null) } diff --git a/src/cli/runtime/runtime-source-fence.test.ts b/src/cli/runtime/runtime-source-fence.test.ts new file mode 100644 index 000000000000..91db5aeb8704 --- /dev/null +++ b/src/cli/runtime/runtime-source-fence.test.ts @@ -0,0 +1,187 @@ +import { createServer, type Server } from 'node:net' +import { randomUUID } from 'node:crypto' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + RUNTIME_SOURCE_ID_ENV, + RUNTIME_SOURCE_INCARNATION_ENV, + RUNTIME_SOURCE_PROFILE_PATH_ENV +} from '../../shared/runtime-source-env' +import { RuntimeClient, RuntimeClientError } from '../runtime-client' +import { resolveFencedRuntimeSource } from './runtime-source-fence' + +const SOURCE_A = '11111111-1111-4111-8111-111111111111' +const SOURCE_B = '22222222-2222-4222-8222-222222222222' + +const servers = new Set() +const savedEnv = { + id: process.env[RUNTIME_SOURCE_ID_ENV], + incarnation: process.env[RUNTIME_SOURCE_INCARNATION_ENV], + profilePath: process.env[RUNTIME_SOURCE_PROFILE_PATH_ENV] +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} + +afterEach(async () => { + restoreEnv(RUNTIME_SOURCE_ID_ENV, savedEnv.id) + restoreEnv(RUNTIME_SOURCE_INCARNATION_ENV, savedEnv.incarnation) + restoreEnv(RUNTIME_SOURCE_PROFILE_PATH_ENV, savedEnv.profilePath) + await Promise.all( + [...servers].map((server) => new Promise((resolve) => server.close(() => resolve()))) + ) + servers.clear() +}) + +type FakeRuntime = { userDataPath: string; methods: string[]; expectations: unknown[] } + +/** A live runtime on its own socket that answers every method and records what reached it. */ +async function startRuntime(args: { + runtimeId: string + installationId?: string +}): Promise { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-source-fence-')) + const isWindows = process.platform === 'win32' + const endpoint = isWindows + ? `\\\\.\\pipe\\orca-source-fence-${randomUUID()}` + : join(userDataPath, 'runtime.sock') + const methods: string[] = [] + const expectations: unknown[] = [] + const server = createServer((socket) => { + let buffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const request: { id: string; method: string; expectedRuntimeSource?: unknown } = JSON.parse( + buffer.slice(0, newline) + ) + methods.push(request.method) + expectations.push(request.expectedRuntimeSource) + const result = + request.method === 'status.get' + ? { + runtimeId: args.runtimeId, + ...(args.installationId + ? { hostDescriptor: { installationId: args.installationId } } + : {}) + } + : { ok: true } + socket.end( + `${JSON.stringify({ id: request.id, ok: true, result, _meta: { runtimeId: args.runtimeId } })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeFileSync( + join(userDataPath, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: args.runtimeId, + pid: 1, + transports: [{ kind: isWindows ? 'named-pipe' : 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + }) + ) + return { userDataPath, methods, expectations } +} + +function launchedBy(sourceId: string, incarnation: string, profilePath?: string): void { + process.env[RUNTIME_SOURCE_ID_ENV] = sourceId + process.env[RUNTIME_SOURCE_INCARNATION_ENV] = incarnation + restoreEnv(RUNTIME_SOURCE_PROFILE_PATH_ENV, profilePath) +} + +async function expectRefusal(promise: Promise, code: string): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught + ) + expect(error).toBeInstanceOf(RuntimeClientError) + expect(error).toMatchObject({ code }) +} + +describe('RuntimeClient runtime-source fence', () => { + it('refuses another live runtime before the request reaches it', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-b', installationId: SOURCE_B }) + launchedBy(SOURCE_A, 'runtime-a') + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await expectRefusal( + client.call('terminal.send', { terminal: 't1', text: 'hi' }), + 'runtime_source_mismatch' + ) + await expectRefusal(client.call('worktree.list'), 'runtime_source_mismatch') + expect(runtime.methods).toEqual(['status.get', 'status.get']) + }) + + it('accepts a restarted runtime of the same source and probes it only once', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-a2', installationId: SOURCE_A }) + launchedBy(SOURCE_A, 'runtime-a1') + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await client.call('terminal.send', { terminal: 't1', text: 'hi' }) + await client.call('worktree.list') + expect(runtime.methods).toEqual(['status.get', 'terminal.send', 'worktree.list']) + // Why: the host refuses the send itself if it is no longer the runtime the probe verified. + const expected = { sourceId: SOURCE_A, runtimeId: 'runtime-a2' } + expect(runtime.expectations).toEqual([undefined, expected, expected]) + }) + + it('sends without a probe when the launching runtime is still the live one', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-a', installationId: SOURCE_A }) + launchedBy(SOURCE_A, 'runtime-a') + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await client.call('worktree.list') + expect(runtime.methods).toEqual(['worktree.list']) + expect(runtime.expectations).toEqual([{ sourceId: SOURCE_A, runtimeId: 'runtime-a' }]) + }) + + it('refuses when the live runtime cannot say which profile it serves', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-old' }) + launchedBy(SOURCE_A, 'runtime-a') + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await expectRefusal(client.call('worktree.list'), 'runtime_source_unverifiable') + expect(runtime.methods).toEqual(['status.get']) + }) + + it('keeps the unfenced path for a CLI started outside an Orca terminal', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-b', installationId: SOURCE_B }) + delete process.env[RUNTIME_SOURCE_ID_ENV] + delete process.env[RUNTIME_SOURCE_INCARNATION_ENV] + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await client.call('worktree.list') + expect(runtime.methods).toEqual(['worktree.list']) + expect(runtime.expectations).toEqual([undefined]) + }) + + it('lets an explicit switch to another profile through, e.g. orca-dev from a packaged terminal', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-dev', installationId: SOURCE_B }) + launchedBy(SOURCE_A, 'runtime-a', join(tmpdir(), 'orca-packaged-profile')) + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await client.call('worktree.list') + expect(runtime.methods).toEqual(['worktree.list']) + }) + + it('still fences a terminal whose CLI fell back to the platform default profile', () => { + const stamp = { sourceId: SOURCE_A, incarnation: 'runtime-a', profilePath: '/profiles/custom' } + expect(resolveFencedRuntimeSource(stamp, '/profiles/default', '/profiles/default')).toBe(stamp) + expect(resolveFencedRuntimeSource(stamp, '/profiles/custom', '/profiles/default')).toBe(stamp) + expect(resolveFencedRuntimeSource(stamp, '/profiles/dev', '/profiles/default')).toBeNull() + }) +}) diff --git a/src/cli/runtime/runtime-source-fence.ts b/src/cli/runtime/runtime-source-fence.ts new file mode 100644 index 000000000000..0210da945d7c --- /dev/null +++ b/src/cli/runtime/runtime-source-fence.ts @@ -0,0 +1,100 @@ +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { isSameUserDataPath } from '../../shared/serve-user-data-path' +import { readRuntimeSourceStamp, type RuntimeSourceStamp } from '../../shared/runtime-source-env' +import type { ExpectedRuntimeSource } from '../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { getPlatformUserDataPath } from './metadata' +import { sendRequest } from './transport' +import { RuntimeClientError, type RuntimeRpcResponse } from './types' + +type StatusProbe = (metadata: RuntimeMetadata) => Promise> + +/** + * The stamp to enforce for a CLI dialing `userDataPath`. Explicitly choosing another profile (e.g. + * `orca-dev` from a packaged Orca's terminal) is the user's target, not a fallback, so it is not fenced. + */ +export function resolveFencedRuntimeSource( + stamp: RuntimeSourceStamp | null, + userDataPath: string, + platformDefaultPath: string | null +): RuntimeSourceStamp | null { + if (!stamp?.profilePath || isSameUserDataPath(userDataPath, stamp.profilePath)) { + return stamp + } + // Why: the platform default is what an unpinned terminal falls back to, which is the case to fence. + return platformDefaultPath !== null && isSameUserDataPath(userDataPath, platformDefaultPath) + ? stamp + : null +} + +/** + * Refuses, before the request is sent, a runtime other than the one that launched this process. + * A new incarnation of the same source (restart, update) is accepted so surviving panes keep working. + * Returns what the request must carry so the host itself refuses it if it is not that runtime. + */ +export class RuntimeSourceFence { + private readonly verifiedRuntimeIds = new Set() + + constructor( + private readonly expected: RuntimeSourceStamp | null, + private readonly probe: StatusProbe + ) { + if (expected) { + this.verifiedRuntimeIds.add(expected.incarnation) + } + } + + async check( + metadata: RuntimeMetadata, + method: string + ): Promise { + // Why status.get is exempt: it is the read-only probe itself. + if (!this.expected || method === 'status.get') { + return undefined + } + const expectation = { sourceId: this.expected.sourceId, runtimeId: metadata.runtimeId } + if (this.verifiedRuntimeIds.has(metadata.runtimeId)) { + return expectation + } + // Why trust the answer: the transport rejects a response from any runtime but metadata's. + const response = await this.probe(metadata) + if (response.ok === false) { + throw new RuntimeClientError( + 'runtime_source_unverifiable', + `Could not confirm that the running Orca is the one that launched this terminal: ${response.error.message}` + ) + } + const sourceId = response.result.hostDescriptor?.installationId + if (!sourceId) { + throw new RuntimeClientError( + 'runtime_source_unverifiable', + 'The running Orca does not report which profile it serves, so this terminal cannot confirm it is the Orca that launched it. Nothing was sent. Open a new terminal from the Orca you want to control.' + ) + } + if (sourceId !== this.expected.sourceId) { + throw new RuntimeClientError( + 'runtime_source_mismatch', + 'The running Orca is not the one that launched this terminal (another profile or instance took over its runtime record). Nothing was sent. Open a new terminal from the Orca you want to control.' + ) + } + this.verifiedRuntimeIds.add(metadata.runtimeId) + return expectation + } +} + +export function createRuntimeSourceFence( + env: Readonly>, + userDataPath: string, + probeTimeoutMs: number +): RuntimeSourceFence { + let platformDefaultPath: string | null = null + try { + platformDefaultPath = getPlatformUserDataPath() + } catch { + // Why: no resolvable default (Windows without APPDATA) means nothing can fall back to it. + } + return new RuntimeSourceFence( + resolveFencedRuntimeSource(readRuntimeSourceStamp(env), userDataPath, platformDefaultPath), + (metadata) => sendRequest(metadata, 'status.get', undefined, probeTimeoutMs) + ) +} diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index f17eb7db92ef..fc38e500bd9d 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -2,7 +2,10 @@ import { readSshBridgeCredential } from '../../shared/ssh-bridge-credential-env' import { createConnection } from 'node:net' import { randomUUID } from 'node:crypto' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' -import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' +import type { + ExpectedRuntimeSource, + RuntimeOrchestrationEnvelope +} from '../../shared/runtime-rpc-envelope' import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema' import { RuntimeClientError, type RuntimeRpcResponse } from './types' import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay' @@ -13,7 +16,9 @@ export async function sendRequest( method: string, params: unknown, timeoutMs: number, - envelope?: RuntimeOrchestrationEnvelope + envelope?: RuntimeOrchestrationEnvelope, + // Why separate from the envelope: federation forwards envelopes to other runtimes. + expectedRuntimeSource?: ExpectedRuntimeSource ): Promise> { if (!isSafeTimerDelayMs(timeoutMs)) { throw new RuntimeClientError( @@ -205,7 +210,8 @@ export async function sendRequest( orchestrationContractVersion: envelope?.orchestrationContractVersion, orchestrationRequestId: envelope?.orchestrationRequestId, compatibilityInvocationId: envelope?.compatibilityInvocationId, - orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence + orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence, + expectedRuntimeSource })}\n` ) }) diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index 56b33827c4b3..064d01880449 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -115,11 +115,12 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ path: ['project', 'setup-delete'], destructive: true, summary: 'Remove a project host setup', - usage: 'orca project setup-delete --setup [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'setup'], + usage: 'orca project setup-delete --setup [--force] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'setup', 'force'], notes: [ 'Independent setups are removed directly.', - 'Repo-backed setups remove the registered repo compatibility record.' + 'Repo-backed setups remove the registered repo compatibility record.', + 'Refuses while the project has open terminals or saved workspace details; --force removes it anyway.' ], examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json'] } diff --git a/src/main/acp/acp-background-task-child-work.ts b/src/main/acp/acp-background-task-child-work.ts new file mode 100644 index 000000000000..ec44fc57c134 --- /dev/null +++ b/src/main/acp/acp-background-task-child-work.ts @@ -0,0 +1,63 @@ +import type { + AgentChildWorkEvidence, + AgentChildWorkEvidenceHandle +} from '../../shared/agent-status-child-work-evidence' +import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards' +import type { AcpTimelineEvent } from './acp-timeline-event' + +/** Keeps a task's handle apart from a subagent's, so a Stop knows which route it takes. */ +const TASK_ID_PREFIX = 'acp-task:' + +function acpBackgroundTaskHandle(taskId: string): AgentChildWorkEvidenceHandle { + return { idKind: 'task_id', id: `${TASK_ID_PREFIX}${taskId}` } +} + +/** The agent's own task id behind a child handle, when the handle names a background task. */ +export function acpBackgroundTaskStopId(childId: string): string | undefined { + return childId.startsWith(TASK_ID_PREFIX) ? childId.slice(TASK_ID_PREFIX.length) : undefined +} + +function backgroundTaskBlocks(event: AcpTimelineEvent) { + return event.type === 'item.update' && 'body' in event && event.body.kind === 'message' + ? event.body.blocks.filter(isBackgroundTaskBlock) + : [] +} + +export function isAcpBackgroundTaskChildWorkEvent(event: AcpTimelineEvent): boolean { + return backgroundTaskBlocks(event).length > 0 +} + +/** A running task is a live record until it ends, and then its record goes, like a command's. */ +export function acpBackgroundTaskChildWork( + event: AcpTimelineEvent, + observedAt: number, + stoppable: boolean +): AgentChildWorkEvidence[] { + return backgroundTaskBlocks(event).flatMap((block): AgentChildWorkEvidence[] => { + const handle = acpBackgroundTaskHandle(block.taskId) + // A handle too long to record could not address the agent's own task either. + if (handle.id.length > AGENT_CHILD_WORK_ID_MAX_LENGTH) { + return [] + } + if (isSettledBackgroundTaskState(block.state)) { + return [{ type: 'removed', observedAt, handle }] + } + return [ + { + type: 'live', + observedAt, + child: { + handle, + kind: block.kind, + residency: 'background', + state: + block.state === 'monitoring' || block.state === 'waiting' ? block.state : 'working', + ...(block.label ? { description: block.label } : {}), + stoppable + } + } + ] + }) +} diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 2657f10d1b08..e0bd89d70e04 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -71,6 +71,15 @@ export type AcpDialect = { request(sessionId: string, id: string): { method: string; params: unknown } response(value: unknown, id: string): { cancelled: boolean; state?: NativeChatSubagentState } } + backgroundTaskStop?: { + /** Missing required target: proves the route exists without addressing any task. */ + probe: { method: string; params: unknown } + /** The agent refusing the route by name: the only answer that hides Stop. */ + lacksRoute(error: AcpAgentError): boolean + request(sessionId: string, taskId: string): { method: string; params: unknown } + /** `gone`: the agent no longer runs a task by that id; its own ending may still follow. */ + response(value: unknown, taskId: string): 'killed' | 'gone' | 'refused' + } injectedPromptIdentity?: true /** A tool update in the shared shape (`rawOutput.stdout`, `rawOutput.exitCode`), read first. */ normalizeToolUpdate?(update: ToolCallUpdate): ToolCallUpdate @@ -98,4 +107,7 @@ export type AcpDialect = { compactionReply?(text: string): AcpCompactionReply | undefined } +/** Which of the dialect's stop routes this agent process proved it answers. */ +export type AcpChildStops = { subagents: boolean; backgroundTasks: boolean } + export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/grok-background-task-stop.ts b/src/main/acp/acp-dialects/grok-background-task-stop.ts new file mode 100644 index 000000000000..fe2bcf523e96 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-background-task-stop.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import type { AcpDialect } from './acp-dialect' + +const response = z.union([ + z.object({ + error: z.null().optional(), + result: z.object({ + taskId: z.string(), + outcome: z.enum(['killed', 'already_exited', 'not_found']) + }) + }), + // Grok's envelope for a kill it could not attempt, such as an unknown session. + z.object({ result: z.null(), error: z.string() }) +]) + +export const grokBackgroundTaskStop: NonNullable = { + probe: { method: '_x.ai/task/kill', params: {} }, + lacksRoute: (error) => error.code === -32601, + // `source` is left to Grok's default: its own per-task UI kill. + request: (sessionId, taskId) => ({ method: '_x.ai/task/kill', params: { sessionId, taskId } }), + response: (value, taskId) => { + const parsed = response.safeParse(value) + if (!parsed.success || (parsed.data.result && parsed.data.result.taskId !== taskId)) { + throw new Error('Grok returned an invalid background task kill response') + } + const { result } = parsed.data + return !result ? 'refused' : result.outcome === 'killed' ? 'killed' : 'gone' + } +} diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 569bca94ebf4..b38eea156b2c 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -6,6 +6,7 @@ import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok- import { grokModelEfforts } from './grok-model-catalog' import { grokSubagentNotification, grokToolSubagents } from './grok-subagents' import { grokSubagentStop } from './grok-subagent-stop' +import { grokBackgroundTaskStop } from './grok-background-task-stop' const tokenCount = z.number().int().nonnegative() const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) @@ -194,6 +195,7 @@ function notification( export const GROK_ACP_DIALECT: AcpDialect = { subagentStop: grokSubagentStop, + backgroundTaskStop: grokBackgroundTaskStop, injectedPromptIdentity: true, toolName: (update) => { const parsed = toolMetaSchema.safeParse(update._meta) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 3f8e9555e9f8..bef2d4c3a166 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -31,7 +31,7 @@ import type { AcpSessionEvent } from './acp-session-runtime' import type { AcpStructuredConnection } from './acp-structured-connection' import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions' import { AcpStructuredLane, acpLaneChildWorkDelivery } from './acp-structured-lane' -import { probeAcpChildStop } from './acp-structured-child-stop' +import { NO_ACP_CHILD_STOPS, probeAcpChildStops } from './acp-structured-child-stop' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' @@ -185,7 +185,7 @@ export async function acquireAcpStructuredSession(input: { deps.readProcessStartTime ) /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ - let subagentStopSupported = false + let childStops = NO_ACP_CHILD_STOPS const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { const lane = new AcpStructuredLane({ sink, @@ -196,7 +196,7 @@ export async function acquireAcpStructuredSession(input: { providerSessionId, dialect: spec.dialect, now, - canStopSubagents: () => subagentStopSupported, + childStops: () => childStops, ...acpLaneChildWorkDelivery(deps, sessionId), logger: deps.logger ?? createStructuredAgentSessionLogger(), onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), @@ -261,7 +261,7 @@ export async function acquireAcpStructuredSession(input: { started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) liveLane = makeLane(started.sessionId) } - subagentStopSupported = await probeAcpChildStop(connection, spec.dialect) + childStops = await probeAcpChildStops(connection, spec.dialect) options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) @@ -280,7 +280,7 @@ export async function acquireAcpStructuredSession(input: { fence: acquire.fence, acquisitionGeneration: generation, spec, - subagentStopSupported, + childStops, connection, lane: liveLane, prompts, diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index ca3e3bd7f66e..b2bd9a3976ed 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -191,6 +191,9 @@ export async function openAcpAdapterRig( agent.on('_x.ai/subagent/cancel', (frame) => agent.fail(frame, -32602, 'Invalid params', 'invalid params: missing field `subagentId`') ) + agent.on('_x.ai/task/kill', (frame) => + agent.fail(frame, -32602, 'Invalid params', 'invalid params: missing field `sessionId`') + ) agent.on('initialize', (frame) => { spawned.push('initialize') agent.reply(frame, { diff --git a/src/main/acp/acp-structured-background-task-stop.test.ts b/src/main/acp/acp-structured-background-task-stop.test.ts new file mode 100644 index 000000000000..68d1b188a515 --- /dev/null +++ b/src/main/acp/acp-structured-background-task-stop.test.ts @@ -0,0 +1,315 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { _internals } from '../agent-hooks/server' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { + closeProviderTimelineRigs, + SESSION as ADAPTER_SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { grokBackgroundTaskStop } from './acp-dialects/grok-background-task-stop' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk +} from './acp-structured-adapter.test-support' +import { openAttachedHostRig, promptIdOf, send } from './acp-structured-host.test-support' +import { acpChildWorkStatusSink } from './acp-structured-child-work.test-support' + +beforeEach(() => _internals.resetCachesForTests()) +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0)) { + await close() + } + await closeProviderTimelineRigs() +}) + +const TASK = '01a10366-1e7f-7191-bcd1-fac24585851f' +const CHILD = `acp-task:${TASK}` + +async function fixture() { + const childWork = acpChildWorkStatusSink() + const hosted = await openAttachedHostRig({}, childWork.sink) + cleanup.push(async () => { + const agent = hosted.rig.child().agent + // Only cleanup answers cancellation; task-stop assertions run before this. + agent.on('session/cancel', () => { + const prompt = agent.frames.findLast((frame) => frame.method === 'session/prompt') + if (prompt) { + agent.reply(prompt, { stopReason: 'cancelled' }) + } + }) + await hosted.host.close(SESSION, 'user-close') + }) + await send(hosted.host, 'Start the dev server') + const prompt = await hosted.rig.frame('session/prompt') + const agent = hosted.rig.child().agent + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'Starting it')) + await hosted.rows() + // As the recorded s6-background Grok run sends them. + const background = async (taskId = TASK, extra: Record = {}) => { + agent.notify('_x.ai/task_backgrounded', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_backgrounded', + tool_call_id: 'call-1', + task_id: taskId, + command: 'pnpm dev', + cwd: '/workspace/project', + output_file: '/workspace/file-1', + description: 'Run the dev server', + ...extra + } + }) + await hosted.rows() + } + const complete = async (snapshot: Record, taskId = TASK) => { + agent.notify('_x.ai/task_completed', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_completed', + task_snapshot: { + task_id: taskId, + command: 'pnpm dev', + completed: true, + kind: 'bash', + description: 'Run the dev server', + is_backgrounded: true, + ...snapshot + }, + will_wake: false + } + }) + await hosted.rows() + } + const strip = async () => + (await hosted.host.history({ sessionId: SESSION, direction: 'tail' })).page.backgroundTasks + const rowState = async () => + (await hosted.rows()) + .flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + .find((block) => block.taskId === TASK)?.state + const targetedStop = (taskId: string) => { + const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const, taskId } + return hosted.host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + } + const kills = () => agent.frames.filter((frame) => frame.method === '_x.ai/task/kill') + return { + ...hosted, + childWork, + prompt, + agent, + background, + complete, + strip, + rowState, + targetedStop, + kills + } +} + +describe('Grok kill reply contract', () => { + it.each([ + [{ result: { taskId: 'task', outcome: 'killed' } }, 'killed'], + [{ result: { taskId: 'task', outcome: 'already_exited' } }, 'gone'], + [{ result: { taskId: 'task', outcome: 'not_found' } }, 'gone'], + [{ result: null, error: 'session not found' }, 'refused'] + ])('reads %j as %s', (reply, outcome) => { + expect(grokBackgroundTaskStop.response(reply, 'task')).toBe(outcome) + }) + + it.each([ + { result: { taskId: 'other', outcome: 'killed' } }, + { result: { taskId: 'task', outcome: 'future_outcome' } }, + { result: { taskId: 'task' } }, + { result: null }, + { killed: true } + ])('rejects a malformed or mismatched reply: %j', (reply) => { + expect(() => grokBackgroundTaskStop.response(reply, 'task')).toThrow( + 'invalid background task kill response' + ) + }) +}) + +describe('Grok background commands in the background-tasks strip', () => { + it('lists a backgrounded command with its own Stop and removes it when Grok says it ended', async () => { + const f = await fixture() + await f.background() + expect(await f.strip()).toMatchObject({ + supportsTaskStop: true, + supportsStopAll: false, + tasks: [{ id: CHILD, kind: 'command', description: 'Run the dev server', stoppable: true }] + }) + expect(f.childWork.views()).toMatchObject([ + { providerId: CHILD, kind: 'command', membership: 'live' } + ]) + f.agent.reply(f.prompt, { stopReason: 'end_turn' }) + await f.rows() + expect((await f.strip())?.tasks).toMatchObject([{ id: CHILD }]) + await f.complete({ exit_code: 0 }) + expect(await f.strip()).toBeNull() + expect(f.childWork.views()).toEqual([]) + expect(await f.rowState()).toBe('done') + }) + + it('lists a monitor as monitoring', async () => { + const f = await fixture() + await f.background(TASK, { monitor_description: 'Watch the build log' }) + expect((await f.strip())?.tasks).toMatchObject([ + { id: CHILD, kind: 'monitor', description: 'Watch the build log', stoppable: true } + ]) + expect(f.childWork.views()).toMatchObject([{ state: 'monitoring' }]) + }) + + it("kills only the named task, and the row leaves on Grok's own completion", async () => { + const f = await fixture() + await f.background() + await f.background('sibling-task') + f.agent.on('_x.ai/task/kill', (frame) => + f.agent.reply(frame, { result: { taskId: TASK, outcome: 'killed' } }) + ) + expect(await f.targetedStop(CHILD)).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(f.kills().at(-1)?.params).toEqual({ sessionId: PROVIDER_SESSION, taskId: TASK }) + expect(f.agent.frames.filter((frame) => frame.method === 'session/cancel')).toEqual([]) + expect(f.rig.child().closed).toBe(false) + // Held until Grok's own ending arrives, so the strip keeps the row in its stopping state. + expect((await f.strip())?.tasks?.map((task) => task.id)).toEqual([ + CHILD, + 'acp-task:sibling-task' + ]) + await f.complete({ signal: 'killed', explicitly_killed: true }) + expect((await f.strip())?.tasks?.map((task) => task.id)).toEqual(['acp-task:sibling-task']) + expect(await f.rowState()).toBe('idle') + }) + + it.each(['already_exited', 'not_found'])( + 'treats %s as gone: the transcript row settles, and a restated live task stays gone', + async (outcome) => { + const f = await fixture() + await f.background() + f.agent.on('_x.ai/task/kill', (frame) => + f.agent.reply(frame, { result: { taskId: TASK, outcome } }) + ) + expect(await f.targetedStop(CHILD)).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await f.rowState()).toBe('unverifiable') + expect(await f.strip()).toBeNull() + expect(f.childWork.views()).toEqual([]) + await f.background() + expect(await f.rowState()).toBe('unverifiable') + expect(await f.strip()).toBeNull() + expect(f.childWork.views()).toEqual([]) + } + ) + + it("still writes Grok's result when its completion follows already_exited", async () => { + const f = await fixture() + await f.background() + f.agent.on('_x.ai/task/kill', (frame) => + f.agent.reply(frame, { result: { taskId: TASK, outcome: 'already_exited' } }) + ) + expect(await f.targetedStop(CHILD)).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await f.rowState()).toBe('unverifiable') + await f.complete({ exit_code: 0 }) + expect(await f.rowState()).toBe('done') + expect(await f.strip()).toBeNull() + }) + + // As for Codex and Claude: running background work must be stopped before the chat is cleared. + it('refuses /clear while a background command runs', async () => { + const f = await fixture() + await f.background() + f.agent.reply(f.prompt, { stopReason: 'end_turn' }) + await f.rows() + const fields = { command: 'clear' as const } + expect( + await f.host.conversationCommand(CALLER, { + ...fields, + envelope: envelope('agentSession.conversationCommand', fields) + }) + ).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'Stop background tasks before using this command.', + details: { reason: 'backgroundTasksRunning' } + } + }) + }) + + // Grok's own refusal means it killed nothing; a reply about another task proves nothing. + it.each([ + ['rpc-error', { ok: false, refusal: { code: 'agent_session_operation_invalid' } }], + ['envelope-error', { ok: false, refusal: { code: 'agent_session_operation_invalid' } }], + ['mismatched-id', { ok: false, refusal: { code: 'agent_session_operation_unknown' } }] + ] as const)('keeps the row after a %s', async (failure, answer) => { + const f = await fixture() + await f.background() + f.agent.on('_x.ai/task/kill', (frame) => { + if (failure === 'rpc-error') { + f.agent.fail(frame, -32603, 'Internal error') + } else if (failure === 'envelope-error') { + f.agent.reply(frame, { result: null, error: 'session actor died' }) + } else { + f.agent.reply(frame, { result: { taskId: 'other', outcome: 'killed' } }) + } + }) + expect(await f.targetedStop(CHILD)).toMatchObject(answer) + expect((await f.strip())?.tasks).toMatchObject([{ id: CHILD, stoppable: true }]) + expect(f.rig.child().closed).toBe(false) + }) +}) + +describe('Grok kill route check', () => { + async function checked(answer: (agent: AcpScriptedAgent, frame: FakeFrame) => void) { + const evidence: AgentChildWorkEvidence[] = [] + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('_x.ai/task/kill', (frame) => answer(agent, frame)), + deps: { onChildWorkEvidence: (_session, edges) => evidence.push(...edges) } + }) + cleanup.push(() => rig.adapter.closeAll()) + await rig.acquire() + rig.child().agent.notify('_x.ai/task_backgrounded', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'task_backgrounded', task_id: TASK, command: 'pnpm dev' } + }) + await rig.settle() + return { rig, evidence } + } + + it('hides Stop only when Grok names the route unknown, and still lists the command', async () => { + const { rig, evidence } = await checked((agent, frame) => { + expect(frame.params).toEqual({}) + agent.fail(frame, -32601, 'Method not found') + }) + // The session still offers Stop for its subagents, whose route answered. + expect(rig.adapter.backgroundTaskStops(ADAPTER_SESSION)?.supportsTaskStop).toBe(true) + expect(evidence).toMatchObject([ + { type: 'live', child: { handle: { id: CHILD }, kind: 'command', stoppable: false } } + ]) + await expect( + rig.adapter.stopBackgroundTasks({ sessionId: ADAPTER_SESSION, fence: 1, taskIds: [CHILD] }) + ).rejects.toThrow('unavailable') + expect(rig.sent('_x.ai/task/kill')).toHaveLength(1) + }) + + it.each([ + [ + 'another refusal', + (agent: AcpScriptedAgent, frame: FakeFrame) => agent.fail(frame, -32603, 'Internal error') + ], + ['no answer', () => {}] + ])('leaves Stop to answer for itself after %s', async (_label, answer) => { + const { evidence } = await checked(answer) + expect(evidence).toMatchObject([{ type: 'live', child: { stoppable: true } }]) + }) +}) diff --git a/src/main/acp/acp-structured-child-stop.test.ts b/src/main/acp/acp-structured-child-stop.test.ts index c39fe5fad2f3..8a5d9b51c97d 100644 --- a/src/main/acp/acp-structured-child-stop.test.ts +++ b/src/main/acp/acp-structured-child-stop.test.ts @@ -53,11 +53,13 @@ describe('Grok targeted child control contract', () => { 'hides targeted Stop when the non-mutating route check fails with %s', async (code) => { const rig = await openAcpAdapterRig({ - script: (agent) => + script: (agent) => { agent.on('_x.ai/subagent/cancel', (frame) => { expect(frame.params).toEqual({}) agent.fail(frame, code, 'Unavailable') }) + agent.on('_x.ai/task/kill', (frame) => agent.fail(frame, -32601, 'Method not found')) + } }) cleanup.push(() => rig.adapter.closeAll()) await rig.acquire() @@ -92,7 +94,10 @@ describe('Grok targeted child control contract', () => { it('bounds an unanswered route check without ending the parent connection', async () => { const rig = await openAcpAdapterRig({ - script: (agent) => agent.on('_x.ai/subagent/cancel', () => {}) + script: (agent) => { + agent.on('_x.ai/subagent/cancel', () => {}) + agent.on('_x.ai/task/kill', (frame) => agent.fail(frame, -32601, 'Method not found')) + } }) cleanup.push(() => rig.adapter.closeAll()) await rig.acquire() diff --git a/src/main/acp/acp-structured-child-stop.ts b/src/main/acp/acp-structured-child-stop.ts index c4994cefa0b9..acabe6ba6b65 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -1,18 +1,29 @@ import type { AcpStructuredSession } from './acp-structured-session' import type { AcpStructuredConnection } from './acp-structured-connection' -import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets' +import { acpBackgroundTaskStopId } from './acp-background-task-child-work' + +type ChildStopResult = { cancelled: boolean; stillRunning?: true } +type SubagentStop = NonNullable +type BackgroundTaskStop = NonNullable + +export const NO_ACP_CHILD_STOPS: AcpChildStops = { subagents: false, backgroundTasks: false } export function acpChildStopCapabilities( session?: AcpStructuredSession ): AgentSessionBackgroundTaskStops | undefined { return session?.journalClosed === null - ? { supportsTaskStop: session.subagentStopSupported === true, supportsStopAll: false } + ? { + supportsTaskStop: + session.childStops?.subagents === true || session.childStops?.backgroundTasks === true, + supportsStopAll: false + } : undefined } -export async function probeAcpChildStop( +async function probeAcpSubagentStop( connection: AcpStructuredConnection, dialect: AcpDialect ): Promise { @@ -29,37 +40,115 @@ export async function probeAcpChildStop( } } +/** Only the agent naming the route unknown hides Stop; any other answer leaves the Stop itself to + * report what happened. */ +async function probeAcpBackgroundTaskStop( + connection: AcpStructuredConnection, + dialect: AcpDialect +): Promise { + const control = dialect.backgroundTaskStop + if (!control) { + return false + } + try { + await connection.requestExtension(control.probe.method, control.probe.params, 2_000) + return true + } catch (error) { + return !(error instanceof AcpAgentError && control.lacksRoute(error)) + } +} + +export async function probeAcpChildStops( + connection: AcpStructuredConnection, + dialect: AcpDialect +): Promise { + const [subagents, backgroundTasks] = await Promise.all([ + probeAcpSubagentStop(connection, dialect), + probeAcpBackgroundTaskStop(connection, dialect) + ]) + return { subagents, backgroundTasks } +} + +async function stopAcpSubagent( + session: AcpStructuredSession, + control: SubagentStop, + id: string, + at: () => number +): Promise { + const request = control.request(session.lane.translator.providerSessionId, id) + let value: unknown + try { + value = await session.connection.requestExtension(request.method, request.params) + } catch (error) { + // Grok answers an ended or unknown subagent normally, so its refusal means it did nothing; anything else leaves the effect unknown. + if (error instanceof AcpAgentError) { + return { cancelled: false, stillRunning: true } + } + throw error + } + const result = control.response(value, id) + if (result.state && result.state !== 'working') { + session.lane.apply(session.lane.translator.reconcileSubagent(id, result.state, at())) + } + return { cancelled: result.cancelled } +} + +async function stopAcpBackgroundTask( + session: AcpStructuredSession, + control: BackgroundTaskStop, + taskId: string +): Promise { + const request = control.request(session.lane.translator.providerSessionId, taskId) + let value: unknown + try { + value = await session.connection.requestExtension(request.method, request.params) + } catch (error) { + // Grok answers a task it no longer holds normally, so a refusal means it killed nothing. + if (error instanceof AcpAgentError) { + return { cancelled: false, stillRunning: true } + } + throw error + } + const outcome = control.response(value, taskId) + if (outcome === 'refused') { + return { cancelled: false, stillRunning: true } + } + // A killed task's own completion ends its row. A gone one settles here, and any completion + // Grok still sends for it (exited but not yet swept) overwrites this with the real result. + if (outcome === 'gone') { + session.lane.apply(session.lane.translator.reconcileBackgroundTask(taskId)) + } + return { cancelled: true } +} + /** The host has already resolved these handles from this parent's stoppable child records. */ export async function stopAcpChildren( session: AcpStructuredSession, fence: number, taskIds: readonly string[], at: () => number -): Promise<{ cancelled: boolean; stillRunning?: true }> { - const control = session.spec.dialect.subagentStop - if (!control || !session.subagentStopSupported || session.fence !== fence) { +): Promise { + const subagentStop = session.childStops?.subagents ? session.spec.dialect.subagentStop : undefined + const taskStop = session.childStops?.backgroundTasks + ? session.spec.dialect.backgroundTaskStop + : undefined + const routed = (id: string) => + acpBackgroundTaskStopId(id) === undefined ? subagentStop !== undefined : taskStop !== undefined + if (session.fence !== fence || (!subagentStop && !taskStop) || !taskIds.every(routed)) { throw new Error('ACP child cancellation is unavailable') } let cancelled = false let stillRunning = false for (const id of taskIds) { - const request = control.request(session.lane.translator.providerSessionId, id) - let value: unknown - try { - value = await session.connection.requestExtension(request.method, request.params) - } catch (error) { - // Grok answers an ended or unknown subagent normally, so its refusal means it did nothing; anything else leaves the effect unknown. - if (error instanceof AcpAgentError) { - stillRunning = true - continue - } - throw error - } - const result = control.response(value, id) - if (result.state && result.state !== 'working') { - session.lane.apply(session.lane.translator.reconcileSubagent(id, result.state, at())) - } + const taskId = acpBackgroundTaskStopId(id) + const result = + taskId !== undefined && taskStop + ? await stopAcpBackgroundTask(session, taskStop, taskId) + : subagentStop + ? await stopAcpSubagent(session, subagentStop, id, at) + : { cancelled: false } cancelled ||= result.cancelled + stillRunning ||= result.stillRunning === true } return { cancelled, ...(stillRunning ? { stillRunning: true as const } : {}) } } diff --git a/src/main/acp/acp-structured-child-work.test.ts b/src/main/acp/acp-structured-child-work.test.ts index dd999aa42715..98a234e2d39a 100644 --- a/src/main/acp/acp-structured-child-work.test.ts +++ b/src/main/acp/acp-structured-child-work.test.ts @@ -330,8 +330,10 @@ describe('Grok roster evidence through the existing host child store', () => { const childWork = acpChildWorkStatusSink() const hosted = await openHostRig({ statusSink: childWork.sink, - script: (agent) => + script: (agent) => { agent.on('_x.ai/subagent/cancel', (frame) => agent.fail(frame, -32601, 'Method not found')) + agent.on('_x.ai/task/kill', (frame) => agent.fail(frame, -32601, 'Method not found')) + } }) const cancelOnCleanup = cleanupScriptedHost(hosted) expect(await hosted.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index 2e6677390d65..2a5446641dac 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -52,7 +52,7 @@ describe('a Grok chat Stop', () => { await send(host, 'again') await waitFor(() => expect(rig.child()).not.toBe(first)) const second = rig.child() - await rig.frame('session/prompt') + const secondPrompt = await rig.frame('session/prompt') expect(count.loads).toBe(1) expect(framesOf(second, 'session/new')).toHaveLength(0) expect(await messages()).toEqual(['hello', 'partial', 'again']) @@ -61,6 +61,9 @@ describe('a Grok chat Stop', () => { expect( transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : [])) ).toEqual(['Cancellation requested.']) + second.agent.on('session/cancel', () => + second.agent.reply(secondPrompt, { stopReason: 'cancelled' }) + ) await host.close(SESSION, 'user-close') }) @@ -160,6 +163,7 @@ describe('a Grok chat Stop naming a turn that has ended', () => { expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) expect(rig.child().exited).toBe(false) expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + agent.on('session/cancel', () => agent.reply(current, { stopReason: 'cancelled' })) await host.close(SESSION, 'user-close') }) diff --git a/src/main/acp/acp-structured-host-subagent-stop.test.ts b/src/main/acp/acp-structured-host-subagent-stop.test.ts index f2c47a4ecb4c..509af927d023 100644 --- a/src/main/acp/acp-structured-host-subagent-stop.test.ts +++ b/src/main/acp/acp-structured-host-subagent-stop.test.ts @@ -58,6 +58,9 @@ async function stoppingChild(reportsOutcome: boolean) { agent.on('_x.ai/subagent/cancel', (frame) => agent.fail(frame, -32602, 'Invalid params', 'invalid params: missing field `subagentId`') ) + agent.on('_x.ai/task/kill', (frame) => + agent.fail(frame, -32602, 'Invalid params', 'invalid params: missing field `sessionId`') + ) agent.on('session/new', (frame) => agent.reply(frame, { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS }) ) diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 6dac885d6ea0..045e1b544d5e 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -11,9 +11,13 @@ import type { AcpTimelineEvent } from './acp-timeline-event' import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' import { AcpTimelineTranslator } from './acp-timeline-translator' import { acpSubagentChildWork, isAcpSubagentChildWorkEvent } from './acp-subagent-child-work' +import { + acpBackgroundTaskChildWork, + isAcpBackgroundTaskChildWorkEvent +} from './acp-background-task-child-work' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import { @@ -42,7 +46,7 @@ export type AcpStructuredLaneDeps = { onChildWorkEvidence?: (evidence: AgentChildWorkEvidence[]) => void onChildWorkFailure?: (error: unknown) => void now?: () => number - canStopSubagents?: () => boolean + childStops?: () => AcpChildStops } export class AcpStructuredLane { @@ -163,7 +167,9 @@ export class AcpStructuredLane { const event = this.backlog[0] const { admission, dropped } = this.assembler.apply( event, - isAcpSubagentChildWorkEvent(event) ? () => this.publishChildWork(event) : undefined + isAcpSubagentChildWorkEvent(event) || isAcpBackgroundTaskChildWorkEvent(event) + ? () => this.publishChildWork(event) + : undefined ) if (dropped === 'stream-mismatch' && event.type === 'text.delta') { this.reportTextDrop({ @@ -195,11 +201,12 @@ export class AcpStructuredLane { } private publishChildWork(event: AcpTimelineEvent): void { - const evidence = acpSubagentChildWork( - event, - this.deps.now?.() ?? Date.now(), - this.deps.canStopSubagents?.() === true - ) + const observedAt = this.deps.now?.() ?? Date.now() + const stops = this.deps.childStops?.() + const evidence = [ + ...acpSubagentChildWork(event, observedAt, stops?.subagents === true), + ...acpBackgroundTaskChildWork(event, observedAt, stops?.backgroundTasks === true) + ] if (!evidence.length) { return } diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index ad3dbcd2e3ff..7299c13c6e30 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -298,7 +298,7 @@ describe('ACP structured session adapter: requests a Stop cancels', () => { cancelled: true }) expect(await question).toMatchObject({ result: { outcome: 'cancelled' } }) - expect(agent.frames.filter((frame) => frame.id === 5)).toHaveLength(1) + expect(agent.frames.filter((frame) => frame.id === 5 && !frame.method)).toHaveLength(1) }) it("shows Grok's plan in the plan row and answers its plan-mode exit at once, with no card", async () => { diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index 629145cf483d..c27179fa9b89 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -8,6 +8,7 @@ import { type StructuredAgentSessionTurnVerdict } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpChildStops } from './acp-dialects/acp-dialect' import type { AcpSessionEvent } from './acp-session-runtime' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' @@ -20,7 +21,7 @@ export type AcpStructuredSession = { fence: number acquisitionGeneration: string spec: AcpLaunchSpec - subagentStopSupported?: boolean + childStops?: AcpChildStops /** The agent's process and its protocol, one owner. */ connection: AcpStructuredConnection lane: AcpStructuredLane diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index 19a6ee5cf5e1..421ecd31f641 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -317,6 +317,7 @@ describe('Steer on a Grok card, through the host', () => { await waitFor(() => expect(framesOf(rig.rig.child(), 'session/cancel')).toHaveLength(1)) agent.reply(prompt, { stopReason: 'cancelled' }) await rig.rig.frame('session/prompt', 1) + answersCancels(agent) await rig.host.close(SESSION, 'user-close') }) diff --git a/src/main/acp/acp-subagent-timeline.test.ts b/src/main/acp/acp-subagent-timeline.test.ts index 9a8a831cbe99..a3848842816f 100644 --- a/src/main/acp/acp-subagent-timeline.test.ts +++ b/src/main/acp/acp-subagent-timeline.test.ts @@ -16,7 +16,7 @@ const retainedSchema = z.object({ }) ), groupOf: z.map(z.string(), z.string()), - settledIds: z.object({ size: z.number() }) + retention: z.object({ settledIdentities: z.object({ size: z.number() }) }) }) function spawnGroups( @@ -99,7 +99,7 @@ describe('ACP subagent retention', () => { expect( [...retained.groups.values()].reduce((total, group) => total + group.results.size, 0) ).toBe(31 * 64) - expect(retained.settledIds.size).toBe(32 * 64) + expect(retained.retention.settledIdentities.size).toBe(32 * 64) expect(timeline.has('live')).toBe(true) expect(timeline.has('0:0')).toBe(false) expect(timeline.has('33:0')).toBe(true) @@ -149,7 +149,7 @@ describe('ACP subagent retention', () => { const retained = retainedSchema.parse(timeline) expect(retained.groups.size).toBe(0) expect(retained.groupOf.size).toBe(0) - expect(retained.settledIds.size).toBe(0) + expect(retained.retention.settledIdentities.size).toBe(0) expect(timeline.translate([{ id: 'child-1', tokens: 9 }, { id: 'new' }], {}, 101)).toEqual([]) expect(retainedSchema.parse(timeline).groups.size).toBe(0) }) diff --git a/src/main/acp/acp-subagent-timeline.ts b/src/main/acp/acp-subagent-timeline.ts index 60eed96d1752..532f782210cc 100644 --- a/src/main/acp/acp-subagent-timeline.ts +++ b/src/main/acp/acp-subagent-timeline.ts @@ -1,4 +1,3 @@ -import { BoundedMap } from '../../shared/bounded-map' import { canReplaceSubagentState, isTerminalSubagentState @@ -15,6 +14,7 @@ import { import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' import { acpSubagentIdentity, type AcpTimelineEvent } from './acp-timeline-event' import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' +import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention' /** Settled history budget; live ownership outlives it until an outcome or session close. */ const MAX_GROUPS = 32 @@ -36,16 +36,22 @@ type RosterGroup = { export class AcpSubagentTimeline { private readonly groups = new Map() private readonly groupOf = new Map() - /** An eviction index, re-derived from the changed groups' entries after each batch. */ - private readonly settledGroups = new Set() - private readonly settledIds = new BoundedMap({ - maxEntries: MAX_GROUPS * MAX_SUBAGENTS_PER_GROUP + private readonly retention = new SubagentRosterRetention(this.groups, { + maxGroups: MAX_GROUPS, + maxSettledIdentities: MAX_GROUPS * MAX_SUBAGENTS_PER_GROUP, + entries: (group) => group.entries.values(), + identities: (group) => group.entries.keys(), + onEvict: (group) => { + for (const id of group.entries.keys()) { + this.groupOf.delete(id) + } + } }) private disposed = false /** Known children include recently evicted outcomes, so they cannot become background tasks. */ has(id: string): boolean { - return this.groupOf.has(id) || this.settledIds.has(id) + return this.groupOf.has(id) || this.retention.hasSettled(id) } /** The journal owns session-end settlement; no later frame may reacquire working ownership. */ @@ -53,8 +59,7 @@ export class AcpSubagentTimeline { this.disposed = true this.groups.clear() this.groupOf.clear() - this.settledGroups.clear() - this.settledIds.clear() + this.retention.clear() } translate( @@ -93,7 +98,8 @@ export class AcpSubagentTimeline { join: groupJoin(group, join) }) } - this.trimSettledGroups(changed) + // Emit the final roster and reply before releasing their cached ownership together. + this.retention.trim(changed) return events } @@ -102,7 +108,7 @@ export class AcpSubagentTimeline { join: ProviderTimelineJoin, at: number ): RosterGroup | undefined { - if (this.settledIds.has(update.id)) { + if (this.retention.hasSettled(update.id)) { return undefined } const known = this.groups.get(this.groupOf.get(update.id) ?? '') @@ -169,31 +175,6 @@ export class AcpSubagentTimeline { return boundSubagentField(seen === 0 ? bounded : `${bounded} ${seen + 1}`, index) } - private trimSettledGroups(changed: Set): void { - for (const group of changed) { - if ([...group.entries.values()].every((entry) => isTerminalSubagentState(entry.state))) { - this.settledGroups.add(group.groupId) - } else { - this.settledGroups.delete(group.groupId) - } - } - // Emit the final roster and reply before releasing their cached ownership together. - for (const groupId of this.settledGroups) { - if (this.groups.size <= MAX_GROUPS) { - break - } - const group = this.groups.get(groupId) - if (group) { - for (const id of group.entries.keys()) { - this.groupOf.delete(id) - this.settledIds.set(id, true) - } - } - this.groups.delete(groupId) - this.settledGroups.delete(groupId) - } - } - private groupEvent(group: RosterGroup, join: ProviderTimelineJoin): AcpTimelineEvent[] { const entries = [...group.entries.values()] const body = subagentGroupJournalBody(group.groupId, entries) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index fbfdfba9dfb0..a5bd8746b3b8 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -161,6 +161,12 @@ export class AcpTimelineTranslator { ) } + /** Settles a task the agent says it no longer runs; its own later ending may still overwrite this. */ + reconcileBackgroundTask(taskId: string): ProviderTimelineEvent[] { + const update = { taskId, state: 'unverifiable' as const, summary: '', error: '' } + return this.backgroundTasks.translate([update], { thread: this.options.sessionId }) + } + sessionEvent(event: AcpSessionEvent, at: number): ProviderTimelineEvent[] { return this.notification( 'session/update', diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts index 8e1e1a47df8d..2d31b2ce252a 100644 --- a/src/main/agent-hooks/manual-compact-hook-stream.test.ts +++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import { RelayAgentHookServer } from '../../wsl-guest/agent-hook-server' import { seedLegacyAgentStatusForTests } from '../../shared/agent-hook-listener/listener-state' import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' diff --git a/src/main/agent-hooks/opencode-plugin-settings.ts b/src/main/agent-hooks/opencode-plugin-settings.ts index e05f488c6a16..bb22a481fa29 100644 --- a/src/main/agent-hooks/opencode-plugin-settings.ts +++ b/src/main/agent-hooks/opencode-plugin-settings.ts @@ -1,4 +1,4 @@ -import type { PluginSources } from '../../relay/plugin-overlay' +import type { PluginSources } from '../../wsl-guest/plugin-overlay' import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' diff --git a/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts b/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts index 1ef842aa0d9e..76bc5450a4ea 100644 --- a/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts +++ b/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts @@ -2,7 +2,7 @@ import { readFileSync, mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import { RelayAgentHookServer } from '../../wsl-guest/agent-hook-server' import { AgentHookServer, _internals } from './server' import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer' import { diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index 6a30fa4b0f03..7c35e6d59813 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -6,7 +6,7 @@ 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 { RelayAgentHookServer } from '../../relay/agent-hook-server' +import { RelayAgentHookServer } from '../../wsl-guest/agent-hook-server' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' import { hookAt, loadCapture, type CapturedHook } from './claude-cancel-capture.test-fixture' diff --git a/src/main/agent-hooks/server-tmux-wire.test.ts b/src/main/agent-hooks/server-tmux-wire.test.ts index 5037d173adc0..7b9ac0d0cf7a 100644 --- a/src/main/agent-hooks/server-tmux-wire.test.ts +++ b/src/main/agent-hooks/server-tmux-wire.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { PANE } from './server.test-fixtures' -import { buildRelayHookEnvelope } from '../../relay/agent-hook-envelope-build' +import { buildRelayHookEnvelope } from '../../wsl-guest/agent-hook-envelope-build' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 6332ce5fde1c..be824aaf09ba 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -1,5 +1,3 @@ -import { createHash } from 'node:crypto' - import type { AgentKind } from '../../../shared/telemetry-events' import { getAgentResumeArgv, @@ -84,7 +82,3 @@ export function paneCacheKeyTabId(key: string): string | null { export function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { return paneCacheKeyTabId(key) === tabId } - -export function hashLaunchToken(value: string): string { - return createHash('sha256').update(value).digest('hex') -} diff --git a/src/main/agent-hooks/wsl-guest-plugin-install.ts b/src/main/agent-hooks/wsl-guest-plugin-install.ts index 40bf43ce0247..9a0264e8ba46 100644 --- a/src/main/agent-hooks/wsl-guest-plugin-install.ts +++ b/src/main/agent-hooks/wsl-guest-plugin-install.ts @@ -6,7 +6,7 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' import { selectOpenCodePluginSources } from './opencode-plugin-settings' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' -import type { PluginSources } from '../../relay/plugin-overlay' +import type { PluginSources } from '../../wsl-guest/plugin-overlay' /** Structural, not the deps type itself, so this stays free of the deps module. */ type GuestPluginInstallDeps = { diff --git a/src/main/agent-hooks/wsl-hook-default-distro.ts b/src/main/agent-hooks/wsl-hook-default-distro.ts deleted file mode 100644 index c6dea41c98db..000000000000 --- a/src/main/agent-hooks/wsl-hook-default-distro.ts +++ /dev/null @@ -1,13 +0,0 @@ -export async function resolveWslHookDefaultDistro( - currentDistro: string | null, - listDistros: () => Promise -): Promise { - if (currentDistro) { - return currentDistro - } - try { - return (await listDistros())[0] ?? null - } catch { - return null - } -} diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index 0955acda34bd..4c4532a9658d 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -14,7 +14,7 @@ import { getOpenCode2PluginSource, getOpenCodePluginSource } from '../opencode/h import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source' import { codexHookService } from '../codex/hook-service' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' -import type { PluginSources } from '../../relay/plugin-overlay' +import type { PluginSources } from '../../wsl-guest/plugin-overlay' import { isWslDistroRunning, resolveWslHookRelayBundle, diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index 87e29f2d414d..d891fb0427ec 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -9,7 +9,7 @@ import { installWslGuestHooks } from './wsl-hook-fs-adapter' import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { openCodePluginSettingsKey } from './opencode-plugin-settings' -import type { PluginSources } from '../../relay/plugin-overlay' +import type { PluginSources } from '../../wsl-guest/plugin-overlay' /** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */ type GuestInstallDeps = { diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index c4707af0f5aa..fc49109c5533 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -7,8 +7,8 @@ import { join } from 'node:path' import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher } from '../../relay/dispatcher' -import { registerWslHookFsHandlers } from '../../relay/wsl-hook-fs-bridge' +import { RelayDispatcher } from '../../wsl-guest/dispatcher' +import { registerWslHookFsHandlers } from '../../wsl-guest/wsl-hook-fs-bridge' import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter' import { diff --git a/src/main/agent-hooks/wsl-hook-relay-resume.ts b/src/main/agent-hooks/wsl-hook-relay-resume.ts deleted file mode 100644 index 278d3e7033df..000000000000 --- a/src/main/agent-hooks/wsl-hook-relay-resume.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function resumeStoppedWslHookRelays( - stoppedByHooksOff: Map, - isDistroRunning: (distro: string) => Promise, - ensureForDistro: (distro: string, codexHomePath?: string) => void -): void { - const distros = [...stoppedByHooksOff] - stoppedByHooksOff.clear() - for (const [distro, codexHomePath] of distros) { - void isDistroRunning(distro) - .then((running) => { - if (running) { - ensureForDistro(distro, codexHomePath) - } - }) - .catch(() => undefined) - } -} diff --git a/src/main/agent-hooks/wsl-opencode-install-race.test.ts b/src/main/agent-hooks/wsl-opencode-install-race.test.ts index ccb8e79a445d..240feeac6c60 100644 --- a/src/main/agent-hooks/wsl-opencode-install-race.test.ts +++ b/src/main/agent-hooks/wsl-opencode-install-race.test.ts @@ -3,9 +3,9 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { TuiAgent } from '../../shared/tui-agent' -import { RelayDispatcher } from '../../relay/dispatcher' -import { PluginOverlayManager } from '../../relay/plugin-overlay' -import { createInstallPluginsHandler } from '../../relay/wsl-install-plugins-handler' +import { RelayDispatcher } from '../../wsl-guest/dispatcher' +import { PluginOverlayManager } from '../../wsl-guest/plugin-overlay' +import { createInstallPluginsHandler } from '../../wsl-guest/wsl-install-plugins-handler' import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' import { diff --git a/src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts b/src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts index 29cf139ace5e..2c04fd5b153a 100644 --- a/src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts +++ b/src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts @@ -4,9 +4,9 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { RelayDispatcher } from '../../relay/dispatcher' -import { PluginOverlayManager } from '../../relay/plugin-overlay' -import { createInstallPluginsHandler } from '../../relay/wsl-install-plugins-handler' +import { RelayDispatcher } from '../../wsl-guest/dispatcher' +import { PluginOverlayManager } from '../../wsl-guest/plugin-overlay' +import { createInstallPluginsHandler } from '../../wsl-guest/wsl-install-plugins-handler' import { WslHookRelayManager } from './wsl-hook-relay-manager' import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' diff --git a/src/main/agent-hooks/wsl-opencode-settings.test.ts b/src/main/agent-hooks/wsl-opencode-settings.test.ts index 0e868635ea2d..c2a260c6d1f3 100644 --- a/src/main/agent-hooks/wsl-opencode-settings.test.ts +++ b/src/main/agent-hooks/wsl-opencode-settings.test.ts @@ -3,9 +3,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import type { TuiAgent } from '../../shared/tui-agent' -import { RelayDispatcher } from '../../relay/dispatcher' -import { PluginOverlayManager } from '../../relay/plugin-overlay' -import { createInstallPluginsHandler } from '../../relay/wsl-install-plugins-handler' +import { RelayDispatcher } from '../../wsl-guest/dispatcher' +import { PluginOverlayManager } from '../../wsl-guest/plugin-overlay' +import { createInstallPluginsHandler } from '../../wsl-guest/wsl-install-plugins-handler' import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' diff --git a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index 3b470e75138e..e921445ef5c2 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -1,7 +1,7 @@ import '../runtime/rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it } from 'vitest' import { AiVaultHandler } from '../../relay/ai-vault-handler' -import type { RelayDispatcher } from '../../relay/dispatcher' +import type { RelayDispatcher } from '../../wsl-guest/dispatcher' import { createSessionSearchClient } from '../../shared/ai-vault-search-client' import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' diff --git a/src/main/ai-vault/remote-session-parse-cache.ts b/src/main/ai-vault/remote-session-parse-cache.ts index 24d63c3dc2a1..afa420c92940 100644 --- a/src/main/ai-vault/remote-session-parse-cache.ts +++ b/src/main/ai-vault/remote-session-parse-cache.ts @@ -22,10 +22,6 @@ const cache = new Map() export type RemoteSessionParseStats = { reused: number; parsed: number } -export function createRemoteSessionParseStats(): RemoteSessionParseStats { - return { reused: 0, parsed: 0 } -} - export function resetRemoteSessionParseCacheForTests(): void { cache.clear() } diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts index 5be88cfe669e..740f9c40a53c 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.test.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts @@ -14,7 +14,6 @@ import * as fsPromises 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 { AiVaultSession } from '../../shared/ai-vault-types' import { ensureSessionParseCacheLoaded, flushSessionParseCachePersistForTests, @@ -130,54 +129,6 @@ async function coldParseStats(path: string): Promise { return stats } -/** - * Schema 2 dropped the `appVersion` equality gate: a cache written by any - * release with this schema is now replayed straight into Agent Session - * History without re-reading the transcript (#17888 — the gate forced a - * multi-gigabyte cold scan after every update). The cost of that reuse is - * that nothing else invalidates a stale row, so `SCHEMA_VERSION` is the only - * remaining compatibility signal and forgetting to bump it ships wrong data. - * - * This table is the reminder. Changing the persisted session shape — or the - * meaning of a field the parsers fill — breaks this `satisfies` and the fix - * is to bump `SCHEMA_VERSION` in session-parse-cache-persistence.ts, not to - * silently extend the list. - */ -const CACHED_SESSION_FIELDS = { - id: true, - executionHostId: true, - executionHostPlatform: true, - agent: true, - sessionId: true, - title: true, - cwd: true, - branch: true, - model: true, - filePath: true, - codexHome: true, - createdAt: true, - updatedAt: true, - modifiedAt: true, - messageCount: true, - totalTokens: true, - previewMessages: true, - previewMessagesTruncated: true, - antigravityOpeningPrompt: true, - firstUserPrompt: true, - lastUserPrompt: true, - queuedMessageCount: true, - subagentTranscriptCount: true, - resumeCommand: true, - subagent: true, - structuredSession: true -} satisfies Record - -describe('cached session compatibility', () => { - it('pins the persisted session shape to the current parse-cache schema', () => { - expect(Object.keys(CACHED_SESSION_FIELDS).length).toBeGreaterThan(0) - }) -}) - describe('session parse cache persistence', () => { it('exposes a copy of the active configuration for background scanners', () => { const configured = { filePath: '/tmp/ai-vault-cache.json', appVersion: APP_VERSION } diff --git a/src/main/bitbucket/user-request.ts b/src/main/bitbucket/user-request.ts index bc9d344e5ca0..b41a0a54a721 100644 --- a/src/main/bitbucket/user-request.ts +++ b/src/main/bitbucket/user-request.ts @@ -54,12 +54,3 @@ export async function fetchBitbucketUserResult( return { ok: false, reason: 'unreachable' } } } - -/** Convenience for callers that only need the account, not the failure reason. */ -export async function fetchBitbucketUser( - config: BitbucketAuthConfig, - timeoutMs: number = USER_REQUEST_TIMEOUT_MS -): Promise { - const result = await fetchBitbucketUserResult(config, timeoutMs) - return result.ok ? result.user : null -} diff --git a/src/main/browser/browser-cookie-decryption.ts b/src/main/browser/browser-cookie-decryption.ts index c8b486e0c539..0e9b61b5028b 100644 --- a/src/main/browser/browser-cookie-decryption.ts +++ b/src/main/browser/browser-cookie-decryption.ts @@ -43,12 +43,6 @@ export function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null return /^v\d\d$/.test(version) ? version : null } -// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which -// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. -export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { - return cookieEncryptionVersion(encryptedBuffer) === 'v20' -} - // Why: a named cause must carry only its exact count; tied causes fall back to unknown. export function buildUndecryptableWarning(counts: { decryptFailed: number diff --git a/src/main/browser/browser-identity-mode-store.ts b/src/main/browser/browser-identity-mode-store.ts index 9f432c6e0064..4853ec9b5420 100644 --- a/src/main/browser/browser-identity-mode-store.ts +++ b/src/main/browser/browser-identity-mode-store.ts @@ -117,13 +117,6 @@ function notifySnapshotListeners(snapshot: BrowserIdentityModeSnapshot): void { } } -export function onBrowserIdentityModeSnapshotChanged( - listener: (snapshot: BrowserIdentityModeSnapshot) => void -): () => void { - snapshotListeners.add(listener) - return () => snapshotListeners.delete(listener) -} - /** * Commits an explicit choice. The record lands durably before this resolves. * diff --git a/src/main/browser/local-ssh-browser-partitions.ts b/src/main/browser/local-ssh-browser-partitions.ts index a314cc12fa7d..44c2ea52bc49 100644 --- a/src/main/browser/local-ssh-browser-partitions.ts +++ b/src/main/browser/local-ssh-browser-partitions.ts @@ -57,10 +57,6 @@ export function isLocalSshBrowserPartition(partition: string): boolean { return preparedByPartition.has(partition) } -export function localSshBrowserPartitionTargetId(partition: string): string | null { - return preparedByPartition.get(partition)?.targetId ?? null -} - export function localSshBrowserPartitionForSession(webSession: Session): string | null { return partitionBySession.get(webSession) ?? null } @@ -218,8 +214,3 @@ export async function releaseLocalSshBrowserPartitionsForTarget(targetId: string } registerBrowserRoutePartitionRetentionProbe((partition) => preparedByPartition.has(partition)) - -export function resetLocalSshBrowserPartitionsForTests(): void { - preparedByIdentityKey.clear() - preparedByPartition.clear() -} diff --git a/src/main/claude-accounts/claude-launch-routing.test.ts b/src/main/claude-accounts/claude-launch-routing.test.ts index c4cc2f42c605..a9eb8e33b2b6 100644 --- a/src/main/claude-accounts/claude-launch-routing.test.ts +++ b/src/main/claude-accounts/claude-launch-routing.test.ts @@ -1,6 +1,6 @@ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { resolveClaudeStructuredInvocation } from '../claude/claude-structured-launch-resolution' @@ -87,27 +87,6 @@ async function launchHomes(f: ReturnType) { } } -const mainRoot = join(__dirname, '..') - -function mainSourceFiles(): string[] { - const files: string[] = [] - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name) - if (entry.isDirectory()) { - walk(path) - } else if ( - entry.name.endsWith('.ts') && - !/\.test\.|test-(?:support|fixture|harness)/.test(entry.name) - ) { - files.push(relative(mainRoot, path).split('\\').join('/')) - } - } - } - walk(mainRoot) - return files -} - describe('one router decision for every Claude launch', () => { it('sends every entry point to System default, then to the account once it signs in', async () => { const f = coveredAccount() @@ -183,53 +162,4 @@ describe('one router decision for every Claude launch', () => { } } ) - - // Terminals, chats, AI commit messages and automations launch through - // ClaudeRuntimeAuthService.prepareForClaudeLaunch; usage through prepareForRateLimitFetch. - it('keeps the Claude selection and account folders out of every launch path but the router', () => { - const routing = - /\b(?:getSelectedClaudeAccountIdForTarget|activeClaudeManagedAccountIds?(?:ByRuntime)?|describeClaudeProfile|wslClaudeProfile|isHostManagedClaudeAccount)\b|\.accountHome\(/ - // Both map settings keys to model-catalog expiry or prewarm; they route nothing. - const allowed = new Set([ - 'native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts', - 'startup/main-process-account-services.ts' - ]) - const files = mainSourceFiles() - // Presence: the scan reaches the router, and the pattern finds what the router reads. - expect(files).toContain('claude-accounts/claude-profile-router.ts') - expect( - readFileSync(join(mainRoot, 'claude-accounts/claude-profile-router.ts'), 'utf8') - ).toMatch(routing) - const bypassing = files.filter( - (file) => - !file.startsWith('claude-accounts/') && - !allowed.has(file) && - routing.test(readFileSync(join(mainRoot, file), 'utf8')) - ) - expect(bypassing).toEqual([]) - }) - - // Terminals and AI commit messages run `claude` by agent id through generic launchers, which call - // prepareForClaudeLaunch for it; a new generic launcher is covered by the entry-point test above. - it('lets only router-aware modules resolve the claude binary or load the Agent SDK', () => { - const spawning = /\bresolveClaudeCommand\b|\bloadClaudeAgentSdk\b/ - const reviewed: Record = { - 'claude/claude-structured-child-env.ts': - 'chat child; its home comes from router.prepareLaunch', - 'claude/claude-stream-json-connection.ts': 'spawns the chat child with that launch env', - 'claude-accounts/claude-profile-router.ts': 'the router: a `--version` probe only', - 'claude-accounts/claude-command-process.ts': 'sign-in into a named account folder', - 'runtime/structured-claude-runtime-adapter.ts': 'wires the routed chat resolver', - 'runtime/structured-agent-session-runtime.ts': 'passes the resolver through', - 'runtime/structured-agent-runtime-registrations.ts': 'passes the resolver through', - 'runtime/structured-agent-model-catalog-discovery.ts': 'model listing on the routed chat env', - 'runtime/orca-runtime-get-worktree-ps.ts': 'wires the routed chat resolver' - } - const found = mainSourceFiles().filter((file) => - spawning.test(readFileSync(join(mainRoot, file), 'utf8')) - ) - // Reach: every reviewed module still exists and still matches, so the list cannot rot quietly. - expect(found.filter((file) => file in reviewed).sort()).toEqual(Object.keys(reviewed).sort()) - expect(found.filter((file) => !(file in reviewed))).toEqual([]) - }) }) diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts index 051970a5b36d..be51effa2cc6 100644 --- a/src/main/claude/claude-subagent-group-row.ts +++ b/src/main/claude/claude-subagent-group-row.ts @@ -93,13 +93,28 @@ export function writeClaudeSubagentGroupRow( ): void { const agents = [...group.entries.values()].map((tracked) => tracked.entry) if (agents.length === 0) { - // The row's last child turned out not to be a subagent. An empty roster is - // not a roster of nothing, so the row goes rather than reading "Ran 0". - if (group.lastSerialized !== null) { + const serialized = JSON.stringify(claudeSubagentGroupBody(group.groupId, [])) + if (serialized === group.lastSerialized) { + return + } + // Reclassification removes the row; refused tombstones remain retryable. + const admission = sink.tryAppendTombstone?.(group.identity) + if (admission && !admission.accepted) { group.lastSerialized = null + return + } + if (!admission) { sink.appendTombstone(group.identity) + } + const publication = sink.tryPublish?.() + if (publication && !publication.accepted) { + group.lastSerialized = null + return + } + if (!publication) { sink.publish() } + group.lastSerialized = serialized return } const body = claudeSubagentGroupBody(group.groupId, agents) @@ -108,7 +123,22 @@ export function writeClaudeSubagentGroupRow( // Nothing changed — a duplicate delivery must not burn a revision. return } + const options = { turnScope: group.turnScope } + const admission = sink.tryAppendItem?.(group.identity, body, options) + if (admission && !admission.accepted) { + group.lastSerialized = null + return + } + if (!admission) { + sink.appendItem(group.identity, body, options) + } + const publication = sink.tryPublish?.() + if (publication && !publication.accepted) { + group.lastSerialized = null + return + } + if (!publication) { + sink.publish() + } group.lastSerialized = serialized - sink.appendItem(group.identity, body, { turnScope: group.turnScope }) - sink.publish() } diff --git a/src/main/claude/claude-subagent-roster-groups.test.ts b/src/main/claude/claude-subagent-roster-groups.test.ts index 0a790f441117..eee98ae3eaf7 100644 --- a/src/main/claude/claude-subagent-roster-groups.test.ts +++ b/src/main/claude/claude-subagent-roster-groups.test.ts @@ -7,6 +7,7 @@ import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { JournaledClaudeSubagentGroup } from './claude-subagent-group-row' import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' import { ClaudeSubagentRosterGroups } from './claude-subagent-roster-groups' +import { inheritedClaudeSubagentGroup } from './claude-subagent-group-row' const entry = (id: string): NativeChatSubagentEntry => ({ id, @@ -51,8 +52,7 @@ describe('ClaudeSubagentRosterGroups', () => { ]) { const groups = new ClaudeSubagentRosterGroups({ journaled: journaledTwiceListed(), - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, - onEvicted: () => {} + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) for (const id of order) { groups.locateOrInherit(id) @@ -65,14 +65,16 @@ describe('ClaudeSubagentRosterGroups', () => { it('keeps a child placed in a held row when another row listing it is evicted', () => { const groups = new ClaudeSubagentRosterGroups({ journaled: journaledTwiceListed(), - currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, - onEvicted: () => {} + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) - groups.locateOrInherit('agent-b') + const inherited = groups.locateOrInherit('agent-b') groups.locateOrInherit('agent-a') + if (inherited) { + groups.trim(inherited.group) + } // Enough of this run's own rows to push the older inherited row out. for (let turn = 0; turn < 31; turn += 1) { - groups.groupFor(`turn-${turn}-new`) + groups.trim(groups.groupFor(`turn-${turn}-new`)) } expect(groups.get('turn-a')).toBeUndefined() expect(groups.locate('agent-b')).toBeNull() @@ -83,10 +85,68 @@ describe('ClaudeSubagentRosterGroups', () => { const live = turnScope('turn-live') const groups = new ClaudeSubagentRosterGroups({ journaled: journaledTwiceListed(), - currentTurnScope: () => live, - onEvicted: () => {} + currentTurnScope: () => live }) expect(groups.locateOrInherit('agent-b')?.group.turnScope).toEqual(turnScope('turn-a')) expect(groups.groupFor('turn-live').turnScope).toEqual(live) }) }) + +it('bounds settled groups and invocation identities without evicting live groups', () => { + const groups = new ClaudeSubagentRosterGroups({ + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) + for (let index = 0; index < 35; index++) { + const group = groups.groupFor(`live-${index}`) + const tracked = inheritedClaudeSubagentGroup( + 'seed', + { + entries: [{ ...entry(`live-child-${index}`), state: 'working' }], + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + () => 1 + ).entries.get(`live-child-${index}`) + if (!tracked) { + throw new Error('missing fixture child') + } + group.entries.set(tracked.entry.id, tracked) + group.lastSerialized = 'written' + groups.place(tracked.entry.id, group.groupId) + groups.trim(group) + } + for (let index = 0; index < 1100; index++) { + const group = groups.groupFor(`settled-${index}`) + const id = `settled-child-${index}` + const tracked = inheritedClaudeSubagentGroup( + 'seed', + { + entries: [entry(id)], + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + () => 1 + ).entries.get(id) + if (!tracked) { + throw new Error('missing fixture child') + } + tracked.invocationIds = new Set([`tool-${index}`]) + group.entries.set(id, tracked) + group.lastSerialized = 'written' + groups.place(id, group.groupId) + groups.trim(group) + } + expect([...groups.values()]).toHaveLength(35) + expect(groups.locate('live-child-0')?.tracked.entry.state).toBe('working') + expect(groups.locate('settled-child-1099')).toBeNull() + expect(groups.hasSettled('settled-child-1099', 'tool-1099')).toBe(true) + expect(groups.hasSettled('settled-child-0', 'tool-0')).toBe(false) + for (const group of groups.values()) { + for (const tracked of group.entries.values()) { + tracked.entry.state = 'completed' + } + groups.trim(group) + } + expect([...groups.values()]).toHaveLength(32) + groups.clear() + expect([...groups.values()]).toHaveLength(0) + expect(groups.hasSettled('settled-child-1099', 'tool-1099')).toBe(false) +}) diff --git a/src/main/claude/claude-subagent-roster-groups.ts b/src/main/claude/claude-subagent-roster-groups.ts index ea4ce76cdfbc..757ceb44c61c 100644 --- a/src/main/claude/claude-subagent-roster-groups.ts +++ b/src/main/claude/claude-subagent-roster-groups.ts @@ -2,6 +2,7 @@ // wrote, and the ones earlier runs of the session journaled, inherited one at a // time as this run's own events reach them. +import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention' import { claudeSubagentGroupIdentity, inheritedClaudeSubagentGroup @@ -10,8 +11,7 @@ import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-t import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' import type { RosterGroup, TrackedEntry } from './claude-subagent-roster-state' -/** Spawn-group rows kept live per session. Bounds an event-accumulated map that - * no provider snapshot ever prunes. */ +/** Settled history target; groups with unfinished children remain owned. */ const MAX_SUBAGENT_GROUPS = 32 export type LocatedClaudeSubagent = { group: RosterGroup; tracked: TrackedEntry } @@ -22,14 +22,33 @@ export class ClaudeSubagentRosterGroups { * from an earlier turn revises that turn's row instead of the live one. */ private readonly groupIdByEntry = new Map() + private readonly retention = new SubagentRosterRetention(this.groups, { + maxGroups: MAX_SUBAGENT_GROUPS, + maxSettledIdentities: 2048, + entries: (group) => [...group.entries.values()].map((tracked) => tracked.entry), + identities: (group) => + [...group.entries] + .filter(([id]) => this.groupIdByEntry.get(id) === group.groupId) + .flatMap(([id, tracked]) => [ + claudeSubagentRetentionIdentity(id, null), + ...[...new Set([...(tracked.invocationIds ?? []), tracked.toolUseId])].map((toolUseId) => + claudeSubagentRetentionIdentity(id, toolUseId) + ) + ]), + onEvict: (group) => { + for (const id of group.entries.keys()) { + if (this.groupIdByEntry.get(id) === group.groupId) { + this.groupIdByEntry.delete(id) + } + } + } + }) + constructor( private readonly deps: { journaled?: ClaudeJournaledRosterSource /** The open turn's scope, which a group this run creates belongs to. */ currentTurnScope: () => AgentJournalTurnScope - /** Once a group leaves the map nothing can reach its children again — not - * even a session sweep — so contact is lost here. */ - onEvicted: (group: RosterGroup) => void } ) {} @@ -88,7 +107,16 @@ export class ClaudeSubagentRosterGroups { this.groupIdByEntry.delete(id) } + hasSettled(id: string, toolUseId: string | null): boolean { + return this.retention.hasSettled(claudeSubagentRetentionIdentity(id, toolUseId)) + } + + trim(group: RosterGroup, retainAccessed = false): void { + this.retention.trim([group], retainAccessed ? group.groupId : undefined) + } + clear(): void { + this.retention.clear() this.groups.clear() this.groupIdByEntry.clear() } @@ -114,21 +142,9 @@ export class ClaudeSubagentRosterGroups { private admit(group: RosterGroup): void { this.groups.set(group.groupId, group) - while (this.groups.size > MAX_SUBAGENT_GROUPS) { - const oldest = this.groups.keys().next() - if (oldest.done || oldest.value === group.groupId) { - break - } - const evicted = this.groups.get(oldest.value) - if (evicted) { - this.deps.onEvicted(evicted) - } - for (const id of evicted?.entries.keys() ?? []) { - if (this.groupIdByEntry.get(id) === oldest.value) { - this.groupIdByEntry.delete(id) - } - } - this.groups.delete(oldest.value) - } } } + +function claudeSubagentRetentionIdentity(id: string, toolUseId: string | null): string { + return JSON.stringify([id, toolUseId]) +} diff --git a/src/main/claude/claude-subagent-roster-retention.test.ts b/src/main/claude/claude-subagent-roster-retention.test.ts new file mode 100644 index 000000000000..17776d48b277 --- /dev/null +++ b/src/main/claude/claude-subagent-roster-retention.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { ClaudeSubagentRoster } from './claude-subagent-roster' +import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster' + +function harness(journaled?: ClaudeJournaledRosterSource) { + const rows = new Map() + let group = 'oldest' + let writes = 0 + let refuseAppend = false + let refusePublish = false + const append = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { + if (identity.provider !== 'orca') { + throw new Error('unexpected roster identity') + } + rows.set(identity.clientMessageId, body) + writes++ + } + const sink: StructuredAgentSessionEventSink = { + appendItem: append, + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body) => { + if (refuseAppend) { + return { accepted: false, reason: 'backpressure' } + } + append(identity, body) + return { accepted: true } + }, + tryPublish: () => + refusePublish ? { accepted: false, reason: 'backpressure' } : { accepted: true } + } + const roster = new ClaudeSubagentRoster({ + sink, + journaled, + currentGroupKey: () => group, + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) + const start = (id: string, tool = id, backgrounded = false): void => { + roster.observeSystemFrame({ + type: 'system', + subtype: 'task_started', + task_type: 'local_agent', + task_id: id, + tool_use_id: tool, + description: id, + is_backgrounded: backgrounded + }) + } + const finish = (id: string, tool = id): void => { + roster.observeSystemFrame({ + type: 'system', + subtype: 'task_notification', + task_id: id, + tool_use_id: tool, + status: 'completed' + }) + } + const agents = (key: string) => { + const body = rows.get(`claude-subagents:${key}`) + return body?.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : [] + } + const churn = (count: number, settled = true): void => { + for (let index = 0; index < count; index++) { + group = `later-${index}` + start(`later-child-${index}`) + if (settled) { + finish(`later-child-${index}`) + } + } + } + return { + roster, + start, + finish, + agents, + churn, + writes: () => writes, + setGroup: (key: string) => { + group = key + }, + refuse: (append: boolean, publish: boolean) => { + refuseAppend = append + refusePublish = publish + } + } +} + +describe('Claude shared settled-only roster retention', () => { + it('keeps every child of the oldest live group past 33 later spawn groups', () => { + const h = harness() + h.start('a', 'tool-a', true) + h.start('b', 'tool-b', true) + h.roster.settleTurn('oldest') + h.churn(34) + expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['working', 'working']) + h.finish('a', 'tool-a') + expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'working']) + h.finish('b', 'tool-b') + expect(h.agents('oldest').map((entry) => entry.id)).toEqual(['a', 'b']) + expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'completed']) + }) + + it('reaches every still-running group in the session-end sweep', () => { + const h = harness() + h.start('a', 'tool-a', true) + h.start('b') + h.churn(34, false) + h.roster.settleSession() + expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable']) + expect(h.agents('later-33')[0].state).toBe('unverifiable') + }) + + it('ignores old announcements after settled eviction and alias-cache churn, but admits a new invocation', () => { + const h = harness() + h.start('a', 'first') + h.finish('a', 'first') + h.start('a', 'second') + h.finish('a', 'second') + h.churn(600) + const before = h.writes() + h.start('a', 'first') + h.start('a', 'second') + h.roster.observeSystemFrame({ + type: 'system', + subtype: 'task_started', + task_type: 'local_agent', + task_id: 'a' + }) + h.roster.observeChildActivity('first') + expect(h.writes()).toBe(before) + expect(h.roster.linkage.settledLinkageFor('first').linkage.agentId).toBe('a') + h.setGroup('resumed') + h.start('a', 'third') + expect(h.agents('resumed')).toEqual([expect.objectContaining({ id: 'a', state: 'working' })]) + }) + + it('keeps an inherited row reachable when child traffic precedes its resume announcement', () => { + let claimed = false + const h = harness({ + canonical: (tool) => (tool === 'original-spawn' ? 'a' : null), + groupOf: (id) => (['a', 'b'].includes(id) ? 'inherited' : null), + claimGroup: (group) => { + if (group !== 'inherited' || claimed) { + return null + } + claimed = true + return { + entries: ['a', 'b'].map((id) => ({ + id, + label: id, + state: 'completed', + startedAt: 1, + settledAt: 2 + })), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + }, + attempt: () => 4 + }) + h.churn(32, false) + h.roster.observeChildActivity('original-spawn') + h.setGroup('resumed') + h.start('a', 'resume-tool', true) + expect(h.agents('inherited').map((entry) => entry.state)).toEqual(['working', 'completed']) + expect(h.agents('resumed')).toHaveLength(0) + expect(h.roster.linkage.settledLinkageFor('original-spawn').linkage).toMatchObject({ + agentId: 'a', + attempt: 5 + }) + }) + + for (const failure of ['append', 'publish']) { + it(`retains a terminal group whose final ${failure} was refused and retries it`, () => { + const h = harness() + h.start('a') + h.start('b') + h.refuse(failure === 'append', failure === 'publish') + h.finish('a') + h.finish('b') + h.refuse(false, false) + h.churn(34) + h.finish('b') + expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'completed']) + const before = h.writes() + h.start('a') + expect(h.writes()).toBe(before) + }) + } +}) diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index db942502ee03..1547f4bd330e 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -367,13 +367,13 @@ describe('ClaudeSubagentRoster', () => { }) describe('groups that no later event can reach', () => { - it('loses contact with a group evicted past the bound', () => { + it('retains live groups past the settled-history bound', () => { const { roster, rolesIn, setGroupKey } = harness('turn-0') for (let index = 0; index < 33; index += 1) { setGroupKey(`turn-${index}`) roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' })) } - expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'working' })]) expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })]) }) diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts index baeb72e3ffbb..548613136d0e 100644 --- a/src/main/claude/claude-subagent-roster.ts +++ b/src/main/claude/claude-subagent-roster.ts @@ -87,8 +87,7 @@ export class ClaudeSubagentRoster { this.now = deps.now ?? (() => Date.now()) this.groups = new ClaudeSubagentRosterGroups({ journaled: deps.journaled, - currentTurnScope: deps.currentTurnScope, - onEvicted: (group) => this.sweep(group, true) + currentTurnScope: deps.currentTurnScope }) this.ids = new ClaudeSubagentIds(deps.journaled?.canonical) this.linkage = new ClaudeSubagentLinkage({ @@ -124,6 +123,12 @@ export class ClaudeSubagentRoster { if (frame.toolUseId) { this.ids.alias(frame.toolUseId, frame.taskId) } + if ( + this.groups.hasSettled(frame.taskId, frame.toolUseId) && + (frame.toolUseId !== null || !this.groups.locate(frame.taskId)) + ) { + return true + } const located = this.groups.locateOrInherit(frame.taskId) ?? (frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null) @@ -158,10 +163,20 @@ export class ClaudeSubagentRoster { */ observeChildActivity(parentToolUseId: string): void { const canonical = this.ids.canonical(parentToolUseId) - if (this.ids.isExcluded(parentToolUseId, canonical)) { + if ( + this.ids.isExcluded(parentToolUseId, canonical) || + this.groups.hasSettled(canonical, parentToolUseId) || + (!this.groups.locate(canonical) && this.groups.hasSettled(canonical, null)) + ) { + return + } + if (this.groups.locate(canonical)) { return } - if (this.groups.locateOrInherit(canonical)) { + const inherited = this.groups.locateOrInherit(canonical) + if (inherited) { + // Child traffic can precede the announcement that reopens an inherited row. + this.groups.trim(inherited.group, true) return } if (this.announcesTasks) { @@ -242,7 +257,6 @@ export class ClaudeSubagentRoster { if (!group) { return } - let changed = false for (const [id, tracked] of group.entries) { if (isTerminalSubagentState(tracked.entry.state)) { continue @@ -254,11 +268,13 @@ export class ClaudeSubagentRoster { ...tracked, entry: { ...tracked.entry, state: 'unverifiable', settledAt: this.now() } }) - changed = true - } - if (changed) { - writeClaudeSubagentGroupRow(this.deps.sink, group) } + this.write(group) + } + + private write(group: RosterGroup): void { + writeClaudeSubagentGroupRow(this.deps.sink, group) + this.groups.trim(group) } private create( @@ -270,6 +286,7 @@ export class ClaudeSubagentRoster { ): void { const group = this.groups.groupFor(this.deps.currentGroupKey() ?? OUTSIDE_TURN) if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) { + this.write(group) return } group.admittedEntries += 1 @@ -291,7 +308,7 @@ export class ClaudeSubagentRoster { } }) this.groups.place(id, group.groupId) - writeClaudeSubagentGroupRow(this.deps.sink, group) + this.write(group) } private revise( @@ -332,7 +349,7 @@ export class ClaudeSubagentRoster { } } group.entries.set(id, next) - writeClaudeSubagentGroupRow(this.deps.sink, group) + this.write(group) } /** Re-key a provisional entry from its tool id onto the canonical task id the @@ -362,6 +379,6 @@ export class ClaudeSubagentRoster { } located.group.entries.delete(id) this.groups.forget(id) - writeClaudeSubagentGroupRow(this.deps.sink, located.group) + this.write(located.group) } } diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index 48decf7539aa..7e7713f2c735 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -27,9 +27,6 @@ const unixTerminationSignals = ['SIGINT', 'SIGTERM'] as const const builderConfig = require('../../../config/electron-builder.config.cjs') as { files?: string[] asarUnpack?: string[] - mac?: { extraResources?: { from?: string; to?: string }[] } - linux?: { extraResources?: { from?: string; to?: string }[] } - win?: { extraResources?: { from?: string; to?: string }[] } } const linuxLauncherAsset = new URL('../../../resources/linux/bin/orca-ide', import.meta.url) const darwinLauncherAsset = new URL('../../../resources/darwin/bin/orca', import.meta.url) @@ -138,47 +135,11 @@ describe('packaged CLI assets', () => { } }) - it('copies runtime dependencies used before Electron asar integration is available', () => { - const runtimeResourceTargets = new Set( - [ - ...(builderConfig.mac?.extraResources ?? []), - ...(builderConfig.linux?.extraResources ?? []), - ...(builderConfig.win?.extraResources ?? []) - ].map((resource) => normalizeResourceTarget(resource.to)) - ) - - expect([...runtimeResourceTargets]).toEqual( - expect.arrayContaining([ - join('node_modules', 'ws'), - join('node_modules', 'tweetnacl'), - join('node_modules', 'zod'), - join('node_modules', '@orca', 'process-host'), - join('node_modules', 'yaml'), - join('node_modules', 'jsonc-parser'), - join('node_modules', 'node-pty'), - join('node_modules', 'sherpa-onnx-darwin-${arch}'), - join('node_modules', 'sherpa-onnx-linux-${arch}'), - join('node_modules', 'sherpa-onnx-win-x64') - ]) - ) - }) - - function normalizeResourceTarget(target: string | undefined): string | undefined { - return target?.replace(/[\\/]/g, sep) - } - itRunsUnixShell('keeps the Linux launcher executable in packaged resources', async () => { const launcherStats = await stat(linuxLauncherAsset) expect(launcherStats.mode & 0o111).not.toBe(0) }) - itRunsUnixShell('replaces the shell process in packaged Unix launchers', async () => { - for (const launcher of [linuxLauncherAsset, darwinLauncherAsset]) { - const content = await readFile(launcher, 'utf8') - expect(content).toContain('ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"') - } - }) - it('retries an incomplete listener-state write', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-listener-state-')) const statePath = join(root, 'listener-state.json') @@ -338,26 +299,25 @@ printf 'arg=%s\\n' "$@" } ) - // Why: registration on every Linux install method now points at this one - // launcher, so its env sanitation and argv passthrough are the contract the - // AppImage, deb, and extracted-tree commands all depend on. - itRunsUnixShell('sanitizes node env and forwards argv verbatim', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-env-')) - try { - const appDir = join(root, 'Orca') - const resourcesDir = join(appDir, 'resources') - const launcherDir = join(resourcesDir, 'bin') - const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') - const launcherPath = join(launcherDir, 'orca-ide') - const cliPath = join(cliDir, 'index.js') + // Observe each launcher's actual child env and argv, including arguments with spaces. + itRunsUnixShell.each(unixLauncherFixtures)( + 'sanitizes node env and forwards argv verbatim through the $name launcher', + async (launcherFixture) => { + const root = await mkdtemp(join(tmpdir(), 'orca-unix-cli-env-')) + try { + const appDir = join(root, ...launcherFixture.appDir) + const launcherPath = join(appDir, ...launcherFixture.launcher) + const electronPath = join(appDir, ...launcherFixture.executable) + const cliPath = join(appDir, ...launcherFixture.cli) - await mkdir(launcherDir, { recursive: true }) - await mkdir(cliDir, { recursive: true }) - await copyFile(linuxLauncherAsset, launcherPath) - await writeFile(cliPath, '', 'utf8') - await writeFile( - join(appDir, 'orca-ide'), - `#!/usr/bin/env bash + await mkdir(dirname(launcherPath), { recursive: true }) + await mkdir(dirname(electronPath), { recursive: true }) + await mkdir(dirname(cliPath), { recursive: true }) + await copyFile(launcherFixture.asset, launcherPath) + await writeFile(cliPath, '', 'utf8') + await writeFile( + electronPath, + `#!/usr/bin/env bash node -e 'console.log(JSON.stringify({ argv: process.argv.slice(1), runAsNode: process.env.ELECTRON_RUN_AS_NODE, @@ -367,37 +327,39 @@ node -e 'console.log(JSON.stringify({ orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null }))' -- "$@" `, - { encoding: 'utf8', mode: 0o755 } - ) + { encoding: 'utf8', mode: 0o755 } + ) - const result = await execFileAsync(launcherPath, ['--help', 'two words'], { - env: { - ...process.env, - NODE_OPTIONS: '--trace-warnings', - NODE_REPL_EXTERNAL_MODULE: 'external-loader' + const result = await execFileAsync(launcherPath, ['--help', 'two words'], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '0', + NODE_OPTIONS: '--trace-warnings', + NODE_REPL_EXTERNAL_MODULE: 'external-loader' + } + }) + const payload = JSON.parse(result.stdout) as { + argv: string[] + runAsNode: string + nodeOptions: string | null + orcaNodeOptions: string | null + nodeReplExternalModule: string | null + orcaNodeReplExternalModule: string | null } - }) - const payload = JSON.parse(result.stdout) as { - argv: string[] - runAsNode: string - nodeOptions: string | null - orcaNodeOptions: string | null - nodeReplExternalModule: string | null - orcaNodeReplExternalModule: string | null - } - expect(payload.argv).toEqual([cliPath, '--help', 'two words']) - expect(payload.runAsNode).toBe('1') - // Why: Electron's node bootstrap must not inherit these, but the CLI - // still needs to see what the user set. - expect(payload.nodeOptions).toBeNull() - expect(payload.orcaNodeOptions).toBe('--trace-warnings') - expect(payload.nodeReplExternalModule).toBeNull() - expect(payload.orcaNodeReplExternalModule).toBe('external-loader') - } finally { - await rm(root, { recursive: true, force: true }) + expect(payload.argv).toEqual([cliPath, '--help', 'two words']) + expect(payload.runAsNode).toBe('1') + // Why: Electron's node bootstrap must not inherit these, but the CLI + // still needs to see what the user set. + expect(payload.nodeOptions).toBeNull() + expect(payload.orcaNodeOptions).toBe('--trace-warnings') + expect(payload.nodeReplExternalModule).toBeNull() + expect(payload.orcaNodeReplExternalModule).toBe('external-loader') + } finally { + await rm(root, { recursive: true, force: true }) + } } - }) + ) itRunsUnixShell('keeps Linux serve on the CLI entrypoint in node mode', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-serve-')) diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index dbe1c358418a..b7e4c6e87f16 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -1,7 +1,7 @@ -import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished } from 'vitest' import { getVersionManagerBinPaths, withCliRuntimeOnPath, @@ -10,6 +10,12 @@ import { resolveCodexCommand } from './command' +function makeCommandFixture(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)) + onTestFinished(() => rmSync(root, { recursive: true, force: true })) + return root +} + function makeExecutable(path: string): void { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, '') @@ -33,7 +39,7 @@ describe('resolveCodexCommand', () => { }) it('prefers Codex already present on PATH', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const pathDir = join(root, 'bin') const commandPath = join(pathDir, 'codex') makeExecutable(commandPath) @@ -46,7 +52,7 @@ describe('resolveCodexCommand', () => { it.skipIf(process.platform === 'win32')( 'skips non-runnable PATH entries and keeps scanning', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const badDir = join(root, 'bad-bin') const goodDir = join(root, 'good-bin') const badCommandPath = join(badDir, 'codex') @@ -65,7 +71,7 @@ describe('resolveCodexCommand', () => { ) it('skips PATH directories named like the command', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const badDir = join(root, 'bad-bin') const goodDir = join(root, 'good-bin') mkdirSync(join(badDir, 'codex'), { recursive: true }) @@ -82,7 +88,7 @@ describe('resolveCodexCommand', () => { }) it('falls back to the newest nvm-installed Codex when PATH misses it', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const v22Path = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'codex') const v24Path = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'codex') makeExecutable(v22Path) @@ -92,7 +98,7 @@ describe('resolveCodexCommand', () => { }) it('finds Codex in pnpm global bin on macOS', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const pnpmPath = join(root, 'Library', 'pnpm', 'codex') makeExecutable(pnpmPath) @@ -100,7 +106,7 @@ describe('resolveCodexCommand', () => { }) it('finds Codex in pnpm global bin on Linux', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const pnpmPath = join(root, '.local', 'share', 'pnpm', 'codex') makeExecutable(pnpmPath) @@ -108,7 +114,7 @@ describe('resolveCodexCommand', () => { }) it('finds Codex in pnpm global bin on Windows', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const pnpmPath = join(root, 'AppData', 'Local', 'pnpm', 'codex.cmd') makeExecutable(pnpmPath) @@ -116,7 +122,7 @@ describe('resolveCodexCommand', () => { }) it('finds Codex in yarn global bin on macOS', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const yarnPath = join(root, '.yarn', 'bin', 'codex') makeExecutable(yarnPath) @@ -124,7 +130,7 @@ describe('resolveCodexCommand', () => { }) it('finds Codex in yarn global bin on Windows', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const yarnPath = join(root, 'AppData', 'Local', 'Yarn', 'bin', 'codex.cmd') makeExecutable(yarnPath) @@ -132,7 +138,7 @@ describe('resolveCodexCommand', () => { }) it('finds Codex in bun global bin', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const bunPath = join(root, '.bun', 'bin', 'codex') makeExecutable(bunPath) @@ -140,7 +146,7 @@ describe('resolveCodexCommand', () => { }) it('finds Codex in bun global bin on Windows', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const bunPath = join(root, '.bun', 'bin', 'codex.exe') makeExecutable(bunPath) @@ -148,7 +154,7 @@ describe('resolveCodexCommand', () => { }) it('finds Codex in mise shims directory', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') const misePath = join(root, '.local', 'share', 'mise', 'shims', 'codex') makeExecutable(misePath) @@ -156,7 +162,7 @@ describe('resolveCodexCommand', () => { }) it('returns the bare command when no filesystem candidate exists', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const root = makeCommandFixture('orca-codex-command-') expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe('codex') }) @@ -169,7 +175,7 @@ describe('resolveClaudeCommand', () => { }) it('prefers claude already present on PATH', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const root = makeCommandFixture('orca-claude-command-') const pathDir = join(root, 'bin') const commandPath = join(pathDir, 'claude') makeExecutable(commandPath) @@ -180,7 +186,7 @@ describe('resolveClaudeCommand', () => { }) it('falls back to the newest nvm-installed claude when PATH misses it', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const root = makeCommandFixture('orca-claude-command-') const v22Path = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'claude') const v24Path = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'claude') makeExecutable(v22Path) @@ -190,7 +196,7 @@ describe('resolveClaudeCommand', () => { }) it('finds claude in pnpm global bin on macOS', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const root = makeCommandFixture('orca-claude-command-') const pnpmPath = join(root, 'Library', 'pnpm', 'claude') makeExecutable(pnpmPath) @@ -198,7 +204,7 @@ describe('resolveClaudeCommand', () => { }) it('finds claude in yarn global bin', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const root = makeCommandFixture('orca-claude-command-') const yarnPath = join(root, '.yarn', 'bin', 'claude') makeExecutable(yarnPath) @@ -206,7 +212,7 @@ describe('resolveClaudeCommand', () => { }) it('finds claude in bun global bin', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const root = makeCommandFixture('orca-claude-command-') const bunPath = join(root, '.bun', 'bin', 'claude') makeExecutable(bunPath) @@ -214,7 +220,7 @@ describe('resolveClaudeCommand', () => { }) it('finds native Windows claude.exe in user-local bin', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const root = makeCommandFixture('orca-claude-command-') const nativePath = join(root, '.local', 'bin', 'claude.exe') makeExecutable(nativePath) @@ -224,7 +230,7 @@ describe('resolveClaudeCommand', () => { }) it('returns the bare command when no filesystem candidate exists', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-claude-command-')) + const root = makeCommandFixture('orca-claude-command-') expect(resolveClaudeCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe('claude') }) @@ -237,7 +243,7 @@ describe('resolveCliCommands', () => { }) it('resolves a batch from PATH and install directories', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-')) + const root = makeCommandFixture('orca-cli-commands-') const pathDir = join(root, 'bin') const pathClaude = join(pathDir, 'claude') const nvmCodex = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'codex') @@ -259,7 +265,7 @@ describe('resolveCliCommands', () => { }) it('deduplicates command names in the returned map', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-cli-commands-')) + const root = makeCommandFixture('orca-cli-commands-') const pathDir = join(root, 'bin') const pathClaude = join(pathDir, 'claude') makeExecutable(pathClaude) @@ -277,7 +283,7 @@ describe('resolveCliCommands', () => { describe('getVersionManagerBinPaths', () => { it('includes volta, asdf, fnm, mise, pnpm, yarn, and bun directories', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-')) + const root = makeCommandFixture('orca-vm-paths-') const paths = getVersionManagerBinPaths({ platform: 'darwin', pathEnv: '', homePath: root }) expect(paths).toContain(join(root, '.volta', 'bin')) @@ -290,7 +296,7 @@ describe('getVersionManagerBinPaths', () => { }) it('includes nvm bin dir when node versions exist', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-')) + const root = makeCommandFixture('orca-vm-paths-') const nodeBin = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'node') makeExecutable(nodeBin) @@ -299,7 +305,7 @@ describe('getVersionManagerBinPaths', () => { }) it('uses platform-specific pnpm path on Linux', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-')) + const root = makeCommandFixture('orca-vm-paths-') const paths = getVersionManagerBinPaths({ platform: 'linux', pathEnv: '', homePath: root }) expect(paths).toContain(join(root, '.local', 'share', 'pnpm')) @@ -307,7 +313,7 @@ describe('getVersionManagerBinPaths', () => { }) it('includes Windows user-local bin for native CLI installers', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-')) + const root = makeCommandFixture('orca-vm-paths-') const paths = getVersionManagerBinPaths({ platform: 'win32', pathEnv: '', homePath: root }) expect(paths).toContain(join(root, '.local', 'bin')) @@ -317,7 +323,7 @@ describe('getVersionManagerBinPaths', () => { describe('withCliRuntimeOnPath', () => { it('pairs a version-manager CLI with its sibling node (stablyai/orca#10932)', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const root = makeCommandFixture('orca-pair-') const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') const v22 = join(root, '.nvm', 'versions', 'node', 'v22.9.0', 'bin') makeExecutable(join(v20, 'node')) @@ -335,7 +341,7 @@ describe('withCliRuntimeOnPath', () => { }) it('leaves PATH untouched for a CLI whose directory ships no node', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const root = makeCommandFixture('orca-pair-') const brew = join(root, 'opt', 'homebrew', 'bin') makeExecutable(join(brew, 'codex')) const env = { PATH: '/usr/bin' } @@ -349,7 +355,7 @@ describe('withCliRuntimeOnPath', () => { }) it('is a no-op when the runtime directory already leads PATH', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const root = makeCommandFixture('orca-pair-') const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') makeExecutable(join(v20, 'node')) makeExecutable(join(v20, 'codex')) @@ -359,7 +365,7 @@ describe('withCliRuntimeOnPath', () => { }) it('reads the Windows path key the child will actually use, whatever its casing', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const root = makeCommandFixture('orca-pair-') const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') makeExecutable(join(v20, 'node.exe')) makeExecutable(join(v20, 'codex.cmd')) @@ -374,7 +380,7 @@ describe('withCliRuntimeOnPath', () => { }) it('writes the Windows Path key without leaving a differently-cased twin', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const root = makeCommandFixture('orca-pair-') const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') makeExecutable(join(v20, 'node.exe')) makeExecutable(join(v20, 'codex.cmd')) diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 65b8e2d8b203..e02c5e71c104 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import type { ChildProcess, spawn } from 'node:child_process' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type TestContext } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -118,15 +118,22 @@ type StubHook = { trustStatus: string } -function createStubRequest(options: { - scenario: string - hooks: StubHook[] - expectedTrustKeys: string[] - managedCommand: string - timeoutMs?: number -}): { request: CodexHookTrustGrantRequest; recordFile: string; pidFile: string } { +function createStubRequest( + options: { + scenario: string + hooks: StubHook[] + expectedTrustKeys: string[] + managedCommand: string + timeoutMs?: number + }, + onTestFinished?: TestContext['onTestFinished'] +): { request: CodexHookTrustGrantRequest; recordFile: string; pidFile: string } { const root = mkdtempSync(join(tmpdir(), 'orca-codex-stub-')) - tempRoots.push(root) + if (onTestFinished) { + onTestFinished(() => rmSync(root, { recursive: true, force: true })) + } else { + tempRoots.push(root) + } const stubPath = join(root, 'stub-app-server.cjs') writeFileSync(stubPath, STUB_SERVER_SOURCE) const recordFile = join(root, 'batch-write-params.json') @@ -398,15 +405,19 @@ describe('runCodexHookTrustGrantSession', () => { expect(isCodexAppServerUnsupportedError(error)).toBe(false) }) - it('kills a hung server at the session deadline', async () => { + it.concurrent('kills a hung server at the session deadline', async (context) => { + const { expect, onTestFinished } = context const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] - const { request, pidFile } = createStubRequest({ - scenario: 'hang', - hooks: keys.map((key) => managedHook(key)), - expectedTrustKeys: keys, - managedCommand: MANAGED_COMMAND, - timeoutMs: STUB_START_DEADLINE_MS - }) + const { request, pidFile } = createStubRequest( + { + scenario: 'hang', + hooks: keys.map((key) => managedHook(key)), + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND, + timeoutMs: STUB_START_DEADLINE_MS + }, + onTestFinished + ) const startedAt = Date.now() await expect(runCodexHookTrustGrantSession(request)).rejects.toBeInstanceOf( @@ -419,17 +430,20 @@ describe('runCodexHookTrustGrantSession', () => { expect(() => process.kill(childPid, 0)).toThrow() }) - it.runIf(process.platform !== 'win32')( + it.runIf(process.platform !== 'win32').concurrent( 'stops a server that ignores its stdin end and SIGTERM after the SIGTERM grace', - async () => { + async ({ expect, onTestFinished }) => { const keys = ['/home/a/.codex/hooks.json:session_start:0:0'] - const { request, pidFile } = createStubRequest({ - scenario: 'wedged', - hooks: keys.map((key) => managedHook(key)), - expectedTrustKeys: keys, - managedCommand: MANAGED_COMMAND, - timeoutMs: STUB_START_DEADLINE_MS - }) + const { request, pidFile } = createStubRequest( + { + scenario: 'wedged', + hooks: keys.map((key) => managedHook(key)), + expectedTrustKeys: keys, + managedCommand: MANAGED_COMMAND, + timeoutMs: STUB_START_DEADLINE_MS + }, + onTestFinished + ) const startedAt = Date.now() await expect(runCodexHookTrustGrantSession(request)).rejects.toBeInstanceOf( @@ -444,14 +458,18 @@ describe('runCodexHookTrustGrantSession', () => { } ) - it('bounds a callback that stalls between RPC requests', async () => { - const { request, pidFile } = createStubRequest({ - scenario: 'happy', - hooks: [], - expectedTrustKeys: [], - managedCommand: MANAGED_COMMAND, - timeoutMs: STUB_START_DEADLINE_MS - }) + it.concurrent('bounds a callback that stalls between RPC requests', async (context) => { + const { expect, onTestFinished } = context + const { request, pidFile } = createStubRequest( + { + scenario: 'happy', + hooks: [], + expectedTrustKeys: [], + managedCommand: MANAGED_COMMAND, + timeoutMs: STUB_START_DEADLINE_MS + }, + onTestFinished + ) const startedAt = Date.now() await expect( diff --git a/src/main/codex/codex-managed-trust-reconciliation.ts b/src/main/codex/codex-managed-trust-reconciliation.ts index 6501089d333f..7bf3b0c41382 100644 --- a/src/main/codex/codex-managed-trust-reconciliation.ts +++ b/src/main/codex/codex-managed-trust-reconciliation.ts @@ -7,9 +7,7 @@ import { normalizeCodexHookSourcePath, parseTrustKey, readHookTrustEntries, - readHookTrustEntriesFromContent, removeHookTrustEntries, - removeHookTrustEntriesFromContent, type CodexEventLabel, type CodexHookTrustState, type CodexTrustEntry @@ -148,17 +146,6 @@ function getCodexManagedHookTrustEntryKeys( return ownedKeys } -export function stripCodexManagedHookTrustEntriesFromConfig( - contents: string, - options: CodexManagedHookTrustOwnershipOptions -): string { - const ownedKeys = getCodexManagedHookTrustEntryKeys( - readHookTrustEntriesFromContent(contents), - options - ) - return removeHookTrustEntriesFromContent(contents, ownedKeys) -} - export function removeCodexManagedHookTrustEntries( options: CodexManagedHookTrustOwnershipOptions & { tomlPath: string } ): void { diff --git a/src/main/codex/codex-structured-journal-limits.ts b/src/main/codex/codex-structured-journal-limits.ts index d4c9bcfa0de9..82ad7a38235c 100644 --- a/src/main/codex/codex-structured-journal-limits.ts +++ b/src/main/codex/codex-structured-journal-limits.ts @@ -11,9 +11,9 @@ export const MAX_CODEX_PENDING_PROMPTS = 128 export const MAX_CODEX_IDENTITY_ENTRIES = 512 export const MAX_CODEX_DETAIL_ENTRIES = 512 export const MAX_CODEX_DETAIL_BYTES = 64 * 1024 -/** Spawn-group rows kept live per session, and children per row. Both bound an - * event-accumulated map that no provider snapshot ever prunes. */ +/** Settled spawn-group history budget; live groups remain until their final write succeeds. */ export const MAX_CODEX_SUBAGENT_GROUPS = 32 +/** Children admitted per spawn-group row. */ export const MAX_CODEX_SUBAGENTS_PER_GROUP = 64 /** Threads whose latest token total is retained. Usage frames arrive for * threads that are not yet (or never become) roster children. */ diff --git a/src/main/codex/codex-subagent-roster-retention.test.ts b/src/main/codex/codex-subagent-roster-retention.test.ts new file mode 100644 index 000000000000..92de0fc2aea3 --- /dev/null +++ b/src/main/codex/codex-subagent-roster-retention.test.ts @@ -0,0 +1,322 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { isSubagentGroupBlock, type NativeChatSubagentState } from '../../shared/native-chat-types' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { CodexSubagentRoster } from './codex-subagent-roster' +import { MAX_CODEX_SUBAGENT_GROUPS } from './codex-structured-journal-limits' + +const THREAD = 'parent' +const ORIGINAL = 'original' +const journals = createTrackedJournalOpener() +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + await journals.closeAll() +}) + +async function session(refuse?: 'append' | 'publish') { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-roster-retention-')) + const journal = await journals.open({ + identity: { + sessionId: 'session-codex-roster-retention', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle(THREAD) + }, + stateDirectory: root, + now: () => 1_000 + }) + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + deferred.bind({ journal, fence: 1, publish: () => {} }) + const append = deferred.sink.tryAppendItem + const publish = deferred.sink.tryPublish + if (!append || !publish) { + throw new Error('the deferred sink must expose append and publish admission') + } + let refusing = false + let writingOriginal = false + const refusal = { accepted: false, reason: 'backpressure' } as const + const sink: StructuredAgentSessionEventSink = { + ...deferred.sink, + tryAppendItem: (identity, body, options) => { + writingOriginal = + identity.provider === 'orca' && + identity.clientMessageId === `codex-subagents:${THREAD}:${ORIGINAL}` + return refusing && writingOriginal && refuse === 'append' + ? refusal + : append(identity, body, options) + }, + tryPublish: () => (refusing && writingOriginal && refuse === 'publish' ? refusal : publish()) + } + let parentTurn = ORIGINAL + let clock = 1_000 + const roster = new CodexSubagentRoster({ + sink, + primaryThreadId: () => THREAD, + activeTurn: () => parentTurn, + turnScopeFor: (_thread, turnId) => ({ kind: 'turn', turnItemId: turnId ?? 'outside-turn' }), + now: () => ++clock + }) + cleanups.push(async () => { + roster.dispose() + deferred.close() + await journal.close() + await rm(root, { recursive: true, force: true }) + }) + const turn = (id: string, state: NativeChatSubagentState, turnId = `execution:${id}`) => + roster.handleTurn({ threadId: id, turnId, state }) + const announce = (id: string, kind = 'started') => + roster.handleItem({ + threadId: THREAD, + turnId: parentTurn, + item: { + type: 'subAgentActivity', + id: `activity:${id}:${kind}`, + agentThreadId: id, + agentPath: '/root/task', + kind + } + }) + function spawn(id: string, parent = ORIGINAL) { + parentTurn = parent + expect(turn(id, 'working')).toEqual({ accepted: true }) + expect(announce(id)).toEqual({ accepted: true }) + } + async function rows() { + await deferred.drained() + return journal + .snapshot() + .items.flatMap((row) => + row.body.kind === 'message' + ? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group })) + : [] + ) + } + return { + roster, + turn, + announce, + spawn, + rows, + original: async () => + (await rows()).find(({ group }) => group.groupId === `${THREAD}:${ORIGINAL}`), + parent: (turnId: string) => { + parentTurn = turnId + }, + refuse: (value: boolean) => { + refusing = value + } + } +} + +type Session = Awaited> +async function laterGroups(s: Session, settled: boolean, count = MAX_CODEX_SUBAGENT_GROUPS + 2) { + for (let index = 0; index < count; index++) { + const id = `later-child-${index}` + s.spawn(id, `later-parent-${index}`) + if (settled) { + expect(s.turn(id, 'completed')).toEqual({ accepted: true }) + } + if (index % 32 === 31) { + await s.rows() + } + } + await s.rows() +} + +function states(s: Awaited>) { + return s?.group.agents.map(({ id, state }) => ({ id, state })) +} + +describe('Codex roster retention through the real sink and journal', () => { + it.each([ + [false, 'completed'], + [true, 'completed'], + [false, 'sweep'], + [true, 'sweep'] + ] as const)( + 'preserves all oldest live children when later groups settled=%s and final event=%s', + async (settled, final) => { + const s = await session() + for (const id of ['old-a', 'old-b', 'old-c']) { + s.spawn(id) + } + const before = await s.original() + expect(before?.group.agents).toHaveLength(3) + await laterGroups(s, settled) + s.parent('current') + s.announce('old-a', 'interacted') + expect(states(await s.original())).toEqual( + ['old-a', 'old-b', 'old-c'].map((id) => ({ id, state: 'working' })) + ) + if (final === 'sweep') { + expect(s.roster.settleSession()).toEqual({ accepted: true }) + } else { + for (const id of ['old-a', 'old-b', 'old-c']) { + s.turn(id, 'completed') + } + } + const after = await s.original() + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(after?.row.turnScope).toEqual(before?.row.turnScope) + expect(states(after)).toEqual( + ['old-a', 'old-b', 'old-c'].map((id) => ({ + id, + state: final === 'sweep' ? 'unverifiable' : 'completed' + })) + ) + expect(after?.group.agents.every((entry) => entry.settledAt !== undefined)).toBe(true) + expect( + (await s.rows()).flatMap(({ group }) => + group.agents.filter((entry) => entry.id === 'old-a') + ) + ).toHaveLength(1) + } + ) + + it('ignores late reports for evicted settled executions even after the execution cache has pruned them', async () => { + const s = await session() + s.spawn('old-a') + s.spawn('old-b') + s.turn('old-a', 'stopped') + s.turn('old-b', 'completed') + const before = await s.original() + await laterGroups(s, true, 270) + expect(s.roster.executions.find('old-a')).toBeUndefined() + expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) + s.parent('current') + s.turn('old-a', 'working') + s.announce('old-a') + s.announce('old-a', 'interacted') + s.turn('old-a', 'completed') + s.announce('old-a', 'completed') + s.roster.handleTokenUsage({ threadId: 'old-a', tokenUsage: { total: { totalTokens: 123 } } }) + expect(await s.original()).toEqual(before) + expect(s.roster.executions.find('old-a')?.execution).toBeNull() + expect( + (await s.rows()).flatMap(({ group }) => group.agents.filter((entry) => entry.id === 'old-a')) + ).toHaveLength(1) + }) + + it.each([ + ['before', false], + ['after', false], + ['before', true], + ['after', true] + ] as const)( + 'admits a genuine new child turn when the announcement is %s its start and execution cache pruned=%s', + async (order, pruned) => { + const s = await session() + s.spawn('old-a') + s.turn('old-a', 'completed') + const before = await s.original() + await laterGroups(s, true, pruned ? 270 : MAX_CODEX_SUBAGENT_GROUPS + 2) + s.parent('follow-up') + if (order === 'before') { + s.announce('old-a', 'interacted') + } + s.turn('old-a', 'working', 'new-execution') + if (order === 'after') { + s.announce('old-a', 'interacted') + } + const followup = () => + s.rows().then((rows) => rows.find(({ group }) => group.groupId === `${THREAD}:follow-up`)) + expect(states(await followup())).toEqual([{ id: 'old-a', state: 'working' }]) + s.turn('old-a', 'working') + s.turn('old-a', 'completed') + expect(states(await followup())).toEqual([{ id: 'old-a', state: 'working' }]) + s.turn('old-a', 'completed', 'new-execution') + expect(states(await followup())).toEqual([{ id: 'old-a', state: 'completed' }]) + expect(await s.original()).toEqual(before) + } + ) + + it('suppresses both settled executions after same-group reuse, eviction and execution-cache churn', async () => { + const s = await session() + s.spawn('old-a') + s.spawn('old-b') + s.turn('old-a', 'completed') + s.announce('old-a', 'interacted') + s.turn('old-a', 'working', 'second-execution') + s.turn('old-a', 'completed', 'second-execution') + s.turn('old-b', 'completed') + const original = await s.original() + await laterGroups(s, true, 270) + expect(s.roster.executions.find('old-a')).toBeUndefined() + expect(s.roster.retentionSizes().settledIdentities).toBeLessThan(2048) + const before = await s.rows() + s.parent('current') + for (const turnId of ['execution:old-a', 'second-execution']) { + s.turn('old-a', 'working', turnId) + s.announce('old-a') + s.turn('old-a', 'completed', turnId) + s.announce('old-a', 'completed') + } + expect(await s.rows()).toEqual(before) + s.announce('old-a', 'interacted') + s.turn('old-a', 'working', 'third-execution') + const current = (await s.rows()).find(({ group }) => group.groupId === `${THREAD}:current`) + expect(states(current)).toEqual([{ id: 'old-a', state: 'working' }]) + s.turn('old-a', 'completed', 'third-execution') + expect(await s.original()).toEqual(original) + }) + + it.each(['append', 'publish'] as const)( + 'retains final retry ownership after %s refuses settlement under group pressure', + async (refuse) => { + const s = await session(refuse) + s.spawn('old-a') + s.spawn('old-b') + const before = await s.original() + await laterGroups(s, false) + s.turn('old-b', 'completed') + s.refuse(true) + expect(s.turn('old-a', 'completed')).toEqual({ accepted: false, reason: 'backpressure' }) + s.spawn('more-live', 'current') + expect(s.roster.retentionSizes().groups).toBeGreaterThan(MAX_CODEX_SUBAGENT_GROUPS) + s.refuse(false) + expect(s.roster.settleSession()).toEqual({ accepted: true }) + const after = await s.original() + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(states(after)).toEqual([ + { id: 'old-a', state: 'completed' }, + { id: 'old-b', state: 'completed' } + ]) + expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) + } + ) + + it.each(['append', 'publish'] as const)( + 'retries a refused final sweep %s after more live groups arrive', + async (refuse) => { + const s = await session(refuse) + s.spawn('old-a') + s.spawn('old-b') + const before = await s.original() + await laterGroups(s, false) + s.refuse(true) + expect(s.roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' }) + s.spawn('more-live', 'current') + s.refuse(false) + expect(s.roster.settleSession()).toEqual({ accepted: true }) + const after = await s.original() + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(states(after)).toEqual([ + { id: 'old-a', state: 'unverifiable' }, + { id: 'old-b', state: 'unverifiable' } + ]) + expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS) + } + ) +}) diff --git a/src/main/codex/codex-subagent-roster-state.ts b/src/main/codex/codex-subagent-roster-state.ts index 02c79411b798..ccbef83ade63 100644 --- a/src/main/codex/codex-subagent-roster-state.ts +++ b/src/main/codex/codex-subagent-roster-state.ts @@ -5,6 +5,12 @@ import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' +import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention' +import { + MAX_CODEX_SUBAGENT_GROUPS, + MAX_CODEX_SUBAGENTS_PER_GROUP +} from './codex-structured-journal-limits' /** The turn a group belongs to when Codex reports activity outside any turn. * Mirrors the generic-frame bucket name so the two read alike in the journal. */ @@ -22,6 +28,41 @@ export type RosterGroup = { lastSerialized: string | null } +/** A reused child thread starts a new execution only when its turn id changes. */ +export function codexSubagentExecutionIdentity(threadId: string, turnId: string): string { + return JSON.stringify([threadId, turnId]) +} + +export function retainSupersededCodexExecution( + group: RosterGroup, + threadId: string, + nextTurnId: string | null, + retention: SubagentRosterRetention +): void { + const previousTurnId = group.executionTurns.get(threadId) + const previous = group.entries.get(threadId) + if ( + typeof previousTurnId === 'string' && + previousTurnId !== nextTurnId && + previous && + isTerminalSubagentState(previous.state) + ) { + retention.rememberSettled(codexSubagentExecutionIdentity(threadId, previousTurnId)) + } +} + +export function createCodexSubagentRosterRetention(groups: Map) { + return new SubagentRosterRetention(groups, { + maxGroups: MAX_CODEX_SUBAGENT_GROUPS, + maxSettledIdentities: MAX_CODEX_SUBAGENT_GROUPS * MAX_CODEX_SUBAGENTS_PER_GROUP, + entries: (group) => group.entries.values(), + identities: (group) => + [...group.executionTurns].flatMap(([threadId, turnId]) => + turnId === null ? [] : [codexSubagentExecutionIdentity(threadId, turnId)] + ) + }) +} + /** Group identity: the parent turn that spawned the children. `agentPath` is a * tree rooted at the parent thread, so every child of one turn shares a row * no matter which thread's stream carried its activity item. */ diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index 3f411fff1a3d..ac56c042ba24 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -569,10 +569,7 @@ describe('CodexSubagentRoster', () => { expect(appended).toHaveLength(atCap) }) - // The eviction is the KNOWN LIMITATION the module documents: `groups` is never - // seeded from the journal, so the evicted group's next child rebuilds its - // durable row from that one child. Pinned so the boundary cannot move silently. - it('caps live spawn groups, and an evicted group rebuilds its row from one child', () => { + it('retains live spawn groups beyond the settled history budget', () => { const { roster, appended, agents } = createHarness() for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) { deliver( @@ -581,10 +578,7 @@ describe('CodexSubagentRoster', () => { `turn-${index}` ) } - const evicted = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0')) - const rowsFor = (identity: AgentJournalItemIdentity): Appended[] => - appended.filter((entry) => JSON.stringify(entry.identity) === JSON.stringify(identity)) - expect(rowsFor(evicted)).toHaveLength(1) + const original = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0')) deliver( roster, @@ -592,8 +586,9 @@ describe('CodexSubagentRoster', () => { 'turn-0' ) - expect(latestIdentity(appended)).toEqual(evicted) - expect(agents().map((agent) => agent.id)).toEqual(['child-late']) + expect(latestIdentity(appended)).toEqual(original) + expect(agents().map((agent) => agent.id)).toEqual(['child-0', 'child-late']) + expect(roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS + 1) }) it('keeps a token count a later thread-map eviction would otherwise retract', () => { diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 43d31756954c..0b55d231fbe8 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -5,15 +5,11 @@ // child turn events supply execution state. // // KNOWN LIMITATION: `groups` is process-local and is never seeded from the -// journal, while the row's identity is keyed on the group id alone. So once a -// group leaves the map its row stays, and the next activity item rebuilds that -// row from one child — rewriting N down to one. Two ways in: eviction past -// MAX_CODEX_SUBAGENT_GROUPS, which drops the oldest-inserted group in-process -// even while it is live, and skips the sweep so its children never latch -// `unverifiable`; and a restart on `threadId:outside-turn`, the one group id -// that outlives the process — `thread/resume` is verified to return the same -// thread, and a real turn id is assumed freshly minted per turn. Seeding from -// the journal is the fix. +// journal, while the row's identity is keyed on the group id alone. After a +// restart loses membership for `threadId:outside-turn`, the next activity +// rebuilds that row from one child — rewriting N down to one. This group id +// outlives the process: `thread/resume` returns the same thread, while real turn +// ids are assumed freshly minted per turn. Seeding from the journal is the fix. import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' @@ -43,13 +39,13 @@ export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subage import { codexSubagentGroupId, codexSubagentGroupIdentity, + codexSubagentExecutionIdentity as executionIdentity, + createCodexSubagentRosterRetention, + retainSupersededCodexExecution, type RosterGroup } from './codex-subagent-roster-state' import type { CodexThreadItem } from './codex-structured-item-translation' -import { - MAX_CODEX_SUBAGENT_GROUPS, - MAX_CODEX_SUBAGENTS_PER_GROUP -} from './codex-structured-journal-limits' +import { MAX_CODEX_SUBAGENTS_PER_GROUP } from './codex-structured-journal-limits' import { CodexThreadTokenTotals } from './codex-thread-token-totals' const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } @@ -66,6 +62,7 @@ export type CodexSubagentRosterDeps = { export class CodexSubagentRoster { private readonly groups = new Map() + private readonly retention = createCodexSubagentRosterRetention(this.groups) /** Every thread's total, members or not; children are selected at write time. */ private readonly tokensByThread = new CodexThreadTokenTotals() private readonly now: () => number @@ -115,7 +112,10 @@ export class CodexSubagentRoster { // Only a spawn names the spawner: other announcements ride whichever agent acted. announcement.spawned ? input.threadId : undefined ) - if (!child?.execution) { + if ( + !child?.execution || + this.retention.hasSettled(executionIdentity(child.agentThreadId, child.execution.turnId)) + ) { return ADMITTED } const group = @@ -150,7 +150,10 @@ export class CodexSubagentRoster { turnId: string state: NativeChatSubagentState }): StructuredAgentSessionSinkAdmission { - if (input.threadId === this.deps.primaryThreadId()) { + if ( + input.threadId === this.deps.primaryThreadId() || + this.retention.hasSettled(executionIdentity(input.threadId, input.turnId)) + ) { return ADMITTED } const observed = this.executions.observeTurn(input.threadId, input.turnId, input.state) @@ -162,7 +165,10 @@ export class CodexSubagentRoster { child: Readonly, execution: CodexChildExecution ): StructuredAgentSessionSinkAdmission { - if (!child.registered) { + if ( + !child.registered || + this.retention.hasSettled(executionIdentity(child.agentThreadId, execution.turnId)) + ) { return ADMITTED } if (execution.state === 'working') { @@ -227,13 +233,15 @@ export class CodexSubagentRoster { dispose(): void { this.unfollow() this.groups.clear() + this.retention.clear() this.tokensByThread.clear() } - private sweep(group: RosterGroup | undefined): StructuredAgentSessionSinkAdmission { - if (!group) { - return ADMITTED - } + retentionSizes(): { groups: number; settledIdentities: number } { + return this.retention.sizes() + } + + private sweep(group: RosterGroup): StructuredAgentSessionSinkAdmission { let changed = false for (const [id, entry] of group.entries) { if (isTerminalSubagentState(entry.state)) { @@ -268,13 +276,6 @@ export class CodexSubagentRoster { lastSerialized: null } this.groups.set(groupId, group) - while (this.groups.size > MAX_CODEX_SUBAGENT_GROUPS) { - const oldest = this.groups.keys().next().value - if (typeof oldest !== 'string' || oldest === groupId) { - break - } - this.groups.delete(oldest) - } return group } @@ -307,6 +308,7 @@ export class CodexSubagentRoster { return } const now = this.now() + retainSupersededCodexExecution(group, child.agentThreadId, turnId, this.retention) group.executionTurns.set(child.agentThreadId, turnId) group.entries.set(child.agentThreadId, { id: child.agentThreadId, @@ -345,6 +347,7 @@ export class CodexSubagentRoster { : (this.deps.sink.appendItem(group.identity, body, options), ADMITTED) if (!admission.accepted) { group.lastSerialized = null + this.retention.trim([group]) return admission } const published = this.deps.sink.tryPublish @@ -357,6 +360,7 @@ export class CodexSubagentRoster { // roster stays queued but never reaches the renderer. group.lastSerialized = null } + this.retention.trim([group]) return published } } diff --git a/src/main/crash-reporting/crashpad-capture.ts b/src/main/crash-reporting/crashpad-capture.ts index beff750c9603..9e2336babfc1 100644 --- a/src/main/crash-reporting/crashpad-capture.ts +++ b/src/main/crash-reporting/crashpad-capture.ts @@ -99,10 +99,6 @@ function resolveDumpDirectory(): string | null { } } -export function getCrashpadDumpDirectory(): string | null { - return crashpadDumpDirectory -} - /** Test seam; production callers go through startCrashpadCapture. */ export function _setCrashpadCaptureStateForTest( state: { dumpDirectory: string | null; started: boolean; startedAtMs?: number } | null diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 839256e1861d..c470fa68f13b 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -4,7 +4,7 @@ // hour's loss; fsync stops it from happening. import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { copyFile, open, readdir, rm, stat } from 'node:fs/promises' +import { open, readdir, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { publishFileWithoutOverwrite, @@ -92,42 +92,6 @@ export async function writeTempFileDurable( } } -/** - * Copy `sourcePath` onto `finalPath` durably: a fresh inode, fsynced, then renamed into place. A - * plain copyFile can be interrupted and leave a torn destination — fatal when the destination is - * the backup someone will fall back to. Returns false when the source does not exist. - */ -export async function copyFileDurable(sourcePath: string, finalPath: string): Promise { - const tmpPath = durableWriteTempPath(finalPath) - let renamed = false - try { - try { - // copyFile stays in the kernel — and clones the extents outright on APFS and btrfs — so - // this does not pull the whole file through the process on every commit. - await copyFile(sourcePath, tmpPath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return false - } - throw error - } - const handle = await open(tmpPath, 'r+') - try { - await handle.sync() - } finally { - await handle.close() - } - await renameFileWithWindowsRetryAsync(tmpPath, finalPath) - renamed = true - await syncDirectory(dirname(finalPath)) - return true - } finally { - if (!renamed) { - await rm(tmpPath, { force: true }).catch(() => {}) - } - } -} - /** Write `payload` to `tmpPath`, fsync it, then rename onto `finalPath` and fsync the directory. */ export async function writeFileDurable( tmpPath: string, diff --git a/src/main/git/repo-detection-async.test.ts b/src/main/git/repo-detection-async.test.ts new file mode 100644 index 000000000000..19c89216d10c --- /dev/null +++ b/src/main/git/repo-detection-async.test.ts @@ -0,0 +1,142 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as runner from './runner' +import { runProcess } from '@orca/process-host' +import { + getGitRepoRoot, + getGitRepoRootAsync, + inspectGitRepoForRegistration, + inspectGitRepoForRegistrationAsync, + isGitRepo, + isGitRepoAsync +} from './repo-detection' + +let fixture: string +async function git(cwd: string, args: string[]): Promise { + const result = await runProcess({ program: 'git', args, cwd, timeoutMs: 15_000 }) + if (result.code !== 0) { + throw new Error(result.stderr) + } +} + +beforeEach(() => { + fixture = mkdtempSync(join(tmpdir(), 'orca-repo-async-')) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(fixture, { recursive: true, force: true }) +}) + +async function expectSameDetection(path: string): Promise { + expect(await isGitRepoAsync(path)).toBe(isGitRepo(path)) + expect(await getGitRepoRootAsync(path)).toBe(getGitRepoRoot(path)) + expect(await inspectGitRepoForRegistrationAsync(path)).toEqual( + inspectGitRepoForRegistration(path) + ) +} + +describe('asynchronous repository detection with real Git', () => { + it('preserves normal, nested, linked-worktree, bare, and folder results', async () => { + const repo = join(fixture, 'repo with spaces') + const nested = join(repo, 'packages', 'web') + mkdirSync(nested, { recursive: true }) + await git(repo, ['init', '--quiet']) + await git(repo, [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca@example.invalid', + 'commit', + '--allow-empty', + '--quiet', + '-m', + 'fixture' + ]) + const linked = join(fixture, 'linked worktree') + await git(repo, ['worktree', 'add', '--quiet', '-b', 'fixture-linked', linked]) + const bare = join(fixture, 'bare.git') + await git(fixture, ['init', '--bare', '--quiet', bare]) + const folder = join(fixture, 'folder') + mkdirSync(folder) + for (const path of [repo, nested, linked, bare, folder]) { + await expectSameDetection(path) + } + expect(await isGitRepoAsync(folder)).toBe(false) + expect(await isGitRepoAsync(bare)).toBe(true) + expect((await inspectGitRepoForRegistrationAsync(linked)).mainRepoPath).not.toBeNull() + }) + + it('preserves genuine metadata fallback when asynchronous Git cannot answer', async () => { + const repo = join(fixture, 'fallback repo') + const nested = join(repo, 'packages') + mkdirSync(nested, { recursive: true }) + await git(repo, ['init', '--quiet']) + vi.spyOn(runner, 'gitExecFileAsync').mockRejectedValue(new Error('Git unavailable')) + expect(await isGitRepoAsync(nested)).toBe(true) + expect(await getGitRepoRootAsync(nested)).toBe(repo.replaceAll('\\', '/')) + expect(await isGitRepoAsync(join(repo, '.git'))).toBe(false) + }) + + it('rejects missing directories and invalid Git markers without changing root fallback', async () => { + const invalid = join(fixture, 'invalid') + mkdirSync(invalid) + writeFileSync(join(invalid, '.git'), 'not a gitdir file') + for (const path of [invalid, join(fixture, 'missing')]) { + await expectSameDetection(path) + expect(await isGitRepoAsync(path)).toBe(false) + } + }) + + it.skipIf(process.platform === 'win32')( + 'preserves newlines, dollar signs and trailing spaces in roots and linked identity', + async () => { + const repo = join(fixture, 'repo\nwith dollar$ and trailing space ') + const nested = join(repo, 'nested\n') + mkdirSync(nested, { recursive: true }) + await git(repo, ['init', '--quiet']) + await git(repo, [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca@example.invalid', + 'commit', + '--allow-empty', + '--quiet', + '-m', + 'fixture' + ]) + const linked = join(fixture, 'linked\n ') + await git(repo, ['worktree', 'add', '--quiet', '-b', 'edge-linked', linked]) + for (const path of [repo, nested, linked]) { + await expectSameDetection(path) + } + expect((await inspectGitRepoForRegistrationAsync(nested)).rootPath).toBe(realpathSync(repo)) + expect((await inspectGitRepoForRegistrationAsync(linked)).mainRepoPath).toBe( + realpathSync(repo) + ) + } + ) + + it('preserves symlink target identity and rejects its invalid nested marker without Git', async () => { + const repo = join(fixture, 'target') + const nested = join(repo, 'nested') + mkdirSync(nested, { recursive: true }) + await git(repo, ['init', '--quiet']) + const alias = join(fixture, 'alias') + symlinkSync(nested, alias, process.platform === 'win32' ? 'junction' : 'dir') + await expectSameDetection(alias) + vi.spyOn(runner, 'gitExecFileSync').mockImplementation(() => { + throw new Error('Git unavailable') + }) + vi.spyOn(runner, 'gitExecFileAsync').mockRejectedValue(new Error('Git unavailable')) + await expectSameDetection(alias) + expect((await inspectGitRepoForRegistrationAsync(alias)).rootPath).toBe( + realpathSync(repo).replaceAll('\\', '/') + ) + writeFileSync(join(nested, '.git'), 'gitdir: missing-metadata') + await expectSameDetection(alias) + expect(await isGitRepoAsync(alias)).toBe(false) + }) +}) diff --git a/src/main/git/repo-detection-command.test.ts b/src/main/git/repo-detection-command.test.ts new file mode 100644 index 000000000000..e3e1120feed0 --- /dev/null +++ b/src/main/git/repo-detection-command.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { gitExecFileAsync, gitExecFileSync } from './runner' +import { gitRepoOutput, runGitRepoCommands, runGitRepoCommandsSync } from './repo-detection-command' + +vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn(), gitExecFileSync: vi.fn() })) +afterEach(() => vi.resetAllMocks()) + +describe('repository command execution', () => { + it('lets another event-loop turn run while Git is pending, with no synchronous spawn', async () => { + let finish: (value: { stdout: string; stderr: string }) => void = () => {} + vi.mocked(gitExecFileAsync).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + let settled = false + const detection = runGitRepoCommands( + gitRepoOutput(['rev-parse', '--show-toplevel'], { cwd: '/repo' }) + ).then((result) => { + settled = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) + expect(gitExecFileSync).not.toHaveBeenCalled() + expect(gitExecFileAsync).toHaveBeenCalledExactlyOnceWith(['rev-parse', '--show-toplevel'], { + cwd: '/repo', + timeout: 15_000 + }) + finish({ stdout: '/repo\n', stderr: '' }) + expect(await detection).toBe('/repo\n') + }) + + it('delivers command failures to the shared decision tree in both execution modes', async () => { + function* withFallback() { + try { + return yield* gitRepoOutput(['rev-parse'], { cwd: '/repo' }) + } catch { + return 'marker fallback' + } + } + vi.mocked(gitExecFileAsync).mockRejectedValue(new Error('Git unavailable')) + vi.mocked(gitExecFileSync).mockImplementation(() => { + throw new Error('Git unavailable') + }) + expect(await runGitRepoCommands(withFallback())).toBe('marker fallback') + expect(runGitRepoCommandsSync(withFallback())).toBe('marker fallback') + }) + + it('preserves an exception from decision logic after a successful command', async () => { + const error = new Error('decision failed') + function* decisionFailure() { + yield* gitRepoOutput(['first'], { cwd: '/repo' }) + throw error + } + vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: 'result', stderr: '' }) + vi.mocked(gitExecFileSync).mockReturnValue('result') + await expect(runGitRepoCommands(decisionFailure())).rejects.toBe(error) + expect(() => runGitRepoCommandsSync(decisionFailure())).toThrow(error) + }) + + it('executes a fallback command yielded while handling a rejected command', async () => { + function* withFallbackCommand() { + try { + return yield* gitRepoOutput(['first'], { cwd: '/repo' }) + } catch { + return yield* gitRepoOutput(['second'], { cwd: '/repo' }) + } + } + vi.mocked(gitExecFileAsync) + .mockRejectedValueOnce(new Error('first failed')) + .mockResolvedValueOnce({ stdout: 'fallback', stderr: '' }) + vi.mocked(gitExecFileSync) + .mockImplementationOnce(() => { + throw new Error('first failed') + }) + .mockReturnValueOnce('fallback') + expect(await runGitRepoCommands(withFallbackCommand())).toBe('fallback') + expect(runGitRepoCommandsSync(withFallbackCommand())).toBe('fallback') + expect(vi.mocked(gitExecFileAsync).mock.calls.map(([args]) => args)).toEqual([ + ['first'], + ['second'] + ]) + expect(vi.mocked(gitExecFileSync).mock.calls.map(([args]) => args)).toEqual([ + ['first'], + ['second'] + ]) + }) +}) diff --git a/src/main/git/repo-detection-command.ts b/src/main/git/repo-detection-command.ts new file mode 100644 index 000000000000..778688e15477 --- /dev/null +++ b/src/main/git/repo-detection-command.ts @@ -0,0 +1,39 @@ +import { gitExecFileAsync, gitExecFileSync } from './runner' + +type GitRepoCommand = { args: string[]; cwd: string } +export type GitRepoCommands = Generator + +export function* gitRepoOutput(args: string[], options: { cwd: string }): GitRepoCommands { + return yield { args, cwd: options.cwd } +} + +// One decision tree keeps Git failures and marker fallbacks identical across both callers. +export async function runGitRepoCommands(commands: GitRepoCommands): Promise { + let step = commands.next() + while (!step.done) { + const command = step.value + try { + const { stdout } = await gitExecFileAsync(command.args, { + cwd: command.cwd, + timeout: 15_000 + }) + step = commands.next(stdout) + } catch (error) { + step = commands.throw(error) + } + } + return step.value +} + +export function runGitRepoCommandsSync(commands: GitRepoCommands): T { + let step = commands.next() + while (!step.done) { + const command = step.value + try { + step = commands.next(gitExecFileSync(command.args, { cwd: command.cwd })) + } catch (error) { + step = commands.throw(error) + } + } + return step.value +} diff --git a/src/main/git/repo-detection-failure-parity.test.ts b/src/main/git/repo-detection-failure-parity.test.ts new file mode 100644 index 000000000000..38c69253a9d0 --- /dev/null +++ b/src/main/git/repo-detection-failure-parity.test.ts @@ -0,0 +1,55 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import * as runner from './runner' +import { inspectGitRepoForRegistration, inspectGitRepoForRegistrationAsync } from './repo-detection' + +let fixture: string +beforeEach(() => { + fixture = mkdtempSync(join(tmpdir(), 'orca-repo-failure-parity-')) + mkdirSync(join(fixture, '.git', 'objects'), { recursive: true }) + mkdirSync(join(fixture, '.git', 'refs'), { recursive: true }) + writeFileSync(join(fixture, '.git', 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(fixture, '.git', 'config'), '[core]\n bare = false\n') +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(fixture, { recursive: true, force: true }) +}) + +it.each([0, 1, 2, 3, 4])( + 'preserves registration fallback and command order when command %i fails', + async (failAt) => { + // Ambiguous combined output requires separate root, Git directory and common directory reads. + const outputs = [ + 'true\nfalse\n', + `true\n${fixture}\nextra\n.git\n.git\n`, + `${fixture}\n`, + '.git\n', + '.git\n' + ] + const sync = vi.spyOn(runner, 'gitExecFileSync') + const async = vi.spyOn(runner, 'gitExecFileAsync') + let syncIndex = 0 + let asyncIndex = 0 + sync.mockImplementation(() => { + const index = syncIndex++ + if (index === failAt) { + throw new Error('rejected stage') + } + return outputs[index] ?? '' + }) + async.mockImplementation(async () => { + const index = asyncIndex++ + if (index === failAt) { + throw new Error('rejected stage') + } + return { stdout: outputs[index] ?? '', stderr: '' } + }) + const expected = inspectGitRepoForRegistration(fixture) + expect(expected).toEqual({ isRepo: true, rootPath: fixture, mainRepoPath: null }) + expect(await inspectGitRepoForRegistrationAsync(fixture)).toEqual(expected) + expect(async.mock.calls.map(([args]) => args)).toEqual(sync.mock.calls.map(([args]) => args)) + } +) diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index 83bf39391893..37e6a2ed0833 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -80,7 +80,7 @@ describe('isGitRepo', () => { git(realRepo, ['init', '--quiet']) withGitUnavailable(() => { - expect(getGitRepoRoot(nestedDir)).toBe(realRepo) + expect(getGitRepoRoot(nestedDir)).toBe(realRepo.replaceAll('\\', '/')) }) }) @@ -330,7 +330,7 @@ describe('isGitRepo', () => { const bareRepo = path.join(tmpDir, 'bare.git') git(tmpDir, ['init', '--bare', '--quiet', bareRepo]) - expect(getGitRepoRoot(bareRepo)).toBe(bareRepo) + expect(getGitRepoRoot(bareRepo)).toBe(bareRepo.replaceAll('\\', '/')) }) }) diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts index 3fe75a1148d5..9be70187a793 100644 --- a/src/main/git/repo-detection.ts +++ b/src/main/git/repo-detection.ts @@ -4,7 +4,12 @@ import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { scanGitMarkerSync, resolveRealPathSync } from './repo-git-marker-scan' -import { gitExecFileSync } from './runner' +import { + gitRepoOutput, + runGitRepoCommands, + runGitRepoCommandsSync, + type GitRepoCommands +} from './repo-detection-command' type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' type GitRepoProbe = { @@ -23,7 +28,7 @@ export type GitRepoRegistrationInfo = { let warnedMarkerFallbackThisSession = false /** Check if a path is a valid git repository (regular or bare). */ -export function isGitRepo(path: string): boolean { +function* isGitRepoCommands(path: string): GitRepoCommands { try { if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return false @@ -32,7 +37,7 @@ export function isGitRepo(path: string): boolean { return false } - return isGitRepoFromProbe(path, probeGitRepo(path).result) + return isGitRepoFromProbe(path, (yield* probeGitRepo(path)).result) } function isGitRepoFromProbe(path: string, result: GitRepoProbeResult): boolean { @@ -54,10 +59,10 @@ function isGitRepoFromProbe(path: string, result: GitRepoProbeResult): boolean { } /** Only a clean pair of negative Git answers is a definitive non-repo. */ -function probeGitRepo(path: string, includeLocation = false): GitRepoProbe { +function* probeGitRepo(path: string, includeLocation = false): GitRepoCommands { try { const records = readGitPathOutput( - gitExecFileSync( + yield* gitRepoOutput( [ 'rev-parse', '--is-inside-work-tree', @@ -76,7 +81,7 @@ function probeGitRepo(path: string, includeLocation = false): GitRepoProbe { : 'indeterminate' const location = includeLocation && insideWorkTree === 'true' && records.length !== 4 - ? readGitRepoDirectories(path) + ? yield* readGitRepoDirectories(path) : { gitDir, commonDir } return { result, insideWorkTree: insideWorkTree === 'true', ...location } } catch { @@ -85,7 +90,9 @@ function probeGitRepo(path: string, includeLocation = false): GitRepoProbe { } /** Reuse one repository discovery across registration's validity, root and worktree checks. */ -export function inspectGitRepoForRegistration(path: string): GitRepoRegistrationInfo { +function* inspectGitRepoForRegistrationCommands( + path: string +): GitRepoCommands { try { if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return { isRepo: false, rootPath: path, mainRepoPath: null } @@ -93,14 +100,14 @@ export function inspectGitRepoForRegistration(path: string): GitRepoRegistration } catch { return { isRepo: false, rootPath: path, mainRepoPath: null } } - const probe = probeGitRepo(path) + const probe = yield* probeGitRepo(path) const isRepo = isGitRepoFromProbe(path, probe.result) let rootPath = path let mainRepoPath: string | null = null if (isRepo && probe.insideWorkTree) { try { const records = readGitPathOutput( - gitExecFileSync( + yield* gitRepoOutput( [ 'rev-parse', '--is-inside-work-tree', @@ -119,9 +126,9 @@ export function inspectGitRepoForRegistration(path: string): GitRepoRegistration ? { toplevel, gitDir, commonDir } : { toplevel: readGitPathOutput( - gitExecFileSync(['rev-parse', '--show-toplevel'], { cwd: path }) + yield* gitRepoOutput(['rev-parse', '--show-toplevel'], { cwd: path }) ), - ...readGitRepoDirectories(path) + ...(yield* readGitRepoDirectories(path)) } if (location.toplevel) { rootPath = normalizeGitRepoRootForInputPath(path, location.toplevel) @@ -145,13 +152,13 @@ export function inspectGitRepoForRegistration(path: string): GitRepoRegistration return { isRepo, rootPath, mainRepoPath } } -export function getGitRepoRoot(path: string): string { +function* getGitRepoRootCommands(path: string): GitRepoCommands { try { if (!existsSync(path) || !statSync(path).isDirectory()) { return path } // A bare repo has no toplevel; keep its marker fallback separate from the boolean probes. - const output = gitExecFileSync(['rev-parse', '--is-inside-work-tree', '--show-toplevel'], { + const output = yield* gitRepoOutput(['rev-parse', '--is-inside-work-tree', '--show-toplevel'], { cwd: path }) const firstNewline = output.indexOf('\n') @@ -171,10 +178,14 @@ function readGitPathOutput(output: string): string { return output.endsWith('\n') ? output.slice(0, -1) : output } -function readGitRepoDirectories(path: string): Pick { +function* readGitRepoDirectories( + path: string +): GitRepoCommands> { return { - gitDir: readGitPathOutput(gitExecFileSync(['rev-parse', '--git-dir'], { cwd: path })), - commonDir: readGitPathOutput(gitExecFileSync(['rev-parse', '--git-common-dir'], { cwd: path })) + gitDir: readGitPathOutput(yield* gitRepoOutput(['rev-parse', '--git-dir'], { cwd: path })), + commonDir: readGitPathOutput( + yield* gitRepoOutput(['rev-parse', '--git-common-dir'], { cwd: path }) + ) } } @@ -191,13 +202,13 @@ function canonicalizeGitDirPath(path: string): string { } /** Return the main-checkout path only when `path` is a linked worktree. */ -export function getLinkedWorktreeMainRepoRoot(path: string): string | null { +function* getLinkedWorktreeMainRepoRootCommands(path: string): GitRepoCommands { try { if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return null } - const mainRepoPath = mainRepoPathFromProbe(path, probeGitRepo(path, true)) - return mainRepoPath ? getGitRepoRoot(mainRepoPath) : null + const mainRepoPath = mainRepoPathFromProbe(path, yield* probeGitRepo(path, true)) + return mainRepoPath ? yield* getGitRepoRootCommands(mainRepoPath) : null } catch { return null } @@ -225,3 +236,31 @@ export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: st } return normalizeRuntimePathSeparators(rootPath) } + +export function isGitRepo(path: string): boolean { + return runGitRepoCommandsSync(isGitRepoCommands(path)) +} + +export function isGitRepoAsync(path: string): Promise { + return runGitRepoCommands(isGitRepoCommands(path)) +} + +export function inspectGitRepoForRegistration(path: string): GitRepoRegistrationInfo { + return runGitRepoCommandsSync(inspectGitRepoForRegistrationCommands(path)) +} + +export function inspectGitRepoForRegistrationAsync(path: string): Promise { + return runGitRepoCommands(inspectGitRepoForRegistrationCommands(path)) +} + +export function getGitRepoRoot(path: string): string { + return runGitRepoCommandsSync(getGitRepoRootCommands(path)) +} + +export function getGitRepoRootAsync(path: string): Promise { + return runGitRepoCommands(getGitRepoRootCommands(path)) +} + +export function getLinkedWorktreeMainRepoRoot(path: string): string | null { + return runGitRepoCommandsSync(getLinkedWorktreeMainRepoRootCommands(path)) +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index da64c013efba..f1bb563be62e 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -11,8 +11,11 @@ import { gitExecFileAsync } from './runner' export { isGitRepo, + isGitRepoAsync, inspectGitRepoForRegistration, + inspectGitRepoForRegistrationAsync, getGitRepoRoot, + getGitRepoRootAsync, getLinkedWorktreeMainRepoRoot, normalizeGitRepoRootForInputPath } from './repo-detection' diff --git a/src/main/hang-watchdog/hang-detection-marker.ts b/src/main/hang-watchdog/hang-detection-marker.ts index e3c48ca715e2..c2be64d960ab 100644 --- a/src/main/hang-watchdog/hang-detection-marker.ts +++ b/src/main/hang-watchdog/hang-detection-marker.ts @@ -20,10 +20,6 @@ export function writeHangDetectionMarker(markerPath: string, marker: HangDetecti writeFileSync(markerPath, JSON.stringify(marker), { mode: 0o600 }) } -export function readHangDetectionMarker(markerPath: string): HangDetectionMarker | null { - return readAvailableHangDetectionMarker(markerPath) ?? null -} - function readAvailableHangDetectionMarker( markerPath: string ): HangDetectionMarker | null | undefined { diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts index 4d386093e15f..c0328f43bb27 100644 --- a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -89,14 +89,3 @@ export async function readLocalLogSnapshot(filePath: string): Promise ({ })) vi.mock('node:fs/promises', () => ({ stat: fake.stat })) vi.mock('node:fs', () => ({ realpathSync: (path: string) => path })) -vi.mock('../git/repo', () => ({ isGitRepo: fake.git, getGitRepoRoot: fake.root })) +vi.mock('../git/repo', () => ({ isGitRepoAsync: fake.git, getGitRepoRootAsync: fake.root })) vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: fake.prepare })) vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: fake.invalidate @@ -108,6 +108,32 @@ describe('folder repo upgrade poll lifetime', () => { expect(vi.getTimerCount()).toBe(0) }) + it.each(['stopped', 'removed', 'path changed', 'already upgraded'])( + 'refuses a stale folder upgrade when its owner is %s during Git discovery', + async (change) => { + const pending = deferred() + fake.git.mockReturnValue(pending.promise) + const repos = [folder()] + const { store } = begin(repos) + await vi.advanceTimersByTimeAsync(25) + expect(fake.git).toHaveBeenCalledTimes(1) + if (change === 'stopped') { + stopFolderRepoGitUpgradeWatch() + } else if (change === 'removed') { + repos.splice(0) + } else if (change === 'path changed') { + repos[0].path = join('mock', 'new-root') + } else { + repos[0].kind = 'git' + } + pending.resolve(true) + await flush() + expect(store.updateRepo).not.toHaveBeenCalled() + expect(fake.prepare).not.toHaveBeenCalled() + expect(fake.reposChanged).not.toHaveBeenCalled() + } + ) + it('stops the remaining folder scan when a pending marker fails after stop', async () => { const pending = deferred() fake.stat.mockReturnValue(pending.promise) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 5fe485ee3a21..ebc58b92b721 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -10,22 +10,36 @@ import type { Repo } from '../../shared/repo-types' // Why: the watch must stay one stat per folder project per tick — counting the real // calls is what keeps a directory-listing fan-out from creeping back in. -const { statCalls, readdirSpy, gitProbes } = vi.hoisted(() => ({ +const { statCalls, readdirSpy, gitProbes, pendingGitChecks } = vi.hoisted(() => ({ statCalls: [] as string[], readdirSpy: vi.fn(), + pendingGitChecks: new Set>(), // Why: the rejected-marker cache exists to stop git respawning every tick; counting the // real probes is the only way that guarantee stays true. gitProbes: [] as string[] })) +function trackGitCheck(pending: Promise): Promise { + const completed = pending.finally(() => pendingGitChecks.delete(completed)) + pendingGitChecks.add(completed) + return completed +} + +async function drainGitChecks(): Promise { + while (pendingGitChecks.size > 0) { + await Promise.allSettled(pendingGitChecks) + } +} + vi.mock('../git/repo', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - isGitRepo: (path: string) => { + isGitRepoAsync: (path: string) => { gitProbes.push(path) - return actual.isGitRepo(path) - } + return trackGitCheck(actual.isGitRepoAsync(path)) + }, + getGitRepoRootAsync: (path: string) => trackGitCheck(actual.getGitRepoRootAsync(path)) } }) @@ -143,13 +157,14 @@ describe('folder repo git upgrade watch', () => { vi.clearAllMocks() root = realpathSync(await mkdtemp(join(tmpdir(), 'folder-repo-upgrade-'))) symlinkedRoot = `${root}-link` - await symlink(root, symlinkedRoot, 'dir') + await symlink(root, symlinkedRoot, process.platform === 'win32' ? 'junction' : 'dir') statCalls.length = 0 gitProbes.length = 0 }) afterEach(async () => { stopFolderRepoGitUpgradeWatch() + await drainGitChecks() await rm(symlinkedRoot, { force: true }) await rm(root, { recursive: true, force: true }) }) @@ -157,6 +172,7 @@ describe('folder repo git upgrade watch', () => { // Real timers: the tick awaits real filesystem stats, which fake timers cannot flush. async function tick(times = 1): Promise { await new Promise((resolve) => setTimeout(resolve, POLL_MS * times + POLL_MS)) + await drainGitChecks() } /** Why: a tick that spawns git can outrun a fixed wait on a loaded machine. */ @@ -165,6 +181,7 @@ describe('folder repo git upgrade watch', () => { while (statCalls.length < count && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, POLL_MS)) } + await drainGitChecks() } it('upgrades a local folder repo once an external git init creates .git', async () => { diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index 8a0a6130c0e0..724c80d0df88 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -9,7 +9,7 @@ import { isFolderRepo } from '../../shared/repo-kind' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' -import { getGitRepoRoot, isGitRepo } from '../git/repo' +import { getGitRepoRootAsync, isGitRepoAsync } from '../git/repo' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' import { notifyReposChanged } from './repos/repos-changed-notification' @@ -101,13 +101,13 @@ function resolveRealPath(pathValue: string): string { * the path the user picked; when a symlinked parent makes those differ, the root reads * as an *external* worktree, and hiding those would hide the project's only workspace. */ -function resolveUpgrade( +async function resolveUpgrade( repoPath: string -): { folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null { - if (!isGitRepo(repoPath)) { +): Promise<{ folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null> { + if (!(await isGitRepoAsync(repoPath))) { return null } - const gitRoot = getGitRepoRoot(repoPath) + const gitRoot = await getGitRepoRootAsync(repoPath) if (resolveRealPath(gitRoot) !== resolveRealPath(repoPath)) { return null } @@ -127,7 +127,18 @@ async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise { }) }) - it('returns no remote agents when the SSH connection is unavailable', async () => { + // Why (#25523): an empty list would be cached as "no agents installed" on that host. + it('rejects instead of listing no agents when the SSH connection is unavailable', async () => { getActiveMultiplexerMock.mockReturnValue(null) registerPreflightHandlers() await expect( - handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) - ).resolves.toEqual([]) + handlers['preflight:detectRemoteAgents'](undefined, { + connectionId: 'ssh-1' + }) + ).rejects.toThrow('SSH host is not connected') }) - it('returns no remote agents when the SSH connection is disposed', async () => { + it('rejects instead of listing no agents when the SSH connection is disposed', async () => { const request = vi.fn() getActiveMultiplexerMock.mockReturnValue({ isDisposed: () => true, @@ -191,8 +194,10 @@ describe('preflight', () => { registerPreflightHandlers() await expect( - handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' }) - ).resolves.toEqual([]) + handlers['preflight:detectRemoteAgents'](undefined, { + connectionId: 'ssh-1' + }) + ).rejects.toThrow('SSH host is not connected') expect(request).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 06eedaf8b78f..5634928b81f0 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -5,6 +5,8 @@ import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-ho import { isSafePtySessionId } from '../../../daemon/pty-session-id' import { isNativeWindowsLocalPtySpawn } from '../../../runtime/terminal-model-query-authority' import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env' +import { getRuntimeSourceStamp } from '../../../runtime/host-descriptor' import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' import { buildPtyHostEnv } from '../host-env/assembly' import { @@ -160,6 +162,10 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null ) + stampRuntimeSourceEnv( + ctx.env, + getRuntimeSourceStamp(ctx.deps.runtime, getAppEnvironment().getPath('userData')) + ) promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) } catch (err) { // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index eeb5fef028c5..774ac8d25944 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -11,6 +11,8 @@ import { } from '../../../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import { buildPtyHostEnv } from '../host-env/assembly' +import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env' +import { getRuntimeSourceStamp } from '../../../runtime/host-descriptor' import { getCompatibleSelectedCodexHomePath, shouldStripInheritedOrcaCodexHome @@ -113,6 +115,10 @@ export function configureLocalPtyProvider(args: { runtime?.getOrchestrationCompatibilityHostId?.(), ctx?.isWsl === true ? ctx.wslDistro : null ) + stampRuntimeSourceEnv( + env, + getRuntimeSourceStamp(runtime, getAppEnvironment().getPath('userData')) + ) if (ctx?.isWsl === true) { addOrcaWslInteropEnv(env) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 4c3f274ec7a8..0e7112619a1d 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -35,6 +35,8 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project- import { resolveManagedSshHostLoginShell } from '../../../pty/managed-ssh-host-login-shell' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env' +import { getRuntimeSourceStamp } from '../../../runtime/host-descriptor' import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' import { clearProviderPtyState } from '../provider/state-cleanup' import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' @@ -284,6 +286,10 @@ export async function prepareRuntimePtySpawn( ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null ) + stampRuntimeSourceEnv( + ctx.env, + getRuntimeSourceStamp(ctx.deps.runtime, getAppEnvironment().getPath('userData')) + ) promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) } catch (error) { // Why: host-env setup can materialize agent hooks/extensions before failing. diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index b6ad18063b1c..1433a1e816cc 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -44,12 +44,14 @@ vi.mock('electron', () => ({ vi.mock('../git/repo', () => ({ isGitRepo: isGitRepoMock, - inspectGitRepoForRegistration: vi.fn((path: string) => ({ + isGitRepoAsync: isGitRepoMock, + inspectGitRepoForRegistrationAsync: vi.fn((path: string) => ({ isRepo: isGitRepoMock(path), rootPath: getGitRepoRootMock(path), mainRepoPath: getLinkedWorktreeMainRepoRootMock(path) })), getGitRepoRoot: getGitRepoRootMock, + getGitRepoRootAsync: getGitRepoRootMock, getLinkedWorktreeMainRepoRoot: getLinkedWorktreeMainRepoRootMock, getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 183e3a4ab7b9..eea5e7677252 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -121,8 +121,10 @@ export function gitRepoModuleMock(actual: typeof RepoModule): Record ({ + getGitRepoRootAsync: getGitRepoRoot, + inspectGitRepoForRegistrationAsync: vi.fn((path: string) => ({ isRepo: isGitRepo(path), rootPath: getGitRepoRoot(path), mainRepoPath: null diff --git a/src/main/ipc/repos/local-repo-registration.ts b/src/main/ipc/repos/local-repo-registration.ts index 43fec4637cd1..4d8c98e8c831 100644 --- a/src/main/ipc/repos/local-repo-registration.ts +++ b/src/main/ipc/repos/local-repo-registration.ts @@ -5,7 +5,11 @@ import { isFolderRepo } from '../../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' -import { inspectGitRepoForRegistration, getGitRepoRoot, getRepoName } from '../../git/repo' +import { + inspectGitRepoForRegistrationAsync, + getGitRepoRootAsync, + getRepoName +} from '../../git/repo' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' @@ -20,7 +24,7 @@ export async function addLocalRepoFromPath( if (repoKind === 'git') { await awaitWindowsHostGitEnvironmentReady({ cwd: path }) } - const gitInfo = repoKind === 'git' ? inspectGitRepoForRegistration(path) : null + const gitInfo = repoKind === 'git' ? await inspectGitRepoForRegistrationAsync(path) : null if (gitInfo && !gitInfo.isRepo) { return { error: `Not a valid git repository: ${path}` } } @@ -52,7 +56,9 @@ export async function addLocalRepoFromPath( // same project and host — a duplicate run-target row that resolves to a transient worktree path. let mainRepoKey: string | null = null if (repoKind === 'git') { - const mainRepoRoot = gitInfo?.mainRepoPath ? getGitRepoRoot(gitInfo.mainRepoPath) : null + const mainRepoRoot = gitInfo?.mainRepoPath + ? await getGitRepoRootAsync(gitInfo.mainRepoPath) + : null if (mainRepoRoot) { mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) // Why !isFolderRepo: only a git-kind main checkout projects onto the same project as its diff --git a/src/main/ipc/repos/nested-repo-import-handler.ts b/src/main/ipc/repos/nested-repo-import-handler.ts index cd7b2a14d2ca..6d1fceb93915 100644 --- a/src/main/ipc/repos/nested-repo-import-handler.ts +++ b/src/main/ipc/repos/nested-repo-import-handler.ts @@ -7,7 +7,7 @@ import type { ProjectGroupImportResult } from '../../../shared/project-group-typ import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' -import { isGitRepo, getRepoName } from '../../git/repo' +import { isGitRepoAsync, getRepoName } from '../../git/repo' import { createNestedProjectGroupResolver, resolveNestedRepoSelection @@ -83,7 +83,7 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider) } else { await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) - if (!isGitRepo(repoPath)) { + if (!(await isGitRepoAsync(repoPath))) { results.push({ path: repoPath, status: 'failed', @@ -152,7 +152,7 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store } importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) - // Why: reaches here only after the isGitRepo guard above confirmed a git repo, so always true. + // Why: reaches here only after the isGitRepoAsync guard above confirmed a git repo, so always true. emitRepoAdded('folder_picker', false, true) } catch (error) { results.push({ diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 81646454320a..00a58f246dfc 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -50,7 +50,9 @@ export function broadcastSshState( } function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { - const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os + 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) diff --git a/src/main/ipc/worktrees/removal/worktree-archive-hook.ts b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts index 4df6bedd49f4..27227ce15fbd 100644 --- a/src/main/ipc/worktrees/removal/worktree-archive-hook.ts +++ b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts @@ -17,7 +17,7 @@ import { * * A failed read is answered as "no hook", which is a known limitation rather than a judgement: a * missing `orca.yaml` is indistinguishable from an unreachable one here, because the relay rewrites - * a non-numeric error code to `-32000` (`src/relay/dispatcher-rpc-routing.ts`), so nothing survives + * a non-numeric error code to `-32000` (`src/wsl-guest/dispatcher-rpc-routing.ts`), so nothing survives * to tell ENOENT from a transport failure. Reporting it as unreadable fired on every SSH repo that * simply has no orca.yaml; blocking on it would refuse those deletes outright. Distinguishing the * two needs a provider contract that reports absence as a successful outcome — tracked in #20196. @@ -52,7 +52,7 @@ export async function getArchiveHooksForRemoval( return getEffectiveHooksFromConfig(repo, yamlHooks) } catch (error) { // Indistinguishable from "there is no orca.yaml": the relay rewrites a non-numeric error code - // to -32000 (src/relay/dispatcher-rpc-routing.ts), so nothing survives to tell ENOENT from a + // to -32000 (src/wsl-guest/dispatcher-rpc-routing.ts), so nothing survives to tell ENOENT from a // transport failure. Logged so an operator can see it; not surfaced, because reporting it as // unreadable fired on every SSH repo that simply has none. Distinguishing them needs a provider // contract that returns absence as a successful outcome — #20196. diff --git a/src/main/jcode/hook-settings.ts b/src/main/jcode/hook-settings.ts index 1e6b2aed903d..063699f544f1 100644 --- a/src/main/jcode/hook-settings.ts +++ b/src/main/jcode/hook-settings.ts @@ -22,11 +22,6 @@ export const JCODE_HOOK_EVENTS = [ ] as const export type JcodeHookEvent = (typeof JCODE_HOOK_EVENTS)[number] -/** jcode waits for this one; the managed script must never block on it. */ -export function isJcodeGateHookEvent(event: JcodeHookEvent): boolean { - return event === 'pre_tool' -} - export function getJcodeConfigPath(env: NodeJS.ProcessEnv = process.env): string { const explicit = env.JCODE_HOME?.trim() return explicit ? join(explicit, 'config.toml') : join(homedir(), '.jcode', 'config.toml') diff --git a/src/main/linear/linear-team-pages.ts b/src/main/linear/linear-team-pages.ts index 624f57c6d8e2..b0e6da1613a4 100644 --- a/src/main/linear/linear-team-pages.ts +++ b/src/main/linear/linear-team-pages.ts @@ -19,6 +19,7 @@ type TeamLabelNode = { id: string name: string color: string + isGroup: boolean } type TeamMemberNode = { @@ -83,7 +84,9 @@ export async function fetchAllTeamLabels(team: { while (labels.pageInfo.hasNextPage) { await labels.fetchNext() } - return labels.nodes.map((l) => ({ id: l.id, name: l.name, color: l.color })) + return labels.nodes + .filter((l) => !l.isGroup) + .map((l) => ({ id: l.id, name: l.name, color: l.color })) } export async function fetchAllTeamMembers(team: { diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts index 55808711824f..0d86b04f3b93 100644 --- a/src/main/linear/teams.test.ts +++ b/src/main/linear/teams.test.ts @@ -32,6 +32,7 @@ type LabelNode = { id: string name: string color: string + isGroup: boolean } type MemberNode = { @@ -55,7 +56,7 @@ function team(id: string, name = id, key = id.toUpperCase()): TeamNode { } function makeLabel(id: string, name = id): LabelNode { - return { id, name, color: '#ff0000' } + return { id, name, color: '#ff0000', isGroup: false } } function makeMember(id: string, displayName = id): MemberNode { @@ -224,6 +225,73 @@ describe('Linear teams', () => { expect(labels).toHaveBeenCalledWith({ first: 100 }) }) + it.each(['getTeamLabels', 'getTeamLabelsOrThrow'] as const)( + '%s excludes groups across pages while keeping child and standalone labels', + async (reader) => { + const connection = makeConnection([ + [{ ...makeLabel('group-1', 'Type'), isGroup: true }], + [ + makeLabel('label-1', 'Urgent'), + { ...makeLabel('label-2', 'Bug'), parent: { id: 'group-1' } } + ], + [{ ...makeLabel('group-2', 'Area'), isGroup: true }], + [{ ...makeLabel('label-3', 'Backend'), parent: { id: 'group-2' } }] + ]) + const labels = vi.fn().mockResolvedValue(connection) + const entry = makeTeamLookupEntry('workspace-1', 'Workspace', { labels }) + getClients.mockReturnValue([entry]) + const readers = await import('./teams') + + await expect(readers[reader]('team-1', 'workspace-1')).resolves.toEqual([ + { id: 'label-1', name: 'Urgent', color: '#ff0000' }, + { id: 'label-2', name: 'Bug', color: '#ff0000' }, + { id: 'label-3', name: 'Backend', color: '#ff0000' } + ]) + expect(connection.fetchNext).toHaveBeenCalledTimes(3) + expect(getClients).toHaveBeenCalledWith('workspace-1') + expect(entry.client.team).toHaveBeenCalledWith('team-1') + expect(labels).toHaveBeenCalledWith({ first: 100 }) + } + ) + + it.each(['getTeamLabels', 'getTeamLabelsOrThrow'] as const)( + '%s finishes pagination when only groups are available', + async (reader) => { + const connection = makeConnection([ + [{ ...makeLabel('group-1'), isGroup: true }], + [{ ...makeLabel('group-2'), isGroup: true }] + ]) + getClients.mockReturnValue([ + makeTeamLookupEntry('workspace-1', 'Workspace', { + labels: vi.fn().mockResolvedValue(connection) + }) + ]) + const readers = await import('./teams') + + await expect(readers[reader]('team-1', 'workspace-1')).resolves.toEqual([]) + expect(connection.fetchNext).toHaveBeenCalledTimes(1) + } + ) + + it('preserves error handling when a later label page fails', async () => { + const error = new Error('later label page failed') + const connection = makeConnection([[makeLabel('label-1')], [makeLabel('label-2')]]) + connection.fetchNext.mockRejectedValue(error) + getClients.mockReturnValue([ + makeTeamLookupEntry('workspace-1', 'Workspace', { + labels: vi.fn().mockResolvedValue(connection) + }) + ]) + const { getTeamLabels, getTeamLabelsOrThrow } = await import('./teams') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(getTeamLabels('team-1', 'workspace-1')).resolves.toEqual([]) + await expect(getTeamLabelsOrThrow('team-1', 'workspace-1')).rejects.toBe(error) + expect(warn).toHaveBeenCalledWith('[linear] getTeamLabels failed:', error) + expect(release).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + it('fetches every page of team states', async () => { const states = vi .fn() diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index 76ba09646b91..9adc23afad9f 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -6,7 +6,7 @@ import { wslHomeSessionDirs, type SessionHomeAgent } from '../ai-vault/session-scanner-roots' -import { getWslHomeAsync, listRunningWslDistrosAsync, listRunningWslHomeDirsAsync } from '../wsl' +import { getWslHomeAsync, listRunningWslHomeDirsAsync } from '../wsl' import { filterPathsToRunningWslDistrosAsync, filterPathsToWslDistros @@ -50,20 +50,6 @@ export type WslTranscriptResolutionSnapshot = { homeDirs?: string[] } -/** One running-distro view shared by every WSL lookup in a resolve attempt. */ -export async function createWslTranscriptResolutionSnapshot( - options: { - includeHomes?: boolean - } = {} -): Promise { - const runningDistros = await listRunningWslDistrosAsync() - if (options.includeHomes === false) { - return { runningDistros } - } - const homes = await Promise.all(runningDistros.map((distro) => getWslHomeAsync(distro))) - return { runningDistros, homeDirs: homes.filter((home): home is string => home !== null) } -} - async function snapshotHomeDirs(snapshot: WslTranscriptResolutionSnapshot): Promise { if (snapshot.homeDirs) { return snapshot.homeDirs diff --git a/src/main/native-chat/subagent-roster-retention.test.ts b/src/main/native-chat/subagent-roster-retention.test.ts new file mode 100644 index 000000000000..5eaacca35688 --- /dev/null +++ b/src/main/native-chat/subagent-roster-retention.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import type { + NativeChatSubagentEntry, + NativeChatSubagentState +} from '../../shared/native-chat-types' +import { SubagentRosterRetention } from './subagent-roster-retention' + +type Group = { + groupId: string + entries: Map + lastSerialized: string | null +} + +function group(groupId: string, state: NativeChatSubagentState, children = 64): Group { + return { + groupId, + lastSerialized: 'admitted', + entries: new Map( + Array.from({ length: children }, (_, index) => { + const id = `${groupId}:${index}` + return [id, { id, label: id, state }] + }) + ) + } +} + +describe('host subagent roster settled retention', () => { + it('shares one bound between superseded executions and evicted group identities', () => { + const groups = new Map() + const retention = new SubagentRosterRetention(groups, { + maxGroups: 1, + maxSettledIdentities: 2048, + entries: (group) => group.entries.values(), + identities: (roster) => roster.entries.keys() + }) + const live = group('live', 'working', 1) + groups.set(live.groupId, live) + for (let index = 0; index < 2100; index++) { + retention.rememberSettled(`superseded-${index}`) + } + const settled = group('settled', 'completed', 1) + groups.set(settled.groupId, settled) + retention.trim([settled]) + expect(retention.sizes()).toEqual({ groups: 1, settledIdentities: 2048 }) + expect(retention.hasSettled('superseded-52')).toBe(false) + expect(retention.hasSettled('superseded-2099')).toBe(true) + expect(retention.hasSettled('settled:0')).toBe(true) + retention.clear() + expect(retention.sizes().settledIdentities).toBe(0) + }) + + it('bounds settled groups and recent identities while retaining every live group', () => { + const groups = new Map() + const retention = new SubagentRosterRetention(groups, { + maxGroups: 32, + maxSettledIdentities: 32 * 64, + entries: (group) => group.entries.values(), + identities: (roster) => roster.entries.keys() + }) + for (let index = 0; index < 35; index++) { + const live = group(`live-${index}`, 'working') + groups.set(live.groupId, live) + retention.trim([live]) + } + for (let index = 0; index < 70; index++) { + const settled = group(`settled-${index}`, 'completed') + groups.set(settled.groupId, settled) + retention.trim([settled]) + } + expect(retention.sizes()).toEqual({ groups: 35, settledIdentities: 32 * 64 }) + expect(retention.hasSettled('settled-0:0')).toBe(false) + expect(retention.hasSettled('settled-69:0')).toBe(true) + expect([...groups.keys()].every((id) => id.startsWith('live-'))).toBe(true) + for (let index = 0; index < 70; index++) { + const settled = group(`more-settled-${index}`, 'completed') + const live = groups.get(`live-${index}`) + if (live) { + for (const entry of live.entries.values()) { + entry.state = 'completed' + } + retention.trim([live]) + } + groups.set(settled.groupId, settled) + retention.trim([settled]) + } + expect(retention.sizes()).toEqual({ groups: 32, settledIdentities: 32 * 64 }) + }) + + it('re-derives settled eligibility when a retained child runs again or its write is refused', () => { + const groups = new Map() + const retention = new SubagentRosterRetention(groups, { + maxGroups: 1, + maxSettledIdentities: 64, + entries: (group) => group.entries.values(), + identities: (roster) => roster.entries.keys() + }) + const old = group('old', 'completed', 1) + groups.set(old.groupId, old) + retention.trim([old]) + old.entries.set('old:0', { id: 'old:0', label: 'old', state: 'working' }) + retention.trim([old]) + const newGroup = group('new', 'working', 1) + groups.set(newGroup.groupId, newGroup) + retention.trim([newGroup]) + expect(groups.has('old')).toBe(true) + old.entries.set('old:0', { id: 'old:0', label: 'old', state: 'completed' }) + old.lastSerialized = null + retention.trim([old]) + expect(groups.has('old')).toBe(true) + old.lastSerialized = 'admitted' + retention.trim([old]) + expect(groups.has('old')).toBe(false) + expect(retention.hasSettled('old:0')).toBe(true) + groups.clear() + retention.clear() + expect(retention.sizes()).toEqual({ groups: 0, settledIdentities: 0 }) + }) +}) + +it('keeps only the accessed inherited row during a trim and re-derives it on the next event', () => { + const groups = new Map() + const retention = new SubagentRosterRetention(groups, { + maxGroups: 32, + maxSettledIdentities: 2048, + entries: (roster) => roster.entries.values(), + identities: (roster) => roster.entries.keys() + }) + for (let index = 0; index < 32; index++) { + const live = group(`live-${index}`, 'working', 1) + groups.set(live.groupId, live) + retention.trim([live]) + } + for (let index = 0; index < 70; index++) { + const inherited = group(`inherited-${index}`, 'completed', 1) + groups.set(inherited.groupId, inherited) + retention.trim([inherited], inherited.groupId) + expect(groups.size).toBe(33) + expect(groups.has(inherited.groupId)).toBe(true) + } + retention.trim([]) + expect(groups.size).toBe(32) + expect(retention.hasSettled('inherited-69:0')).toBe(true) + const resumed = group('resumed', 'completed', 1) + groups.set(resumed.groupId, resumed) + retention.trim([resumed], resumed.groupId) + resumed.entries.set('resumed:0', { id: 'resumed:0', label: 'resumed', state: 'working' }) + retention.trim([resumed]) + expect(groups.has(resumed.groupId)).toBe(true) + expect(groups.size).toBe(33) +}) diff --git a/src/main/native-chat/subagent-roster-retention.ts b/src/main/native-chat/subagent-roster-retention.ts new file mode 100644 index 000000000000..6d4f5d865f5e --- /dev/null +++ b/src/main/native-chat/subagent-roster-retention.ts @@ -0,0 +1,75 @@ +import { BoundedMap } from '../../shared/bounded-map' +import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + +type RetainedRosterGroup = { + groupId: string + lastSerialized: string | null +} + +/** Bounds settled history; live children and refused final writes keep their ownership. */ +export class SubagentRosterRetention { + private readonly settledGroups = new Set() + private readonly settledIdentities: BoundedMap + + constructor( + private readonly groups: Map, + private readonly options: { + maxGroups: number + maxSettledIdentities: number + entries: (group: Group) => Iterable + identities: (group: Group) => Iterable + onEvict?: (group: Group) => void + } + ) { + this.settledIdentities = new BoundedMap({ maxEntries: options.maxSettledIdentities }) + } + + hasSettled(identity: string): boolean { + return this.settledIdentities.has(identity) + } + + /** Superseded executions share the evicted history's single bounded budget. */ + rememberSettled(identity: string): void { + this.settledIdentities.set(identity, true) + } + + trim(changed: Iterable, retainedGroupId?: string): void { + for (const group of changed) { + if ( + group.lastSerialized !== null && + [...this.options.entries(group)].every((entry) => isTerminalSubagentState(entry.state)) + ) { + this.settledGroups.add(group.groupId) + } else { + this.settledGroups.delete(group.groupId) + } + } + for (const groupId of this.settledGroups) { + if (this.groups.size <= this.options.maxGroups) { + break + } + if (groupId === retainedGroupId) { + continue + } + const group = this.groups.get(groupId) + if (group) { + for (const identity of this.options.identities(group)) { + this.rememberSettled(identity) + } + this.options.onEvict?.(group) + } + this.groups.delete(groupId) + this.settledGroups.delete(groupId) + } + } + + sizes(): { groups: number; settledIdentities: number } { + return { groups: this.groups.size, settledIdentities: this.settledIdentities.size } + } + + clear(): void { + this.settledGroups.clear() + this.settledIdentities.clear() + } +} diff --git a/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts b/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts index f601ebe0f11e..488da4be413d 100644 --- a/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts +++ b/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts @@ -90,10 +90,3 @@ export function isWslTranscriptFsProcessHandle( ): value is WslTranscriptFsProcessHandle { return 'wslTranscriptFsProcessHandle' in value } - -export function resetWslTranscriptFsProcessClientForTests(): void { - for (const client of clientsByLane.values()) { - client.dispose() - } - clientsByLane.clear() -} diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index f6d07361abe2..977e9ef18c98 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -104,15 +104,6 @@ describe('OpenCode status plugin module contract', () => { expect(module.default?.server).toBeTypeOf('function') }) - it('rejects the shape OpenCode refuses: a default export without server()', async () => { - const module = await loadPluginModule() - - // Why: pins the specific reason the loader fails a module — `setup` alone is not - // accepted, so a default export must never regress to it. - expect(module.default).not.toBeUndefined() - expect(Object.hasOwn(module.default ?? {}, 'server')).toBe(true) - }) - it('keeps the named factory export so the factory-based loader still resolves', async () => { const module = await loadPluginModule() diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts index 06c3cb74c638..ab162b8175b5 100644 --- a/src/main/orca-profiles/profile-project-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -73,7 +73,6 @@ vi.mock('electron', () => ({ })) async function loadTransferModule() { - vi.resetModules() return import('./profile-project-transfer') } diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 4925a59442bd..67f4cebe7ace 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -11,15 +11,11 @@ * Chromium proves available at startup. */ import process from 'node:process' -import { - getAppEnvironment, - setAppEnvironment, - type AppEnvironment -} from '../../shared/app-environment' -import { setSecretStore, type SecretStore } from '../../shared/secret-store' +import { getAppEnvironment } from '../../shared/app-environment' import type { ServeReadiness } from '../server/serve-readiness' -import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' -import { getOrcadCliLauncherPath, prepareOrcadCliLauncher } from './orcad-cli-launcher' +import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths' +import { prepareOrcadCliLauncher } from './orcad-cli-launcher' +import { installOrcadHostAdapters, runOrcadQuitHandlers } from './orcad-host-adapters' import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address' import { flushOrcadProfileStoreForShutdown, @@ -37,72 +33,13 @@ import { changedAiVaultSearchSettings, type AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' +import { stampRuntimeSourceEnv } from '../../shared/runtime-source-env' export { parseArgs } +export { installOrcadHostAdapters } -let runOrcadQuitHandlers = (): void => {} let closeOrcadObservability = (): void => {} -function createNodeAppEnvironment(): AppEnvironment { - const quitHandlers: (() => void)[] = [] - // The main signal handler awaits runtime and browser teardown before process.exit. - // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. - runOrcadQuitHandlers = (): void => { - const errors: unknown[] = [] - for (const handler of quitHandlers.splice(0)) { - try { - handler() - } catch (error) { - errors.push(error) - } - } - // Why throw: a quit handler that failed may leave a writer running, which keeps the lock. - if (errors.length > 0) { - throw new AggregateError(errors, 'orcad_quit_handlers_failed') - } - } - return { - getPath: resolveOrcadPath, - getAppPath: () => resolveOrcadInstallRoot(), - getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', - // Why still true: consumers read this as "production build, not a dev checkout" — - // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH - // hydration. Answering false to satisfy a path resolver would relax a security - // posture. Layout questions must ask whether the app root is an asar archive - // instead (see parcel-watcher-entry-path.ts). - isPackaged: () => true, - getCliLauncherPath: getOrcadCliLauncherPath, - onWillQuit: (handler) => quitHandlers.push(handler), - exit: (code = 0) => process.exit(code), - // Why []: there are no Chromium processes on this host to measure. - getAppMetrics: () => [] - } -} - -/** - * Why not silently plaintext: `isEncryptionAvailable() === false` already makes every - * caller fall back to unsealed storage, which is a security posture, not a detail. - * `describeProtectionGap()` gives the reason a client can surface. - */ -function createNodeSecretStore(): SecretStore { - return { - isEncryptionAvailable: () => false, - encryptString: () => { - throw new Error('orcad_secret_sealing_unavailable') - }, - decryptString: () => { - throw new Error('orcad_secret_sealing_unavailable') - }, - describeProtectionGap: () => - 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' - } -} - -export function installOrcadHostAdapters(): void { - setAppEnvironment(createNodeAppEnvironment()) - setSecretStore(createNodeSecretStore()) -} - export type OrcadOptions = { port?: number json?: boolean @@ -133,6 +70,8 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() + // Why: an orcad started from an Orca terminal must not fence its own CLI children to that Orca. + stampRuntimeSourceEnv(process.env, null) const { readiness, instance, stop } = await startOrcadWithHost( resolveUserDataPath(), (registerCleanup) => startOrcadRuntime(options, registerCleanup), diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts new file mode 100644 index 000000000000..fb2f69162367 --- /dev/null +++ b/src/main/orcad/orcad-host-adapters.ts @@ -0,0 +1,75 @@ +import process from 'node:process' +import { + setAppEnvironment, + type AppEnvironment +} from '../../shared/app-environment' +import { setSecretStore, type SecretStore } from '../../shared/secret-store' +import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths' +import { getOrcadCliLauncherPath } from './orcad-cli-launcher' + +let installedQuitHandlers = (): void => {} + +/** Run every `onWillQuit` handler installed by the Node app environment. */ +export function runOrcadQuitHandlers(): void { + installedQuitHandlers() +} + +function createNodeAppEnvironment(): AppEnvironment { + const quitHandlers: (() => void)[] = [] + // The main signal handler awaits runtime and browser teardown before process.exit. + // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. + installedQuitHandlers = (): void => { + const errors: unknown[] = [] + for (const handler of quitHandlers.splice(0)) { + try { + handler() + } catch (error) { + errors.push(error) + } + } + // Why throw: a quit handler that failed may leave a writer running, which keeps the lock. + if (errors.length > 0) { + throw new AggregateError(errors, 'orcad_quit_handlers_failed') + } + } + return { + getPath: resolveOrcadPath, + getAppPath: () => resolveOrcadInstallRoot(), + getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', + // Why still true: consumers read this as "production build, not a dev checkout" — + // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH + // hydration. Answering false to satisfy a path resolver would relax a security + // posture. Layout questions must ask whether the app root is an asar archive + // instead (see parcel-watcher-entry-path.ts). + isPackaged: () => true, + getCliLauncherPath: getOrcadCliLauncherPath, + onWillQuit: (handler) => quitHandlers.push(handler), + exit: (code = 0) => process.exit(code), + // Why []: there are no Chromium processes on this host to measure. + getAppMetrics: () => [] + } +} + +/** + * Why not silently plaintext: `isEncryptionAvailable() === false` already makes every + * caller fall back to unsealed storage, which is a security posture, not a detail. + * `describeProtectionGap()` gives the reason a client can surface. + */ +function createNodeSecretStore(): SecretStore { + return { + isEncryptionAvailable: () => false, + encryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + decryptString: () => { + throw new Error('orcad_secret_sealing_unavailable') + }, + describeProtectionGap: () => + 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' + } +} + +export function installOrcadHostAdapters(): void { + setAppEnvironment(createNodeAppEnvironment()) + setSecretStore(createNodeSecretStore()) +} diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts index 1bd42aa6ef31..4965f7fc822b 100644 --- a/src/main/persistence-protected-secret-fail-closed.test.ts +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -34,7 +34,6 @@ vi.mock('./telemetry/cohort-classifier', () => ({ })) async function createStore() { - vi.resetModules() const { setSecretStore } = await import('../shared/secret-store') setSecretStore({ isEncryptionAvailable: () => { @@ -62,8 +61,7 @@ async function createStore() { describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') - // Why here: userData resolves through AppEnvironment, and this must point at this - // file's temp dir rather than the global fake's shared one, after resetModules. + // Each reopened store must resolve userData to this fixture's directory. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index ee75c72c5a0b..842aa015baca 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -46,7 +46,6 @@ vi.mock('./telemetry/cohort-classifier', () => ({ })) async function createStore() { - vi.resetModules() const { setSecretStore } = await import('../shared/secret-store') setSecretStore({ isEncryptionAvailable: () => { @@ -69,8 +68,6 @@ async function createStore() { describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') - // Why here: userData resolves through AppEnvironment, and this must point at this - // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index b70c2c01573f..72967bc50cad 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -45,7 +45,6 @@ vi.mock('./telemetry/cohort-classifier', () => ({ })) async function createStore() { - vi.resetModules() const { setSecretStore } = await import('../shared/secret-store') setSecretStore({ isEncryptionAvailable: () => cipherState.encryptionAvailable, @@ -64,8 +63,6 @@ async function createStore() { describeProtectionGap: () => null }) const { Store, initDataPath } = await import('./persistence') - // Why here: userData resolves through AppEnvironment, and this must point at this - // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) diff --git a/src/main/plugins/plugin-host-conformance.test.ts b/src/main/plugins/plugin-host-conformance.test.ts index bc43dd741de6..43ec5c2f227d 100644 --- a/src/main/plugins/plugin-host-conformance.test.ts +++ b/src/main/plugins/plugin-host-conformance.test.ts @@ -6,7 +6,7 @@ import { createPluginPanelCallAdmission } from '../../shared/plugins/plugin-panel-call-admission' import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' -import type { MethodHandler } from '../../relay/dispatcher' +import type { MethodHandler } from '../../wsl-guest/dispatcher' import { RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, diff --git a/src/main/ports/workspace-port-execution-host.test.ts b/src/main/ports/workspace-port-execution-host.test.ts new file mode 100644 index 000000000000..6f1112890362 --- /dev/null +++ b/src/main/ports/workspace-port-execution-host.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + killWorkspacePortOnExecutionHost, + scanWorkspacePortsOnExecutionHost, + type WorkspacePortExecutionHostDeps +} from './workspace-port-execution-host' +import type { WorkspacePortScanResult } from '../../shared/workspace-ports' + +const scanWorkspacePortsMock = vi.hoisted(() => vi.fn()) + +vi.mock('./local-workspace-port-scanner', () => ({ + scanWorkspacePorts: scanWorkspacePortsMock +})) + +const LOCAL_PROBE = { id: 'repo::/srv/app', repoId: 'repo', displayName: 'app', path: '/srv/app' } + +// Same pid and port on this host and on the SSH host: only the host binding tells them apart. +const PID = 4242 +const PORT = 3020 + +function localWorkspaceScan(): WorkspacePortScanResult { + return { + platform: 'linux', + scannedAt: 1, + ports: [ + { + id: `0.0.0.0:${PORT}:${PID}`, + kind: 'workspace', + bindHost: '0.0.0.0', + connectHost: 'localhost', + port: PORT, + pid: PID, + protocol: 'http', + owner: { + worktreeId: LOCAL_PROBE.id, + repoId: LOCAL_PROBE.repoId, + displayName: LOCAL_PROBE.displayName, + path: LOCAL_PROBE.path, + confidence: 'cwd' + } + } + ] + } +} + +function deps( + request?: (method: string) => Promise, + connected = true +): WorkspacePortExecutionHostDeps & { request: ReturnType } { + const requestMock = vi.fn(request ?? (async () => ({ ports: [], platform: 'linux' }))) + return { + request: requestMock, + getLocalProbes: async () => [LOCAL_PROBE], + getSshMultiplexer: (targetId) => + connected && targetId === 'box' ? { request: requestMock } : undefined + } +} + +describe('scanWorkspacePortsOnExecutionHost', () => { + afterEach(() => { + scanWorkspacePortsMock.mockReset() + vi.restoreAllMocks() + }) + + it('scans this host for a local workspace and names the host', async () => { + scanWorkspacePortsMock.mockResolvedValue(localWorkspaceScan()) + const d = deps() + + const result = await scanWorkspacePortsOnExecutionHost('local', d) + + expect(scanWorkspacePortsMock).toHaveBeenCalledWith([LOCAL_PROBE], undefined, undefined) + expect(d.request).not.toHaveBeenCalled() + expect(result).toMatchObject({ executionHostId: 'local', platform: 'linux' }) + expect(result.ports).toHaveLength(1) + }) + + it("scans the workspace's SSH host through its relay, never this host", async () => { + const d = deps(async () => ({ + platform: 'linux', + ports: [{ port: PORT, host: '0.0.0.0', pid: PID, processName: 'node' }] + })) + + const result = await scanWorkspacePortsOnExecutionHost('ssh:box', d) + + expect(scanWorkspacePortsMock).not.toHaveBeenCalled() + expect(d.request).toHaveBeenCalledWith('ports.detect', undefined, expect.any(Object)) + expect(result).toMatchObject({ + executionHostId: 'ssh:box', + platform: 'linux', + ports: [ + { + kind: 'external', + bindHost: '0.0.0.0', + connectHost: 'localhost', + port: PORT, + pid: PID, + processName: 'node' + } + ] + }) + expect(result.unavailableReason).toBeUndefined() + }) + + it('reports a disconnected SSH host as unavailable, not as an empty host', async () => { + const result = await scanWorkspacePortsOnExecutionHost('ssh:box', deps(undefined, false)) + + expect(result).toMatchObject({ executionHostId: 'ssh:box', ports: [] }) + expect(result.unavailableReason).toMatch(/not connected/) + }) + + it('reports a failed or unsupported relay scan as unavailable', async () => { + const failed = await scanWorkspacePortsOnExecutionHost( + 'ssh:box', + deps(async () => { + throw new Error('Request "ports.detect" timed out after 10000ms') + }) + ) + const unsupported = await scanWorkspacePortsOnExecutionHost( + 'ssh:box', + deps(async () => ({ ports: [], platform: 'darwin' })) + ) + const malformed = await scanWorkspacePortsOnExecutionHost( + 'ssh:box', + deps(async () => ({ ports: [{ port: 'x' }], platform: 'linux' })) + ) + + expect(failed.unavailableReason).toMatch(/timed out/) + expect(unsupported.unavailableReason).toMatch(/not supported/) + expect(malformed.unavailableReason).toMatch(/invalid/) + }) + + it('refuses a workspace hosted by another server instead of scanning here', async () => { + const d = deps() + const result = await scanWorkspacePortsOnExecutionHost('runtime:env-2', d) + + expect(scanWorkspacePortsMock).not.toHaveBeenCalled() + expect(d.request).not.toHaveBeenCalled() + expect(result.unavailableReason).toBeTruthy() + }) +}) + +describe('killWorkspacePortOnExecutionHost', () => { + afterEach(() => { + scanWorkspacePortsMock.mockReset() + vi.restoreAllMocks() + }) + + it('stops a workspace-owned listener on this host after re-proving ownership', async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + scanWorkspacePortsMock.mockResolvedValue(localWorkspaceScan()) + + const result = await killWorkspacePortOnExecutionHost( + 'local', + { executionHostId: 'local', pid: PID, port: PORT }, + deps() + ) + + expect(result).toEqual({ ok: true }) + expect(scanWorkspacePortsMock).toHaveBeenCalledWith([LOCAL_PROBE], undefined, { + requireMetadata: true + }) + expect(kill).toHaveBeenCalledWith(PID, 'SIGTERM') + }) + + it("never signals this host's same pid for a row scanned on the SSH host", async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + scanWorkspacePortsMock.mockResolvedValue(localWorkspaceScan()) + + const result = await killWorkspacePortOnExecutionHost( + 'local', + { executionHostId: 'ssh:box', pid: PID, port: PORT }, + deps() + ) + + expect(result).toMatchObject({ ok: false }) + expect(kill).not.toHaveBeenCalled() + expect(scanWorkspacePortsMock).not.toHaveBeenCalled() + }) + + it('refuses to stop on an SSH host, where ownership cannot be proven', async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + const d = deps() + + const result = await killWorkspacePortOnExecutionHost( + 'ssh:box', + { executionHostId: 'ssh:box', pid: PID, port: PORT }, + d + ) + + expect(result).toEqual({ + ok: false, + reason: 'Stopping processes on an SSH host is not supported yet.' + }) + expect(kill).not.toHaveBeenCalled() + expect(d.request).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ports/workspace-port-execution-host.ts b/src/main/ports/workspace-port-execution-host.ts new file mode 100644 index 000000000000..5d2b7612eb01 --- /dev/null +++ b/src/main/ports/workspace-port-execution-host.ts @@ -0,0 +1,154 @@ +import { + normalizeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import type { DetectedPort } from '../../shared/ssh-types' +import type { + WorkspacePort, + WorkspacePortHostKillRequest, + WorkspacePortHostScanResult, + WorkspacePortKillResult, + WorkspacePortProbe +} from '../../shared/workspace-ports' +import type { SshMultiplexerRequestOptions } from '../ssh/ssh-channel-multiplexer' +import { connectHostForBindHost } from './local-workspace-port-address' +import { killWorkspacePort, scanWorkspacePortProbes } from './workspace-port-ownership' + +// Why: the relay walks /proc/*/fd on demand; a slow host must answer "unavailable", not hang the RPC. +const SSH_PORT_SCAN_TIMEOUT_MS = 10_000 + +type SshPortScanMultiplexer = { + request: ( + method: string, + params?: Record, + options?: SshMultiplexerRequestOptions + ) => Promise +} + +export type WorkspacePortExecutionHostDeps = { + /** Workspaces this host runs itself; the only processes Stop may signal here. */ + getLocalProbes: () => Promise + /** Undefined means "not connected", never "the host's processes exited". */ + getSshMultiplexer: (targetId: string) => SshPortScanMultiplexer | undefined +} + +function unavailable(executionHostId: string, reason: string): WorkspacePortHostScanResult { + return { + executionHostId, + platform: 'unknown', + scannedAt: Date.now(), + ports: [], + unavailableReason: reason + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function isDetectedPort(value: unknown): value is DetectedPort { + if (!value || typeof value !== 'object') { + return false + } + const port: { port?: unknown; host?: unknown; pid?: unknown; processName?: unknown } = value + return ( + typeof port.port === 'number' && + Number.isSafeInteger(port.port) && + typeof port.host === 'string' && + (port.pid === undefined || typeof port.pid === 'number') && + (port.processName === undefined || typeof port.processName === 'string') + ) +} + +// Why `external`: the relay reports listeners without workspace attribution, so no row it returns +// can be treated as workspace-owned (which is what makes a row stoppable). +function toUnattributedPort(port: DetectedPort): WorkspacePort { + return { + id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`, + kind: 'external', + bindHost: port.host, + connectHost: connectHostForBindHost(port.host), + port: port.port, + ...(port.pid !== undefined ? { pid: port.pid } : {}), + ...(port.processName !== undefined ? { processName: port.processName } : {}), + protocol: 'unknown' + } +} + +async function scanSshExecutionHost( + executionHostId: string, + targetId: string, + deps: WorkspacePortExecutionHostDeps +): Promise { + let response: unknown + try { + const mux = deps.getSshMultiplexer(targetId) + if (!mux) { + return unavailable(executionHostId, 'The SSH host is not connected.') + } + response = await mux.request('ports.detect', undefined, { + timeoutMs: SSH_PORT_SCAN_TIMEOUT_MS + }) + } catch (error) { + return unavailable(executionHostId, `Could not scan the SSH host: ${errorMessage(error)}`) + } + const result: { ports?: unknown; platform?: unknown } = + response && typeof response === 'object' ? response : {} + if (!Array.isArray(result.ports) || !result.ports.every(isDetectedPort)) { + return unavailable(executionHostId, 'The SSH host returned an invalid port scan.') + } + // Why: the relay answers an empty list on platforms it cannot scan; that is "could not look". + if (result.platform !== 'linux' && result.platform !== 'win32') { + return unavailable(executionHostId, 'Port detection is not supported on this SSH host.') + } + return { + executionHostId, + platform: result.platform, + scannedAt: Date.now(), + ports: result.ports.map(toUnattributedPort) + } +} + +/** Scans the execution host a workspace resolved to on this endpoint, and names it on the result. */ +export async function scanWorkspacePortsOnExecutionHost( + executionHostId: ExecutionHostId, + deps: WorkspacePortExecutionHostDeps +): Promise { + const host = parseExecutionHostId(executionHostId) + if (host?.kind === 'local') { + return { ...(await scanWorkspacePortProbes(await deps.getLocalProbes())), executionHostId } + } + if (host?.kind === 'ssh') { + return scanSshExecutionHost(executionHostId, host.targetId, deps) + } + return unavailable(executionHostId, 'This workspace does not run on this server.') +} + +/** + * Stops a listener only on the host the workspace resolves to now, and only when that is also the + * host the row was scanned on, so a row from one machine can never signal a same-numbered pid on + * another. + */ +export async function killWorkspacePortOnExecutionHost( + resolvedHostId: ExecutionHostId, + request: Omit, + deps: WorkspacePortExecutionHostDeps +): Promise { + if (normalizeExecutionHostId(request.executionHostId) !== resolvedHostId) { + return { + ok: false, + reason: 'This port was found on a different host than the workspace runs on. Rescan first.' + } + } + const host = parseExecutionHostId(resolvedHostId) + if (host?.kind === 'local') { + return killWorkspacePort(await deps.getLocalProbes(), { pid: request.pid, port: request.port }) + } + // Why: the relay can neither attribute a listener to a workspace nor signal it, so ownership + // cannot be proven on the SSH host; refusing keeps Stop from ever acting on the wrong machine. + if (host?.kind === 'ssh') { + return { ok: false, reason: 'Stopping processes on an SSH host is not supported yet.' } + } + return { ok: false, reason: 'This workspace does not run on this server.' } +} diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts index 754172d3b422..99dc3b49c0b8 100644 --- a/src/main/ports/workspace-port-ownership.ts +++ b/src/main/ports/workspace-port-ownership.ts @@ -12,7 +12,8 @@ import { scanWorkspacePorts } from './local-workspace-port-scanner' import type { WorkspacePortScanOptions } from './local-workspace-port-scan-state' export type WorkspacePortProbeInput = WorkspacePortProbe & { - connectionId?: string | null + /** True only when this host itself runs the workspace. */ + runsHere: boolean } export function getStoreWorkspacePortProbes( @@ -61,7 +62,7 @@ export function filterWorkspacePortProbes( repoId?: string ): WorkspacePortProbe[] { return worktrees.flatMap((worktree) => { - if ((repoId && worktree.repoId !== repoId) || worktree.connectionId) { + if ((repoId && worktree.repoId !== repoId) || !worktree.runsHere) { return [] } return [ diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index cb37591a8843..9e8b3050b856 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -221,9 +221,8 @@ export async function refreshShellPathAndDetectAgents( export async function detectRemoteAgents(args: { connectionId: string }): Promise { const mux = getActiveMultiplexer(args.connectionId) if (!mux || mux.isDisposed()) { - // Why: remote agent detection is passive UI polling. A disconnected host has - // no detectable agents until reconnect, but should not spam IPC errors. - return [] + // Why: an unreachable host is unverifiable, not a host with no agents; [] would be cached as one. + throw new Error('SSH host is not connected; its agents cannot be listed.') } const result = (await mux.request('preflight.detectAgents', { commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS diff --git a/src/main/preflight/workspace-agent-detection.test.ts b/src/main/preflight/workspace-agent-detection.test.ts index ce10e12d9f2e..4417a271d503 100644 --- a/src/main/preflight/workspace-agent-detection.test.ts +++ b/src/main/preflight/workspace-agent-detection.test.ts @@ -10,6 +10,11 @@ import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' import { splitWorktreeId } from '../../shared/worktree/id' +import { + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, + WORKSPACE_ON_OTHER_RUNTIME +} from '../../shared/preflight-other-runtime-refusal-capability' +import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' import { resolveWorkspaceAgentDetectionHost, type WorkspaceAgentDetectionStore @@ -245,6 +250,25 @@ describe('resolveWorkspaceAgentDetectionHost on a Windows host', () => { 'worktree_execution_host_unresolved' ) }) + it('#13752: refuses a worktree or folder another runtime owns instead of probing here', () => { + const s = store({ + repos: [ + repo({ id: 'r1', path: '/srv/r1', executionHostId: 'runtime:env-b' }), + repo({ + id: 'r2', + path: '/srv/r2', + executionHostId: 'runtime:env-b', + connectionId: 'b-ssh', + projectGroupId: 'g1' + }) + ], + projectGroups: [GROUP], + folderWorkspaces: [{ ...FOLDER, folderPath: '/srv', executionHostId: 'runtime:env-b' }] + }) + expect(() => resolveFor(s, 'r1::/srv/r1')).toThrow(WORKSPACE_ON_OTHER_RUNTIME) + expect(() => resolveFor(s, 'r2::/srv/r2')).toThrow(WORKSPACE_ON_OTHER_RUNTIME) + expect(() => resolveFor(s, folderWorkspaceKey('f1'))).toThrow(WORKSPACE_ON_OTHER_RUNTIME) + }) }) describe('preflight.detectAgents resolves the workspace on the host', () => { @@ -296,4 +320,20 @@ describe('preflight.detectAgents resolves the workspace on the host', () => { }) expect(refreshMock).toHaveBeenCalledWith(wslContext('Ubuntu', 'p1', 'global-default')) }) + + it('#13752: a paired client naming a workspace another runtime owns gets a refusal', async () => { + const dispatcher = dispatcherFor( + store({ repos: [repo({ id: 'r1', path: '/srv/r1', executionHostId: 'runtime:env-b' })] }) + ) + const response = await dispatcher.dispatch({ + id: '1', + authToken: 't', + method: 'preflight.detectAgents', + params: { worktreeId: 'r1::/srv/r1' } + }) + expect(detectInstalledMock).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: false, error: { message: WORKSPACE_ON_OTHER_RUNTIME } }) + // Clients let a host decide a shared repo id only when it advertises this refusal. + expect(RUNTIME_CAPABILITIES).toContain(PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY) + }) }) diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index 9b700225f8b3..7132e05a047c 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -1,7 +1,9 @@ +import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/preflight-other-runtime-refusal-capability' import { getRepoExecutionHostId, getSshTargetIdForExecutionHost, - LOCAL_EXECUTION_HOST_ID + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId } from '../../shared/execution-host' import { findFolderWorkspaceCandidateRepos, @@ -86,7 +88,10 @@ function resolveWorktree( if (sshTargetId) { return { kind: 'ssh', connectionId: sshTargetId } } - // A worktree another runtime owns is not this host's to probe; keep the host default. + // Why refuse: another runtime's worktree is not this host's to probe, and its list would mislead. + if (parseExecutionHostId(routing.hostId)?.kind === 'runtime') { + throw new Error(WORKSPACE_ON_OTHER_RUNTIME) + } return routing.hostId === LOCAL_EXECUTION_HOST_ID && routing.repo ? { kind: 'local', repo: routing.repo, path: worktree.path } : null @@ -108,6 +113,9 @@ function resolveFolderWorkspace( if (host.kind === 'ambiguous') { throw new Error('worktree_execution_host_unresolved') } + if (host.kind === 'runtime') { + throw new Error(WORKSPACE_ON_OTHER_RUNTIME) + } const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) const candidates = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId) // Why one candidate: a folder spanning several repos has no single project runtime to pick. diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index 1f25a85a26f1..84abbf74ae60 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -1,7 +1,7 @@ import { readFile, readdir, stat } from 'node:fs/promises' import { basename, join } from 'node:path' import type { NestedRepoCandidate, NestedRepoScanResult } from '../../shared/project-group-types' -import { isGitRepo } from '../git/repo' +import { isGitRepoAsync } from '../git/repo' import { isIgnoredNestedRepoDirectory, normalizeNestedRepoScanOptions, @@ -58,7 +58,8 @@ export async function scanNestedRepos(args: { joinPath: join, basename, hasGitMarker, - isSelectedPathGitRepo: async (path: string) => isGitRepo(path) || (await hasGitMarker(path)) + isSelectedPathGitRepo: async (path: string) => + (await isGitRepoAsync(path)) || (await hasGitMarker(path)) } const buildResult = (selectedPathKind: NestedRepoScanResult['selectedPathKind']) => ({ selectedPath: args.path, diff --git a/src/main/providers/ssh-review-draft-context.test.ts b/src/main/providers/ssh-review-draft-context.test.ts index 9fb0d7359a10..de542c5c982b 100644 --- a/src/main/providers/ssh-review-draft-context.test.ts +++ b/src/main/providers/ssh-review-draft-context.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' import { ReviewDraftContextError } from '../../shared/review-draft-context-error' import { RelayContext } from '../../relay/context' -import { RelayDispatcher } from '../../relay/dispatcher' +import { RelayDispatcher } from '../../wsl-guest/dispatcher' import { GitHandler } from '../../relay/git-handler' import { createGitTempDir, removeGitTempDir } from '../../relay/git-handler-test-harness' import { gitCommit, gitInit } from '../../relay/git-handler-test-setup' @@ -13,7 +13,7 @@ import { FrameDecoder, MessageType, parseJsonRpcMessage -} from '../../relay/protocol' +} from '../../wsl-guest/protocol' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { SshGitProvider } from './ssh-git-provider' diff --git a/src/main/pty/posix-pty-process-groups.ts b/src/main/pty/posix-pty-process-groups.ts index cfe7485fdd45..1a365dc26a93 100644 --- a/src/main/pty/posix-pty-process-groups.ts +++ b/src/main/pty/posix-pty-process-groups.ts @@ -162,20 +162,6 @@ export function isPosixPtyRootStopped(output: string, rootPid: number): boolean ) } -/** The root was stopped by flow control; preserve independently stopped jobs. */ -export function getPosixPtyStoppedJobGroups(output: string, rootPid: number): Set { - const rows = parseProcessRows(output) - const root = rows.find((row) => row.pid === rootPid) - return new Set( - rows - .filter( - (row) => - root && row.tty === root.tty && row.pgid !== root.pgid && /^[Tt]/.test(row.state ?? '') - ) - .map((row) => row.pgid) - ) -} - export function getPosixPtyProcessGroups( output: string, rootPid: number, diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index a108a1ae55f6..2616597ef9cc 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -81,7 +81,10 @@ describe('addOrcaWslInteropEnv', () => { ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu', + ORCA_RUNTIME_SOURCE_ID: '11111111-1111-4111-8111-111111111111', + ORCA_RUNTIME_SOURCE_INCARNATION: 'runtime-a', + ORCA_RUNTIME_SOURCE_PROFILE_PATH: 'C:\\Users\\a\\AppData\\Roaming\\orca' } addOrcaWslInteropEnv(env) @@ -109,6 +112,9 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u') + expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_ID/u') + expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_INCARNATION/u') + expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_PROFILE_PATH/u') }) it('overwrites caller host evidence with native runtime WSL authority', () => { diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 0044399009f4..80b42bf3da79 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -10,6 +10,11 @@ import { } from '../../shared/setup-agent-sequencing' import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' import { ORCA_IMAGE_PROTOCOL_ENV } from '../../shared/terminal-image-protocol' +import { + RUNTIME_SOURCE_ID_ENV, + RUNTIME_SOURCE_INCARNATION_ENV, + RUNTIME_SOURCE_PROFILE_PATH_ENV +} from '../../shared/runtime-source-env' const WSLENV_ENTRY_SEPARATOR = ':' @@ -95,6 +100,10 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u', 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u', 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u', + `${RUNTIME_SOURCE_ID_ENV}/u`, + `${RUNTIME_SOURCE_INCARNATION_ENV}/u`, + // Why /u: compared with the Windows CLI's own userData path, never read as a guest path. + `${RUNTIME_SOURCE_PROFILE_PATH_ENV}/u`, 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_AGENT_HOOK_ENV/u', diff --git a/src/main/rate-limits/antigravity-usage-command.ts b/src/main/rate-limits/antigravity-usage-command.ts index cd9f0eeededc..133f92fc593e 100644 --- a/src/main/rate-limits/antigravity-usage-command.ts +++ b/src/main/rate-limits/antigravity-usage-command.ts @@ -52,17 +52,3 @@ export const ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES = 512 * 1024 export function antigravityCommandName(): string { return TUI_AGENT_CONFIG.antigravity.detectCmd } - -/** - * Why the args are never appended to a configured launch command: a user's Antigravity launch - * command may carry its own flags, a wrapper script, or a shell pipeline, and appending `-p /usage` - * to that either runs the wrong program or feeds the slash command to the wrong argv slot. The quota - * read resolves the plain executable itself instead. - */ -export function isPlainAntigravityExecutable(command: string): boolean { - const trimmed = command.trim() - if (trimmed.length === 0) { - return false - } - return !/[\s"'|&;<>$`()]/.test(trimmed) -} diff --git a/src/main/rate-limits/time-zone-wall-clock.ts b/src/main/rate-limits/time-zone-wall-clock.ts deleted file mode 100644 index f29e6fd4f62f..000000000000 --- a/src/main/rate-limits/time-zone-wall-clock.ts +++ /dev/null @@ -1,92 +0,0 @@ -export type WallClockDateParts = { - year: number - monthIndex: number - day: number - hour: number - minute: number -} - -export function buildWallClockTimestamp( - parts: WallClockDateParts, - timeZone: string | null -): number | null { - if (!timeZone) { - const localDate = new Date(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute) - return isMatchingDateParts(localDate, parts) ? localDate.getTime() : null - } - - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone, - hourCycle: 'h23', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }) - const timestamp = buildTimeZoneTimestamp(parts, formatter) - if (timestamp === null) { - return null - } - const resolvedParts = getTimeZoneDateParts(timestamp, formatter) - return resolvedParts && areMatchingWallClockParts(resolvedParts, parts) ? timestamp : null -} - -function buildTimeZoneTimestamp( - parts: WallClockDateParts, - formatter: Intl.DateTimeFormat -): number | null { - const utcGuess = Date.UTC(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute) - const firstOffset = getTimeZoneOffsetMs(utcGuess, formatter) - if (firstOffset === null) { - return null - } - const firstTimestamp = utcGuess - firstOffset - const secondOffset = getTimeZoneOffsetMs(firstTimestamp, formatter) - return secondOffset === null ? null : utcGuess - secondOffset -} - -function getTimeZoneOffsetMs(timestamp: number, formatter: Intl.DateTimeFormat): number | null { - const parts = getTimeZoneDateParts(timestamp, formatter) - if (!parts) { - return null - } - return Date.UTC(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute) - timestamp -} - -function getTimeZoneDateParts( - timestamp: number, - formatter: Intl.DateTimeFormat -): WallClockDateParts | null { - const parts = Object.fromEntries( - formatter.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]) - ) - const year = Number(parts.year) - const monthIndex = Number(parts.month) - 1 - const day = Number(parts.day) - const hour = Number(parts.hour) - const minute = Number(parts.minute) - return [year, monthIndex, day, hour, minute].every(Number.isFinite) - ? { year, monthIndex, day, hour, minute } - : null -} - -function isMatchingDateParts(date: Date, parts: WallClockDateParts): boolean { - return ( - date.getFullYear() === parts.year && - date.getMonth() === parts.monthIndex && - date.getDate() === parts.day && - date.getHours() === parts.hour && - date.getMinutes() === parts.minute - ) -} - -function areMatchingWallClockParts(left: WallClockDateParts, right: WallClockDateParts): boolean { - return ( - left.year === right.year && - left.monthIndex === right.monthIndex && - left.day === right.day && - left.hour === right.hour && - left.minute === right.minute - ) -} diff --git a/src/main/runtime/agent-session-spawn-token-process-scan.ts b/src/main/runtime/agent-session-spawn-token-process-scan.ts index 55e860f93205..dac5fe8fcc6f 100644 --- a/src/main/runtime/agent-session-spawn-token-process-scan.ts +++ b/src/main/runtime/agent-session-spawn-token-process-scan.ts @@ -53,21 +53,6 @@ export async function scanAgentSessionSpawnTokenProcesses( return observed } -/** - * Diagnostic evidence only. A null result is deliberately typed as - * `unverifiable`, not as an empty process set; callers must never use this - * Linux read-back as ownership proof. - */ -export async function scanAgentSessionSpawnTokenEvidence( - platform: NodeJS.Platform = process.platform, - variable: string = CODEX_SPAWN_TOKEN_ENV -): Promise { - const processes = await scanAgentSessionSpawnTokenProcesses(platform, variable) - return processes === null - ? { status: 'unverifiable', processes: null, platform } - : { status: 'verified', processes } -} - /** Pids carrying one specific token, or null when the host could not enumerate. */ export async function findAgentSessionSpawnTokenProcesses( spawnToken: string, diff --git a/src/main/runtime/browser-tab-create-folder-workspace.test.ts b/src/main/runtime/browser-tab-create-folder-workspace.test.ts new file mode 100644 index 000000000000..23166d349767 --- /dev/null +++ b/src/main/runtime/browser-tab-create-folder-workspace.test.ts @@ -0,0 +1,59 @@ +/** + * A server-hosted browser tab for a folder workspace (#21353). Only the client-hosted branch used + * the folder-aware resolver, so `id:folder:…` failed with selector_not_found on the server path. + */ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' + +vi.mock('electron', () => ({ + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: vi.fn() } +})) + +vi.mock('../ipc/browser-tab-registration-wait', () => ({ + waitForTabRegistration: vi.fn(), + waitForWorktreeTabRegistration: vi.fn() +})) + +vi.mock('../browser/browser-session-registry', () => ({ + browserSessionRegistry: { + getDefaultProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }), + getProfile: () => ({ id: 'default', partition: 'persist:orca-browser' }), + resolveKnownPartition: () => 'persist:orca-browser' + } +})) + +const FOLDER = 'folder:folder-1' + +function createHost(overrides: Record): RuntimeBrowserCommandHost { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test host implements only the members browserTabCreate reads. + return { + resolveWorktreeSelector: vi.fn(async () => { + throw new Error('selector_not_found') + }), + resolveBrowserWorkspace: vi.fn(async () => ({ id: FOLDER })), + getAgentBrowserBridge: () => null, + getAvailableAuthoritativeWindow: vi.fn(() => null), + ...overrides + } as unknown as RuntimeBrowserCommandHost +} + +describe('browser.tabCreate on a folder workspace', () => { + it('creates a server-hosted page for the folder workspace', async () => { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const createTab = vi.fn(async () => ({ browserPageId: 'page-folder' })) + const host = createHost({ + getOffscreenBrowserBackend: vi.fn(() => ({ createTab })) + }) + + await expect( + new RuntimeBrowserCommands(host).browserTabCreate( + { worktree: `id:${FOLDER}`, url: 'about:blank' }, + { pairedDeviceId: 'device-a', clientKind: 'runtime' } + ) + ).resolves.toEqual({ browserPageId: 'page-folder' }) + + expect(host.resolveBrowserWorkspace).toHaveBeenCalledWith(`id:${FOLDER}`) + expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ worktreeId: FOLDER })) + }) +}) diff --git a/src/main/runtime/host-descriptor.test.ts b/src/main/runtime/host-descriptor.test.ts new file mode 100644 index 000000000000..b4f7e4ac42bf --- /dev/null +++ b/src/main/runtime/host-descriptor.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { RuntimeHostDescriptorSchema } from '../../shared/runtime-host-descriptor' +import { + HOST_INSTALLATION_FILENAME, + computeHostMachineBinding, + loadHostDescriptor, + getRuntimeSourceStamp, + loadOrCreateHostInstallationId, + publishHostDescriptor +} from './host-descriptor' + +function profile(): string { + return mkdtempSync(join(tmpdir(), 'orca-host-descriptor-')) +} + +function storedId(userDataPath: string): unknown { + return JSON.parse(readFileSync(join(userDataPath, HOST_INSTALLATION_FILENAME), 'utf8')) + .installationId +} + +describe('loadOrCreateHostInstallationId', () => { + it('creates the id once and returns the same id on every later load', async () => { + const userDataPath = profile() + const first = await loadOrCreateHostInstallationId(userDataPath) + expect(await loadOrCreateHostInstallationId(userDataPath)).toBe(first) + expect(storedId(userDataPath)).toBe(first) + }) + + it('concurrent creators all return the one id that was stored', async () => { + const userDataPath = profile() + const ids = await Promise.all( + Array.from({ length: 4 }, () => loadOrCreateHostInstallationId(userDataPath)) + ) + expect(new Set(ids).size).toBe(1) + expect(storedId(userDataPath)).toBe(ids[0]) + }) + + it('concurrent repairs of a malformed file never fork the id', async () => { + const userDataPath = profile() + writeFileSync(join(userDataPath, HOST_INSTALLATION_FILENAME), '{not json') + const ids = await Promise.all( + Array.from({ length: 4 }, () => loadOrCreateHostInstallationId(userDataPath)) + ) + expect(new Set(ids).size).toBe(1) + expect(RuntimeHostDescriptorSchema.shape.installationId.safeParse(ids[0]).success).toBe(true) + expect(storedId(userDataPath)).toBe(ids[0]) + }) +}) + +describe('loadHostDescriptor', () => { + it('publishes an id and a binding keyed by that id', async () => { + const userDataPath = profile() + const descriptor = await loadHostDescriptor(userDataPath, () => 'machine-a') + expect(RuntimeHostDescriptorSchema.safeParse(descriptor).success).toBe(true) + expect(descriptor?.machineBinding).toBe( + computeHostMachineBinding(descriptor!.installationId, 'machine-a', userDataPath) + ) + expect(await loadHostDescriptor(userDataPath, () => 'machine-a')).toEqual(descriptor) + }) + + it('changes only the binding when the profile moves to another machine', async () => { + const userDataPath = profile() + const here = await loadHostDescriptor(userDataPath, () => 'machine-a') + const moved = await loadHostDescriptor(userDataPath, () => 'machine-b') + expect(moved?.installationId).toBe(here?.installationId) + expect(moved?.machineBinding).not.toBe(here?.machineBinding) + }) + + it('binds to the profile path, so a copied profile on the same machine differs', () => { + const id = '0b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e' + expect(computeHostMachineBinding(id, 'machine-a', '/profiles/one')).not.toBe( + computeHostMachineBinding(id, 'machine-a', '/profiles/two') + ) + }) + + it('never uses the same binding for two installations on one machine', () => { + expect( + computeHostMachineBinding('0b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e', 'machine-a', '/p') + ).not.toBe(computeHostMachineBinding('1b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e', 'machine-a', '/p')) + }) + + it('omits the binding when no machine id is readable', async () => { + const descriptor = await loadHostDescriptor(profile(), () => null) + expect(descriptor).toEqual({ installationId: descriptor?.installationId }) + }) + + it('publishes nothing, and writes nothing, when the stored id cannot be read', async () => { + const userDataPath = profile() + const blocker = join(userDataPath, 'blocker') + writeFileSync(blocker, '') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A read that fails with anything but ENOENT (here ENOTDIR) must not mint a replacement id. + expect(await loadHostDescriptor(join(blocker, 'profile'), () => 'machine-a')).toBeNull() + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) +}) + +describe('getRuntimeSourceStamp', () => { + it('names the profile as the source and the runtime id as the incarnation', () => { + const installationId = '0b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e' + publishHostDescriptor('runtime-stamp-1', { installationId }) + try { + expect( + getRuntimeSourceStamp({ getRuntimeId: () => 'runtime-stamp-1' }, '/profiles/a') + ).toEqual({ + sourceId: installationId, + incarnation: 'runtime-stamp-1', + profilePath: '/profiles/a' + }) + } finally { + publishHostDescriptor('runtime-stamp-1', null) + } + }) + + it('stamps nothing before a descriptor is published or without a runtime', () => { + expect(getRuntimeSourceStamp({ getRuntimeId: () => 'runtime-unpublished' })).toBeNull() + expect(getRuntimeSourceStamp(undefined)).toBeNull() + }) +}) diff --git a/src/main/runtime/host-descriptor.ts b/src/main/runtime/host-descriptor.ts new file mode 100644 index 000000000000..73cf5e7284f8 --- /dev/null +++ b/src/main/runtime/host-descriptor.ts @@ -0,0 +1,138 @@ +import { createHmac, randomUUID } from 'node:crypto' +import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { + RuntimeHostDescriptorSchema, + type RuntimeHostDescriptor +} from '../../shared/runtime-host-descriptor' +import { profileStateAccessMachineIdentity } from '../persistence/profile-state/profile-state-access-identity' +import { withFileTransactionLock } from '../file-transaction-lock' +import type { RuntimeSourceStamp } from '../../shared/runtime-source-env' + +export const HOST_INSTALLATION_FILENAME = 'host-installation.json' +const MAX_INSTALLATION_FILE_BYTES = 4 * 1024 +const InstallationFileSchema = RuntimeHostDescriptorSchema.pick({ installationId: true }) + +type InstallationRead = + | { kind: 'valid'; installationId: string } + | { kind: 'missing' } + | { kind: 'malformed' } + +function errorCode(error: unknown): unknown { + return error instanceof Error && 'code' in error ? error.code : undefined +} + +function readInstallation(filePath: string): InstallationRead { + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return { kind: 'missing' } + } + // Why throw: regenerating over a file we merely cannot read would silently fork this host's identity. + throw error + } + if (raw.length > MAX_INSTALLATION_FILE_BYTES) { + return { kind: 'malformed' } + } + try { + const parsed = InstallationFileSchema.safeParse(JSON.parse(raw)) + return parsed.success + ? { kind: 'valid', installationId: parsed.data.installationId } + : { kind: 'malformed' } + } catch { + return { kind: 'malformed' } + } +} + +/** Reads this profile's installation id, creating it once; concurrent creators converge on one. */ +export async function loadOrCreateHostInstallationId(userDataPath: string): Promise { + const filePath = join(userDataPath, HOST_INSTALLATION_FILENAME) + const current = readInstallation(filePath) + if (current.kind === 'valid') { + return current.installationId + } + // Why the lock and a re-read: a creator or repairer decides only after any racing one has published. + return await withFileTransactionLock(filePath, async () => { + const locked = readInstallation(filePath) + if (locked.kind === 'valid') { + return locked.installationId + } + const installationId = randomUUID() + const draftPath = `${filePath}.${installationId}.draft` + try { + writeFileSync(draftPath, JSON.stringify({ installationId }), { flag: 'wx', mode: 0o600 }) + renameSync(draftPath, filePath) + } finally { + rmSync(draftPath, { force: true }) + } + return installationId + }) +} + +/** Keyed by the installation id so the binding never works as a cross-app machine identifier. */ +export function computeHostMachineBinding( + installationId: string, + machineIdentity: string, + userDataPath: string +): string { + return createHmac('sha256', installationId) + .update(`${machineIdentity}\0${resolve(userDataPath)}`) + .digest('base64url') +} + +/** Null when the profile cannot hold an installation id; the binding is dropped when no machine id is readable. */ +export async function loadHostDescriptor( + userDataPath: string, + readMachineIdentity: () => string | null = profileStateAccessMachineIdentity +): Promise { + let installationId: string + try { + installationId = await loadOrCreateHostInstallationId(userDataPath) + } catch (error) { + console.warn('[runtime] Host descriptor unavailable:', error) + return null + } + const machineIdentity = readMachineIdentity() + return machineIdentity + ? { + installationId, + machineBinding: computeHostMachineBinding(installationId, machineIdentity, userDataPath) + } + : { installationId } +} + +// Keyed by runtime id because the RPC server that owns the profile loads it, while status.get only sees the runtime. +const publishedByRuntimeId = new Map() + +export function publishHostDescriptor( + runtimeId: string, + descriptor: RuntimeHostDescriptor | null +): void { + if (descriptor) { + publishedByRuntimeId.set(runtimeId, descriptor) + } else { + publishedByRuntimeId.delete(runtimeId) + } +} + +export function getPublishedHostDescriptor(runtimeId: string): RuntimeHostDescriptor | null { + return publishedByRuntimeId.get(runtimeId) ?? null +} + +/** Null until this runtime publishes a descriptor; unstamped processes keep the unfenced CLI path. */ +export function getRuntimeSourceStamp( + runtime: { getRuntimeId?: () => string } | null | undefined, + userDataPath?: string +): RuntimeSourceStamp | null { + const runtimeId = runtime?.getRuntimeId?.() + const descriptor = runtimeId ? getPublishedHostDescriptor(runtimeId) : null + return runtimeId && descriptor + ? { + sourceId: descriptor.installationId, + incarnation: runtimeId, + ...(userDataPath ? { profilePath: userDataPath } : {}) + } + : null +} diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index 7bd3461d01aa..289e7ccb0d60 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -1,7 +1,7 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { parsePairingCode } from '../../shared/pairing' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types' @@ -87,9 +87,12 @@ describe('paired runtime navigation isolation', () => { seedSessionTabs(runtime) } + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-navigation-isolation-')) + onTestFinished(() => rmSync(userDataPath, { recursive: true, force: true })) + const server = new OrcaRuntimeRpcServer({ runtime, - userDataPath: mkdtempSync(join(tmpdir(), 'orca-navigation-isolation-')), + userDataPath, enableWebSocket: true, wsPort: 0 }) diff --git a/src/main/runtime/native-terminal-availability.ts b/src/main/runtime/native-terminal-availability.ts index 85eb3df57f07..656cf38b3471 100644 --- a/src/main/runtime/native-terminal-availability.ts +++ b/src/main/runtime/native-terminal-availability.ts @@ -31,10 +31,6 @@ export function setRuntimeTerminalUnavailableCause( unavailableCause = cause } -export function runtimeTerminalUnavailableCause(): RuntimeTerminalUnavailableCause | null { - return unavailableCause -} - /** The degradation entry for the recorded cause, or null when nothing is degraded. */ export function runtimeTerminalDegradation(): RuntimeDegradation | null { const cause = unavailableCause diff --git a/src/main/runtime/orca-runtime-list-managed-worktrees.ts b/src/main/runtime/orca-runtime-list-managed-worktrees.ts index 2b125def527d..24e651942b2d 100644 --- a/src/main/runtime/orca-runtime-list-managed-worktrees.ts +++ b/src/main/runtime/orca-runtime-list-managed-worktrees.ts @@ -11,6 +11,8 @@ import type { RuntimeStore } from './runtime-store-contract' import { getWorkspaceAttachments } from '../../shared/workspace-attachments' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import type { + WorkspacePortHostKillRequest, + WorkspacePortHostScanResult, WorkspacePortKillRequest, WorkspacePortKillResult, WorkspacePortProbe, @@ -21,6 +23,14 @@ import { killWorkspacePort, scanWorkspacePortProbes } from '../ports/workspace-port-ownership' +import { + killWorkspacePortOnExecutionHost, + scanWorkspacePortsOnExecutionHost, + type WorkspacePortExecutionHostDeps +} from '../ports/workspace-port-execution-host' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' +import { resolveWorktreeHostRouting } from './worktree-launch-host-repo' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' export class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce { listManagedWorktrees( @@ -153,22 +163,45 @@ export class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreS return killWorkspacePort(await this.getWorkspacePortProbes(args.repoId), args) } + // Why the workspace, not a client-named host: this server resolves where the workspace runs. + async scanWorkspacePortsOnHost(worktreeSelector: string): Promise { + const { executionHostId } = await this.resolveRuntimeFileTarget(worktreeSelector) + return scanWorkspacePortsOnExecutionHost(executionHostId, this.getWorkspacePortHostDeps()) + } + + async killWorkspacePortOnHost( + args: WorkspacePortHostKillRequest + ): Promise { + const { executionHostId } = await this.resolveRuntimeFileTarget(args.worktree) + return killWorkspacePortOnExecutionHost(executionHostId, args, this.getWorkspacePortHostDeps()) + } + + protected getWorkspacePortHostDeps(): WorkspacePortExecutionHostDeps { + return { + getLocalProbes: () => this.getWorkspacePortProbes(), + getSshMultiplexer: getActiveMultiplexer + } + } + // Why: remote clients may invoke this over RPC, so the runtime derives // allowed worktree paths from its own store instead of trusting client paths. protected async getWorkspacePortProbes(repoId?: string): Promise { - const reposById = new Map( - this.requireStore() - .getRepos() - .map((repo) => [repo.id, repo]) - ) + const repos = this.requireStore().getRepos() return filterWorkspacePortProbes( - (await this.listResolvedWorktrees()).map((worktree) => ({ - id: worktree.id, - repoId: worktree.repoId, - displayName: worktree.displayName, - path: worktree.git.path, - connectionId: reposById.get(worktree.repoId)?.connectionId ?? null - })), + (await this.listResolvedWorktrees()).map((worktree) => { + // Why host routing, not the repo-id row's connectionId: an SSH repo may carry only + // `executionHostId`, and its remote path must never authorize a local listener. + const routing = resolveWorktreeHostRouting(repos, worktree) + return { + id: worktree.id, + repoId: worktree.repoId, + displayName: worktree.displayName, + path: worktree.git.path, + runsHere: + routing.kind === 'unowned' || + (routing.kind === 'resolved' && routing.hostId === LOCAL_EXECUTION_HOST_ID) + } + }), repoId ) } diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index be727819a06d..18604c368b74 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -45,6 +45,7 @@ import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-c import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown' +import { countRepoTerminalsNotKnownExited } from './project-removal-impact' export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTerminalDrivers { protected readonly preservedBranchCleanup = new RuntimePreservedBranchCleanup(() => @@ -265,7 +266,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin (this as RuntimeCommandSurfaceHost).cloneRepo(url, destination, hostId), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId), - notifyReposChanged: () => this.notifyReposChanged() + notifyReposChanged: () => this.notifyReposChanged(), + countLiveTerminalsForRepo: (repo) => + countRepoTerminalsNotKnownExited(repo, this.ptysById.values(), (ptyId) => + this.isPtyKnownExited(ptyId) + ) }) protected readonly projectGroups = new RuntimeProjectGroupController({ diff --git a/src/main/runtime/orca-runtime-workspace-ports-host.test.ts b/src/main/runtime/orca-runtime-workspace-ports-host.test.ts new file mode 100644 index 000000000000..ff084e306ef0 --- /dev/null +++ b/src/main/runtime/orca-runtime-workspace-ports-host.test.ts @@ -0,0 +1,102 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeWithListManagedWorktrees } from './orca-runtime-list-managed-worktrees' + +const muxes = vi.hoisted(() => new Map }>()) +const scanWorkspacePortsMock = vi.hoisted(() => vi.fn()) + +vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + getActiveMultiplexer: (targetId: string) => muxes.get(targetId) +})) +vi.mock('../ports/local-workspace-port-scanner', () => ({ + scanWorkspacePorts: scanWorkspacePortsMock +})) + +const proto = OrcaRuntimeWithListManagedWorktrees.prototype + +function runtimeResolvingTo(executionHostId: string) { + const host = { + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: {}, executionHostId })), + getWorkspacePortProbes: vi.fn(async () => []), + getWorkspacePortHostDeps: proto['getWorkspacePortHostDeps'] + } + return { + host, + scan: (selector: string) => proto.scanWorkspacePortsOnHost.call(host, selector), + kill: (args: Parameters[0]) => + proto.killWorkspacePortOnHost.call(host, args) + } +} + +describe('host-scoped workspace port scans on the server', () => { + afterEach(() => { + muxes.clear() + scanWorkspacePortsMock.mockReset() + vi.restoreAllMocks() + }) + + it("scans a folder workspace's SSH host, not this server", async () => { + const request = vi.fn(async () => ({ platform: 'linux', ports: [{ port: 3020, host: '::' }] })) + muxes.set('box', { request }) + const runtime = runtimeResolvingTo('ssh:box') + + const result = await runtime.scan('folder:fw-1') + + expect(runtime.host.resolveRuntimeFileTarget).toHaveBeenCalledWith('folder:fw-1') + expect(request).toHaveBeenCalledWith('ports.detect', undefined, expect.any(Object)) + expect(scanWorkspacePortsMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ executionHostId: 'ssh:box', ports: [{ port: 3020 }] }) + }) + + it('answers unavailable for an SSH workspace whose host is not connected', async () => { + const result = await runtimeResolvingTo('ssh:box').scan('id:repo::/srv/app') + + expect(result.unavailableReason).toMatch(/not connected/) + expect(scanWorkspacePortsMock).not.toHaveBeenCalled() + }) + + it('refuses Stop when the workspace now resolves to a different host than the row', async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + const runtime = runtimeResolvingTo('local') + + const result = await runtime.kill({ + worktree: 'id:repo::/srv/app', + executionHostId: 'ssh:box', + pid: 4242, + port: 3020 + }) + + expect(result).toMatchObject({ ok: false }) + expect(kill).not.toHaveBeenCalled() + }) + + it('authorizes Stop only with workspaces this host runs itself', async () => { + const repos = [ + { id: 'local-repo', path: '/srv/app' }, + // An SSH repo may carry only its execution host, with no legacy connectionId. + { id: 'ssh-repo', path: '/srv/private', executionHostId: 'ssh:box' }, + { id: 'legacy-ssh-repo', path: '/srv/legacy', connectionId: 'box' }, + { id: 'runtime-repo', path: '/srv/env', executionHostId: 'runtime:env-2' } + ] + const worktree = (repoId: string, path: string) => ({ + id: `${repoId}::${path}`, + repoId, + displayName: '', + git: { path } + }) + const host = { + requireStore: () => ({ getRepos: () => repos }), + listResolvedWorktrees: async () => [ + worktree('local-repo', '/srv/app'), + worktree('ssh-repo', '/srv/private'), + worktree('legacy-ssh-repo', '/srv/legacy'), + worktree('runtime-repo', '/srv/env') + ] + } + + const probes = await proto['getWorkspacePortProbes'].call(host) + + expect(probes.map((probe) => probe.path)).toEqual(['/srv/app']) + }) +}) diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index 96f41571c427..d860128fa4a7 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -43,8 +43,6 @@ const CHAT_ADDRESS = `orca_session_id:${CHAT_SESSION}` const TERMINAL_HANDLE = 'term_worker' // A structured worker's mailbox key: the handle it was minted, which its preamble never shows. const CHAT_WORKER_HANDLE = 'structworker_1' -// `skill-guides/orchestration.md` on main, plus the one Orca session ID line. -const MAIN_KERNEL_LINES = 198 + 1 const db = new OrchestrationDb(':memory:') const SELF_LINE = `\nYour Orca session ID is: ${CHAT_ADDRESS}` @@ -195,18 +193,6 @@ describe('a chat agent and a terminal agent see the same text but for how each i ) }) -describe('the orchestration guide an agent loads', () => { - const kernel = readFileSync(join(process.cwd(), 'skill-guides', 'orchestration.md'), 'utf8') - - it('has no chat-only section and grows only by the Orca session ID line', () => { - expect(kernel.split('\n').length - 1).toBeLessThanOrEqual(MAIN_KERNEL_LINES) - expect(kernel).not.toMatch(/chat|session:|ORCA_CLI_COMMAND|\/clear|end your turn/i) - expect(kernel).toContain( - '`ORCA status --json` shows your Orca session ID as `caller.orcaSessionId` when you have one.' - ) - }) -}) - describe('agent-read text about an Orca session ID', () => { const guideDir = join(process.cwd(), 'skill-guides') const guide = [ diff --git a/src/main/runtime/project-host-setup-delete-guard.test.ts b/src/main/runtime/project-host-setup-delete-guard.test.ts new file mode 100644 index 000000000000..37c2e5d7368d --- /dev/null +++ b/src/main/runtime/project-host-setup-delete-guard.test.ts @@ -0,0 +1,150 @@ +// `orca project setup-delete` used to drop a repo-backed project even while its terminals were +// running and its workspaces had saved details, orphaning both with no warning. +import { describe, expect, it, vi } from 'vitest' +import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { Repo } from '../../shared/repo-types' +import { + countRepoTerminalsNotKnownExited, + countRepoWorkspacesWithMetadata +} from './project-removal-impact' + +const repo: Repo = { + id: 'repo-1', + path: '/work/app', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' +} + +function makeController(options: { + liveTerminals: number + metaIds: string[] + canonicalMetaIdsForHost?: string[] + targetRepo?: Repo +}) { + const targetRepo = options.targetRepo ?? repo + const { projects, setups: projectHostSetups } = projectHostSetupProjectionFromRepos([targetRepo]) + const deleteProjectHostSetup = vi.fn(() => ({ + project: projects[0], + setup: projectHostSetups[0], + repo: targetRepo + })) + const store = { + getProjects: () => projects, + getProjectHostSetups: () => projectHostSetups, + getAllWorktreeMeta: () => + Object.fromEntries(options.metaIds.map((id) => [id, { hostId: undefined }])), + getAllWorktreeMetaForHost: (hostId: string) => + options.canonicalMetaIdsForHost + ? Object.fromEntries(options.canonicalMetaIdsForHost.map((id) => [id, { hostId }])) + : undefined, + deleteProjectHostSetup + } + const controller = new RuntimeProjectHostSetupController({ + getStore: () => store as never, + listRepos: () => [targetRepo], + addRepo: vi.fn(), + addRemoteRepo: vi.fn(), + cloneRepo: vi.fn(), + invalidateResolvedWorktrees: vi.fn(), + invalidateWorktreeScan: vi.fn(), + notifyReposChanged: vi.fn(), + countLiveTerminalsForRepo: () => options.liveTerminals + }) + return { controller, deleteProjectHostSetup, setupId: projectHostSetups[0].id } +} + +describe('project setup delete guard', () => { + it('refuses a CLI delete while terminals are live, and deletes with --force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 2, + metaIds: [] + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /2 terminals still open.*--force/ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + + controller.deleteSetup({ setupId, force: true }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) + + it('refuses while workspaces have saved details', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['repo-1::/work/app', 'other-repo::/work/other'] + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /saved details for 1 workspace\./ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + }) + + it('sees sibling-host metadata kept only in the host-qualified maps', () => { + // Same repo id and path on local and SSH: the raw-id map holds only the local row. + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['repo-1::/work/app'], + canonicalMetaIdsForHost: ['repo-1::/work/app'], + targetRepo: { ...repo, connectionId: 'box' } + }) + + expect(() => controller.deleteSetup({ setupId, force: false })).toThrow( + /saved details for 1 workspace\./ + ) + expect(deleteProjectHostSetup).not.toHaveBeenCalled() + }) + + it('deletes an unused project without --force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 0, + metaIds: ['other-repo::/work/other'] + }) + + controller.deleteSetup({ setupId, force: false }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) + + it('keeps the unchecked delete for older clients that omit force', () => { + const { controller, deleteProjectHostSetup, setupId } = makeController({ + liveTerminals: 3, + metaIds: ['repo-1::/work/app'] + }) + + controller.deleteSetup({ setupId }) + expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) + }) +}) + +describe('project removal impact', () => { + it('counts only this repo and host, and treats unverifiable terminals as live', () => { + const ptys = [ + { ptyId: 'local-1', worktreeId: 'repo-1::/work/app', connectionId: null }, + { ptyId: 'local-exited', worktreeId: 'repo-1::/work/app', connectionId: null }, + { ptyId: 'ssh-1', worktreeId: 'repo-1::/work/app', connectionId: 'box' }, + { ptyId: 'other-repo', worktreeId: 'repo-10::/work/app', connectionId: null } + ] + const isKnownExited = (ptyId: string) => ptyId === 'local-exited' + + expect(countRepoTerminalsNotKnownExited(repo, ptys, isKnownExited)).toBe(1) + expect( + countRepoTerminalsNotKnownExited({ ...repo, connectionId: 'box' }, ptys, isKnownExited) + ).toBe(1) + }) + + it('counts metadata on the repo host only, reading unstamped rows as local', () => { + const meta = { + 'repo-1::/work/app': {}, + 'repo-1::/work/feature': { hostId: 'local' as const }, + 'repo-1::/srv/app': { hostId: 'ssh:box' as const }, + 'repo-10::/work/app': {} + } + + expect(countRepoWorkspacesWithMetadata(repo, meta)).toBe(2) + expect(countRepoWorkspacesWithMetadata({ ...repo, connectionId: 'box' }, meta)).toBe(1) + }) +}) diff --git a/src/main/runtime/project-removal-impact.ts b/src/main/runtime/project-removal-impact.ts new file mode 100644 index 000000000000..04708e0c4904 --- /dev/null +++ b/src/main/runtime/project-removal-impact.ts @@ -0,0 +1,73 @@ +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId +} from '../../shared/execution-host' +import { splitWorktreeId } from '../../shared/worktree/id' +import { worktreePtyBelongsToHost, type WorktreePtyHostFence } from './worktree-pty-host-fence' + +export type ProjectRemovalImpact = { + liveTerminals: number + workspacesWithMetadata: number +} + +type RepoIdentity = Pick + +function repoPtyHostFence(repo: RepoIdentity): WorktreePtyHostFence { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + return host?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: host.environmentId } + : { resolvedConnectionId: host?.kind === 'ssh' ? host.targetId : null } +} + +/** Terminals not known to have exited count: a lost-contact PTY may still be running. */ +export function countRepoTerminalsNotKnownExited( + repo: RepoIdentity, + ptys: Iterable<{ ptyId: string; worktreeId: string; connectionId: string | null }>, + isKnownExited: (ptyId: string) => boolean +): number { + const fence = repoPtyHostFence(repo) + let count = 0 + for (const pty of ptys) { + if ( + splitWorktreeId(pty.worktreeId)?.repoId === repo.id && + worktreePtyBelongsToHost(pty.ptyId, pty.connectionId, fence) && + !isKnownExited(pty.ptyId) + ) { + count += 1 + } + } + return count +} + +/** Same host rule as the repo prune: unstamped metadata predates host stamping and is local. */ +export function countRepoWorkspacesWithMetadata( + repo: RepoIdentity, + metaById: Record> +): number { + const hostId = getRepoExecutionHostId(repo) + const prefix = `${repo.id}::` + return Object.entries(metaById).filter( + ([id, meta]) => id.startsWith(prefix) && (meta.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId + ).length +} + +export function describeProjectRemovalRefusal(impact: ProjectRemovalImpact): string | null { + const parts: string[] = [] + if (impact.liveTerminals > 0) { + parts.push( + `${impact.liveTerminals} terminal${impact.liveTerminals === 1 ? '' : 's'} still open` + ) + } + if (impact.workspacesWithMetadata > 0) { + parts.push( + `saved details for ${impact.workspacesWithMetadata} workspace${impact.workspacesWithMetadata === 1 ? '' : 's'}` + ) + } + if (parts.length === 0) { + return null + } + return `This project has ${parts.join(' and ')}. Removing it detaches those terminals from Orca and deletes the saved workspace details. Re-run with --force to remove it anyway.` +} diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 88b6b5220a7f..85ab2c94f5eb 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -53,6 +53,8 @@ export type RpcRequest = { orchestrationRequestId?: string compatibilityInvocationId?: string orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence + // Why unknown: untrusted wire input; runtime-source-admission.ts validates it. + expectedRuntimeSource?: unknown } export type LegacyCoordinatorAuthorityProof = Readonly<{ diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index cad7cded626b..ddaebed0d7a0 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -1,5 +1,6 @@ import { defineMethod } from '../core' import { getRemoteServerUpdaterSnapshot } from '../../remote-server-updater' +import { getPublishedHostDescriptor } from '../../host-descriptor' export const STATUS_METHODS = [ defineMethod({ @@ -11,8 +12,10 @@ export const STATUS_METHODS = [ // hostname; the wait is capped below the CLI's status probe so a slow lookup never reads as down. await runtime.machineNameReady() const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) + const hostDescriptor = getPublishedHostDescriptor(runtime.getRuntimeId()) return { ...runtime.getStatus(), + ...(hostDescriptor ? { hostDescriptor } : {}), ...(pairedDeviceId ? { pairedDeviceId } : {}), appVersion: snapshot.appVersion, remoteUpdateSupport: snapshot.support diff --git a/src/main/runtime/rpc/methods/workspace-ports.test.ts b/src/main/runtime/rpc/methods/workspace-ports.test.ts index 75ce15053c2e..e5f0be51937c 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.test.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.test.ts @@ -6,6 +6,13 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { WORKSPACE_PORT_METHODS } from './workspace-ports' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' +function hostScopedRuntime( + methods: Partial> +): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispatch reaches only the runtime id and the one host-scoped method each test stubs. + return { getRuntimeId: () => 'test-runtime', ...methods } as unknown as OrcaRuntimeService +} + function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } @@ -53,4 +60,60 @@ describe('workspace port RPC methods', () => { }) expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + + it('scans the host a workspace resolves to and returns that host with the rows', async () => { + const scan = { + executionHostId: 'ssh:box', + platform: 'linux', + scannedAt: 1, + ports: [] + } + const runtime = hostScopedRuntime({ + scanWorkspacePortsOnHost: vi.fn().mockResolvedValue(scan) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('workspacePorts.scanHost', { worktree: 'id:repo-1::/srv/app' }) + ) + + expect(runtime.scanWorkspacePortsOnHost).toHaveBeenCalledWith('id:repo-1::/srv/app') + expect(response).toMatchObject({ ok: true, result: scan }) + }) + + it('binds a host-scoped Stop to the workspace and the host its row was scanned on', async () => { + const runtime = hostScopedRuntime({ + killWorkspacePortOnHost: vi.fn().mockResolvedValue({ ok: true }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('workspacePorts.killHost', { + worktree: 'id:repo-1::/srv/app', + executionHostId: 'local', + pid: 1234, + port: 5173 + }) + ) + + expect(runtime.killWorkspacePortOnHost).toHaveBeenCalledWith({ + worktree: 'id:repo-1::/srv/app', + executionHostId: 'local', + pid: 1234, + port: 5173 + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('refuses a host-scoped Stop that names no host', async () => { + const runtime = hostScopedRuntime({ killWorkspacePortOnHost: vi.fn() }) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('workspacePorts.killHost', { worktree: 'id:w', pid: 1, port: 2 }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.killWorkspacePortOnHost).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts index dbc0b4ef9f1e..b8813593fbed 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.ts @@ -1,6 +1,8 @@ import { defineMethod } from '../core' import { + WorkspacePortKillHostParams, WorkspacePortKillParams, + WorkspacePortScanHostParams, WorkspacePortScanParams } from '../../../../shared/rpc-contract/workspace-ports-params' @@ -21,5 +23,23 @@ export const WORKSPACE_PORT_METHODS = [ pid: params.pid, port: params.port }) + }), + defineMethod({ + name: 'workspacePorts.scanHost', + permission: 'workspace', + params: WorkspacePortScanHostParams, + handler: async (params, { runtime }) => runtime.scanWorkspacePortsOnHost(params.worktree) + }), + defineMethod({ + name: 'workspacePorts.killHost', + permission: 'workspace', + params: WorkspacePortKillHostParams, + handler: async (params, { runtime }) => + runtime.killWorkspacePortOnHost({ + worktree: params.worktree, + executionHostId: params.executionHostId, + pid: params.pid, + port: params.port + }) }) ] diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts b/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts deleted file mode 100644 index 3834a417ddb2..000000000000 --- a/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { RuntimeCapability } from '../../../shared/protocol-version' -import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' -import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' - -export function parseMobileE2EEV2ClientCapabilities( - plaintext: string -): readonly RuntimeCapability[] | null { - try { - const message = parseRemoteRuntimeJsonText(plaintext) as Record - if ( - Object.keys(message).sort().join(',') !== 'clientCapabilities,type,v' || - message.type !== 'e2ee_client_capabilities' || - message.v !== 1 - ) { - return null - } - return parseRuntimeClientCapabilities(message.clientCapabilities) - } catch { - return null - } -} diff --git a/src/main/runtime/rpc/rpc-request-admission-prologue.ts b/src/main/runtime/rpc/rpc-request-admission-prologue.ts index 90369e56b5aa..9d2f7b562c5a 100644 --- a/src/main/runtime/rpc/rpc-request-admission-prologue.ts +++ b/src/main/runtime/rpc/rpc-request-admission-prologue.ts @@ -9,6 +9,7 @@ import type { import { errorResponse } from './errors' import type { OrcaRuntimeService } from '../orca-runtime' import { orchestrationMigrationFence } from './orchestration-contract-fence' +import { runtimeSourceFence } from './runtime-source-admission' import type { RpcDispatchStreamingOptions } from './dispatcher-stream-options' import { mapDispatcherError } from './dispatcher-error-response' import { parseRpcRequestParams } from './dispatcher-request-parsing' @@ -51,6 +52,10 @@ export function admitRpcRequest( errorResponse(rawRequest.id, meta, 'method_not_found', `Unknown method: ${rawRequest.method}`) ) } + const sourceFence = runtimeSourceFence(rawRequest, meta) + if (sourceFence) { + return reject(sourceFence) + } const migrationFence = orchestrationMigrationFence(rawRequest, meta) if (migrationFence) { return reject(migrationFence) diff --git a/src/main/runtime/rpc/runtime-source-admission.test.ts b/src/main/runtime/rpc/runtime-source-admission.test.ts new file mode 100644 index 000000000000..1cfaca8ffdfd --- /dev/null +++ b/src/main/runtime/rpc/runtime-source-admission.test.ts @@ -0,0 +1,123 @@ +import './unused-default-rpc-methods.test-fixture' +import { z } from 'zod' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import { publishHostDescriptor } from '../host-descriptor' +import { defineMethod, type RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' + +const SOURCE_A = '11111111-1111-4111-8111-111111111111' +const SOURCE_B = '22222222-2222-4222-8222-222222222222' + +describe('runtime source admission', () => { + const published: string[] = [] + + afterEach(() => { + for (const runtimeId of published.splice(0)) { + publishHostDescriptor(runtimeId, null) + } + }) + + function createHarness(installationId: string | null = SOURCE_A) { + const runtime = new OrcaRuntimeService() + const runtimeId = runtime.getRuntimeId() + if (installationId) { + publishHostDescriptor(runtimeId, { installationId }) + published.push(runtimeId) + } + const effect = vi.fn(() => ({ sent: true })) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [ + defineMethod({ + name: 'terminal.send', + permission: 'workspace', + params: z.object({ text: z.string() }), + handler: effect + }) + ] + }) + const request = (expectedRuntimeSource?: unknown): RpcRequest => ({ + id: 'rpc_1', + authToken: 'token', + method: 'terminal.send', + params: { text: 'hi' }, + ...(expectedRuntimeSource === undefined ? {} : { expectedRuntimeSource }) + }) + return { runtimeId, dispatcher, effect, request } + } + + it('refuses a request meant for another profile before the handler runs', async () => { + const { runtimeId, dispatcher, effect, request } = createHarness() + + const response = await dispatcher.dispatch(request({ sourceId: SOURCE_B, runtimeId })) + + expect(response).toMatchObject({ ok: false, error: { code: 'runtime_source_mismatch' } }) + expect(effect).not.toHaveBeenCalled() + }) + + it('refuses a request verified against another runtime of the same profile', async () => { + const { dispatcher, effect, request } = createHarness() + + const response = await dispatcher.dispatch( + request({ sourceId: SOURCE_A, runtimeId: 'runtime-other' }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'runtime_source_mismatch' } }) + expect(effect).not.toHaveBeenCalled() + }) + + it('runs a request meant for this runtime', async () => { + const { runtimeId, dispatcher, effect, request } = createHarness() + + const response = await dispatcher.dispatch(request({ sourceId: SOURCE_A, runtimeId })) + + expect(response).toMatchObject({ ok: true, result: { sent: true } }) + expect(effect).toHaveBeenCalledOnce() + }) + + it('keeps today’s behaviour for a client that sends no expectation (old CLI)', async () => { + const { dispatcher, effect, request } = createHarness(null) + + const response = await dispatcher.dispatch(request()) + + expect(response).toMatchObject({ ok: true }) + expect(effect).toHaveBeenCalledOnce() + }) + + it('refuses when this runtime cannot say which profile it serves', async () => { + const { runtimeId, dispatcher, effect, request } = createHarness(null) + + const response = await dispatcher.dispatch(request({ sourceId: SOURCE_A, runtimeId })) + + expect(response).toMatchObject({ ok: false, error: { code: 'runtime_source_unverifiable' } }) + expect(effect).not.toHaveBeenCalled() + }) + + it.each([null, 'source', { sourceId: SOURCE_A }, { sourceId: '', runtimeId: 'r' }])( + 'refuses a malformed expectation %j', + async (expectation) => { + const { dispatcher, effect, request } = createHarness() + + const response = await dispatcher.dispatch(request(expectation)) + + expect(response).toMatchObject({ ok: false, error: { code: 'bad_request' } }) + expect(effect).not.toHaveBeenCalled() + } + ) + + it('fences the streaming transport too', async () => { + const { runtimeId, dispatcher, effect, request } = createHarness() + const replies: string[] = [] + + await dispatcher.dispatchStreaming(request({ sourceId: SOURCE_B, runtimeId }), (reply) => + replies.push(reply) + ) + + expect(JSON.parse(replies[0])).toMatchObject({ + ok: false, + error: { code: 'runtime_source_mismatch' } + }) + expect(effect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/runtime-source-admission.ts b/src/main/runtime/rpc/runtime-source-admission.ts new file mode 100644 index 000000000000..f9c417ca1124 --- /dev/null +++ b/src/main/runtime/rpc/runtime-source-admission.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' +import type { ExpectedRuntimeSource } from '../../../shared/runtime-rpc-envelope' +import { getPublishedHostDescriptor } from '../host-descriptor' +import type { RpcEnvelopeMeta, RpcRequest, RpcResponse } from './core' +import { errorResponse } from './errors' + +const SourceField = z.string().min(1).max(4_096) +const ExpectedRuntimeSourceSchema: z.ZodType = z.object({ + sourceId: SourceField, + runtimeId: SourceField +}) + +/** Refuses, before any handler runs, a request whose client expects another runtime. */ +export function runtimeSourceFence(request: RpcRequest, meta: RpcEnvelopeMeta): RpcResponse | null { + if (request.expectedRuntimeSource === undefined) { + return null + } + const parsed = ExpectedRuntimeSourceSchema.safeParse(request.expectedRuntimeSource) + if (!parsed.success) { + return errorResponse( + request.id, + meta, + 'bad_request', + 'Malformed expectedRuntimeSource. Nothing was run.' + ) + } + const sourceId = getPublishedHostDescriptor(meta.runtimeId)?.installationId + if (!sourceId) { + return errorResponse( + request.id, + meta, + 'runtime_source_unverifiable', + 'This Orca does not know which profile it serves, so it cannot confirm it is the Orca that launched this terminal. Nothing was run.' + ) + } + if (sourceId !== parsed.data.sourceId || meta.runtimeId !== parsed.data.runtimeId) { + return errorResponse( + request.id, + meta, + 'runtime_source_mismatch', + 'This request was meant for another Orca (another profile or instance). Nothing was run. Open a new terminal from the Orca you want to control.' + ) + } + return null +} diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 52013ce0ff58..d5d73d980a98 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -1,9 +1,9 @@ import { EventEmitter } from 'node:events' -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { connect } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, afterEach, vi } from 'vitest' +import { describe, expect, it, afterEach, onTestFinished, vi } from 'vitest' import WebSocket from 'ws' import { WebSocketTransport } from './ws-transport' import { rejectNodeWebSocketOverCapacity } from './node-websocket-lifecycle' @@ -14,6 +14,7 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' function makeTls() { const userDataPath = mkdtempSync(join(tmpdir(), 'ws-transport-test-')) + onTestFinished(() => rmSync(userDataPath, { recursive: true, force: true })) return loadOrCreateTlsCertificate(userDataPath) } diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts b/src/main/runtime/runtime-browser-commands-browser-tab-create.ts index 67c39c7f5b73..cab26fb987c9 100644 --- a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts +++ b/src/main/runtime/runtime-browser-commands-browser-tab-create.ts @@ -39,10 +39,9 @@ export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCo navigation: params.navigation, clientKind: caller?.clientKind }) + // Why: every placement must accept folder workspaces, not only client-hosted pages (#21353). const worktree = params.worktree - ? params.placement?.kind === 'client' - ? await this.host.resolveBrowserWorkspace(params.worktree) - : await this.host.resolveWorktreeSelector(params.worktree) + ? await this.host.resolveBrowserWorkspace(params.worktree) : undefined const worktreeId = worktree?.id const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId) diff --git a/src/main/runtime/runtime-file-command-host.ts b/src/main/runtime/runtime-file-command-host.ts index 0f9d684e3a19..f86bed0247c7 100644 --- a/src/main/runtime/runtime-file-command-host.ts +++ b/src/main/runtime/runtime-file-command-host.ts @@ -173,17 +173,3 @@ export function isMobileBinaryPath(relativePath: string): boolean { } return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) } - -export function isRuntimeDirectoryEntry(entry: { - isDirectory(): boolean - isSymbolicLink(): boolean -}): boolean { - // Why: listings are passive UI reads; don't stat symlink targets here (explicit open/expand resolves them). - if (entry.isSymbolicLink()) { - return false - } - if (entry.isDirectory()) { - return true - } - return false -} diff --git a/src/main/runtime/runtime-nested-repo-import.ts b/src/main/runtime/runtime-nested-repo-import.ts index ba2df920b519..81ab6d99f001 100644 --- a/src/main/runtime/runtime-nested-repo-import.ts +++ b/src/main/runtime/runtime-nested-repo-import.ts @@ -9,7 +9,7 @@ import type { } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import { awaitWindowsHostGitEnvironmentReady } from '../git/runner' -import { getRepoName, isGitRepo } from '../git/repo' +import { getRepoName, isGitRepoAsync } from '../git/repo' import { scanNestedRepos } from '../project-groups/nested-repo-discovery' import { createNestedProjectGroupResolver, @@ -77,7 +77,7 @@ export class RuntimeNestedRepoImport { for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) - if (!isGitRepo(repoPath)) { + if (!(await isGitRepoAsync(repoPath))) { results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) continue } 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 bb3d928d4061..6defe84800bb 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -43,7 +43,8 @@ function makeController(): { cloneRepo, invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), - notifyReposChanged: vi.fn() + notifyReposChanged: vi.fn(), + countLiveTerminalsForRepo: () => 0 }) return { controller, diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index bfcbb4e5fe19..c3661f06acd2 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -24,6 +24,11 @@ import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-look import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' +import { readAllWorktreeMetaForRepo } from '../persistence/host-qualified-worktree-meta' +import { + countRepoWorkspacesWithMetadata, + describeProjectRemovalRefusal +} from './project-removal-impact' type RuntimeProjectHostSetupDependencies = { getStore: () => RuntimeStore | null @@ -40,6 +45,7 @@ type RuntimeProjectHostSetupDependencies = { invalidateResolvedWorktrees: () => void invalidateWorktreeScan: (repoId: string) => void notifyReposChanged: () => void + countLiveTerminalsForRepo: (repo: Repo) => number } // Why clone alone still refuses: nothing in this process clones onto an SSH host. `cloneRepo` runs @@ -147,6 +153,9 @@ export class RuntimeProjectHostSetupController { if (!store?.deleteProjectHostSetup) { throw new Error('runtime_unavailable') } + if (args.force === false) { + this.assertSetupRemovalIsUnused(store, args.setupId) + } const result = store.deleteProjectHostSetup(args) if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) @@ -154,6 +163,33 @@ export class RuntimeProjectHostSetupController { return result } + // Why only for an explicit `force: false`: older clients omit it and must keep their delete. + private assertSetupRemovalIsUnused(store: RuntimeStore, setupId: string): void { + const setup = this.listSetups().find((entry) => entry.id === setupId) + // Same exact-host match the store's delete uses, so a sibling host's row is never measured. + const repo = setup?.repoId + ? this.deps + .listRepos() + .find( + (entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId + ) + : undefined + if (!repo) { + return + } + const refusal = describeProjectRemovalRefusal({ + liveTerminals: this.deps.countLiveTerminalsForRepo(repo), + workspacesWithMetadata: countRepoWorkspacesWithMetadata( + repo, + // Host-qualified read: a same-id, same-path sibling host keeps its rows only in canonical maps. + readAllWorktreeMetaForRepo(store, repo) + ) + }) + if (refusal) { + throw new Error(refusal) + } + } + private completeSetup( args: ProjectHostSetupExistingFolderArgs, initialRepo: Repo, diff --git a/src/main/runtime/runtime-repository-registration-controller.ts b/src/main/runtime/runtime-repository-registration-controller.ts index cc64e20a9acb..01099f34b63d 100644 --- a/src/main/runtime/runtime-repository-registration-controller.ts +++ b/src/main/runtime/runtime-repository-registration-controller.ts @@ -9,7 +9,7 @@ import { } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import { gitExecFileAsync, awaitWindowsHostGitEnvironmentReady } from '../git/runner' -import { getRepoName, isGitRepo } from '../git/repo' +import { getRepoName, isGitRepoAsync } from '../git/repo' import { invalidateAuthorizedRootsCache, isENOENT } from '../ipc/filesystem-auth' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' @@ -40,7 +40,7 @@ export class RuntimeRepositoryRegistrationController { if (kind === 'git') { await awaitWindowsHostGitEnvironmentReady({ cwd: path }) } - if (kind === 'git' && !isGitRepo(path)) { + if (kind === 'git' && !(await isGitRepoAsync(path))) { throw new Error(`Not a valid git repository: ${path}`) } const existing = store.getRepos().find((repo) => { diff --git a/src/main/runtime/runtime-rpc-pairing-offer.test.ts b/src/main/runtime/runtime-rpc-pairing-offer.test.ts index ce443bc8cb05..1dabeea095d8 100644 --- a/src/main/runtime/runtime-rpc-pairing-offer.test.ts +++ b/src/main/runtime/runtime-rpc-pairing-offer.test.ts @@ -6,6 +6,7 @@ import { OrcaRuntimeService } from './orca-runtime' import { OrcaRuntimeRpcServer } from './runtime-rpc' import { parsePairingCode } from '../../shared/pairing' import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' +import { STATUS_METHODS } from './rpc/methods/status' vi.mock('../git/worktree', () => { const worktrees = [ @@ -275,3 +276,40 @@ describe('OrcaRuntimeRpcServer', () => { } }) }) + +describe('host descriptor publication', () => { + it('status.get and runtime offers carry the same descriptor, stable across restarts', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const start = async () => { + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + return { runtime, server } + } + const statusOf = async (runtime: OrcaRuntimeService) => + (await STATUS_METHODS[0].handler(undefined, { runtime })).hostDescriptor + const first = await start() + const descriptor = await statusOf(first.runtime) + expect(descriptor?.installationId).toBeTruthy() + const runtimeOffer = first.server.createPairingOffer({ address: '127.0.0.1' }) + const mobileOffer = first.server.createPairingOffer({ address: '127.0.0.1', scope: 'mobile' }) + expect( + runtimeOffer.available && parsePairingCode(runtimeOffer.pairingUrl)?.hostDescriptor + ).toEqual(descriptor) + expect(mobileOffer.available && parsePairingCode(mobileOffer.pairingUrl)).toBeTruthy() + expect( + mobileOffer.available && parsePairingCode(mobileOffer.pairingUrl)?.hostDescriptor + ).toBeUndefined() + await first.server.stop() + + const second = await start() + expect(await statusOf(second.runtime)).toEqual(descriptor) + await second.server.stop() + expect(await statusOf(second.runtime)).toBeUndefined() + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 20cd17078297..084283485a35 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -25,6 +25,7 @@ import { sweepOrphanedRuntimeSockets } from './runtime-rpc-socket-metadata' import { errorMessage } from '../../../shared/error-message' +import { loadHostDescriptor, publishHostDescriptor } from '../host-descriptor' export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { async start(): Promise { @@ -90,6 +91,8 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { this.deviceRegistry = pairingIdentity.deviceRegistry this.e2eeKeypair = pairingIdentity.e2eeKeypair this.pairingInitializationFailure = null + this.hostDescriptor = await loadHostDescriptor(this.userDataPath) + publishHostDescriptor(this.runtime.getRuntimeId(), this.hostDescriptor) try { const host = this.resolveInitialWebSocketBindHost() const { transport, endpoint } = await this.startWebSocketTransport({ diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 7a3a263f9ae4..1cd226606522 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -182,13 +182,16 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { console.error('[runtime] Failed to persist pairing credential:', error) return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) } + const hostDescriptor = this.hostDescriptor const pairingUrl = encodePairingOffer({ v: PAIRING_OFFER_VERSION, endpoint, deviceToken: device.token, publicKeyB64, pairedDeviceId: device.deviceId, - scope + scope, + // Why runtime scope only: mobile never pins it, and every byte densifies the phone's QR. + ...(hostDescriptor && scope === 'runtime' ? { hostDescriptor } : {}) }) return { available: true, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index 68dd7f2cad8c..314423a2febd 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -1,4 +1,5 @@ import { RuntimeRpcMobilePairing } from './runtime-rpc-mobile-pairing' +import { publishHostDescriptor } from '../host-descriptor' export type RuntimeRpcClientActivity = { openConnections: number @@ -37,6 +38,9 @@ export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { this.transports = [] this.metadataOwnershipWatch?.stop() this.metadataOwnershipWatch = null + if (this.hostDescriptor) { + publishHostDescriptor(this.runtime.getRuntimeId(), null) + } this.mobileSocketWiring = null this.detachWebSocketWiring = null if (this.petVoiceRelay) { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index d25e3a015ab4..54f08cb05e18 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -7,6 +7,7 @@ import type { RpcTransport } from '../rpc/transport' import type { WebSocket } from 'ws' import type { DeviceRegistry } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' +import type { RuntimeHostDescriptor } from '../../../shared/runtime-host-descriptor' import type { UnpairedDeviceAuthThrottle } from '../rpc/unpaired-device-auth-throttle' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import { RelayRevokeOutbox } from '../relay/relay-revoke-outbox' @@ -62,6 +63,7 @@ export class RuntimeRpcState { protected readonly pushUnregisterOutbox: PushUnregisterOutbox protected deviceRegistry: DeviceRegistry | null = null protected e2eeKeypair: E2EEKeypair | null = null + protected hostDescriptor: RuntimeHostDescriptor | null = null protected pairingInitializationFailure: PairingOfferUnavailable | null = null protected tlsFingerprint: string | null = null protected activeTransports: RpcTransport[] = [] diff --git a/src/main/runtime/runtime-worktree-status-projection.ts b/src/main/runtime/runtime-worktree-status-projection.ts index fce985b3d94d..0d6ca0e675ab 100644 --- a/src/main/runtime/runtime-worktree-status-projection.ts +++ b/src/main/runtime/runtime-worktree-status-projection.ts @@ -263,13 +263,6 @@ export function mapExplicitAgentStateToRuntimeTerminalStatus( } } -export function mergeWorktreeStatus( - current: RuntimeWorktreeStatus, - next: RuntimeWorktreeStatus -): RuntimeWorktreeStatus { - return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current -} - export function mergeWorktreeSummaryStatus( summary: RuntimeWorktreePsSummary, next: RuntimeWorktreeStatus, diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index be1a021e0f6d..bc3d5945a5ec 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -399,6 +399,28 @@ describe('skill discovery', () => { expect(rootPaths).toContain('/workspace/current/.claude/skills') }) + it('reports an SSH-backed repository as a remote-repo source instead of dropping it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const runtimeRepo = makeRepo('/runtime/repo') + runtimeRepo.executionHostId = 'runtime:environment-1' + const repos = [makeRepo('/remote/repo', 'ssh-1'), runtimeRepo] + + const result = await discoverSkills({ homeDir: join(root, 'home'), repos, includeCwd: false }) + const homeOnly = await discoverSkills({ + homeDir: join(root, 'home'), + repos, + includeCwd: false, + sourceKinds: ['home'] + }) + + const remoteSources = result.sources.filter((source) => source.skippedReason === 'remote-repo') + expect(remoteSources).toEqual([ + expect.objectContaining({ path: '/remote/repo', sourceKind: 'repo', exists: false }) + ]) + expect(result.sources.some((source) => source.path.startsWith('/remote/repo/'))).toBe(false) + expect(homeOnly.sources.some((source) => source.skippedReason === 'remote-repo')).toBe(false) + }) + it('scans each provider home skill root', () => { const roots = buildSkillDiscoverySources({ homeDir: '/home/test', diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index 075261dce5d1..f7fd2865c3b2 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -19,6 +19,7 @@ import { type SkillScanRoot } from './skill-discovery-sources' import { rootMayContainSourceKind } from './skill-discovery-source-filter' +import { buildRemoteRepoSkillSources } from './skill-remote-repo-sources' import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources' import { findSkillFiles } from './skill-root-file-walk' import { runSkillCandidateTasks } from './skill-candidate-concurrency' @@ -283,16 +284,21 @@ export async function discoverSkills(args: { : []) ].filter((root) => rootMayContainSourceKind(root, args.sourceKinds)) const scans = await Promise.all(roots.map((root) => scanRootShared(root, refresh))) - const sources: SkillDiscoverySource[] = roots.map((root, index) => ({ - ...root, - providers: [...root.providers], - exists: scans[index].value.exists, - skippedReason: scans[index].value.unavailable - ? 'unavailable' - : scans[index].value.exists - ? undefined - : 'missing' - })) + const sources: SkillDiscoverySource[] = [ + ...roots.map((root, index): SkillDiscoverySource => ({ + ...root, + providers: [...root.providers], + exists: scans[index].value.exists, + skippedReason: scans[index].value.unavailable + ? 'unavailable' + : scans[index].value.exists + ? undefined + : 'missing' + })), + ...(!args.sourceKinds?.length || args.sourceKinds.includes('repo') + ? buildRemoteRepoSkillSources(args.repos ?? []) + : []) + ] const normalizedNames = args.names?.map((name) => name.trim().toLowerCase()).filter(Boolean) const expectedNames = normalizedNames?.length ? new Set(normalizedNames) : undefined const seen = new Map() diff --git a/src/main/skills/skill-remote-repo-sources.ts b/src/main/skills/skill-remote-repo-sources.ts new file mode 100644 index 000000000000..bebbc7786259 --- /dev/null +++ b/src/main/skills/skill-remote-repo-sources.ts @@ -0,0 +1,29 @@ +import { basename } from 'node:path' +import type { SkillDiscoverySource } from '../../shared/skills' +import type { Repo } from '../../shared/repo-types' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' +import { stablePathId } from './skill-discovery-classification' + +/** SSH repos this host cannot read, reported so the gap is visible instead of silent (#11466). */ +export function buildRemoteRepoSkillSources(repos: readonly Repo[]): SkillDiscoverySource[] { + const sources = new Map() + for (const repo of repos) { + const hostId = getRepoExecutionHostId(repo) + // Why SSH only: a paired runtime's repos are scanned by that runtime when it is the focus. + if (parseExecutionHostId(hostId)?.kind !== 'ssh') { + continue + } + const id = `repo-remote-${stablePathId(`${hostId}\0${repo.path}`)}` + sources.set(id, { + id, + label: `Repo ${basename(repo.path)}`, + path: repo.path, + sourceKind: 'repo', + providers: ['agent-skills', 'claude'], + owner: null, + exists: false, + skippedReason: 'remote-repo' + }) + } + return [...sources.values()] +} diff --git a/src/main/skills/skill-ssh-relay-service.ts b/src/main/skills/skill-ssh-relay-service.ts index 7f9a7d009dc8..27dbe7ac130d 100644 --- a/src/main/skills/skill-ssh-relay-service.ts +++ b/src/main/skills/skill-ssh-relay-service.ts @@ -40,14 +40,6 @@ const NON_RETRYABLE_SKILL_TRANSFER_ERRORS = new Set([ 'skill-install-ssh-download-unavailable' ]) -export async function supportsSkillManagementOnSsh( - provider: SkillSshProviderSource -): Promise { - return (await skillSshRelayCapabilities(requireSkillSshRelayClient(provider))).includes( - SKILL_MANAGEMENT_CAPABILITY - ) -} - export async function installSkillOnSshHost(input: { provider: SkillSshProviderSource userDataPath: string diff --git a/src/main/ssh/managed-server-fence-recheck.ts b/src/main/ssh/managed-server-fence-recheck.ts index b71d58d9ec68..163b814ee8c1 100644 --- a/src/main/ssh/managed-server-fence-recheck.ts +++ b/src/main/ssh/managed-server-fence-recheck.ts @@ -84,7 +84,3 @@ export function scheduleManagedServerFenceRecheck(targetId: string, loop: FenceR } timer = setTimeout(() => void tick(0), loop.intervalMs ?? FENCE_RECHECK_INTERVAL_MS) } - -export function cancelManagedServerFenceRecheck(targetId: string): void { - loops.get(targetId)?.() -} diff --git a/src/main/ssh/orcad-remote-windows-commands.test.ts b/src/main/ssh/orcad-remote-windows-commands.test.ts index 7455fc750e18..b7ad2a9472e9 100644 --- a/src/main/ssh/orcad-remote-windows-commands.test.ts +++ b/src/main/ssh/orcad-remote-windows-commands.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./ssh-relay-deploy-helpers', () => ({ @@ -220,26 +218,6 @@ describe('Windows command lines for both DefaultShells', () => { ) }) -describe('no -EncodedCommand in the W1 builders', () => { - it.each([ - 'orcad-remote-windows-node.ts', - 'orcad-windows-host-script.ts', - 'orcad-remote-launch-windows.ts', - 'orcad-remote-launch.ts', - 'orcad-remote-process-control.ts', - 'orcad-remote-readiness-wait.ts', - 'orcad-remote-record-file.ts', - 'orcad-remote-build-hash.ts', - 'orcad-managed-remote-stop.ts', - 'orcad-remote-runtime-control.ts' - ])('%s', (file) => { - const code = readFileSync(join(__dirname, file), 'utf8') - .replace(/\/\*[\s\S]*?\*\//gu, '') - .replace(/^\s*\/\/.*$/gmu, '') - expect(code).not.toMatch(/EncodedCommand|powerShellCommand/u) - }) -}) - describe('Windows launch report', () => { it('reads a PID, a refusal, or a failure', () => { expect( diff --git a/src/main/ssh/orcad-tunnel-transport-memo.ts b/src/main/ssh/orcad-tunnel-transport-memo.ts index 529146c9f59a..631282d28824 100644 --- a/src/main/ssh/orcad-tunnel-transport-memo.ts +++ b/src/main/ssh/orcad-tunnel-transport-memo.ts @@ -14,7 +14,3 @@ export function rememberOrcadTunnelTransport( export function knownOrcadTunnelTransport(targetId: string): OrcadTunnelTransport | null { return transports.get(targetId) ?? null } - -export function resetOrcadTunnelTransportMemoForTests(): void { - transports.clear() -} diff --git a/src/main/ssh/relay-socket-path-limit.ts b/src/main/ssh/relay-socket-path-limit.ts index 328f42e1f8fa..00ed251d8225 100644 --- a/src/main/ssh/relay-socket-path-limit.ts +++ b/src/main/ssh/relay-socket-path-limit.ts @@ -30,10 +30,6 @@ export function remoteSocketPathFitsLimit(host: RemoteHostPlatform, sockPath: st /** Fixed-length, per-uid base. `/tmp` is the only POSIX directory whose length is not user-dependent. */ export const SHORT_RELAY_SOCKET_DIR_PREFIX = '/tmp/.orca-relay-' -export function shortRelaySocketDirForUid(uid: string): string { - return `${SHORT_RELAY_SOCKET_DIR_PREFIX}${uid}` -} - /** * The version segment the relocated socket lives under, named to match the version * directories in `$HOME/.orca-remote` so one sweep pattern covers both bases. diff --git a/src/main/ssh/ssh-config-alias-claim.ts b/src/main/ssh/ssh-config-alias-claim.ts index 74a3ea0d44ca..11da6543e453 100644 --- a/src/main/ssh/ssh-config-alias-claim.ts +++ b/src/main/ssh/ssh-config-alias-claim.ts @@ -66,10 +66,6 @@ const CLAIM_CACHE_TTL_MS = 5_000 let cachedClaims: { key: string; readAt: number; claims: SshConfigAliasClaims } | null = null -export function invalidateSshConfigAliasClaimCache(): void { - cachedClaims = null -} - /** * Parse of `~/.ssh/config` (Includes expanded), or null when it cannot be read. * diff --git a/src/main/ssh/ssh-plain-ssh-session.ts b/src/main/ssh/ssh-plain-ssh-session.ts index b5894a116875..74e7b19644ec 100644 --- a/src/main/ssh/ssh-plain-ssh-session.ts +++ b/src/main/ssh/ssh-plain-ssh-session.ts @@ -2,7 +2,7 @@ * 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 } from '../../shared/ssh-types' +import type { SshPlainSshMode, SshRemotePlatform } from '../../shared/ssh-types' import { isCurrentPtyExit } from '../ipc/pty' import { acceptSshPtyOutputData, @@ -31,7 +31,9 @@ export class SshPlainSshModeSession { readonly targetId: string, readonly mode: SshPlainSshMode, private readonly ptyProvider: SshPlainShellPtyProvider, - private readonly fsProvider: SshSftpFilesystemProvider + 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: { @@ -57,7 +59,14 @@ export class SshPlainSshModeSession { mode, windowsHost ) - const session = new SshPlainSshModeSession(targetId, mode, ptyProvider, fsProvider) + 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 diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 19dac11ffd1b..77b39183f642 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -5,7 +5,7 @@ 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 '../../relay/dispatcher' +import { RelayDispatcher } from '../../wsl-guest/dispatcher' import { AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD, diff --git a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts b/src/main/ssh/ssh-relay-session-plain-ssh.test.ts index d5077a2fbf4b..a850fa275087 100644 --- a/src/main/ssh/ssh-relay-session-plain-ssh.test.ts +++ b/src/main/ssh/ssh-relay-session-plain-ssh.test.ts @@ -127,6 +127,16 @@ describe('SshRelaySession plain SSH mode (runtime rung D)', () => { 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() diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index fe0c8a4260d9..8ef28fd18e89 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -132,6 +132,35 @@ describe('buildHostCliEnv', () => { 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. diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 9c15287d166c..5e5eb59e2ab3 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -16,6 +16,7 @@ import { 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, @@ -75,6 +76,8 @@ export type HostCliPassthroughOptions = { 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 @@ -115,6 +118,7 @@ export function buildHostCliEnv(args: { 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) { @@ -152,6 +156,12 @@ export function buildHostCliEnv(args: { 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 @@ -219,7 +229,8 @@ export async function runHostOrcaCliPassthrough( remoteCwd: request.cwd, bridgeCredential: credential.token, runtimeAuthority: request.runtimeAuthority, - artifactInput: request.artifactInput + artifactInput: request.artifactInput, + runtimeSource: options.runtimeSource }) try { diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 74318cd7182a..721df7c0a2d3 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -8,6 +8,7 @@ import type { RpcResponse } from '../runtime/rpc/core' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { getRuntimeSourceStamp } from '../runtime/host-descriptor' import { HostCliUnavailableError, runHostOrcaCliPassthrough, @@ -84,7 +85,10 @@ export async function runRemoteOrcaCli( let passthroughFailure: HostCliUnavailableError | null = null try { - return await runHostOrcaCliPassthrough(request, passthroughOptions) + return await runHostOrcaCliPassthrough(request, { + runtimeSource: getRuntimeSourceStamp(runtime), + ...passthroughOptions + }) } catch (err) { if (!(err instanceof HostCliUnavailableError)) { throw err diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 70747c926f48..c0d8123f3a7a 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -393,6 +393,25 @@ describe('configureOrcaUserDataPathEnv', () => { expect(configuredUserDataPath).toBe('/tmp/current-orca-user-data') }) + + it('drops a runtime source inherited from the Orca terminal that started this Orca', async () => { + const { app } = await import('electron') + const { configureOrcaUserDataPathEnv } = await import('./configure-process') + const saved = { ...process.env } + process.env.ORCA_RUNTIME_SOURCE_ID = 'parent-source' + process.env.ORCA_RUNTIME_SOURCE_INCARNATION = 'parent-runtime' + app.setPath('userData', '/tmp/current-orca-user-data') + let inherited: (string | undefined)[] = [] + + try { + configureOrcaUserDataPathEnv() + inherited = [process.env.ORCA_RUNTIME_SOURCE_ID, process.env.ORCA_RUNTIME_SOURCE_INCARNATION] + } finally { + process.env = saved + } + + expect(inherited).toEqual([undefined, undefined]) + }) }) describe('shouldInstallManagedHooks', () => { diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index e7f3385ef043..3cb41bcff988 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -8,6 +8,7 @@ import { getMainE2EConfig } from '../e2e-config' import { DISABLED_CHROMIUM_FEATURES } from './disabled-chromium-features' import { readHttp1CompatibilityMarker } from './http1-compatibility-marker' import { checkServeUserDataPath } from './serve-user-data-path-guard' +import { stampRuntimeSourceEnv } from '../../shared/runtime-source-env' import { hasMissingProfileStateDatabaseWithRetainedAuthority, readActiveProfileId, @@ -243,6 +244,8 @@ export function configureOrcaUserDataPathEnv(): void { } // Why: relaunches can inherit a stale ORCA_USER_DATA_PATH; canonicalize before CLI-shared modules build runtime-home paths. process.env.ORCA_USER_DATA_PATH = userDataPath + // Why: an Orca started from another Orca's terminal must not fence its own CLI children to the parent. + stampRuntimeSourceEnv(process.env, null) } export function shouldInstallManagedHooks(isDev: boolean): boolean { diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 2e3ae99a4d43..8b374679803d 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -17,13 +17,7 @@ import { import { logStartupMilestone } from './startup-diagnostics' import { notifyMainWindowBecameVisible } from '../window/main-window-visibility' import { setTrayAttention } from '../tray/system-tray' -import { - createSystemTrayDeferred, - getSystemTrayOptions, - showMainWindowFromTray, - showRendererRecoveryPrompt, - syncMacMenuBarIcon -} from './main-window-actions' +import { createSystemTrayDeferred, showRendererRecoveryPrompt } from './main-window-actions' import { attachMainWindowCoreServices } from './main-window-core-services' import { clearMainWindowAgentStatusListeners, @@ -230,10 +224,3 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} loadMainWindow(window) return window } - -export function configureWindowActions(): void { - // Kept as a named seam for startup composition; action callbacks are state-backed. - void getSystemTrayOptions - void showMainWindowFromTray - void syncMacMenuBarIcon -} diff --git a/src/main/startup/synthetic-title-runtime.ts b/src/main/startup/synthetic-title-runtime.ts index e28b992edd42..57782d7087d6 100644 --- a/src/main/startup/synthetic-title-runtime.ts +++ b/src/main/startup/synthetic-title-runtime.ts @@ -1,11 +1,7 @@ import { registerPaneKeyTeardownListener, getPtyIdForPaneKey } from '../ipc/pty' import { agentHookServer } from '../agent-hooks/server' import type { AgentStatusState } from '../../shared/agent-status-types' -import { - getSyntheticAgentTitleProfile, - shouldDriveSyntheticAgentTitleFromHook, - type SyntheticAgentTitleProfile -} from '../../shared/synthetic-agent-title' +import type { SyntheticAgentTitleProfile } from '../../shared/synthetic-agent-title' import { advanceSyntheticTitleSpinnerEntries, getSyntheticTitleSpinnerPaneKeyToStop, @@ -164,14 +160,3 @@ export function initializeSyntheticTitleRuntime(): void { }) agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) } - -export function driveSyntheticTitleForAgentStatus( - paneKey: string, - agentType: string | null | undefined, - agentState: AgentStatusState -): void { - const profile = getSyntheticAgentTitleProfile(agentType) - if (profile && shouldDriveSyntheticAgentTitleFromHook(agentType, agentState)) { - driveSyntheticTitleFromHook(paneKey, agentState, profile) - } -} diff --git a/src/main/startup/windows-install-dir-package-acl-repair.test.ts b/src/main/startup/windows-install-dir-package-acl-repair.test.ts index c0d88c24ad16..4ad7425a25c6 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.test.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.test.ts @@ -1,7 +1,7 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' @@ -46,7 +46,9 @@ function fakeRunner(reply: (spec: ProcessSpec) => Partial = () => } function userDataDir(): string { - return mkdtempSync(join(tmpdir(), 'orca-acl-repair-')) + const path = mkdtempSync(join(tmpdir(), 'orca-acl-repair-')) + onTestFinished(() => rmSync(path, { recursive: true, force: true })) + return path } function repair( diff --git a/src/main/windows/windows-msys-job.win32.test.ts b/src/main/windows/windows-msys-job.win32.test.ts index 9bf5485102e9..f29c7bd8d0fb 100644 --- a/src/main/windows/windows-msys-job.win32.test.ts +++ b/src/main/windows/windows-msys-job.win32.test.ts @@ -49,11 +49,21 @@ describeOnWindows('MSYS terminal job ownership', () => { } }) try { - proc.write( - `${quotePosixShell(process.execPath.replace(/\\/g, '/'))} ${quotePosixShell(script.replace(/\\/g, '/'))}\r` - ) + const startupDeadline = Date.now() + 15_000 try { - await vi.waitFor(() => expect(childPid).toBeDefined(), { timeout: 15_000 }) + await vi.waitFor(() => expect(output).toMatch(/bash-\d+\.\d+[$#] $/), { + timeout: 15_000 + }) + expect( + startupDeadline - Date.now(), + 'Bash readiness consumed the startup budget' + ).toBeGreaterThan(0) + proc.write( + `${quotePosixShell(process.execPath.replace(/\\/g, '/'))} ${quotePosixShell(script.replace(/\\/g, '/'))}\r` + ) + const remainingStartupMs = startupDeadline - Date.now() + expect(remainingStartupMs, 'PTY write consumed the startup budget').toBeGreaterThan(0) + await vi.waitFor(() => expect(childPid).toBeDefined(), { timeout: remainingStartupMs }) } catch (cause) { throw new Error( JSON.stringify({ shellPid: proc.pid, exit, jobPids: listPtyJobProcessIds(proc), output }), diff --git a/src/relay/agent-exec-disposal.test.ts b/src/relay/agent-exec-disposal.test.ts index 475239d63e3b..46a6ff2fca1f 100644 --- a/src/relay/agent-exec-disposal.test.ts +++ b/src/relay/agent-exec-disposal.test.ts @@ -7,7 +7,7 @@ import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime' import { createFakeChild, requestContext } from './agent-exec-handler-test-harness' -import type { MethodHandler, RelayDispatcher } from './dispatcher' +import type { MethodHandler, RelayDispatcher } from '../wsl-guest/dispatcher' // Why an untyped mock: the fake child stubs only what AgentExecHandler reads from a ChildProcess. const { spawnMock, loginShellMock } = vi.hoisted(() => ({ @@ -124,8 +124,8 @@ it('kills a child tracked after the fence instead of throwing', async () => { const lifetime = new RelayAgentProcessLifetime() void lifetime.dispose() const child = createFakeChild() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: track reads only pid, kill and the close/error events the fake emits. expect(() => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: track reads only pid, kill and the close/error events the fake emits. lifetime.track(child as unknown as Parameters[0]) ).not.toThrow() if (process.platform !== 'win32') { diff --git a/src/relay/agent-exec-handler-test-harness.ts b/src/relay/agent-exec-handler-test-harness.ts index 317a6bd66521..1ee67a33015a 100644 --- a/src/relay/agent-exec-handler-test-harness.ts +++ b/src/relay/agent-exec-handler-test-harness.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { vi } from 'vitest' -import type { MethodHandler, RequestContext } from './dispatcher' +import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher' import { AgentExecHandler } from './agent-exec-handler' export function withPlatform(platform: NodeJS.Platform, fn: () => T): T { diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index d54c2098be1e..009a2cd6ddda 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -3,7 +3,7 @@ import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' import { spawn, type ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' import { delimiter, join } from 'node:path' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { terminateRelaySubprocessTree } from './subprocess-tree-termination' diff --git a/src/relay/agent-hook-retired-pane-suppression.test.ts b/src/relay/agent-hook-retired-pane-suppression.test.ts index c31825cdee7f..f7e00d3281aa 100644 --- a/src/relay/agent-hook-retired-pane-suppression.test.ts +++ b/src/relay/agent-hook-retired-pane-suppression.test.ts @@ -2,13 +2,13 @@ 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 { RelayAgentHookServer } from './agent-hook-server' +import { RelayAgentHookServer } from '../wsl-guest/agent-hook-server' import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' import type { PtyHandler, PtySurfaceRetiredListener } from './pty-handler' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import type { ClaudeTerminalEvidence } from '../shared/claude-terminal-interrupt' const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/relay/ai-vault-handler.test.ts b/src/relay/ai-vault-handler.test.ts index 3951b58ea5f2..6e62369c45dd 100644 --- a/src/relay/ai-vault-handler.test.ts +++ b/src/relay/ai-vault-handler.test.ts @@ -11,7 +11,7 @@ import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { AiVaultHandler } from './ai-vault-handler' import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state' diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts index 428253349bf4..07271f4b268c 100644 --- a/src/relay/ai-vault-handler.ts +++ b/src/relay/ai-vault-handler.ts @@ -20,7 +20,7 @@ import { import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' import { relayLogLine } from './relay-diagnostic-log' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator' import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state' diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts index 66209b3e1213..887d902cc9a1 100644 --- a/src/relay/external-automations-handler.test.ts +++ b/src/relay/external-automations-handler.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ExternalAutomationCommandExecutor } from './external-automation-command-executor' import { ExternalAutomationsHandler } from './external-automations-handler' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' const execFileMock = vi.hoisted(() => vi.fn((...args: unknown[]) => { diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index 6c5d8af98ae0..c9805376d959 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { ExternalAutomationCommandExecutor, type ExternalAutomationCommandRunner diff --git a/src/relay/fs-file-stream-shutdown.test.ts b/src/relay/fs-file-stream-shutdown.test.ts index c26fb73b2c4d..21c839bf8280 100644 --- a/src/relay/fs-file-stream-shutdown.test.ts +++ b/src/relay/fs-file-stream-shutdown.test.ts @@ -2,10 +2,10 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { readRelayFileStreamMetadata } from './fs-handler-file-read' import { RelayStreamRegistry } from './fs-stream-registry' -import { STREAM_CHUNK_SIZE } from './protocol' +import { STREAM_CHUNK_SIZE } from '../wsl-guest/protocol' let directory: string let filePath: string diff --git a/src/relay/fs-handler-file-range-dispatch.test.ts b/src/relay/fs-handler-file-range-dispatch.test.ts index 5599ef37ad3c..3e0ad07ccacd 100644 --- a/src/relay/fs-handler-file-range-dispatch.test.ts +++ b/src/relay/fs-handler-file-range-dispatch.test.ts @@ -4,8 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' -import { RelayDispatcher } from './dispatcher' -import { encodeJsonRpcFrame, RelayErrorCode } from './protocol' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, RelayErrorCode } from '../wsl-guest/protocol' import { FileRangeReadRequestError, MAX_FILE_RANGE_READ_BYTES } from '../shared/file-range-read' /** Minimal dispatcher: this suite only needs the registered request handlers. diff --git a/src/relay/fs-handler-file-range.test.ts b/src/relay/fs-handler-file-range.test.ts index 0d601b9b9fe9..1d1d55f38b9b 100644 --- a/src/relay/fs-handler-file-range.test.ts +++ b/src/relay/fs-handler-file-range.test.ts @@ -9,8 +9,8 @@ import { MAX_FILE_RANGE_READ_BYTES, validateFileRangeRequest } from '../shared/file-range-read' -import { HEADER_LENGTH, prepareJsonRpcPayload } from './protocol' -import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission' +import { HEADER_LENGTH, prepareJsonRpcPayload } from '../wsl-guest/protocol' +import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from '../wsl-guest/dispatcher-writer-admission' let roots: string[] = [] diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index e0f7fa590a96..ed666328b16c 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,8 +1,8 @@ import { open, readFile, stat } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { extname } from 'node:path' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' +import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from '../wsl-guest/protocol' import type { RelayStreamRegistry } from './fs-stream-registry' import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer' import { diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index fd76bda75ef5..d666a0599007 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -19,7 +19,7 @@ import { expandQuickOpenGitFileListing, parseQuickOpenGitLsFilesEntry } from '../shared/quick-open-readdir-walk' -import { buildRelayGitEnv } from './relay-command-env' +import { buildRelayGitEnv } from '../wsl-guest/relay-command-env' /** * List files using `git ls-files`. Fallback when rg is not installed. diff --git a/src/relay/fs-handler-git-search.ts b/src/relay/fs-handler-git-search.ts index 27703465b33d..07d3d5284d09 100644 --- a/src/relay/fs-handler-git-search.ts +++ b/src/relay/fs-handler-git-search.ts @@ -18,7 +18,7 @@ import { absorbPendingRipgrepSpawnError, killSpawnedRipgrepProcess } from '../shared/ripgrep-process-availability' -import { buildRelayGitEnv } from './relay-command-env' +import { buildRelayGitEnv } from '../wsl-guest/relay-command-env' /** * Text search using `git grep`. Fallback when rg is not installed. diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index a7f06d62e93f..6216fe61c885 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -17,7 +17,7 @@ import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' -import { buildRelayCommandEnv } from './relay-command-env' +import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' import { ListFilesScanCoordinator, diff --git a/src/relay/fs-handler-list-files-result-limit.test.ts b/src/relay/fs-handler-list-files-result-limit.test.ts index becf607213fe..44320c7aaa5b 100644 --- a/src/relay/fs-handler-list-files-result-limit.test.ts +++ b/src/relay/fs-handler-list-files-result-limit.test.ts @@ -21,7 +21,7 @@ vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) import { FsHandler } from './fs-handler' import { RelayContext } from './context' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits' type ListFilesHandler = ( diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 394ed8521878..cafa549a290b 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -32,7 +32,7 @@ import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { QuickOpenPathRanker } from '../shared/quick-open-path-search' -import { buildRelayCommandEnv } from './relay-command-env' +import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' import { pathRipgrepCommand, resolveRelayRipgrepCommand, diff --git a/src/relay/fs-handler-ripgrep-fallback.test.ts b/src/relay/fs-handler-ripgrep-fallback.test.ts index a4d1a1fbaec1..0487a237c4d4 100644 --- a/src/relay/fs-handler-ripgrep-fallback.test.ts +++ b/src/relay/fs-handler-ripgrep-fallback.test.ts @@ -46,7 +46,7 @@ import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { RelayContext } from './context' import { FsHandler } from './fs-handler' import { runListFilesScan } from './fs-list-files-fallback-chain' -import { buildRelayCommandEnv } from './relay-command-env' +import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' type FsHandlerInternals = { search(params: Record): Promise diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index 9cbfd6873766..480bb36485e2 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' -import type { RelayDispatcher } from './dispatcher' -import { MAX_CONCURRENT_STREAMS, STREAM_CHUNK_SIZE } from './protocol' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' +import { MAX_CONCURRENT_STREAMS, STREAM_CHUNK_SIZE } from '../wsl-guest/protocol' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { mkdtempSync, writeFileSync } from 'node:fs' diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index a7d47f63862d..3385160b56b1 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -28,7 +28,7 @@ import { ripgrepMissingCwdError, RipgrepUnavailableError } from '../shared/ripgrep-process-availability' -import { buildRelayCommandEnv } from './relay-command-env' +import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' import { pathRipgrepCommand, resolveRelayRipgrepCommand, diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 63963016c1b0..d86173f4e01b 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { MAX_TEXT_FILE_SIZE } from './fs-handler-utils' import { RelayContext } from './context' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs' diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index bcef1a280d9b..0e131646c084 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -6,7 +6,7 @@ import { quickOpenRecentCandidateSet } from '../shared/quick-open-recent-candida import { QUICK_OPEN_SEARCH_VERSION } from '../shared/quick-open-path-search' import { pathsExistOnRelay } from './fs-path-existence' import { tmpdir } from 'node:os' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts index f54ebaea3692..30933e226fc6 100644 --- a/src/relay/fs-list-files-cancel.integration.test.ts +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -62,7 +62,7 @@ import { SshChannelMultiplexer, type MultiplexerTransport } from '../main/ssh/ssh-channel-multiplexer' -import { RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import { RelayContext } from './context' import { FsHandler } from './fs-handler' import { LIST_FILES_SUPERSEDED_MESSAGE } from './fs-list-files-scan-coordinator' diff --git a/src/relay/fs-list-files-dispatch-test-harness.ts b/src/relay/fs-list-files-dispatch-test-harness.ts index de1086f35444..5f05dd83889b 100644 --- a/src/relay/fs-list-files-dispatch-test-harness.ts +++ b/src/relay/fs-list-files-dispatch-test-harness.ts @@ -1,6 +1,6 @@ import { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' import { RelayContext } from './context' -import { RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import { FsHandler } from './fs-handler' export function createRelayFileListingRequestHarness() { diff --git a/src/relay/fs-list-files-fallback-chain.ts b/src/relay/fs-list-files-fallback-chain.ts index 1d705591d3a1..fbc629f26484 100644 --- a/src/relay/fs-list-files-fallback-chain.ts +++ b/src/relay/fs-list-files-fallback-chain.ts @@ -8,7 +8,7 @@ import { } from '../shared/file-listing-cancellation' import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk' import { buildInstallRgMessage, buildRipgrepRequiredMessage } from '../shared/quick-open-install-rg' -import { buildRelayCommandEnv } from './relay-command-env' +import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { QuickOpenPathRanker } from '../shared/quick-open-path-search' diff --git a/src/relay/fs-list-files-large-response.integration.test.ts b/src/relay/fs-list-files-large-response.integration.test.ts index 27c7ee7e648a..5fb9f9356233 100644 --- a/src/relay/fs-list-files-large-response.integration.test.ts +++ b/src/relay/fs-list-files-large-response.integration.test.ts @@ -21,8 +21,8 @@ import { } from '../main/ssh/ssh-channel-multiplexer' import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader' import { RelayContext } from './context' -import { RelayDispatcher } from './dispatcher' -import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from '../wsl-guest/dispatcher-writer-admission' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' import { GitResponseStreamRegistry } from './git-response-stream' diff --git a/src/relay/fs-markdown-document-listing.test.ts b/src/relay/fs-markdown-document-listing.test.ts index e54cc57e9b47..08795cb8cff1 100644 --- a/src/relay/fs-markdown-document-listing.test.ts +++ b/src/relay/fs-markdown-document-listing.test.ts @@ -9,7 +9,7 @@ import { listFilesWithRg } from './fs-handler-list-files' import { listRelayMarkdownDocuments } from './fs-markdown-document-listing' import { RelayContext } from './context' import { FsHandler } from './fs-handler' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' it.skipIf(process.platform === 'win32')( 'keeps readable SSH Markdown documents when a child directory is unreadable', diff --git a/src/relay/fs-search-cancellation.test.ts b/src/relay/fs-search-cancellation.test.ts index 918285255024..24e347964255 100644 --- a/src/relay/fs-search-cancellation.test.ts +++ b/src/relay/fs-search-cancellation.test.ts @@ -29,10 +29,10 @@ vi.mock('./relay-bundled-ripgrep', () => ({ import { searchWithRg } from './fs-handler-utils' import { searchWithGitGrep } from './fs-handler-git-fallback' -import { RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import { FsHandler } from './fs-handler' import { RelayContext } from './context' -import { encodeJsonRpcFrame } from './protocol' +import { encodeJsonRpcFrame } from '../wsl-guest/protocol' function createProcess(spawned = true) { const child = new ChildProcess() diff --git a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts index 98d95fd7f8a2..a73c888a2c61 100644 --- a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts +++ b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts @@ -27,11 +27,11 @@ import { } from '../main/ssh/ssh-channel-multiplexer' import { readFileViaStream } from '../main/ssh/ssh-filesystem-stream-reader' -import { RelayDispatcher } from './dispatcher' -import type { SinkWriteSettlement } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import type { SinkWriteSettlement } from '../wsl-guest/dispatcher' import { RelayContext } from './context' import { FsHandler } from './fs-handler' -import { STREAM_CHUNK_SIZE } from './protocol' +import { STREAM_CHUNK_SIZE } from '../wsl-guest/protocol' // One framed fs.streamChunk: 256KB raw → base64 (4/3) + JSON envelope + header. const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512 diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index e0cc395df2d9..55e043d328c4 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -1,6 +1,6 @@ import type { FileHandle } from 'node:fs/promises' import { settlesWithin } from './settles-within' -import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol' +import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from '../wsl-guest/protocol' import { RelayStreamAckWindow } from './relay-stream-ack-window' type StreamEntry = { handle: FileHandle; ack: RelayStreamAckWindow } diff --git a/src/relay/git-handler-comparison-operations.ts b/src/relay/git-handler-comparison-operations.ts index 72428a33d852..fa32bf95981d 100644 --- a/src/relay/git-handler-comparison-operations.ts +++ b/src/relay/git-handler-comparison-operations.ts @@ -1,4 +1,4 @@ -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import { GitHandlerOperationContext } from './git-handler-operation-context' import { branchCompare as branchCompareOp } from './git-handler-ops' import { commitCompare as commitCompareOp } from './git-handler-commit-diff-ops' diff --git a/src/relay/git-handler-exec-operations.ts b/src/relay/git-handler-exec-operations.ts index f7d0c320697c..cd43ad0c1705 100644 --- a/src/relay/git-handler-exec-operations.ts +++ b/src/relay/git-handler-exec-operations.ts @@ -1,4 +1,4 @@ -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import { GitHandlerOperationContext } from './git-handler-operation-context' import { validateGitExecArgs } from './git-handler-ops' import { gitExecMutatesRepository } from '../shared/git-exec-mutation' diff --git a/src/relay/git-handler-fetch-operations.ts b/src/relay/git-handler-fetch-operations.ts index fd13cdc07c24..a2ee86461097 100644 --- a/src/relay/git-handler-fetch-operations.ts +++ b/src/relay/git-handler-fetch-operations.ts @@ -1,4 +1,4 @@ -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import { GitHandlerOperationContext } from './git-handler-operation-context' import { assertValidGitPushTarget } from '../shared/git-push-target-validation' import type { GitPushTarget } from '../shared/worktree/types' diff --git a/src/relay/git-handler-object-diff-operations.ts b/src/relay/git-handler-object-diff-operations.ts index c335deb0c5e3..bb668664e9c8 100644 --- a/src/relay/git-handler-object-diff-operations.ts +++ b/src/relay/git-handler-object-diff-operations.ts @@ -1,4 +1,4 @@ -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import { GitHandlerOperationContext } from './git-handler-operation-context' import { branchDiffEntries } from './git-handler-ops' import { diff --git a/src/relay/git-handler-operation-context.ts b/src/relay/git-handler-operation-context.ts index 928006f72904..cf79b4a089a6 100644 --- a/src/relay/git-handler-operation-context.ts +++ b/src/relay/git-handler-operation-context.ts @@ -1,4 +1,4 @@ -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import type { GitStatusReadLeaseOwner } from '../shared/git-status-read-lease-owner' import type { GitCapabilityCache } from '../shared/git-capability-cache' import type { SubmodulePathsCache } from './git-handler-submodule-ops' diff --git a/src/relay/git-handler-read-operations.ts b/src/relay/git-handler-read-operations.ts index cde06f53996b..b031694ec448 100644 --- a/src/relay/git-handler-read-operations.ts +++ b/src/relay/git-handler-read-operations.ts @@ -1,5 +1,5 @@ import * as path from 'node:path' -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import { GitHandlerOperationContext } from './git-handler-operation-context' import { getStatusOp } from './git-handler-status-ops' import { streamRelayGitStdout } from './git-stdout-stream' diff --git a/src/relay/git-handler-registration.ts b/src/relay/git-handler-registration.ts index 69eadd5a888e..f492ecf845aa 100644 --- a/src/relay/git-handler-registration.ts +++ b/src/relay/git-handler-registration.ts @@ -1,4 +1,4 @@ -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import type { GitHandlerOperationSet } from './git-handler-operation-set' export function registerGitHandlers( diff --git a/src/relay/git-handler-stage-worktree-scope.test.ts b/src/relay/git-handler-stage-worktree-scope.test.ts index 54f92c494613..9a2616ed1176 100644 --- a/src/relay/git-handler-stage-worktree-scope.test.ts +++ b/src/relay/git-handler-stage-worktree-scope.test.ts @@ -7,8 +7,8 @@ import { execFileSync } from 'node:child_process' import { GitHandler } from './git-handler' import { RelayContext } from './context' import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit' -import { RelayDispatcher } from './dispatcher' -import type { MethodHandler } from './dispatcher-contract' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import type { MethodHandler } from '../wsl-guest/dispatcher-contract' import { gitCommit, gitInit } from './git-handler-test-setup' const OVER_CAP = DEFAULT_GIT_STATUS_LIMIT + 50 diff --git a/src/relay/git-handler-sync-operations.ts b/src/relay/git-handler-sync-operations.ts index 9c0922c34dff..a05213585dbf 100644 --- a/src/relay/git-handler-sync-operations.ts +++ b/src/relay/git-handler-sync-operations.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import { GitHandlerOperationContext } from './git-handler-operation-context' import { resolveRelayPushTarget } from './git-handler-push-target' import { normalizeGitErrorMessage, runPullWithDivergenceFallback } from '../shared/git-remote-error' diff --git a/src/relay/git-handler-test-setup.ts b/src/relay/git-handler-test-setup.ts index cbf1247a502e..9eba417894bc 100644 --- a/src/relay/git-handler-test-setup.ts +++ b/src/relay/git-handler-test-setup.ts @@ -7,7 +7,7 @@ */ import { vi } from 'vitest' import { execFileSync } from 'node:child_process' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' const TEST_GIT_USER_EMAIL = 'test@test.com' const TEST_GIT_USER_NAME = 'Test' diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts index 80948e5f6080..14c19a3802d8 100644 --- a/src/relay/git-handler-worktree-operations.ts +++ b/src/relay/git-handler-worktree-operations.ts @@ -1,7 +1,7 @@ import { throwIfSignalAborted } from '../shared/abort-signal-reason' import { annotateWorktreeLocksFromAdmin } from '../shared/git-worktree-admin' import * as path from 'node:path' -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import { expandTilde } from './context' import { GitHandlerOperationContext } from './git-handler-operation-context' import { isUnsupportedWorktreeListZError } from './git-handler-utils' diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index e58b1e4abe4b..b2f425548efa 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,4 +1,4 @@ -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' import { MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES } from '../shared/in-flight-promise-dedupe' @@ -12,7 +12,7 @@ import { import { GitResponseStreamRegistry, maybeStreamRpcResponse } from './git-response-stream' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' -import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env' +import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from '../wsl-guest/relay-command-env' import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' import type { GitHandlerCommandOptions, diff --git a/src/relay/git-response-pty-echo-backpressure.integration.test.ts b/src/relay/git-response-pty-echo-backpressure.integration.test.ts index 4f3eee4a87ad..cf60f4ab437d 100644 --- a/src/relay/git-response-pty-echo-backpressure.integration.test.ts +++ b/src/relay/git-response-pty-echo-backpressure.integration.test.ts @@ -27,11 +27,11 @@ import { } from '../main/ssh/ssh-channel-multiplexer' import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader' -import { RelayDispatcher } from './dispatcher' -import type { SinkWriteSettlement } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import type { SinkWriteSettlement } from '../wsl-guest/dispatcher' import { RelayContext } from './context' import { GitHandler } from './git-handler' -import { GIT_RESPONSE_CHUNK_SIZE } from './protocol' +import { GIT_RESPONSE_CHUNK_SIZE } from '../wsl-guest/protocol' // One framed git.responseChunk: base64 (4/3) + JSON envelope + header slack. const FRAMED_CHUNK_BYTES = Math.ceil((GIT_RESPONSE_CHUNK_SIZE * 4) / 3) + 512 diff --git a/src/relay/git-response-stream-ownership.test.ts b/src/relay/git-response-stream-ownership.test.ts index 66d7ead79351..3e23ae2e7f1e 100644 --- a/src/relay/git-response-stream-ownership.test.ts +++ b/src/relay/git-response-stream-ownership.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' +import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from '../wsl-guest/protocol' async function flushPump(): Promise { await new Promise((resolve) => setImmediate(resolve)) diff --git a/src/relay/git-response-stream-retention.test.ts b/src/relay/git-response-stream-retention.test.ts index 8c18b45beca7..8898eaecdb5e 100644 --- a/src/relay/git-response-stream-retention.test.ts +++ b/src/relay/git-response-stream-retention.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import { GitResponseStreamRegistry } from './git-response-stream' async function collect(): Promise { diff --git a/src/relay/git-response-stream-shutdown.test.ts b/src/relay/git-response-stream-shutdown.test.ts index b3e14467131d..bb05a79f2c08 100644 --- a/src/relay/git-response-stream-shutdown.test.ts +++ b/src/relay/git-response-stream-shutdown.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' +import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from '../wsl-guest/protocol' const context: RequestContext = { clientId: 7, isStale: () => false } diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts index 254c140478a7..86c0f9f986cd 100644 --- a/src/relay/git-response-stream.ts +++ b/src/relay/git-response-stream.ts @@ -13,13 +13,13 @@ // `git.responseAck` can credit the ack window a pump parks on — the other's // streams would stall at STREAM_ACK_WINDOW_CHUNKS forever. See // `relay-runtime-services.ts` for the wiring. -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { GIT_RESPONSE_CHUNK_SIZE, GIT_RESPONSE_STREAM_THRESHOLD, STREAM_ACK_WINDOW_CHUNKS, type GitResponseStreamMarker -} from './protocol' +} from '../wsl-guest/protocol' import { settlesWithin } from './settles-within' import { RelayStreamAckWindow } from './relay-stream-ack-window' import { errorMessage } from '../shared/error-message' diff --git a/src/relay/git-stdout-stream.ts b/src/relay/git-stdout-stream.ts index 7354544a8091..c30cdfb00b07 100644 --- a/src/relay/git-stdout-stream.ts +++ b/src/relay/git-stdout-stream.ts @@ -4,7 +4,7 @@ import { forceTerminateProcessTree } from '@orca/process-host/process-tree-termi import { createChildTerminationReporter } from '@orca/process-host/child-termination-reporter' import { GitCommandTimeoutError, gitCommandTimeoutMs } from '../shared/git-command-timeout' import { expandTilde } from './context' -import { buildRelayGitEnv } from './relay-command-env' +import { buildRelayGitEnv } from '../wsl-guest/relay-command-env' import { acquireRelayGitAdmission } from './git-handler-command-termination' const DEFAULT_RELAY_GIT_STREAM_MAX_BYTES = 10 * 1024 * 1024 diff --git a/src/relay/integration.test.ts b/src/relay/integration.test.ts index f6b7cee28449..8e5cea005017 100644 --- a/src/relay/integration.test.ts +++ b/src/relay/integration.test.ts @@ -19,7 +19,7 @@ import { type MultiplexerTransport } from '../main/ssh/ssh-channel-multiplexer' -import { RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import { RelayContext } from './context' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' diff --git a/src/relay/managed-hook-installer.test.ts b/src/relay/managed-hook-installer.test.ts index aba8f51490d5..4f80225e06bd 100644 --- a/src/relay/managed-hook-installer.test.ts +++ b/src/relay/managed-hook-installer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD } from '../shared/agent-hook-relay' -import type { MethodHandler, RequestContext } from './dispatcher' +import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher' import { registerManagedHookInstaller, type ManagedHookRuntime } from './managed-hook-installer' function captureHandler(loadRuntime: () => ManagedHookRuntime): MethodHandler { diff --git a/src/relay/managed-hook-installer.ts b/src/relay/managed-hook-installer.ts index e88089f486ad..20c4fde66b8b 100644 --- a/src/relay/managed-hook-installer.ts +++ b/src/relay/managed-hook-installer.ts @@ -3,7 +3,7 @@ import { AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, type AgentHookInstallManagedHooksParams } from '../shared/agent-hook-relay' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import type { AgentHookTarget } from '../shared/agent-hook-types' import { isManagedAgentHookTarget } from '../shared/managed-agent-hook-targets' import { parseClaudeCliVersion } from '../main/claude/claude-hook-event-versions' diff --git a/src/relay/omp-fresh-launch-environment.test.ts b/src/relay/omp-fresh-launch-environment.test.ts index 4765aecbdbd0..a3ad51b502ce 100644 --- a/src/relay/omp-fresh-launch-environment.test.ts +++ b/src/relay/omp-fresh-launch-environment.test.ts @@ -5,10 +5,10 @@ import { tmpdir } from 'node:os' import type * as NodeOs from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import { PtyHandler } from './pty-handler' import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' -import { PluginOverlayManager } from './plugin-overlay' +import { PluginOverlayManager } from '../wsl-guest/plugin-overlay' import { withFreshOmpLaunch } from '../shared/omp-fresh-launch' const state = vi.hoisted(() => ({ home: '' })) diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts index 0650662762c9..8fc9c5f47997 100644 --- a/src/relay/opencode-hook-selection.test.ts +++ b/src/relay/opencode-hook-selection.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../shared/agent-hook-relay' const mocks = vi.hoisted(() => ({ @@ -33,7 +33,7 @@ vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ vi.mock('../main/pty/posix-pty-process-groups', () => ({ forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) })) -vi.mock('./agent-hook-server', () => ({ +vi.mock('../wsl-guest/agent-hook-server', () => ({ RelayAgentHookServer: class { start = async () => {} stop = () => {} diff --git a/src/relay/plugin-host-call-handler.ts b/src/relay/plugin-host-call-handler.ts index c3f73c53c7bf..e4000608f5b8 100644 --- a/src/relay/plugin-host-call-handler.ts +++ b/src/relay/plugin-host-call-handler.ts @@ -1,4 +1,4 @@ -import type { MethodHandler, RequestContext } from './dispatcher' +import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher' import { admitPluginPanelCall, createPluginPanelCallAdmission, diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index 6d17b78f5439..45a389b7c94a 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { MethodHandler, RequestContext } from './dispatcher' +import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher' const { readFileMock, readdirMock, readlinkMock } = vi.hoisted(() => ({ readFileMock: vi.fn(), @@ -301,6 +301,36 @@ describe('PortScanHandler Linux cancellation', () => { platform: 'linux' }) }) + + it('fails instead of answering "no listeners" when the socket table is unreadable', async () => { + mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) + const scanTable = readFileMock.getMockImplementation() + readFileMock.mockImplementation(async (path: string) => { + if (path === '/proc/net/tcp') { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + } + return scanTable?.(path) + }) + + await expect(capturePortDetectHandler()({}, requestContext())).rejects.toThrow( + /Could not read \/proc\/net\/tcp/ + ) + }) + + it('reads a host with IPv6 off, where tcp6 is absent', async () => { + mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) + const scanTable = readFileMock.getMockImplementation() + readFileMock.mockImplementation(async (path: string) => { + if (path === '/proc/net/tcp6') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return scanTable?.(path) + }) + + await expect(capturePortDetectHandler()({}, requestContext())).resolves.toMatchObject({ + ports: [{ port: 3000 }] + }) + }) }) describe('parseHexAddress', () => { diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index a9fa7351122e..29fdca9a19f2 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -1,6 +1,6 @@ import { readFile, readdir, readlink } from 'node:fs/promises' import { getProcessOutputFields } from '../shared/process-output-field-scanner' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { scanWindowsListeningPorts } from './windows-port-scan' // Keep in sync with src/shared/ssh-types.ts — DetectedPort @@ -40,8 +40,8 @@ export class PortScanHandler { private async scanLinuxListeningPorts(signal?: AbortSignal): Promise { signal?.throwIfAborted() const [tcp4, tcp6] = await Promise.all([ - this.readProcNet('/proc/net/tcp', signal), - this.readProcNet('/proc/net/tcp6', signal) + this.readProcNet('/proc/net/tcp', signal, false), + this.readProcNet('/proc/net/tcp6', signal, true) ]) signal?.throwIfAborted() @@ -97,15 +97,21 @@ export class PortScanHandler { private async readProcNet( path: string, - signal?: AbortSignal + signal: AbortSignal | undefined, + absentMeansNone: boolean ): Promise<{ port: number; host: string; inode: number }[]> { signal?.throwIfAborted() let content: string try { content = await readFile(path, 'utf-8') - } catch { + } catch (error) { signal?.throwIfAborted() - return [] + // Why: tcp6 is absent when IPv6 is off; any other failure means the listeners were never + // read, which must not be reported as "none listening". + if (absentMeansNone && error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return [] + } + throw new Error(`Could not read ${path} to list listening ports.`) } signal?.throwIfAborted() diff --git a/src/relay/pty-handler-output-drain-differential.test.ts b/src/relay/pty-handler-output-drain-differential.test.ts index d6c7e92470f3..3afd9a37d817 100644 --- a/src/relay/pty-handler-output-drain-differential.test.ts +++ b/src/relay/pty-handler-output-drain-differential.test.ts @@ -19,7 +19,7 @@ const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) import { PtyHandler } from './pty-handler' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' // Mirrors the relay drain constants; kept local so a constant change fails this oracle loudly. const CHUNK_CHARS = 16 * 1024 diff --git a/src/relay/pty-handler-output-streaming.test.ts b/src/relay/pty-handler-output-streaming.test.ts index f9e241e04f09..2d13b5d9b8ee 100644 --- a/src/relay/pty-handler-output-streaming.test.ts +++ b/src/relay/pty-handler-output-streaming.test.ts @@ -37,8 +37,8 @@ vi.mock('../main/shell-prompt-readiness-probe', () => ({ })) import { PtyHandler } from './pty-handler' -import { RelayDispatcher } from './dispatcher' -import { encodeJsonRpcFrame } from './protocol' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame } from '../wsl-guest/protocol' import type { RelayPtySourcePublication } from './relay-pty-source-publication' import { beginPtyHandlerTest, diff --git a/src/relay/pty-handler-owner-inventory-clone-budget.test.ts b/src/relay/pty-handler-owner-inventory-clone-budget.test.ts index bb283e479922..e76f955e4c0d 100644 --- a/src/relay/pty-handler-owner-inventory-clone-budget.test.ts +++ b/src/relay/pty-handler-owner-inventory-clone-budget.test.ts @@ -2,8 +2,12 @@ import './mock-descendant-sweep' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as CryptoModule from 'node:crypto' import type * as SnapshotModule from '../shared/process-table-snapshot-reader' -import { RelayDispatcher } from './dispatcher' -import { encodeJsonRpcFrame, parseJsonRpcMessage, type JsonRpcResponse } from './protocol' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import { + encodeJsonRpcFrame, + parseJsonRpcMessage, + type JsonRpcResponse +} from '../wsl-guest/protocol' import { PtyHandler } from './pty-handler' import { ClaimedAgentPtyOwnerRegistry } from '../shared/claimed-agent-pty-owner' import { diff --git a/src/relay/pty-handler-shell-recovery.test.ts b/src/relay/pty-handler-shell-recovery.test.ts index 54497cd1763b..43580756b60a 100644 --- a/src/relay/pty-handler-shell-recovery.test.ts +++ b/src/relay/pty-handler-shell-recovery.test.ts @@ -1,8 +1,8 @@ import './mock-descendant-sweep' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PROCESS_BOUNDARY_GROUND } from '../shared/terminal-mode-reset-profiles' -import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +import { RelayDispatcher, type RelayClientSessionIdentity } from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { PtyHandler } from './pty-handler' import { TEST_PTY_ID_MINT_EPOCH } from './pty-handler-test-harness' import { RelayPtySourcePublication } from './relay-pty-source-publication' diff --git a/src/relay/pty-handler-source-publication.test.ts b/src/relay/pty-handler-source-publication.test.ts index 341931cffb5f..d283ea4e5b0a 100644 --- a/src/relay/pty-handler-source-publication.test.ts +++ b/src/relay/pty-handler-source-publication.test.ts @@ -5,8 +5,8 @@ import { RelayDispatcher, type RelayClientSessionIdentity, type SinkWriteSettlement -} from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +} from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { PtyHandler } from './pty-handler' import { TEST_PTY_ID_MINT_EPOCH, testPtyId } from './pty-handler-test-harness' import { RelayPtySourcePublication } from './relay-pty-source-publication' diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index 39ab83503a10..038d944e3295 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -41,7 +41,7 @@ vi.mock('../main/shell-prompt-readiness-probe', () => ({ })) import { MAX_RELAY_PTY_SESSIONS, PtyHandler } from './pty-handler' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { beginPtyHandlerTest, createMockDispatcher, diff --git a/src/relay/pty-handler-test-harness.ts b/src/relay/pty-handler-test-harness.ts index fe6e17c5d9c8..b9259a721a6e 100644 --- a/src/relay/pty-handler-test-harness.ts +++ b/src/relay/pty-handler-test-harness.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest' import type { Mock } from 'vitest' import * as ptyChildProcessInspection from './pty-child-process-inspection' import { PtyHandler } from './pty-handler' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' export const TEST_PTY_ID_MINT_EPOCH = 'test-mint-epoch' diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 6ccf242ae30d..0e6fc386877b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -12,7 +12,7 @@ import { applyOpenCodePluginSelection, restoreOpenCodeCapabilities } from './opencode-plugin-selection' -import { resolveCommandPathForRelay } from './preflight-handler' +import { resolveCommandPathForRelay } from '../wsl-guest/preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -22,7 +22,7 @@ import { basename, join } from 'node:path' import { randomUUID } from 'node:crypto' import { resolveWindowsGitBashShellPath } from '../main/git-bash' import { isSupportedWindowsShellOverride } from '../shared/windows-terminal-shell' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { resolveDefaultShell, resolveProcessCwd, diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 8c5a2e664167..69d8097a743e 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -1,22 +1,22 @@ import { AGENT_HOOK_INFER_INTERRUPT_METHOD } from '../shared/agent-hook-interrupt-reconciliation' import { homedir } from 'node:os' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' -import { RelayAgentHookServer } from './agent-hook-server' -import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' -import { PluginOverlayManager } from './plugin-overlay' -import { installOpenCodePluginInCanonicalConfig } from './opencode-canonical-config' +import { RelayAgentHookServer } from '../wsl-guest/agent-hook-server' +import { endpointDirForRelaySocket } from '../wsl-guest/agent-hook-endpoint-coordinates' +import { PluginOverlayManager } from '../wsl-guest/plugin-overlay' +import { installOpenCodePluginInCanonicalConfig } from '../wsl-guest/opencode-canonical-config' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' -import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' -import { assertPluginSourceUnderByteCap } from './plugin-source-limit' +import { publishAgentHookEnvelope } from '../wsl-guest/agent-hook-envelope-publication' +import { assertPluginSourceUnderByteCap } from '../wsl-guest/plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir, resolveOmpConfigDirName -} from './plugin-overlay-env' +} from '../wsl-guest/plugin-overlay-env' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType diff --git a/src/relay/relay-bundled-ripgrep.ts b/src/relay/relay-bundled-ripgrep.ts index 5c2dd89f3be8..54cb5e562caf 100644 --- a/src/relay/relay-bundled-ripgrep.ts +++ b/src/relay/relay-bundled-ripgrep.ts @@ -10,7 +10,7 @@ import { isTransientRipgrepSpawnError } from '../shared/ripgrep-process-availability' import { relayLogLine } from './relay-diagnostic-log' -import { buildRelayCommandEnv } from './relay-command-env' +import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' export const PATH_RIPGREP_COMMAND = 'rg' diff --git a/src/relay/relay-client-resync-marker.ts b/src/relay/relay-client-resync-marker.ts index 4d6353ea59b4..5fe6361c09cf 100644 --- a/src/relay/relay-client-resync-marker.ts +++ b/src/relay/relay-client-resync-marker.ts @@ -1,4 +1,4 @@ -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' type RetainedMarker = { params: Record; estimatedBytes: number } diff --git a/src/relay/relay-connect-channel.ts b/src/relay/relay-connect-channel.ts index 3bf0b7ae870d..6536597339f7 100644 --- a/src/relay/relay-connect-channel.ts +++ b/src/relay/relay-connect-channel.ts @@ -1,6 +1,6 @@ import { createConnection } from 'node:net' -import { DispatcherClientWriter } from './dispatcher-client-writer' -import { RELAY_SENTINEL } from './protocol' +import { DispatcherClientWriter } from '../wsl-guest/dispatcher-client-writer' +import { RELAY_SENTINEL } from '../wsl-guest/protocol' import { readLaunchVersion, runConnectHandshake } from './relay-handshake' const CONNECT_TIMEOUT_MS = 5_000 diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 8cfde8edfc36..fcf3850157a2 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -9,7 +9,7 @@ import type { WatcherProcessHooks, WatcherProcessSubscription } from '../main/ipc/parcel-watcher-process-subscription' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import { createRelayWatcherProcessPool } from './relay-watcher-process-pool' diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index c9220f09334e..3ec6fb90760f 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -1,4 +1,4 @@ -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' import { WATCHER_IGNORE_DIRS, diff --git a/src/relay/relay-grace-lifecycle.test.ts b/src/relay/relay-grace-lifecycle.test.ts index adc1d7ce7626..409d6c906d7a 100644 --- a/src/relay/relay-grace-lifecycle.test.ts +++ b/src/relay/relay-grace-lifecycle.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from 'vitest' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import type { PtyHandler } from './pty-handler' import { RelayGraceLifecycle } from './relay-grace-lifecycle' diff --git a/src/relay/relay-grace-lifecycle.ts b/src/relay/relay-grace-lifecycle.ts index 8415b71793cb..b38301cce080 100644 --- a/src/relay/relay-grace-lifecycle.ts +++ b/src/relay/relay-grace-lifecycle.ts @@ -6,7 +6,7 @@ import { } from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { errorMessage } from '../shared/error-message' type RelayGraceLifecycleOptions = { diff --git a/src/relay/relay-handshake-roundtrip.test.ts b/src/relay/relay-handshake-roundtrip.test.ts index bfc345e83664..fbfecc8b1221 100644 --- a/src/relay/relay-handshake-roundtrip.test.ts +++ b/src/relay/relay-handshake-roundtrip.test.ts @@ -16,7 +16,7 @@ import { type DecodedFrame, type HandshakeMessage, MessageType -} from './protocol' +} from '../wsl-guest/protocol' import { relayTestSocketPath } from './relay-test-socket-path' // Why: --connect normally calls process.exit on mismatch / fatal handshake diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index fb7003675092..03b01fa4f4e0 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -10,7 +10,7 @@ import { encodeHandshakeFrame, parseHandshakeMessage, type DecodedFrame -} from './protocol' +} from '../wsl-guest/protocol' import { relayLogLine } from './relay-diagnostic-log' import { describeRelayRuntime } from './relay-runtime-identity' diff --git a/src/relay/relay-orca-cli-channel.ts b/src/relay/relay-orca-cli-channel.ts index 1d528c632801..3eec5a9b6cff 100644 --- a/src/relay/relay-orca-cli-channel.ts +++ b/src/relay/relay-orca-cli-channel.ts @@ -1,5 +1,5 @@ import { createConnection } from 'node:net' -import { DispatcherClientWriter } from './dispatcher-client-writer' +import { DispatcherClientWriter } from '../wsl-guest/dispatcher-client-writer' import { pickRemoteCliEnv } from './remote-cli-env' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input' @@ -14,7 +14,7 @@ import { parseJsonRpcMessage, type DecodedFrame, type JsonRpcResponse -} from './protocol' +} from '../wsl-guest/protocol' import { readLaunchVersion, runConnectHandshake } from './relay-handshake' const CONNECT_TIMEOUT_MS = 5_000 diff --git a/src/relay/relay-oversized-notification-survival.test.ts b/src/relay/relay-oversized-notification-survival.test.ts index 40eb6b62f329..2dae43b4cd0f 100644 --- a/src/relay/relay-oversized-notification-survival.test.ts +++ b/src/relay/relay-oversized-notification-survival.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest' -import { RelayDispatcher } from './dispatcher' -import { relayWriterControlReserve } from './dispatcher-writer-admission' -import { encodeJsonRpcFrame, HEADER_LENGTH, parseJsonRpcMessage } from './protocol' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import { relayWriterControlReserve } from '../wsl-guest/dispatcher-writer-admission' +import { encodeJsonRpcFrame, HEADER_LENGTH, parseJsonRpcMessage } from '../wsl-guest/protocol' import { AGENT_HOOK_NOTIFICATION_METHOD, type AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' -import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' +import { publishAgentHookEnvelope } from '../wsl-guest/agent-hook-envelope-publication' import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, diff --git a/src/relay/relay-primary-channel.ts b/src/relay/relay-primary-channel.ts index e3dbffeae7f0..92aab95a5550 100644 --- a/src/relay/relay-primary-channel.ts +++ b/src/relay/relay-primary-channel.ts @@ -1,6 +1,6 @@ import { closeSync, openSync } from 'node:fs' -import { RelayDispatcher } from './dispatcher' -import { RELAY_SENTINEL } from './protocol' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import { RELAY_SENTINEL } from '../wsl-guest/protocol' /** * Why the `\\.\` device prefix and not bare `NUL`: node's fs resolves a relative path diff --git a/src/relay/relay-pty-consumer-owner-displacement.test.ts b/src/relay/relay-pty-consumer-owner-displacement.test.ts index 155abc542f0c..81f090f84727 100644 --- a/src/relay/relay-pty-consumer-owner-displacement.test.ts +++ b/src/relay/relay-pty-consumer-owner-displacement.test.ts @@ -3,8 +3,8 @@ import { RelayDispatcher, type RelayClientSessionIdentity, type SinkWriteSettlement -} from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +} from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' import { PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, diff --git a/src/relay/relay-pty-publication-admission.test.ts b/src/relay/relay-pty-publication-admission.test.ts index d1cb9551cfe8..3be0325e50bf 100644 --- a/src/relay/relay-pty-publication-admission.test.ts +++ b/src/relay/relay-pty-publication-admission.test.ts @@ -4,8 +4,8 @@ import { type RelayClientSessionIdentity, type RelayClientSinkOptions, type SinkWriteSettlement -} from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +} from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { RelayPtySourcePublication } from './relay-pty-source-publication' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-pty-source-activation.ts b/src/relay/relay-pty-source-activation.ts index 3898dda6df17..e8a48d13455a 100644 --- a/src/relay/relay-pty-source-activation.ts +++ b/src/relay/relay-pty-source-activation.ts @@ -8,7 +8,7 @@ import type { PtySourceDeliveryIdentity, PtySourceDeliverySnapshot } from '../shared/pty-source-credit-contract' -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import type { RelayPtySourceDeliveryRecord, RelayPtySourceSendScheduler diff --git a/src/relay/relay-pty-source-cancellation-exit.test.ts b/src/relay/relay-pty-source-cancellation-exit.test.ts index 8a1f5d39ebbc..d039f98baff3 100644 --- a/src/relay/relay-pty-source-cancellation-exit.test.ts +++ b/src/relay/relay-pty-source-cancellation-exit.test.ts @@ -4,8 +4,8 @@ import { RelayDispatcher, type RelayClientSessionIdentity, type SinkWriteSettlement -} from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +} from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { RelayPtySourcePublication } from './relay-pty-source-publication' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-pty-source-exit-publication.test.ts b/src/relay/relay-pty-source-exit-publication.test.ts index a7820c4aa5f3..8e01b4ddfa45 100644 --- a/src/relay/relay-pty-source-exit-publication.test.ts +++ b/src/relay/relay-pty-source-exit-publication.test.ts @@ -3,7 +3,7 @@ import type { PtySourceDeliveryIdentity, PtySourceDeliverySnapshot } from '../shared/pty-source-credit-contract' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { RelayPtySourceLegacyExitIndex, sealAndPublishPtySourceExit, diff --git a/src/relay/relay-pty-source-exit-publication.ts b/src/relay/relay-pty-source-exit-publication.ts index 6257ae254420..552493122996 100644 --- a/src/relay/relay-pty-source-exit-publication.ts +++ b/src/relay/relay-pty-source-exit-publication.ts @@ -1,4 +1,4 @@ -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { onceSinkSettlement, type RelayPtySourceDeliveryRecord, diff --git a/src/relay/relay-pty-source-output.ts b/src/relay/relay-pty-source-output.ts index 0f6ea798a9b7..d9302734aebb 100644 --- a/src/relay/relay-pty-source-output.ts +++ b/src/relay/relay-pty-source-output.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import type { RelayPtySourceDeliveryRecord } from './relay-pty-source-send-scheduler' import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-pty-source-publication.test.ts b/src/relay/relay-pty-source-publication.test.ts index af75613debdd..6e9150f6a875 100644 --- a/src/relay/relay-pty-source-publication.test.ts +++ b/src/relay/relay-pty-source-publication.test.ts @@ -3,8 +3,8 @@ import { RelayDispatcher, type RelayClientSessionIdentity, type SinkWriteSettlement -} from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +} from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { RelayPtySourcePublication } from './relay-pty-source-publication' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-pty-source-publication.ts b/src/relay/relay-pty-source-publication.ts index c1959fd24d13..b62a718a0866 100644 --- a/src/relay/relay-pty-source-publication.ts +++ b/src/relay/relay-pty-source-publication.ts @@ -22,7 +22,7 @@ import { sealAndPublishTrackedPtySourceExit, type PtyExitParams } from './relay-pty-source-exit-publication' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { appendPtySourceOutput, projectPtySourceOutputToLegacy, diff --git a/src/relay/relay-pty-source-recovery-completion.test.ts b/src/relay/relay-pty-source-recovery-completion.test.ts index ba5f8d6787f5..d46cd32f09c2 100644 --- a/src/relay/relay-pty-source-recovery-completion.test.ts +++ b/src/relay/relay-pty-source-recovery-completion.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import type { RelayDispatcher, SinkWriteSettlement } from './dispatcher' +import type { RelayDispatcher, SinkWriteSettlement } from '../wsl-guest/dispatcher' import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' import { RelayPtySourceSendScheduler, diff --git a/src/relay/relay-pty-source-recovery-completion.ts b/src/relay/relay-pty-source-recovery-completion.ts index bc4c79e1be6d..e5fc2a32f31b 100644 --- a/src/relay/relay-pty-source-recovery-completion.ts +++ b/src/relay/relay-pty-source-recovery-completion.ts @@ -1,4 +1,4 @@ -import type { RelayDispatcher, SinkWriteSettlement } from './dispatcher' +import type { RelayDispatcher, SinkWriteSettlement } from '../wsl-guest/dispatcher' import type { RelayPtySourceDeliveryRecord } from './relay-pty-source-send-scheduler' import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-pty-source-recovery-interleavings.test.ts b/src/relay/relay-pty-source-recovery-interleavings.test.ts index 0c35b25c1a7f..fc7588f507b7 100644 --- a/src/relay/relay-pty-source-recovery-interleavings.test.ts +++ b/src/relay/relay-pty-source-recovery-interleavings.test.ts @@ -5,8 +5,8 @@ import { RelayDispatcher, type RelayClientSessionIdentity, type SinkWriteSettlement -} from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +} from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { RelayPtySourceCreditLedger } from './pty-source-credit-ledger' import { RelayPtySourcePublication } from './relay-pty-source-publication' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-pty-source-recovery-window.test.ts b/src/relay/relay-pty-source-recovery-window.test.ts index 6f3f8186178c..361b90566036 100644 --- a/src/relay/relay-pty-source-recovery-window.test.ts +++ b/src/relay/relay-pty-source-recovery-window.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, it } from 'vitest' -import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' +import { RelayDispatcher, type RelayClientSessionIdentity } from '../wsl-guest/dispatcher' import { boundedPtyRecoveryEnd } from './relay-pty-source-activation' -import { encodeJsonRpcFrame, MessageType } from './protocol' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { RelayPtySourcePublication } from './relay-pty-source-publication' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-pty-source-restore-retry.test.ts b/src/relay/relay-pty-source-restore-retry.test.ts index 0eaf29d61c0a..c22cba9e3504 100644 --- a/src/relay/relay-pty-source-restore-retry.test.ts +++ b/src/relay/relay-pty-source-restore-retry.test.ts @@ -3,8 +3,8 @@ import { RelayDispatcher, type RelayClientSessionIdentity, type SinkWriteSettlement -} from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +} from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { RelayPtySourcePublication } from './relay-pty-source-publication' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-pty-source-send-scheduler.test.ts b/src/relay/relay-pty-source-send-scheduler.test.ts index 7c130be70452..c57f34a11d95 100644 --- a/src/relay/relay-pty-source-send-scheduler.test.ts +++ b/src/relay/relay-pty-source-send-scheduler.test.ts @@ -3,7 +3,7 @@ import type { PtySourceDeliveryIdentity, PtySourceDeliverySnapshot } from '../shared/pty-source-credit-contract' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { RelayPtySourceSendScheduler, type RelayPtySourceDeliveryRecord, diff --git a/src/relay/relay-pty-source-send-scheduler.ts b/src/relay/relay-pty-source-send-scheduler.ts index bbec1a824d34..b687a7291275 100644 --- a/src/relay/relay-pty-source-send-scheduler.ts +++ b/src/relay/relay-pty-source-send-scheduler.ts @@ -4,7 +4,7 @@ import type { } from '../shared/pty-source-credit-contract' import type { PtySourceRecoveryCheckpoint } from '../shared/pty-source-recovery-contract' import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' -import type { RelayDispatcher, SinkWriteSettlement } from './dispatcher' +import type { RelayDispatcher, SinkWriteSettlement } from '../wsl-guest/dispatcher' import { PTY_SOURCE_SCHEDULER_MAX_FRAMES, PTY_SOURCE_SCHEDULER_MAX_SU diff --git a/src/relay/relay-pty-source-superseded-activation.test.ts b/src/relay/relay-pty-source-superseded-activation.test.ts index 759bbbef1f5f..c2bc99ad850d 100644 --- a/src/relay/relay-pty-source-superseded-activation.test.ts +++ b/src/relay/relay-pty-source-superseded-activation.test.ts @@ -4,8 +4,8 @@ import { type RelayClientSessionIdentity, type RequestContext, type SinkWriteSettlement -} from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +} from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { RelayPtySourcePublication } from './relay-pty-source-publication' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' diff --git a/src/relay/relay-reconnect-listener-credential-gate.test.ts b/src/relay/relay-reconnect-listener-credential-gate.test.ts index d9794b9511a0..4edb91a6ea51 100644 --- a/src/relay/relay-reconnect-listener-credential-gate.test.ts +++ b/src/relay/relay-reconnect-listener-credential-gate.test.ts @@ -11,8 +11,8 @@ import { FrameDecoder, parseHandshakeMessage, RELAY_VERSION -} from './protocol' -import type { RelayDispatcher } from './dispatcher' +} from '../wsl-guest/protocol' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' const noopCallbacks = { detachPrimaryInput: () => {}, diff --git a/src/relay/relay-reconnect-listener-peer-end.test.ts b/src/relay/relay-reconnect-listener-peer-end.test.ts index f1dddda344a0..b10698982795 100644 --- a/src/relay/relay-reconnect-listener-peer-end.test.ts +++ b/src/relay/relay-reconnect-listener-peer-end.test.ts @@ -6,8 +6,8 @@ import { tmpdir } from 'node:os' import * as path from 'node:path' import { RelayReconnectListener } from './relay-reconnect-listener' import { RelaySocketOwnership } from './relay-socket-ownership' -import { encodeHandshakeFrame, FrameDecoder, RELAY_VERSION } from './protocol' -import { RelayDispatcher } from './dispatcher' +import { encodeHandshakeFrame, FrameDecoder, RELAY_VERSION } from '../wsl-guest/protocol' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import type { PtyConsumerCloseCause } from '../shared/pty-consumer-session-contract' describe.skipIf(process.platform === 'win32')('reconnect listener peer end', () => { diff --git a/src/relay/relay-reconnect-listener-peer-write-error.test.ts b/src/relay/relay-reconnect-listener-peer-write-error.test.ts index cd411f03b7e4..dd443c9aecf1 100644 --- a/src/relay/relay-reconnect-listener-peer-write-error.test.ts +++ b/src/relay/relay-reconnect-listener-peer-write-error.test.ts @@ -6,8 +6,8 @@ import { tmpdir } from 'node:os' import * as path from 'node:path' import { RelayReconnectListener } from './relay-reconnect-listener' import { RelaySocketOwnership } from './relay-socket-ownership' -import { encodeHandshakeFrame, FrameDecoder, RELAY_VERSION } from './protocol' -import { RelayDispatcher } from './dispatcher' +import { encodeHandshakeFrame, FrameDecoder, RELAY_VERSION } from '../wsl-guest/protocol' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import type { PtyConsumerCloseCause } from '../shared/pty-consumer-session-contract' // Why: on Windows named pipes a relay write can fail with EPIPE/ECONNRESET before EOF is read. The diff --git a/src/relay/relay-reconnect-listener.ts b/src/relay/relay-reconnect-listener.ts index 9d7ebb675d36..984ff3272640 100644 --- a/src/relay/relay-reconnect-listener.ts +++ b/src/relay/relay-reconnect-listener.ts @@ -1,5 +1,5 @@ import type { Socket } from 'node:net' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { setupDaemonHandshake } from './relay-handshake' import { relayLogLine } from './relay-diagnostic-log' import type { RelaySocketOwnership } from './relay-socket-ownership' diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index e19e2e20973b..e7306c1a3cae 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -5,13 +5,13 @@ import { parseUnameToRelayPlatform, RELAY_REMOTE_DIR } from '../main/ssh/relay-p import { DEFAULT_AI_VAULT_SEARCH_SETTINGS } from '../shared/ai-vault-search-settings' import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' import { installInProcessSessionSearchService } from '../main/ai-vault-search/session-search-in-process-service' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { RelayContext, expandTilde } from './context' import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' import { GitResponseStreamRegistry } from './git-response-stream' -import { PreflightHandler } from './preflight-handler' +import { PreflightHandler } from '../wsl-guest/preflight-handler' import { ExternalAutomationsHandler } from './external-automations-handler' import { PortScanHandler } from './port-scan-handler' import { AgentExecHandler } from './agent-exec-handler' diff --git a/src/relay/relay-stream-ack-window.ts b/src/relay/relay-stream-ack-window.ts index de4b1aecda43..7d1f11b7807b 100644 --- a/src/relay/relay-stream-ack-window.ts +++ b/src/relay/relay-stream-ack-window.ts @@ -1,4 +1,4 @@ -import { STREAM_ACK_STALL_RECHECK_MS } from './protocol' +import { STREAM_ACK_STALL_RECHECK_MS } from '../wsl-guest/protocol' /** Waiters wake on ack, abort, or a stall recheck so a vanished client cannot strand a pump. */ export class RelayStreamAckWindow { diff --git a/src/relay/relay-watch-root-capacity.test.ts b/src/relay/relay-watch-root-capacity.test.ts index 9e2d5d862a85..8bf03b9ff6b8 100644 --- a/src/relay/relay-watch-root-capacity.test.ts +++ b/src/relay/relay-watch-root-capacity.test.ts @@ -4,7 +4,7 @@ import * as path from 'node:path' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { RelayContext } from './context' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { FsHandler } from './fs-handler' import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback' import { createMockDispatcher } from './relay-fs-test-dispatcher' diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts index 82b10e2fdf3a..ebddbc26260e 100644 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ b/src/relay/relay-watcher-event-emitter.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' -import { RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES, DEFAULT_PRODUCER_QUEUE_MAX_BYTES, DISPATCHER_CONTROL_QUEUE_MAX_FRAMES -} from './dispatcher-writer-admission' -import type { RelayClientSinkOptions, RelayClientWrite } from './dispatcher-writer-sink' -import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from './legacy-relay-publication-ledger' -import { HEADER_LENGTH, parseJsonRpcMessage } from './protocol' +} from '../wsl-guest/dispatcher-writer-admission' +import type { RelayClientSinkOptions, RelayClientWrite } from '../wsl-guest/dispatcher-writer-sink' +import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from '../wsl-guest/legacy-relay-publication-ledger' +import { HEADER_LENGTH, parseJsonRpcMessage } from '../wsl-guest/protocol' import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' type WatcherFrameEvent = { kind: string; absolutePath: string; isDirectory?: boolean } diff --git a/src/relay/relay-watcher-event-emitter.ts b/src/relay/relay-watcher-event-emitter.ts index 4baf89535bbd..828fd836492d 100644 --- a/src/relay/relay-watcher-event-emitter.ts +++ b/src/relay/relay-watcher-event-emitter.ts @@ -1,6 +1,6 @@ import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' import { resolveRuntimePath } from '../shared/cross-platform-path' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { createRelayClientResyncMarkerPublisher, type RelayClientResyncMarkerPublisher diff --git a/src/relay/relay-watcher-frame-chunking.test.ts b/src/relay/relay-watcher-frame-chunking.test.ts index 8b9782972c85..058ca631c757 100644 --- a/src/relay/relay-watcher-frame-chunking.test.ts +++ b/src/relay/relay-watcher-frame-chunking.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type RelayClientSinkOptions } from './dispatcher' -import { HEADER_LENGTH, encodeJsonRpcFrame, parseJsonRpcMessage } from './protocol' +import { RelayDispatcher, type RelayClientSinkOptions } from '../wsl-guest/dispatcher' +import { HEADER_LENGTH, encodeJsonRpcFrame, parseJsonRpcMessage } from '../wsl-guest/protocol' import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' diff --git a/src/relay/relay-watcher-parent-removal.test.ts b/src/relay/relay-watcher-parent-removal.test.ts index 972513cbc594..e1e36acdb314 100644 --- a/src/relay/relay-watcher-parent-removal.test.ts +++ b/src/relay/relay-watcher-parent-removal.test.ts @@ -3,7 +3,7 @@ import type { WatcherProcessHooks, WatcherProcessSubscription } from '../main/ipc/parcel-watcher-process-subscription' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' class ParentRemovalPool { diff --git a/src/relay/relay-watcher-pending-setup-waiters.test.ts b/src/relay/relay-watcher-pending-setup-waiters.test.ts index 99ddc6a412d2..85a1dbfcd7bd 100644 --- a/src/relay/relay-watcher-pending-setup-waiters.test.ts +++ b/src/relay/relay-watcher-pending-setup-waiters.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { WatcherProcessSubscription } from '../main/ipc/parcel-watcher-process-subscription' import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' class PendingSetupPool { diff --git a/src/relay/relay-watcher-removal-fence.ts b/src/relay/relay-watcher-removal-fence.ts index 9b0b62ab7808..28859c256efa 100644 --- a/src/relay/relay-watcher-removal-fence.ts +++ b/src/relay/relay-watcher-removal-fence.ts @@ -2,7 +2,7 @@ import type { RelayWatcherTeardownState, RelayWatcherTeardownTracker } from './relay-watcher-teardown-tracker' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' import { isPathInsideOrEqual } from '../shared/cross-platform-path' import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' diff --git a/src/relay/relay-watcher-setup-wait.ts b/src/relay/relay-watcher-setup-wait.ts index 8502b228cbef..95cc65b38b01 100644 --- a/src/relay/relay-watcher-setup-wait.ts +++ b/src/relay/relay-watcher-setup-wait.ts @@ -1,6 +1,6 @@ import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' import { errorMessage } from '../shared/error-message' diff --git a/src/relay/relay-watcher-shared-byte-index.test.ts b/src/relay/relay-watcher-shared-byte-index.test.ts index 418bcc9d7b50..41ed16aaff8a 100644 --- a/src/relay/relay-watcher-shared-byte-index.test.ts +++ b/src/relay/relay-watcher-shared-byte-index.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' -import { RelayDispatcher } from './dispatcher' -import type { RelayClientSinkOptions, RelayClientWrite } from './dispatcher-writer-sink' -import { encodeJsonRpcFrame } from './protocol' +import { RelayDispatcher } from '../wsl-guest/dispatcher' +import type { RelayClientSinkOptions, RelayClientWrite } from '../wsl-guest/dispatcher-writer-sink' +import { encodeJsonRpcFrame } from '../wsl-guest/protocol' import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' type WatcherPayload = { kind: string; absolutePath: string; isDirectory?: boolean } diff --git a/src/relay/relay-watcher-shutdown.test.ts b/src/relay/relay-watcher-shutdown.test.ts index 530b029fa73b..9d92b36d4567 100644 --- a/src/relay/relay-watcher-shutdown.test.ts +++ b/src/relay/relay-watcher-shutdown.test.ts @@ -1,7 +1,7 @@ import { expect, it, vi } from 'vitest' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' function fixture() { diff --git a/src/relay/relay-watcher-terminal-notifier.ts b/src/relay/relay-watcher-terminal-notifier.ts index add044f4460c..f5ef6b86f426 100644 --- a/src/relay/relay-watcher-terminal-notifier.ts +++ b/src/relay/relay-watcher-terminal-notifier.ts @@ -1,4 +1,4 @@ -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' type RelayTerminalWatchOwners = { rootPath: string diff --git a/src/relay/relay-windows-path-ripgrep.test.ts b/src/relay/relay-windows-path-ripgrep.test.ts index aa8601fdab56..f27c14200a93 100644 --- a/src/relay/relay-windows-path-ripgrep.test.ts +++ b/src/relay/relay-windows-path-ripgrep.test.ts @@ -10,7 +10,9 @@ import { } from './relay-bundled-ripgrep' // These tests isolate PATH safety; effective-environment coverage lives in the cache suite. -vi.mock('./relay-command-env', () => ({ buildRelayCommandEnv: () => ({ ...process.env }) })) +vi.mock('../wsl-guest/relay-command-env', () => ({ + buildRelayCommandEnv: () => ({ ...process.env }) +})) const originalPlatform = process.platform const originalPath = process.env.PATH diff --git a/src/relay/remote-artifact-cli-forwarding.ts b/src/relay/remote-artifact-cli-forwarding.ts index d08efdb2c220..f82ae8adb82c 100644 --- a/src/relay/remote-artifact-cli-forwarding.ts +++ b/src/relay/remote-artifact-cli-forwarding.ts @@ -1,6 +1,6 @@ import type { RemoteArtifactInput } from '../shared/artifact-cli-bridge' -import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission' -import { encodeJsonRpcFrame } from './protocol' +import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from '../wsl-guest/dispatcher-writer-admission' +import { encodeJsonRpcFrame } from '../wsl-guest/protocol' export type RemoteArtifactCliForwardingParams = { argv: string[] diff --git a/src/relay/remote-artifact-cli-input.test.ts b/src/relay/remote-artifact-cli-input.test.ts index 9de3d6a8ab68..3c7cf1024683 100644 --- a/src/relay/remote-artifact-cli-input.test.ts +++ b/src/relay/remote-artifact-cli-input.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../shared/artifacts' import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input' -import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission' +import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from '../wsl-guest/dispatcher-writer-admission' import { assertRemoteArtifactCliForwardingFits, remoteArtifactCliForwardingFrameBytes, diff --git a/src/relay/remote-artifact-cli-input.ts b/src/relay/remote-artifact-cli-input.ts index 5b5322081dbf..56d5ad0329c9 100644 --- a/src/relay/remote-artifact-cli-input.ts +++ b/src/relay/remote-artifact-cli-input.ts @@ -2,7 +2,7 @@ import { basename, extname, resolve } from 'node:path' import type { RemoteArtifactInput } from '../shared/artifact-cli-bridge' import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../shared/artifacts' import { readArtifactFileWithinLimit } from '../shared/artifact-file-read' -import { MAX_MESSAGE_SIZE } from './protocol' +import { MAX_MESSAGE_SIZE } from '../wsl-guest/protocol' export type PreparedRemoteArtifactCliInput = { stdin?: string diff --git a/src/relay/session-search-transport.test.ts b/src/relay/session-search-transport.test.ts index 4331c1b78213..04be40876eac 100644 --- a/src/relay/session-search-transport.test.ts +++ b/src/relay/session-search-transport.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from '../wsl-guest/dispatcher' import { AiVaultHandler } from './ai-vault-handler' import { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' import { createSessionSearchClient } from '../shared/ai-vault-search-client' diff --git a/src/relay/skill-install-handler.test.ts b/src/relay/skill-install-handler.test.ts index d2f19b5a38af..3a46506d0936 100644 --- a/src/relay/skill-install-handler.test.ts +++ b/src/relay/skill-install-handler.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { MethodHandler, RelayDispatcher } from './dispatcher' +import type { MethodHandler, RelayDispatcher } from '../wsl-guest/dispatcher' import { createSkillPackageArchive } from '../main/skills/skill-package-creation' import { createSkillBundleArchive } from '../main/skills/skill-bundle-creation' import { diff --git a/src/relay/skill-install-handler.ts b/src/relay/skill-install-handler.ts index d79d3d689be4..91abf85d1c23 100644 --- a/src/relay/skill-install-handler.ts +++ b/src/relay/skill-install-handler.ts @@ -42,8 +42,8 @@ import { getTuiAgentDetectionProbeCommands, resolveDetectedTuiAgentIds } from '../shared/tui-agent-detection-commands' -import type { RelayDispatcher } from './dispatcher' -import { isCommandOnPathForRelay } from './preflight-handler' +import type { RelayDispatcher } from '../wsl-guest/dispatcher' +import { isCommandOnPathForRelay } from '../wsl-guest/preflight-handler' import type { SkillInstallDestinationAuthority } from '../main/skills/skill-install-destinations' import { previewSharedSkillBundleInstall, diff --git a/src/relay/ssh-pty-consumer-session-adapter.test.ts b/src/relay/ssh-pty-consumer-session-adapter.test.ts index 7c38799b876d..87b938c4f830 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.test.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.test.ts @@ -5,8 +5,8 @@ import { PTY_CONSUMER_OWNER_HELD_GRACE_FLOOR_MS, PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR } from '../shared/pty-consumer-session' -import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' -import { encodeJsonRpcFrame, MessageType } from './protocol' +import { RelayDispatcher, type RelayClientSessionIdentity } from '../wsl-guest/dispatcher' +import { encodeJsonRpcFrame, MessageType } from '../wsl-guest/protocol' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' const endpointIdentity: RelayClientSessionIdentity = { diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index 906edd47ff82..c5d5fedcae04 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -11,7 +11,7 @@ import type { PtySourceSpan, PtySourceTransform } from '../shared/pty-source-credit-contract' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher' import type { PtySourceSendReservation } from './pty-source-credit-ledger' import { SshPtySourceCreditAdapter } from './ssh-pty-source-credit-adapter' import { diff --git a/src/relay/ssh-pty-open-client-request.ts b/src/relay/ssh-pty-open-client-request.ts index 48d6d255117d..f776cc1a99d6 100644 --- a/src/relay/ssh-pty-open-client-request.ts +++ b/src/relay/ssh-pty-open-client-request.ts @@ -1,5 +1,5 @@ import type { PtyConsumerSessionHello } from '../shared/pty-consumer-session' -import type { RelayClientSessionIdentity, RequestContext } from './dispatcher' +import type { RelayClientSessionIdentity, RequestContext } from '../wsl-guest/dispatcher' export type OpenClientParams = PtyConsumerSessionHello & { protocolVersion: number diff --git a/src/relay/subprocess-test-utils.ts b/src/relay/subprocess-test-utils.ts index b721f17968f8..db0744d53bc7 100644 --- a/src/relay/subprocess-test-utils.ts +++ b/src/relay/subprocess-test-utils.ts @@ -8,7 +8,7 @@ import { type JsonRpcRequest, type JsonRpcResponse, type JsonRpcNotification -} from './protocol' +} from '../wsl-guest/protocol' export type RelayProcess = { proc: ChildProcess diff --git a/src/relay/windows-port-scan.test.ts b/src/relay/windows-port-scan.test.ts index b8ba9e27d4d4..6626bad5c23e 100644 --- a/src/relay/windows-port-scan.test.ts +++ b/src/relay/windows-port-scan.test.ts @@ -5,7 +5,7 @@ vi.mock('@orca/process-host', () => ({ runProcess: (spec: unknown) => runProcessMock(spec) })) -vi.mock('./relay-command-env', () => ({ +vi.mock('../wsl-guest/relay-command-env', () => ({ buildRelayCommandEnv: () => ({ PATH: 'C:\\Windows\\System32' }) })) @@ -197,13 +197,13 @@ describe('scanWindowsListeningPorts', () => { expect(powershell.timeoutMs).toBe(5000) }) - it('tries pwsh when Windows PowerShell cannot answer, then gives up empty', async () => { + it('tries pwsh when Windows PowerShell cannot answer, then fails instead of answering empty', async () => { runProcessMock .mockResolvedValueOnce(ok('')) .mockRejectedValueOnce(new Error('powershell unavailable')) .mockRejectedValueOnce(new Error('pwsh unavailable')) - await expect(scanWindowsListeningPorts()).resolves.toEqual([]) + await expect(scanWindowsListeningPorts()).rejects.toThrow(/Could not list listening ports/) expect(specs().map((spec) => spec.program)).toEqual([ expect.stringMatching(/netstat\.exe$/), diff --git a/src/relay/windows-port-scan.ts b/src/relay/windows-port-scan.ts index 9fcb5a61b4dc..30e4f572767c 100644 --- a/src/relay/windows-port-scan.ts +++ b/src/relay/windows-port-scan.ts @@ -6,7 +6,7 @@ import { } from '@orca/process-host/windows-system-binary' import { getProcessOutputFields } from '../shared/process-output-field-scanner' import type { DetectedPort } from './port-scan-handler' -import { buildRelayCommandEnv } from './relay-command-env' +import { buildRelayCommandEnv } from '../wsl-guest/relay-command-env' import { relayLogLine } from './relay-diagnostic-log' const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) @@ -44,12 +44,14 @@ export async function scanWindowsListeningPorts(signal?: AbortSignal): Promise ({ execFileMock: vi.fn(), diff --git a/src/relay/workspace-space-scan.test.ts b/src/relay/workspace-space-scan.test.ts index b3eee3392496..afa01b32973d 100644 --- a/src/relay/workspace-space-scan.test.ts +++ b/src/relay/workspace-space-scan.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import type * as NodeProcess from 'node:process' import { afterEach, describe, expect, it, vi } from 'vitest' import type * as WorkspaceSpaceScanBudgetModule from '../shared/workspace-space-scan-budget' -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' vi.mock('node:process', async () => { const actual = await vi.importActual('node:process') diff --git a/src/relay/workspace-space-scan.ts b/src/relay/workspace-space-scan.ts index 73a684548334..ef020ab0f040 100644 --- a/src/relay/workspace-space-scan.ts +++ b/src/relay/workspace-space-scan.ts @@ -19,7 +19,7 @@ import { createWorkspaceSpaceScanBudget, WorkspaceSpaceScanCapacityError } from '../shared/workspace-space-scan-budget' -import type { RequestContext } from './dispatcher' +import type { RequestContext } from '../wsl-guest/dispatcher' const RELAY_FS_CONCURRENCY = 48 const DU_TIMEOUT_MS = 120_000 diff --git a/src/renderer/src/app-shell/TitlebarLeftControls.tsx b/src/renderer/src/app-shell/TitlebarLeftControls.tsx index 352e3d121fba..7d496d9844bb 100644 --- a/src/renderer/src/app-shell/TitlebarLeftControls.tsx +++ b/src/renderer/src/app-shell/TitlebarLeftControls.tsx @@ -15,7 +15,7 @@ import { } from '@/store/slices/worktree-nav-history' import { useShortcutLabel } from '../hooks/useShortcutLabel' import { useAppStore } from '../store' -import { hasCustomTitleBar, isMac } from './app-window-chrome' +import { hasCustomTitleBar, hasMacTrafficLights } from './app-window-chrome' import type { AppChromeLayout } from './use-app-chrome-layout' /** @@ -42,7 +42,7 @@ export function TitlebarLeftControls({ layout }: { layout: AppChromeLayout }): R }`} >
- {isMac && !layout.isFullScreen ? ( + {hasMacTrafficLights && !layout.isFullScreen ? (
) : hasCustomTitleBar ? ( /* Why: Windows/Linux remove the native title bar, so render the logo plus a ··· button that pops the application menu (as Alt does). */ diff --git a/src/renderer/src/app-shell/app-window-chrome.test.ts b/src/renderer/src/app-shell/app-window-chrome.test.ts new file mode 100644 index 000000000000..3c5c23b91167 --- /dev/null +++ b/src/renderer/src/app-shell/app-window-chrome.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as AppWindowChrome from './app-window-chrome' + +async function loadChromeOnMac(): Promise { + vi.resetModules() + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }) + return import('./app-window-chrome') +} + +describe('macOS traffic-light inset', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('reserves the traffic-light inset in the macOS desktop window', async () => { + const chrome = await loadChromeOnMac() + expect(chrome.hasMacTrafficLights).toBe(true) + expect(chrome.MAC_TRAFFIC_LIGHTS_WIDTH).toBe('80px') + }) + + // #19299: a browser tab has no native window controls to make room for. + it('reserves nothing in a paired web client on a Mac browser', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + const chrome = await loadChromeOnMac() + expect(chrome.isMac).toBe(true) + expect(chrome.hasMacTrafficLights).toBe(false) + expect(chrome.MAC_TRAFFIC_LIGHTS_WIDTH).toBe('0px') + }) +}) diff --git a/src/renderer/src/app-shell/app-window-chrome.ts b/src/renderer/src/app-shell/app-window-chrome.ts index 8305650c4d03..2a34e7c8df2c 100644 --- a/src/renderer/src/app-shell/app-window-chrome.ts +++ b/src/renderer/src/app-shell/app-window-chrome.ts @@ -18,5 +18,7 @@ export const WINDOW_CONTROLS_WIDTH = hasCustomTitleBar ? '138px' : '0px' export const WINDOW_CONTROLS_HEIGHT = hasCustomTitleBar ? '36px' : '0px' // Why: macOS paints traffic lights on the window's top-left edge. Windows and Linux paint their -// controls on the right, so only macOS needs a surface to keep the left edge uncovered. -export const MAC_TRAFFIC_LIGHTS_WIDTH = isMac ? '80px' : '0px' +// controls on the right, and a paired web client's browser tab has none, so only a macOS desktop +// window needs a surface to keep the left edge uncovered. +export const hasMacTrafficLights = isMac && !isPairedWebClientWindow() +export const MAC_TRAFFIC_LIGHTS_WIDTH = hasMacTrafficLights ? '80px' : '0px' diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx index 7146e77d6d13..9366a83b4b1e 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx @@ -133,6 +133,7 @@ describe('new workspace card file drop ownership', () => { ({ source }: { source: string }) => { const target = resolveComposerAttachmentTarget({ selectedProjectGroup: group, + selectedRepo: { id: 'source-repo' }, selectedRepoPath: source, selectedRepoExecutionHostId: 'ssh:ssh-a', selectedRepoSettings: {}, @@ -146,6 +147,7 @@ describe('new workspace card file drop ownership', () => { connectionId: target.connectionId, selectedRepoPath: target.path ?? undefined, selectedRepoSettings: target.settings, + selectedWorktreeId: target.worktreeId, setAgentPrompt: () => {}, setAttachmentPaths: attach }) diff --git a/src/renderer/src/components/activity/activity-thread-controls.tsx b/src/renderer/src/components/activity/activity-thread-controls.tsx index 2218df0cd54a..f986c12eea88 100644 --- a/src/renderer/src/components/activity/activity-thread-controls.tsx +++ b/src/renderer/src/components/activity/activity-thread-controls.tsx @@ -2,7 +2,6 @@ import React from 'react' import { ChevronDown } from 'lucide-react' import { AgentStateDot } from '@/components/AgentStateDot' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' import { useNow } from '@/hooks/use-now' import { cn } from '@/lib/utils' import { formatShortTimeAgo } from '@/lib/short-time-ago' @@ -52,23 +51,6 @@ export function EventTime({ ) } -export function ActivityProjectLabel({ repo }: { repo: Repo | null }): React.JSX.Element { - const label = - repo?.displayName?.trim() || - translate('auto.components.activity.ActivityPrototypePage.5651b216c6', 'Unknown project') - return ( -
- {repo ? : null} - - {label} - -
- ) -} - export function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null { if (!repo) { return null diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx index 05fa78dbe3a9..30ccc40602b3 100644 --- a/src/renderer/src/components/agent/AgentCombobox.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -222,6 +222,13 @@ describe('AgentCombobox', () => { expect(markup).not.toContain(' { + const markup = renderToStaticMarkup() + + expect(markup).toContain(AGENT_FAVICON_ASSETS.dsh) + expect(AGENT_CATALOG.map((agent) => agent.id)).not.toContain('dsb') + }) + it('renders bundled favicons for favicon-domain agents instead of the remote Google service', () => { // Why: previously loaded from Google's favicon service (#8451). Iterate the // full asset map so missing files/key mismatches fail the test. diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.tsx index f67556b33256..17c082352085 100644 --- a/src/renderer/src/components/agent/AgentSettingsDialog.tsx +++ b/src/renderer/src/components/agent/AgentSettingsDialog.tsx @@ -15,6 +15,7 @@ import { } from '@/lib/windows-terminal-capabilities' import { isWebClientLocation } from '@/lib/web-client-location' import { useWindowsTerminalCapabilityOwnerKey } from '@/hooks/useWindowsTerminalCapabilityOwnerKey' +import { resolveSettingsHostScope } from '@/components/settings/settings-host-scope' type AgentSettingsDialogProps = { open: boolean @@ -27,6 +28,8 @@ export default function AgentSettingsDialog({ }: AgentSettingsDialogProps): React.JSX.Element | null { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) const isWindowsRenderer = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') const isWebClient = isWebClientLocation() @@ -49,6 +52,13 @@ export default function AgentSettingsDialog({ if (!settings) { return null } + // Why: no picker here; the pane names the default host it detects on. + const hostScope = resolveSettingsHostScope({ + choice: null, + defaultEnvironmentId: settings.activeRuntimeEnvironmentId, + savedEnvironmentIds: runtimeEnvironments.map((environment) => environment.id), + catalogHydrated: runtimeEnvironmentCatalogHydrated + }) return ( @@ -72,6 +82,7 @@ export default function AgentSettingsDialog({ AutomationHostCacheEntry | null { - return (stableKey) => cache.getByKey(stableKey) -} diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 1fd5812ab783..2e76f5a17791 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -4,7 +4,6 @@ import type { Automation, AutomationCreateInput, AutomationRun, - AutomationRunsPage, AutomationUpdateInput } from '../../../../shared/automations-types' import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' @@ -133,20 +132,6 @@ export async function listAutomationRunsForTarget( return result.runs } -export async function listAutomationRunsPageForTarget( - target: AutomationHostTarget, - automationId: string, - options: { limit?: number; cursor?: string } = {} -): Promise { - const result = await callRuntimeRpc( - target, - 'automation.runs', - { automationId, ...options }, - { timeoutMs: 15_000 } - ) - return { runs: result.runs, nextCursor: 'nextCursor' in result ? result.nextCursor : null } -} - export async function updateAutomationForTarget( automation: Automation, updates: AutomationUpdateInput, diff --git a/src/renderer/src/components/automations/automation-run-open-target.ts b/src/renderer/src/components/automations/automation-run-open-target.ts index 023cf2a855dc..f873a5065c0b 100644 --- a/src/renderer/src/components/automations/automation-run-open-target.ts +++ b/src/renderer/src/components/automations/automation-run-open-target.ts @@ -1,7 +1,15 @@ import type { AutomationRun } from '../../../../shared/automations-types' -import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import { + isWebTerminalSurfaceTabId, + toWebTerminalSurfaceTabId +} from '../../../../shared/terminal-surface-id' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { terminalLayoutContainsLeaf } from '../../../../shared/workspace-session-pane-ownership' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { resolveOwner } from '@/lib/resolve-owner' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' export type AutomationRunPaneTarget = { tabId: string @@ -109,6 +117,58 @@ export function canOpenAutomationRunOpenTarget(args: { return isAutomationRunPaneMounted(args) } +export type AutomationRunTerminalState = { + hasTerminalTab: (tabId: string) => boolean + terminalLayoutsByTabId: Readonly> + ptyIdsByTabId: Readonly> +} + +/** + * The run's pane in this client. A run on a paired server names the server's tab, pane and PTY; + * this client shows that pane as a mirrored tab whose PTY id is the server's terminal handle, so + * the server's PTY id cannot be compared and the mirrored pane must carry a PTY from that server. + */ +export function resolveAutomationRunTerminalTarget( + run: AutomationRun, + terminals: AutomationRunTerminalState, + ownerEnvironmentId: string | null +): AutomationRunPaneTarget | null { + const tabId = getAutomationRunOpenTabId(run) + if (!tabId) { + return null + } + const local = resolveAutomationRunOpenTarget({ + run, + terminalTabExists: terminals.hasTerminalTab(tabId), + currentLayout: terminals.terminalLayoutsByTabId[tabId], + livePtyIds: terminals.ptyIdsByTabId[tabId] ?? [] + }) + const parsed = parsePaneKey(run.terminalPaneKey ?? '') + if (local || !ownerEnvironmentId || !parsed || isWebTerminalSurfaceTabId(tabId)) { + return local + } + const mirroredTabId = toWebTerminalSurfaceTabId(tabId) + const layout = terminals.terminalLayoutsByTabId[mirroredTabId] + const mirroredPtyId = layout?.ptyIdsByLeafId?.[parsed.leafId] + if ( + !run.terminalPtyId || + !terminals.hasTerminalTab(mirroredTabId) || + !layout?.root || + !mirroredPtyId || + !terminalLayoutContainsLeaf(layout.root, parsed.leafId) || + parseRemoteRuntimePtyId(mirroredPtyId)?.environmentId !== ownerEnvironmentId || + !(terminals.ptyIdsByTabId[mirroredTabId] ?? []).includes(mirroredPtyId) + ) { + return null + } + return { + tabId: mirroredTabId, + paneKey: makePaneKey(mirroredTabId, parsed.leafId), + leafId: parsed.leafId, + ptyId: mirroredPtyId + } +} + export function buildAutomationRunOpenLayout({ target, currentLayout @@ -128,3 +188,18 @@ export function buildAutomationRunOpenLayout({ : currentLayout.ptyIdsByLeafId } } + +/** The paired server that owns the run's workspace, or null when this client runs it itself. */ +export function getAutomationRunOwnerEnvironmentId( + state: WorktreeOperationRouteState, + workspaceId: string | null | undefined, + hostId?: ExecutionHostId +): string | null { + if (!workspaceId) { + return null + } + const owner = resolveOwner(state, { workspaceId, ...(hostId ? { hostId } : {}) }) + return owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment' + ? owner.owner.endpoint.environmentId + : null +} diff --git a/src/renderer/src/components/automations/automation-run-view-state.ts b/src/renderer/src/components/automations/automation-run-view-state.ts index 7d05de8bc4a9..48bf9c22b7c4 100644 --- a/src/renderer/src/components/automations/automation-run-view-state.ts +++ b/src/renderer/src/components/automations/automation-run-view-state.ts @@ -51,11 +51,14 @@ export function canRerunAutomationRun({ export function getAutomationRunViewState({ run, workspaceExists, - terminalTargetExists + terminalTargetExists, + terminalOnPairedServer = false }: { run: AutomationRun workspaceExists: boolean terminalTargetExists: boolean + /** The workspace's paired server holds the terminal; only it can say whether it still lives. */ + terminalOnPairedServer?: boolean }): AutomationRunViewState { if (run.workspaceId && workspaceExists && terminalTargetExists) { return { @@ -66,6 +69,15 @@ export function getAutomationRunViewState({ } } + if (run.workspaceId && workspaceExists && terminalOnPairedServer) { + return { + availability: 'terminal', + actionLabel: 'View run', + statusLabel: 'Run terminal is on the paired server.', + canOpen: true + } + } + // Why: leftover paneKey/ptyId after the user closes the tab is not a live // terminal — offer Resume so we reopen the session instead of View run. if (run.workspaceId && workspaceExists) { diff --git a/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts b/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts new file mode 100644 index 000000000000..25777a936a79 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-workspace-action.paired-server.test.ts @@ -0,0 +1,146 @@ +/** + * A run that executed on a paired server (#21213) names the server's tab, pane and PTY. This client + * shows that pane as a mirrored tab keyed by the server's terminal handle, so matching the server's + * PTY id against local state always failed and the run read "Run terminal is unavailable.". + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' + +const mocks = vi.hoisted(() => { + const state: Record = {} + return { + state, + toastError: vi.fn(), + toastMessage: vi.fn(), + activateAndRevealWorktree: vi.fn(() => true), + activateWebRuntimeSessionTab: vi.fn(async () => true), + activateTerminalTabOnOwner: vi.fn() + } +}) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: mocks.toastMessage } })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab +})) +vi.mock('@/lib/terminal-tab-owner-activation', () => ({ + activateTerminalTabOnOwner: mocks.activateTerminalTabOnOwner +})) + +import { createAutomationRunWorkspaceAction } from './automation-run-workspace-action' +import type { AutomationsPageActionContext } from './automations-page-action-context' + +const ENV = 'env-a' +const WT = 'repo-a::/srv/app' +const LEAF = '11111111-1111-4111-8111-111111111111' +const HOST_TAB = 'host-tab-1' +const MIRRORED_TAB = toWebTerminalSurfaceTabId(HOST_TAB) +const MIRRORED_PTY = toRemoteRuntimePtyId('term-handle-1', ENV) +const worktree = { id: WT, repoId: 'repo-a', hostId: `runtime:${ENV}` as const } + +const run: AutomationRun = { + id: 'run-1', + automationId: 'automation-1', + title: 'Run 1', + scheduledFor: 1, + status: 'completed', + trigger: 'manual', + workspaceId: WT, + workspaceDisplayName: 'app', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: HOST_TAB, + terminalPaneKey: `${HOST_TAB}:${LEAF}`, + // The server's own PTY id, which this client never sees. + terminalPtyId: 'pty-on-server-7', + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: 1, + dispatchedAt: 1, + createdAt: 1 +} + +function storeState(mirrored: boolean): Record { + return { + // Focus is on this computer: owner routing must not depend on it. + settings: { activeRuntimeEnvironmentId: null }, + activeWorktreeId: null, + repos: [{ id: 'repo-a', executionHostId: `runtime:${ENV}` }], + worktreesByRepo: { 'repo-a': [{ ...worktree, runtimeOwnerEnvironmentId: ENV }] }, + getTab: (tabId: string) => (mirrored && tabId === MIRRORED_TAB ? { id: tabId } : undefined), + terminalLayoutsByTabId: mirrored + ? { + [MIRRORED_TAB]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: MIRRORED_PTY } + } + } + : {}, + ptyIdsByTabId: mirrored ? { [MIRRORED_TAB]: [MIRRORED_PTY] } : {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + setTabLayout: vi.fn(), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn() + } +} + +function openRun(): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action reads only these members. + const context = { + store: { repoForRow: () => undefined, worktreeForRow: () => worktree }, + list: { selectedRow: { key: 'row' } } + } as unknown as AutomationsPageActionContext + createAutomationRunWorkspaceAction(context)(run) +} + +describe('opening a paired-server automation run', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the mirrored pane of the run', () => { + mocks.state = storeState(true) + + openRun() + + expect(mocks.toastError).not.toHaveBeenCalled() + expect(mocks.state.setActiveTab).toHaveBeenCalledWith(MIRRORED_TAB) + expect(mocks.activateTerminalTabOnOwner).toHaveBeenCalledWith(WT, MIRRORED_TAB, LEAF) + }) + + it('asks the server to open the pane when the mirror has not loaded it yet', async () => { + mocks.state = storeState(false) + + openRun() + await vi.waitFor(() => expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalled()) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(WT) + expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: WT, tabId: HOST_TAB, environmentId: ENV, leafId: LEAF }) + ) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('says the terminal is unavailable only when the server has no such pane', async () => { + mocks.state = storeState(false) + mocks.activateWebRuntimeSessionTab.mockResolvedValueOnce(false) + + openRun() + + await vi.waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith('Run terminal is unavailable.') + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-run-workspace-action.test.ts b/src/renderer/src/components/automations/automation-run-workspace-action.test.ts index 3ab07e405899..2a9af5a0edf7 100644 --- a/src/renderer/src/components/automations/automation-run-workspace-action.test.ts +++ b/src/renderer/src/components/automations/automation-run-workspace-action.test.ts @@ -65,6 +65,15 @@ vi.mock('@/store', () => ({ terminalLayoutsByTabId: mocks.terminalLayoutsByTabId, ptyIdsByTabId: mocks.ptyIdsByTabId, tabsByWorktree: mocks.tabsByWorktree, + getTab: (tabId: string) => { + for (const tabs of Object.values(mocks.tabsByWorktree)) { + const tab = tabs.find((candidate) => candidate.id === tabId) + if (tab) { + return tab + } + } + return null + }, sleepingAgentSessionsByPaneKey: mocks.sleepingAgentSessionsByPaneKey }), setState: (...args: unknown[]) => mocks.setState(...args) diff --git a/src/renderer/src/components/automations/automation-run-workspace-action.ts b/src/renderer/src/components/automations/automation-run-workspace-action.ts index e47864b769ec..adaa01c34c3e 100644 --- a/src/renderer/src/components/automations/automation-run-workspace-action.ts +++ b/src/renderer/src/components/automations/automation-run-workspace-action.ts @@ -11,11 +11,16 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '@/store' +import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation' +import { activateWebRuntimeSessionTab } from '@/runtime/web-runtime-session' +import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent' import { buildAutomationRunOpenLayout, getAutomationRunOpenTabId, + getAutomationRunOwnerEnvironmentId, isAutomationRunPaneMounted, - resolveAutomationRunOpenTarget + resolveAutomationRunOpenTarget, + resolveAutomationRunTerminalTarget } from './automation-run-open-target' import { getAutomationRunViewState } from './automation-run-view-state' import type { AutomationsPageActionContext } from './automations-page-action-context' @@ -132,6 +137,32 @@ function reopenClosedRunSessionTab(run: AutomationRun, agent: TuiAgent | undefin return launchedTabId } +function workspaceUnavailableMessage(): string { + return translate( + 'auto.components.automations.AutomationsPage.e1bf9b1512', + 'Workspace is not available.' + ) +} + +async function openRunTerminalOnOwner( + worktreeId: string, + environmentId: string, + paneRef: { tabId: string; leafId: string } +): Promise { + const opened = await activateWebRuntimeSessionTab({ + worktreeId, + tabId: paneRef.tabId, + environmentId, + leafId: paneRef.leafId, + expectedCurrentLocalTabId: resolveWebSessionVisibleTabId(useAppStore.getState(), worktreeId) + }) + if (!opened) { + toast.error( + translate('components.automations.runTerminalUnavailable', 'Run terminal is unavailable.') + ) + } +} + /** Opens the original run terminal when its host-qualified workspace is alive. */ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsPageActionContext) { const { repoForRow, worktreeForRow } = store @@ -142,6 +173,9 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP ? (worktreeForRow(selectedRow, repoForRow(selectedRow), run.workspaceId) ?? null) : null const appStore = useAppStore.getState() + const ownerEnvironmentId = runWorktree + ? getAutomationRunOwnerEnvironmentId(appStore, run.workspaceId, runWorktree.hostId) + : null const openTabId = getAutomationRunOpenTabId(run) const terminalTabExists = Boolean(findPreservedRunTerminalTab(run)) const currentLayout = openTabId ? appStore.terminalLayoutsByTabId[openTabId] : null @@ -151,23 +185,45 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP terminalTabExists, currentLayout }) - const terminalTarget = resolveAutomationRunOpenTarget({ + const localTarget = resolveAutomationRunOpenTarget({ run, terminalTabExists, currentLayout, livePtyIds }) + const terminalTarget = + localTarget ?? + resolveAutomationRunTerminalTarget( + run, + { + hasTerminalTab: (tabId) => Boolean(appStore.getTab(tabId)), + terminalLayoutsByTabId: appStore.terminalLayoutsByTabId, + ptyIdsByTabId: appStore.ptyIdsByTabId + }, + ownerEnvironmentId + ) const runViewState = getAutomationRunViewState({ run, workspaceExists: Boolean(runWorktree), - terminalTargetExists: paneMounted + terminalTargetExists: paneMounted || terminalTarget !== null, + terminalOnPairedServer: ownerEnvironmentId !== null }) if (!run.workspaceId || !runWorktree || !runViewState.canOpen) { toast.error(runViewState.statusLabel) return } + const paneRef = parsePaneKey(run.terminalPaneKey ?? '') if (runViewState.availability === 'terminal' && !terminalTarget) { - toast.error(runViewState.statusLabel) + if (!ownerEnvironmentId || !paneRef) { + toast.error(runViewState.statusLabel) + return + } + if (!activateAndRevealWorktree(run.workspaceId)) { + toast.error(workspaceUnavailableMessage()) + return + } + // Why: the mirror may not hold the pane yet; the server focuses it when it arrives. + void openRunTerminalOnOwner(run.workspaceId, ownerEnvironmentId, paneRef) return } const workspaceId = run.workspaceId @@ -175,12 +231,7 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP if (activateAndRevealWorktree(workspaceId)) { return true } - toast.error( - translate( - 'auto.components.automations.AutomationsPage.e1bf9b1512', - 'Workspace is not available.' - ) - ) + toast.error(workspaceUnavailableMessage()) return false } // Why: a still-mounted pane is View run — never launch another --resume into it. @@ -188,16 +239,30 @@ export function createAutomationRunWorkspaceAction({ store, list }: AutomationsP if (!revealWorkspace()) { return } - if (terminalTarget && currentLayout) { + if (terminalTarget && currentLayout && terminalTarget.tabId === openTabId) { appStore.setTabLayout( terminalTarget.tabId, buildAutomationRunOpenLayout({ target: terminalTarget, currentLayout }) ) appStore.setActiveTab(terminalTarget.tabId) appStore.setActiveTabType('terminal', workspaceId) + activateTerminalTabOnOwner(workspaceId, terminalTarget.tabId, terminalTarget.leafId) } return } + if (terminalTarget) { + const targetLayout = appStore.terminalLayoutsByTabId[terminalTarget.tabId] + if (targetLayout && revealWorkspace()) { + appStore.setTabLayout( + terminalTarget.tabId, + buildAutomationRunOpenLayout({ target: terminalTarget, currentLayout: targetLayout }) + ) + appStore.setActiveTab(terminalTarget.tabId) + appStore.setActiveTabType('terminal', workspaceId) + activateTerminalTabOnOwner(workspaceId, terminalTarget.tabId, terminalTarget.leafId) + return + } + } // Why: remount first so worktree activation cannot steal another sleeping tab. const remountedTabId = reopenClosedRunSessionTab(run, selectedRow?.automation.agentId) if (!revealWorkspace()) { diff --git a/src/renderer/src/components/browser-pane/browser-client-page-metadata-reporting.ts b/src/renderer/src/components/browser-pane/browser-client-page-metadata-reporting.ts index c9a8ef9c65ce..f197be9cd294 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-metadata-reporting.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-metadata-reporting.ts @@ -62,7 +62,3 @@ export function startBrowserClientPageMetadataPublisher(options: { export function forgetBrowserClientPageMetadataReports(browserPageId: string): void { warnedByPageId.delete(browserPageId) } - -export function resetBrowserClientPageMetadataReportsForTests(): void { - warnedByPageId.clear() -} diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.ts index 3c2b1c8a7061..5d8984c9a3a1 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.ts @@ -32,21 +32,6 @@ export function releaseBrowserPageMount(pageId: string): void { emit() } -export function useBrowserPageMountAdmission(pageId: string): boolean { - useSyncExternalStore( - (listener) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - () => { - void version - return isBrowserPageMountAdmitted(pageId) - }, - () => false - ) - return isBrowserPageMountAdmitted(pageId) -} - export function useAnyBrowserPageMountAdmission(pageIds: readonly string[]): boolean { useSyncExternalStore( (listener) => { diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts index 47e72f713e94..f7ee28fbe6ce 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts @@ -76,7 +76,7 @@ export function openDocPreviewExternally(document: DocPreviewDocument): void { // OS; a resolved remote owner downloads first, and no owner at all is refused below. const ownedByThisMachine = connectionId === null && runtimeEnvironmentId === null && worktreeRoot !== null - if (ownedByThisMachine && canClientOsOpenWorkspaceFile(fileContext, document.filePath)) { + if (ownedByThisMachine && canClientOsOpenWorkspaceFile(fileContext)) { void window.api.shell.openFilePath(document.filePath, LOCAL_EXECUTION_HOST_ID) return } diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index 173a1346d34f..80eb86f1691a 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -127,6 +127,29 @@ describe('AgentKanbanBoard', () => { expect(screen.getByText('Needs You')).toBeInTheDocument() }) + it('names the visible column regions and keeps cards inside their region', () => { + renderBoard([card({ bucket: 'attention', worktreeName: 'needs-review' })]) + + expect( + screen.getAllByRole('region').map((region) => region.getAttribute('aria-label')) + ).toEqual(['Needs You', 'Working', 'Done']) + expect( + within(screen.getByRole('region', { name: 'Needs You' })).getByText('needs-review') + ).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Idle' })).not.toBeInTheDocument() + }) + + it('uses the visible translated names for all regions when idle agents are shown', async () => { + await i18n.changeLanguage('zh') + renderBoard([], { showIdle: true }) + + expect(screen.getAllByRole('region')).toHaveLength(4) + for (const name of ['需要你', '工作中', '已完成', '空闲']) { + const region = screen.getByRole('region', { name }) + expect(within(region).getByText(name)).toBeInTheDocument() + } + }) + it('offers project filters without agent-state map filters', async () => { renderBoard([card({ paneKey: 'busy' })]) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index 067ed2a2136a..cc1669e0ca26 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -80,13 +80,18 @@ function KanbanColumn({ now: number onOpenTerminal: (card: DashboardCard) => void }): React.JSX.Element { + const label = bucketLabel(bucket) + return ( // Why: attention no longer tints the whole column — the cards inside carry // their own state color, so a column border would double-signal it. -
+
- {bucketLabel(bucket)} + {label} {cards.length} diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx index 06e2cb38721b..bd39c429875f 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx @@ -148,6 +148,11 @@ vi.mock('@/store', () => { return { useAppStore } }) +// Paste encoding is under test here; owner routing is covered by AgentTerminalPreview.test.tsx. +vi.mock('./terminal-preview-api', () => ({ + terminalPreviewApiFor: () => window.api.terminalPreview +})) + import { AgentTerminalPreview } from './AgentTerminalPreview' describe('AgentTerminalPreview clipboard routes', () => { diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index d31f7293e76c..598d11b2beb9 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -140,7 +140,36 @@ vi.mock('@/store', () => { return { useAppStore } }) +type RemotePreviewSubscription = { + terminal: string + callbacks: { + onSnapshot: (data: string, meta?: { cols?: number; rows?: number; seq?: number }) => void + onData: (data: string, meta?: { seq?: number }) => void + } +} +const multiplexerHarness = vi.hoisted(() => { + const environments: string[] = [] + const subscriptions: RemotePreviewSubscription[] = [] + return { environments, subscriptions, sendInput: vi.fn(() => true), close: vi.fn() } +}) +vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({ + getRemoteRuntimeTerminalMultiplexer: (environmentId: string) => { + multiplexerHarness.environments.push(environmentId) + return { + subscribeTerminal: async (args: RemotePreviewSubscription) => { + multiplexerHarness.subscriptions.push(args) + return { + sendInput: multiplexerHarness.sendInput, + claimViewport: vi.fn(() => true), + close: multiplexerHarness.close + } + } + } + } +})) + import { AgentTerminalPreview } from './AgentTerminalPreview' +import { resetRemoteRuntimeTerminalPreviewSessionsForTests } from './remote-runtime-terminal-preview-api' describe('AgentTerminalPreview', () => { const input = vi.fn(async (_ptyId: string, _data: string) => true) @@ -192,6 +221,10 @@ describe('AgentTerminalPreview', () => { afterEach(() => { cleanup() + vi.useRealTimers() + resetRemoteRuntimeTerminalPreviewSessionsForTests() + multiplexerHarness.environments.length = 0 + multiplexerHarness.subscriptions.length = 0 vi.clearAllMocks() }) @@ -723,4 +756,40 @@ describe('AgentTerminalPreview', () => { await vi.advanceTimersByTimeAsync(150) expect(connect).toHaveBeenCalledTimes(2) }) + + it("previews a paired host's pane from that host, not this app's main process", async () => { + render() + + await waitFor(() => expect(multiplexerHarness.subscriptions).toHaveLength(1)) + expect(multiplexerHarness.environments).toEqual(['env-1']) + expect(multiplexerHarness.subscriptions[0]!.terminal).toBe('term-7') + expect(connect).not.toHaveBeenCalled() + + act(() => + multiplexerHarness.subscriptions[0]!.callbacks.onSnapshot('agent screen', { + cols: 100, + rows: 30, + seq: 5 + }) + ) + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + expect(terminal.write).toHaveBeenCalledWith('agent screen', expect.any(Function)) + + act(() => multiplexerHarness.subscriptions[0]!.callbacks.onData('live', { seq: 9 })) + expect(terminal.write).toHaveBeenCalledWith('live', expect.any(Function)) + act(() => { + terminalHarness.userInputListener?.() + terminal.onDataListener?.('y') + }) + expect(multiplexerHarness.sendInput).toHaveBeenCalledWith('y') + }) + + it('shows the unavailable message instead of a blank dialog when the bridge is missing', async () => { + // The web client's fallback proxy resolves every missing bridge call to undefined. + Object.assign(window.api.terminalPreview, { connect: async () => undefined }) + const view = render() + + await waitFor(() => expect(view.getByText(/No live terminal/)).toBeInTheDocument()) + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 15cdaa1285ce..2193424b91ba 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -31,6 +31,7 @@ import { installPreviewTerminalRightClickPaste } from './preview-terminal-right- import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/terminal-native-copy-gutter' import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' +import { terminalPreviewApiFor } from './terminal-preview-api' const PREVIEW_SCROLLBACK_ROWS = 24 // Preview snapshots bound history; pane scrollback would only cost memory. @@ -116,6 +117,7 @@ export function AgentTerminalPreview({ let disposeKeyHandler: (() => void) | null = null let disposeNativeCopyGutterTrim: (() => void) | null = null let disposeTerminalCompatibility: (() => void) | null = null + const previewApi = terminalPreviewApiFor(ptyId) // Why one read: the xterm's advertisement and its mirror must never disagree. const mountTerminalInput = terminalInputRef.current // Why: mirrors the pane's tracker — the policy needs the flags the TUI @@ -174,7 +176,7 @@ export function AgentTerminalPreview({ payload.data, () => { if (!disposed) { - void window.api.terminalPreview.ack(ptyId, payload.bytes) + void previewApi.ack(ptyId, payload.bytes) } }, true @@ -243,12 +245,12 @@ export function AgentTerminalPreview({ if (userInputDisposable ? !signaledUserInput : replayDepth > 0) { return } - void window.api.terminalPreview.input(ptyId, data) + void previewApi.input(ptyId, data) }) } const replayConnection = ( - connection: Awaited>, + connection: Awaited>, replaceExisting: boolean, requestRefresh: () => void ): void => { @@ -320,37 +322,41 @@ export function AgentTerminalPreview({ terminal.focus() } + const releaseTerminal = (): void => { + offData?.() + offData = null + userInputDisposable?.dispose() + userInputDisposable = null + disposeImeNativeTextBridge() + disposeTerminalCompatibility?.() + disposeTerminalCompatibility = null + disposeKeyHandler?.() + disposeKeyHandler = null + disposeNativeCopyGutterTrim?.() + disposeNativeCopyGutterTrim = null + terminal?.dispose() + terminal = null + terminalRef.current = null + void previewApi.unsubscribe(ptyId) + } + const setup = async (replaceExisting = false): Promise => { if (refreshInFlight) { refreshAgain = true return } refreshInFlight = true - const connection = await window.api.terminalPreview.connect(ptyId, { - scrollbackRows: PREVIEW_SCROLLBACK_ROWS - }) + // Why: a failed or absent transport must show the unavailable message, not a blank dialog. + const connection = await previewApi + .connect(ptyId, { scrollbackRows: PREVIEW_SCROLLBACK_ROWS }) + .catch(() => null) if (disposed) { return } - const snap = connection.snapshot - if (!snap) { + if (!connection?.snapshot) { refreshInFlight = false setPtyGone(true) - offData?.() - offData = null - userInputDisposable?.dispose() - userInputDisposable = null - disposeImeNativeTextBridge() - disposeTerminalCompatibility?.() - disposeTerminalCompatibility = null - disposeKeyHandler?.() - disposeKeyHandler = null - disposeNativeCopyGutterTrim?.() - disposeNativeCopyGutterTrim = null - terminal?.dispose() - terminal = null - terminalRef.current = null - void window.api.terminalPreview.unsubscribe(ptyId) + releaseTerminal() return } refreshInFlight = false @@ -375,7 +381,7 @@ export function AgentTerminalPreview({ pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source) }) - offData = window.api.terminalPreview.onData((payload) => { + offData = previewApi.onData((payload) => { if (payload.ptyId !== ptyId) { return } @@ -398,15 +404,7 @@ export function AgentTerminalPreview({ boxResizeObserver?.disconnect() disposeAppMenuClipboard() disposeRightClickPaste() - offData?.() - userInputDisposable?.dispose() - disposeImeNativeTextBridge() - disposeTerminalCompatibility?.() - disposeKeyHandler?.() - disposeNativeCopyGutterTrim?.() - void window.api.terminalPreview.unsubscribe(ptyId) - terminal?.dispose() - terminalRef.current = null + releaseTerminal() } }, [ ptyId, diff --git a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts index 14470931ca84..d7a4c1321503 100644 --- a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts +++ b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts @@ -1,8 +1,4 @@ -import type { - DashboardCard, - DashboardCardReview, - DashboardWorkspace -} from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot' export type DashboardReviewFilter = DashboardCardReview['state'] | 'none' @@ -40,34 +36,6 @@ function cardSearchText(card: DashboardCard): string { .toLocaleLowerCase() } -function workspaceSearchText(workspace: DashboardWorkspace): string { - return [ - workspace.worktreeName, - workspace.repoName, - workspace.review ? `#${workspace.review.number}` : '' - ] - .filter(Boolean) - .join(' ') - .toLocaleLowerCase() -} - -function workspaceMatchesFilters( - workspace: DashboardWorkspace, - normalizedQuery: string, - filters: DashboardFilters -): boolean { - const reviewState = workspace.review?.state ?? (workspace.hasReview ? null : 'none') - return ( - (normalizedQuery.length === 0 || workspaceSearchText(workspace).includes(normalizedQuery)) && - (filters.projects.length === 0 || filters.projects.includes(workspace.repoId)) && - (filters.workspaceStatuses.length === 0 || - (workspace.workspaceStatusId !== undefined && - filters.workspaceStatuses.includes(workspace.workspaceStatusId))) && - (filters.reviewStates.length === 0 || - (reviewState !== null && filters.reviewStates.includes(reviewState))) - ) -} - export function filterDashboardCards( cards: DashboardCard[], query: string, @@ -88,17 +56,6 @@ export function filterDashboardCards( }) } -export function filterDashboardWorkspaces( - workspaces: DashboardWorkspace[], - query: string, - filters: DashboardFilters -): DashboardWorkspace[] { - const normalizedQuery = query.trim().toLocaleLowerCase() - return workspaces.filter((workspace) => - workspaceMatchesFilters(workspace, normalizedQuery, filters) - ) -} - export function toggleDashboardFilter(values: T[], value: T): T[] { return values.includes(value) ? values.filter((candidate) => candidate !== value) diff --git a/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts b/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts index a9d94f84f289..913cf381b8fd 100644 --- a/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts +++ b/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts @@ -1,5 +1,6 @@ import type { Terminal } from '@xterm/xterm' import { clampTerminalViewport } from '../../../../shared/terminal-viewport' +import { terminalPreviewApiFor } from './terminal-preview-api' const FIT_REQUEST_DEBOUNCE_MS = 200 /** @@ -52,10 +53,12 @@ export function createPreviewGridClaim(args: { return } lastRequestedFit = fitKey - // The resize triggers a main-side resync push; the reconnect snapshot + // The resize triggers a resync push from the PTY's owner; the reconnect snapshot // carries the new grid. If the claim didn't land (a phone owns the size), // the dialog's scaled fallback rendering stays correct as-is. - void window.api.terminalPreview.fit(args.ptyId, cols, rows).catch(() => undefined) + void terminalPreviewApiFor(args.ptyId) + .fit(args.ptyId, cols, rows) + .catch(() => undefined) } const schedule = (): void => { diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts index 7bebeacd3e4e..8511810bb6f1 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts @@ -8,6 +8,7 @@ import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits' import { pasteTerminalText } from '@/components/terminal-pane/terminal-bracketed-paste' import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' +import { terminalPreviewApiFor } from './terminal-preview-api' export type PreviewTerminalPasteSource = 'keyboard' | 'app-menu' | 'right-click' @@ -62,7 +63,7 @@ export function createPreviewClipboardPaster(deps: { await executeTerminalPastePlan(plan, { // Why: stream large pastes so the renderer never emits one huge IPC payload. pasteText: (text, options) => pasteTerminalText(pasteTerminal, text, options), - writePty: (data) => window.api.terminalPreview.input(deps.ptyId, data), + writePty: (data) => terminalPreviewApiFor(deps.ptyId).input(deps.ptyId, data), isTargetCurrent: targetIsCurrent, // Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY. canContinue: () => true diff --git a/src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.test.ts b/src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.test.ts new file mode 100644 index 000000000000..ec40efce3b3c --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RemoteRuntimeMultiplexedTerminalCallbacks } from '@/runtime/remote-runtime-terminal-multiplexer' +import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' + +type Subscription = { + environmentId: string + terminal: string + clientId: string + callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + stream: { + sendInput: ReturnType + claimViewport: ReturnType + serializeBufferOutcome: ReturnType + close: ReturnType + } +} + +const harness = vi.hoisted(() => { + const subscriptions: Subscription[] = [] + return { subscriptions } +}) + +vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({ + getRemoteRuntimeTerminalMultiplexer: (environmentId: string) => ({ + subscribeTerminal: async (args: { + terminal: string + client: { id: string } + callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + }) => { + const stream = { + sendInput: vi.fn(() => true), + claimViewport: vi.fn(() => true), + serializeBufferOutcome: vi.fn(), + close: vi.fn() + } + harness.subscriptions.push({ + environmentId, + terminal: args.terminal, + clientId: args.client.id, + callbacks: args.callbacks, + stream + }) + return stream + } + }) +})) + +import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit' +import { + remoteRuntimeTerminalPreviewApi as api, + resetRemoteRuntimeTerminalPreviewSessionsForTests +} from './remote-runtime-terminal-preview-api' + +const PTY = 'remote:env-a@@term-1' + +async function openPreview(): Promise<{ + sub: Subscription + payloads: TerminalPreviewDataPayload[] +}> { + const payloads: TerminalPreviewDataPayload[] = [] + api.onData((payload) => payloads.push(payload)) + const connecting = api.connect(PTY, { scrollbackRows: 24 }) + await vi.waitFor(() => expect(harness.subscriptions).toHaveLength(1)) + const sub = harness.subscriptions[0]! + sub.callbacks.onSnapshot('screen', { cols: 90, rows: 30, seq: 10 }) + await expect(connecting).resolves.toEqual({ + snapshot: expect.objectContaining({ data: 'screen', cols: 90, rows: 30, seq: 10 }), + replay: [] + }) + return { sub, payloads } +} + +describe('remoteRuntimeTerminalPreviewApi', () => { + afterEach(() => { + resetRemoteRuntimeTerminalPreviewSessionsForTests() + harness.subscriptions.length = 0 + }) + + it('subscribes on the host named by the pty id with a preview-only client', async () => { + const { sub, payloads } = await openPreview() + expect(sub.environmentId).toBe('env-a') + expect(sub.terminal).toBe('term-1') + expect(sub.clientId).toMatch(/^dashboard-preview:/) + + sub.callbacks.onData('live', { seq: 14 }) + expect(payloads).toEqual([{ type: 'data', ptyId: PTY, data: 'live', bytes: 4 }]) + await expect(api.input(PTY, 'x')).resolves.toBe(true) + expect(sub.stream.sendInput).toHaveBeenCalledWith('x') + }) + + it('refuses an id without its owning host instead of guessing one', async () => { + await expect(api.connect('remote:term-1')).resolves.toEqual({ snapshot: null, replay: [] }) + expect(harness.subscriptions).toHaveLength(0) + }) + + it('refreshes on the same stream and replays only output past the snapshot', async () => { + const { sub, payloads } = await openPreview() + let answer: (value: unknown) => void = () => {} + sub.stream.serializeBufferOutcome.mockReturnValue(new Promise((resolve) => (answer = resolve))) + + const refreshing = api.connect(PTY, { scrollbackRows: 24 }) + sub.callbacks.onData('covered', { seq: 20 }) + sub.callbacks.onData('after', { seq: 25 }) + answer({ + availability: { kind: 'snapshot' }, + snapshot: { data: 'fresh', cols: 90, rows: 30, seq: 20 } + }) + + await expect(refreshing).resolves.toEqual({ + snapshot: expect.objectContaining({ data: 'fresh', seq: 20 }), + replay: [{ data: 'after', mode: 'live' }] + }) + expect(sub.stream.serializeBufferOutcome).toHaveBeenCalledWith({ scrollbackRows: 24 }) + expect(harness.subscriptions).toHaveLength(1) + expect(payloads).toEqual([]) + }) + + it('drops covered output the host forwards after a requested snapshot and slices a straddler', async () => { + const { sub, payloads } = await openPreview() + sub.stream.serializeBufferOutcome.mockResolvedValue({ + availability: { kind: 'snapshot' }, + snapshot: { data: 'fresh', cols: 90, rows: 30, seq: 20 } + }) + await expect(api.connect(PTY)).resolves.toMatchObject({ replay: [] }) + + // A tagged reply is followed by every chunk the host buffered, covered ones included. + sub.callbacks.onData('old', { seq: 18, rawLength: 3 }) + sub.callbacks.onData('ABCDE', { seq: 23, rawLength: 5 }) + sub.callbacks.onData('next', { seq: 27, rawLength: 4 }) + expect(payloads).toEqual([ + { type: 'data', ptyId: PTY, data: 'CDE', bytes: 3 }, + { type: 'data', ptyId: PTY, data: 'next', bytes: 4 } + ]) + }) + + it('returns transport credit only once the preview has parsed the chunk', async () => { + const { sub } = await openPreview() + const credit = vi.fn() + deliverTerminalDataWithDeferredCredit(credit, () => + sub.callbacks.onData('live', { seq: 14, rawLength: 4 }) + ) + expect(credit).not.toHaveBeenCalled() + + await api.ack(PTY, 4) + expect(credit).toHaveBeenCalledTimes(1) + }) + + it('returns held credit when the preview closes', async () => { + const { sub } = await openPreview() + const credit = vi.fn() + deliverTerminalDataWithDeferredCredit(credit, () => sub.callbacks.onData('live', { seq: 14 })) + + await api.unsubscribe(PTY) + expect(credit).toHaveBeenCalledTimes(1) + }) + + it('asks for a repaint when the grid changes, not when the same grid is re-announced', async () => { + const { sub, payloads } = await openPreview() + await expect(api.fit(PTY, 120, 40)).resolves.toEqual({ cols: 120, rows: 40 }) + expect(sub.stream.claimViewport).toHaveBeenCalledWith(120, 40) + + sub.callbacks.onFitOverrideChanged?.({ mode: 'remote-desktop-fit', cols: 90, rows: 30 }) + expect(payloads).toEqual([]) + sub.callbacks.onFitOverrideChanged?.({ mode: 'remote-desktop-fit', cols: 120, rows: 40 }) + expect(payloads).toEqual([{ type: 'resync', ptyId: PTY }]) + }) + + it('reports an ended stream as unavailable on the next connect', async () => { + const { sub, payloads } = await openPreview() + sub.callbacks.onEnd?.('exited') + expect(payloads).toEqual([{ type: 'resync', ptyId: PTY }]) + + await expect(api.connect(PTY)).resolves.toEqual({ snapshot: null, replay: [] }) + expect(sub.stream.close).toHaveBeenCalled() + }) + + it('reopens after loss of contact rather than calling the pane gone', async () => { + const { sub } = await openPreview() + sub.callbacks.onTransportClose?.({ recoverable: true }) + + const reopening = api.connect(PTY) + await vi.waitFor(() => expect(harness.subscriptions).toHaveLength(2)) + harness.subscriptions[1]!.callbacks.onSnapshot('again', { cols: 90, rows: 30, seq: 3 }) + await expect(reopening).resolves.toMatchObject({ snapshot: { data: 'again' } }) + expect(sub.stream.serializeBufferOutcome).not.toHaveBeenCalled() + }) + + it('closes the host stream on unsubscribe so its viewport claim is released', async () => { + const { sub } = await openPreview() + await api.unsubscribe(PTY) + expect(sub.stream.close).toHaveBeenCalledTimes(1) + await expect(api.input(PTY, 'x')).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.ts b/src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.ts new file mode 100644 index 000000000000..7009e01214d9 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.ts @@ -0,0 +1,334 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-delivery-credit' +import { + getRemoteRuntimeTerminalMultiplexer, + type RemoteRuntimeMultiplexedTerminal, + type RemoteRuntimeMultiplexedTerminalCallbacks +} from '@/runtime/remote-runtime-terminal-multiplexer' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload, + TerminalPreviewReplayChunk +} from '../../../../shared/terminal-preview' + +type TerminalPreviewApi = typeof window.api.terminalPreview + +const CONNECT_TIMEOUT_MS = 15_000 +const SNAPSHOT_RETRY_ATTEMPTS = 3 +const SNAPSHOT_RETRY_DELAY_MS = 150 +const FALLBACK_COLS = 80 +const FALLBACK_ROWS = 24 +const UNAVAILABLE: TerminalPreviewConnectResult = { snapshot: null, replay: [] } + +type SnapshotMeta = Parameters[1] +type OutputMeta = Parameters[1] +type HeldChunk = { data: string; meta: OutputMeta; release: () => void } + +type PreviewSession = { + stream: RemoteRuntimeMultiplexedTerminal | null + /** The pending first connect; a refresh issued before it lands shares it. */ + opening: Promise | null + /** The host ended the stream; the pane is gone or unreachable, never "exited" by guess. */ + ended: boolean + closed: boolean + /** Live chunks held while a snapshot is in flight, so the boundary can drop covered bytes. */ + gate: HeldChunk[] | null + /** Output high-water of the applied snapshot; a tagged request still forwards covered chunks. */ + snapshotSeq: number | undefined + /** Transport credit per emitted chunk, returned in order as the preview parses them. */ + credits: (() => void)[] + grid: { cols: number; rows: number } | null +} + +const sessions = new Map() +const listeners = new Set<(payload: TerminalPreviewDataPayload) => void>() + +function emit(payload: TerminalPreviewDataPayload): void { + for (const listener of listeners) { + listener(payload) + } +} + +function snapshotResult( + session: PreviewSession, + data: string, + meta: SnapshotMeta, + replay: TerminalPreviewReplayChunk[] +): TerminalPreviewConnectResult { + session.grid = + typeof meta?.cols === 'number' && typeof meta.rows === 'number' + ? { cols: meta.cols, rows: meta.rows } + : null + return { + snapshot: { + data, + // Why: hosts that omit dims serialized at the PTY grid; the box fit and claim correct it. + cols: meta?.cols ?? FALLBACK_COLS, + rows: meta?.rows ?? FALLBACK_ROWS, + seq: meta?.seq, + pendingEscapeTailAnsi: meta?.pendingEscapeTailAnsi, + kittyKeyboardFlags: meta?.kittyKeyboardFlags + }, + replay + } +} + +/** The part of a chunk the snapshot at `snapshotSeq` does not already contain. */ +function outputAfterSnapshot( + data: string, + meta: OutputMeta, + snapshotSeq: number | undefined +): string | null { + const seq = meta?.seq + const rawLength = meta?.rawLength ?? (meta?.transformed ? undefined : data.length) + if (snapshotSeq === undefined || seq === undefined || rawLength === undefined) { + return data + } + if (seq <= snapshotSeq) { + return null + } + const start = seq - rawLength + if (start >= snapshotSeq) { + return data + } + // A transformed span cannot be split by offset; the host drops it on the same boundary. + return meta?.transformed ? null : data.slice(snapshotSeq - start) +} + +// Why: chunks flushed before a requested snapshot carry no proof of order without a seq. +function releaseGate(session: PreviewSession, snapshotSeq: number | undefined) { + const held = session.gate ?? [] + session.gate = null + session.snapshotSeq = snapshotSeq + const replay: TerminalPreviewReplayChunk[] = [] + for (const chunk of held) { + const data = + chunk.meta?.seq === undefined + ? null + : outputAfterSnapshot(chunk.data, chunk.meta, snapshotSeq) + if (data) { + replay.push({ data, mode: 'live' }) + } + chunk.release() + } + return replay +} + +function releaseCredits(session: PreviewSession): void { + for (const release of session.credits.splice(0)) { + release() + } + for (const chunk of session.gate?.splice(0) ?? []) { + chunk.release() + } +} + +function closeSession(ptyId: string, session: PreviewSession): void { + session.closed = true + releaseCredits(session) + session.stream?.close() + session.stream = null + if (sessions.get(ptyId) === session) { + sessions.delete(ptyId) + } +} + +function openSession(ptyId: string, environmentId: string, handle: string) { + const session: PreviewSession = { + stream: null, + opening: null, + ended: false, + closed: false, + gate: [], + snapshotSeq: undefined, + credits: [], + grid: null + } + sessions.set(ptyId, session) + let initialized = false + const opening = new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => { + closeSession(ptyId, session) + finish(UNAVAILABLE) + }, CONNECT_TIMEOUT_MS) + function finish(result: TerminalPreviewConnectResult): void { + if (!settled) { + settled = true + clearTimeout(timer) + session.opening = null + resolve(result) + } + } + const markEnded = (): void => { + session.ended = true + finish(UNAVAILABLE) + if (initialized && !session.closed) { + emit({ type: 'resync', ptyId }) + } + } + void getRemoteRuntimeTerminalMultiplexer(environmentId) + .subscribeTerminal({ + terminal: handle, + // Why a preview-only id: its viewport claim must release with this stream, not a pane's. + client: { id: `dashboard-preview:${createBrowserUuid()}`, type: 'desktop' }, + callbacks: { + onSnapshot: (data, meta) => { + if (initialized) { + // A host-pushed recovery image follows dropped output; repaint through a fresh request. + emit({ type: 'resync', ptyId }) + return + } + initialized = true + finish(snapshotResult(session, data, meta, releaseGate(session, meta?.seq))) + }, + onData: (data, meta) => { + if (session.closed) { + return + } + const noCredit = (): void => {} + if (session.gate) { + session.gate.push({ + data, + meta, + release: takeCurrentTerminalDeliveryCredit() ?? noCredit + }) + return + } + const uncovered = outputAfterSnapshot(data, meta, session.snapshotSeq) + if (uncovered) { + // Why: hold the host's credit until the preview parses the chunk, like a pane does. + session.credits.push(takeCurrentTerminalDeliveryCredit() ?? noCredit) + emit({ type: 'data', ptyId, data: uncovered, bytes: uncovered.length }) + } + }, + onFitOverrideChanged: (event) => { + if (event.cols !== session.grid?.cols || event.rows !== session.grid?.rows) { + emit({ type: 'resync', ptyId }) + } + }, + onEnd: markEnded, + onError: markEnded, + onTransportClose: () => { + // Why: loss of contact is not pane death — drop the session so a resync reopens it. + if (sessions.get(ptyId) === session) { + sessions.delete(ptyId) + } + session.stream = null + releaseCredits(session) + finish(UNAVAILABLE) + if (initialized && !session.closed) { + emit({ type: 'resync', ptyId }) + } + } + } + }) + .then( + (stream) => { + if (session.closed) { + stream.close() + return + } + session.stream = stream + }, + () => { + closeSession(ptyId, session) + finish(UNAVAILABLE) + } + ) + }) + session.opening = opening + return opening +} + +async function refreshSession( + ptyId: string, + session: PreviewSession, + scrollbackRows: number | undefined +): Promise { + const stream = session.stream + if (!stream) { + return UNAVAILABLE + } + session.gate = [] + for (let attempt = 0; attempt < SNAPSHOT_RETRY_ATTEMPTS; attempt++) { + const outcome = await stream.serializeBufferOutcome({ scrollbackRows }).catch(() => null) + if (session.closed) { + return UNAVAILABLE + } + if (sessions.get(ptyId) !== session) { + // The transport dropped mid-request; reopen rather than report the pane gone. + return remoteRuntimeTerminalPreviewApi.connect(ptyId, { scrollbackRows }) + } + const snapshot = outcome?.snapshot + if (snapshot) { + return snapshotResult(session, snapshot.data, snapshot, releaseGate(session, snapshot.seq)) + } + if (outcome?.availability.kind !== 'retry-worthy') { + break + } + await new Promise((resolve) => setTimeout(resolve, SNAPSHOT_RETRY_DELAY_MS)) + } + for (const chunk of session.gate?.splice(0) ?? []) { + chunk.release() + } + session.gate = null + return UNAVAILABLE +} + +/** + * Dashboard preview of a paired host's terminal over the same multiplexed stream panes use: + * the owning host serializes the snapshot and holds the live boundary, so any host that serves + * panes also serves previews. + */ +export const remoteRuntimeTerminalPreviewApi: TerminalPreviewApi = { + connect: (ptyId, opts) => { + const parts = parseRemoteRuntimePtyId(ptyId) + // Why: an id without its owner cannot be routed; guessing the active server could show another host's pane. + if (!parts?.environmentId) { + return Promise.resolve(UNAVAILABLE) + } + const existing = sessions.get(ptyId) + if (existing?.ended) { + closeSession(ptyId, existing) + return Promise.resolve(UNAVAILABLE) + } + if (existing?.opening) { + return existing.opening + } + if (existing) { + return refreshSession(ptyId, existing, opts?.scrollbackRows) + } + return openSession(ptyId, parts.environmentId, parts.handle) + }, + input: (ptyId, data) => Promise.resolve(sessions.get(ptyId)?.stream?.sendInput(data) ?? false), + fit: (ptyId, cols, rows) => { + // Why: the host answers the claim with a fit-override event, which triggers the resync repaint. + const claimed = sessions.get(ptyId)?.stream?.claimViewport(cols, rows) ?? false + return Promise.resolve(claimed ? { cols, rows } : null) + }, + // Why in order: the preview acknowledges chunks as xterm parses them, which is emit order. + ack: (ptyId) => { + sessions.get(ptyId)?.credits.shift()?.() + return Promise.resolve() + }, + unsubscribe: (ptyId) => { + const session = sessions.get(ptyId) + if (session) { + closeSession(ptyId, session) + } + return Promise.resolve() + }, + onData: (callback) => { + listeners.add(callback) + return () => listeners.delete(callback) + } +} + +export function resetRemoteRuntimeTerminalPreviewSessionsForTests(): void { + for (const [ptyId, session] of sessions) { + closeSession(ptyId, session) + } + listeners.clear() +} diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-api.ts b/src/renderer/src/components/dashboard-popout/terminal-preview-api.ts new file mode 100644 index 000000000000..d7de014f8637 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/terminal-preview-api.ts @@ -0,0 +1,7 @@ +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import { remoteRuntimeTerminalPreviewApi } from './remote-runtime-terminal-preview-api' + +/** A paired host's pane previews from that host; everything else from this app's main process. */ +export function terminalPreviewApiFor(ptyId: string): typeof window.api.terminalPreview { + return isRemoteRuntimePtyId(ptyId) ? remoteRuntimeTerminalPreviewApi : window.api.terminalPreview +} diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts index ca46e0b8f4af..ea677cebe631 100644 --- a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts +++ b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts @@ -15,4 +15,11 @@ describe('terminalPreviewUnavailableMessage', () => { expect(fromPtyId).not.toMatch(/pane has closed/) expect(terminalPreviewUnavailableMessage({ hostKind: 'ssh' })).toBe(fromPtyId) }) + + it('reports a paired-server card as remote instead of claiming its pane closed (#25688)', () => { + const remote = terminalPreviewUnavailableMessage({ hostKind: 'ssh' }) + expect(terminalPreviewUnavailableMessage({ hostKind: 'remote' })).toBe(remote) + expect(terminalPreviewUnavailableMessage({ ptyId: 'remote:env-1@@term_1' })).toBe(remote) + expect(terminalPreviewUnavailableMessage({ ptyId: 'remote:term_1' })).toBe(remote) + }) }) diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts index 07080084bfc2..08718f9ed06c 100644 --- a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts +++ b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts @@ -1,12 +1,14 @@ import { translate } from '@/i18n/i18n' import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' /** * A missing buffer snapshot only proves the pane exited when the client could have * observed it. `SshPtyProvider` reports no authoritative buffer snapshot and the relay * exposes no snapshot RPC, so for a remote pty the absence is loss of contact — - * `unverifiable`, never `exited`. See docs/reference/ssh-execution-boundary.md. + * `unverifiable`, never `exited`. A paired server's pane is likewise held by that server, not by + * this client. See docs/reference/ssh-execution-boundary.md. */ export function terminalPreviewUnavailableMessage(source: { ptyId?: string | null @@ -14,6 +16,8 @@ export function terminalPreviewUnavailableMessage(source: { }): string { const isRemote = source.hostKind === 'ssh' || + source.hostKind === 'remote' || + isRemoteRuntimePtyId(source.ptyId) || (typeof source.ptyId === 'string' && parseAppSshPtyId(source.ptyId) !== null) return isRemote ? translate( diff --git a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx index eb462a2d3a91..987cc1faf479 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx @@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { SettingsSegmentedControl, SettingsSwitch } from '../settings/SettingsFormControls' import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' +import { canOpenAgentDashboardPopout } from './agent-dashboard-open-mode' type AgentDashboardSettingsMenuProps = { /** Called after the mode switches to pop-out so the host can hand the board @@ -62,51 +63,55 @@ export function AgentDashboardSettingsMenu({ -
- - - {translate( - 'auto.components.settings.ExperimentalPane.agentDashboard.modeLabel', - 'Open as' - )} - - - {translate( - 'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy', - 'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.' - )} - - -
-
- -
- + {canOpenAgentDashboardPopout() ? ( + <> +
+ + + {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeLabel', + 'Open as' + )} + + + {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy', + 'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.' + )} + + +
+
+ +
+ + + ) : null}
@@ -122,7 +127,9 @@ export function AgentDashboardSettingsMenu({ { - void updateSettings({ experimentalAgentDashboardShowIdle: !showIdle }) + void updateSettings({ + experimentalAgentDashboardShowIdle: !showIdle + }) }} ariaLabel={translate('dashboardPopout.settings.showIdle', 'Show idle agents')} /> diff --git a/src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts b/src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts new file mode 100644 index 000000000000..53aba6f971bc --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toggleAgentDashboardFromShortcut } from '../../hooks/ipc-events/agent-dashboard-command' +import { resolveAgentDashboardMode } from './agent-dashboard-open-mode' + +function dashboardState(): Parameters[0] { + return { + activeView: 'terminal', + settings: { + experimentalAgentDashboardPopout: true, + experimentalAgentDashboardMode: 'popout' + }, + agentDashboardDrawerOpen: false, + setSidebarOpen: vi.fn(), + setAgentDashboardDrawerOpen: vi.fn() + } +} + +describe('Agent Dashboard open mode', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('opens the pop-out window on desktop when the setting asks for it', () => { + const state = dashboardState() + const openPopout = vi.fn() + toggleAgentDashboardFromShortcut(state, openPopout) + expect(openPopout).toHaveBeenCalledTimes(1) + expect(state.setAgentDashboardDrawerOpen).not.toHaveBeenCalled() + }) + + // #22218: a browser tab has no second window, so pop-out was a silent no-op. + it('opens the in-window board in a web client even when the setting says pop-out', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + const state = dashboardState() + const openPopout = vi.fn() + expect(resolveAgentDashboardMode(state.settings)).toBe('in-window') + toggleAgentDashboardFromShortcut(state, openPopout) + expect(openPopout).not.toHaveBeenCalled() + expect(state.setAgentDashboardDrawerOpen).toHaveBeenCalledWith(true) + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts b/src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts new file mode 100644 index 000000000000..69606c872592 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts @@ -0,0 +1,16 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' + +/** A browser tab cannot open a second Orca window, so a web client only has the in-window board. */ +export function canOpenAgentDashboardPopout(): boolean { + return !isPairedWebClientWindow() +} + +export function resolveAgentDashboardMode( + settings: Pick | null | undefined +): AgentDashboardMode { + return canOpenAgentDashboardPopout() + ? (settings?.experimentalAgentDashboardMode ?? 'in-window') + : 'in-window' +} diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts new file mode 100644 index 000000000000..f1a8c8c83780 --- /dev/null +++ b/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorkspace: vi.fn(), + activateTabAndFocusPane: vi.fn(), + activateTerminalTabOnOwner: vi.fn() +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace +})) +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: mocks.activateTabAndFocusPane +})) +vi.mock('@/lib/terminal-tab-owner-activation', () => ({ + activateTerminalTabOnOwner: mocks.activateTerminalTabOnOwner +})) + +import { revealDashboardAgent } from './reveal-dashboard-agent' + +const args = { + repoId: 'repo-1', + worktreeId: 'wt-1', + executionHostId: 'runtime:env-1' as const, + tabId: 'web-terminal-host-tab', + leafId: '11111111-1111-4111-8111-111111111111' +} + +describe('revealDashboardAgent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("focuses the card's own tab on the workspace's owner", () => { + mocks.activateAndRevealWorkspace.mockReturnValue(true) + + expect(revealDashboardAgent(args)).toBe(true) + + expect(mocks.activateTerminalTabOnOwner).toHaveBeenCalledWith('wt-1', args.tabId, args.leafId) + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith(args.tabId, args.leafId, { + flashFocusedPane: true + }) + // Why: the owner call captures the visible tab, so it must follow the local activation. + expect(mocks.activateTabAndFocusPane.mock.invocationCallOrder[0]).toBeLessThan( + mocks.activateTerminalTabOnOwner.mock.invocationCallOrder[0] + ) + }) + + it('touches no tab when the workspace cannot be revealed', () => { + mocks.activateAndRevealWorkspace.mockReturnValue(false) + + expect(revealDashboardAgent(args)).toBe(false) + + expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts index 9da364c72eff..9113c9c97f5f 100644 --- a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts +++ b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts @@ -1,5 +1,6 @@ import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation' import type { DashboardRevealAgentArgs } from '../../../../shared/dashboard-snapshot' /** @@ -19,5 +20,6 @@ export function revealDashboardAgent(args: DashboardRevealAgentArgs): boolean { return false } activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) + activateTerminalTabOnOwner(args.worktreeId, args.tabId, args.leafId) return true } diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index fb78c95df84a..173339cccad1 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -69,18 +69,6 @@ export function isExternalReloadableEditorTab(file: OpenFile): boolean { ) } -// Why: combined "Changes"/"All Changes" tabs render working-tree diffs but -// match no single path (their relativePath is a label, filePath the worktree -// root). The fs watcher must still notify them so terminal/agent edits reload -// the affected section. Branch/commit combined diffs compare committed refs, so -// working-tree changes don't affect them and are intentionally excluded. -export function isWorkingTreeCombinedDiffTab(file: OpenFile): boolean { - return ( - file.mode === 'diff' && - (file.diffSource === 'combined-uncommitted' || file.diffSource === 'combined-all') - ) -} - export function canAutoSaveOpenFile(file: OpenFile): boolean { // Why: read-only tabs (AI Vault View Log) must never autosave — writing an // agent-owned transcript can corrupt the provider's resume history. diff --git a/src/renderer/src/components/editor/markdown-scan-ranges.ts b/src/renderer/src/components/editor/markdown-scan-ranges.ts index 3e471be72d98..5a95454176f3 100644 --- a/src/renderer/src/components/editor/markdown-scan-ranges.ts +++ b/src/renderer/src/components/editor/markdown-scan-ranges.ts @@ -51,10 +51,6 @@ export function markdownFenceRanges(content: string): MarkdownFenceRanges { return ranges } -export function isInsideRange(index: number, ranges: MarkdownFenceRanges): boolean { - return ranges.some(([start, end]) => index >= start && index < end) -} - function rangeEndAt(index: number, ranges: MarkdownFenceRanges): number { for (const [start, end] of ranges) { if (index >= start && index < end) { diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index d4175b5d10f4..a5389a91c1a2 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -22,7 +22,7 @@ import type { EditorPanelContentLoadOptions } from './useEditorPanelExternalCont import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' import { findRestoredEditorWorkspaceRuntimeOwner } from './restored-editor-workspace-runtime-owner' import type { RuntimeWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route' -import { editorTabFileAccess } from '@/lib/local-file-access' +import { editorTabFileAccess, isClientLocalReadOnlyTab } from '@/lib/local-file-access' import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' const inFlightFileReads = new Map>() @@ -97,10 +97,13 @@ export function useEditorPanelFileContentLoader({ activeSettings, restoredOpenFile?.runtimeEnvironmentId ) - // Why: liveTail tabs are AI Vault logs discovered on this client, so the - // worktree's SSH owner must never be inferred for them (a stamp still routes). 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 + // never be inferred for them (a stamp still routes). + const isClientLocalTab = restoredOpenFile + ? isClientLocalReadOnlyTab(restoredOpenFile) + : false readConnectionId = connectionId let readWorktreeId = worktreeId let readRelativePath = restoredOpenFile?.relativePath ?? relativePath @@ -125,7 +128,7 @@ export function useEditorPanelFileContentLoader({ }) } const workspaceRuntimeOwner = - restoredOpenFile && !isLiveTailLogTab + restoredOpenFile && !isClientLocalTab ? findRestoredEditorWorkspaceRuntimeOwner( useAppStore.getState(), restoredOpenFile, @@ -140,6 +143,7 @@ export function useEditorPanelFileContentLoader({ return } if ( + !isClientLocalTab && resolvedConnectionId === undefined && !readSettings?.activeRuntimeEnvironmentId?.trim() && !isWorktreeConnectionResolved(worktreeId ?? null) @@ -155,11 +159,11 @@ export function useEditorPanelFileContentLoader({ // (or was opened outside) the terminal-link path that stamps the target id. const externalSshOwnerId = restoredOpenFile.externalSshTargetId?.trim() || - (isLiveTailLogTab ? undefined : connectionId) - const runtimeEnvironmentId = isLiveTailLogTab + (isClientLocalTab ? undefined : connectionId) + const runtimeEnvironmentId = isClientLocalTab ? undefined : readSettings?.activeRuntimeEnvironmentId?.trim() - if (isLiveTailLogTab) { + if (isClientLocalTab) { readConnectionId = undefined } else { const currentState = useAppStore.getState() diff --git a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.host-connection.test.tsx b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.host-connection.test.tsx index ad6b4cb012a0..af567bd10bb8 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.host-connection.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.host-connection.test.tsx @@ -148,6 +148,43 @@ describe('editor file loads while the SSH host connects', () => { expect(fileContents[file.id]?.loadError).toBe(SSH_PROVIDER_UNAVAILABLE) }) + it('reads a read-only link to this computer here, never through the workspace SSH host', async () => { + setHost('connecting') + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'notes', isBinary: false }) + const clientTab: OpenFile = { + ...file, + id: 'tab-client', + filePath: '/Users/me/notes.md', + relativePath: '/Users/me/notes.md', + runtimeEnvironmentId: null, + readOnly: true + } + let load: EditorPanelFileContentLoader | null = null + function LoaderHarness(): null { + load = useEditorPanelFileContentLoader({ + fileLoadRetryAttemptsRef: { current: {} }, + fileReadGenerationCounterRef: { current: 0 }, + fileReadGenerationRef: { current: {} }, + openFilesRef: { current: [clientTab] }, + outstandingFileReadsRef: { current: {} }, + setFileContents: () => {} + }) + return null + } + act(() => root.render()) + await act(async () => + load?.(clientTab.filePath, clientTab.id, clientTab.worktreeId, clientTab.relativePath) + ) + + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/Users/me/notes.md', + connectionId: undefined, + access: { kind: 'user-file' } + }) + ) + }) + it('spends no retry budget while connecting, then reloads exactly once when the host connects', () => { setHost('connecting') const attemptsRef: { current: Record } = { current: {} } diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-timeline.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-timeline.tsx index fb6508d336d1..3d06764ac4da 100644 --- a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-timeline.tsx +++ b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-timeline.tsx @@ -6,15 +6,7 @@ import type { GitHubIssueTimelineTarget } from '../../../../../shared/github/comment-types' import { translate } from '@/i18n/i18n' -import { - getTimelineStateReasonLabel, - getTimelineTargetLabel, - type IssueConversationEntry -} from './issue-conversation-entries' -import { - renderCommentCard, - type ConversationCommentCardContext -} from './conversation-tab-comment-card' +import { getTimelineStateReasonLabel, getTimelineTargetLabel } from './issue-conversation-entries' export function renderTimelineTarget( target: GitHubIssueTimelineTarget | undefined @@ -154,15 +146,6 @@ export function renderTimelineActivity(activity: GitHubIssueTimelineItem): React ) } -export function renderIssueConversationEntry( - entry: IssueConversationEntry, - ctx: ConversationCommentCardContext -): React.JSX.Element { - return entry.kind === 'comment' - ? renderCommentCard(entry.comment, false, ctx) - : renderTimelineActivity(entry.activity) -} - export function ConversationTabTimelineActivity({ activity }: { 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 a50f41fc9608..24a3bf218c2a 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 @@ -3,9 +3,8 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context' import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' import { getTaskPageGitHubDuplicateCandidates, getTaskPageGitHubDuplicateTargetErrorMessage, @@ -64,18 +63,8 @@ function GHEditSectionItem({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const duplicateIssueCandidates = useGitHubDuplicateIssueCandidates(item, duplicatePickerOpen) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(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. 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 487411e75e81..cece1174e852 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 @@ -8,7 +8,7 @@ import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { - getGitHubMutationRoutingSettings, + getGitHubRepoRoutingSettings, getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' @@ -56,9 +56,7 @@ export function PRActionsPanel({ const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) const sourceSettings = useAppStore( - useShallow((s) => - getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) - ) + useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)) ) const mergeTarget = getActiveRuntimeTarget(sourceSettings) const prRepo = resolvePullRequestRepo(item, projectOrigin) 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 4a6db255e19b..23fcfda0962c 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 @@ -12,12 +12,11 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' -import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context' import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { TaskSourceContext } from '../../../../../shared/task-source-context' import { translate } from '@/i18n/i18n' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } 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' @@ -56,18 +55,8 @@ export function PRReviewersPanel({ reviewRequests: item.reviewRequests })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? null, sourceContext)) ) const reviewerInputRef = useRef(null) const reviewerInputFocusFrameRef = useRef(null) diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 838348cbb5bb..7b1100fcc3d7 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -27,7 +27,8 @@ import { parseProjectInput } from './project-picker-input' import { getProjectPickerBrowseHost, listProjectViewsForRuntime, - resolveProjectRefForRuntime + resolveProjectRefForRuntime, + type ProjectSourceSettings } from './project-picker-runtime' import type { ProjectPickerChoice, ResolvedProjectSelection } from './project-picker-selection' import { useProjectPickerBrowse } from './useProjectPickerBrowse' @@ -45,6 +46,7 @@ type Props = { title?: string } | null onSelect: (selection: ResolvedProjectSelection) => void + sourceSettings: ProjectSourceSettings } const EMPTY_PROJECT_SETTINGS: GitHubProjectSettings = { @@ -54,7 +56,11 @@ const EMPTY_PROJECT_SETTINGS: GitHubProjectSettings = { activeProject: null } -export default function ProjectPicker({ activeProject, onSelect }: Props): React.JSX.Element { +export default function ProjectPicker({ + activeProject, + onSelect, + sourceSettings +}: Props): React.JSX.Element { const settings = useAppStore((state) => state.settings) const updateSettings = useAppStore((state) => state.updateSettings) const mountedRef = useMountedRef() @@ -71,7 +77,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const [viewList, setViewList] = useState([]) const [viewLoading, setViewLoading] = useState(false) const browseHost = getProjectPickerBrowseHost(activeProject ?? projectSettings.activeProject) - const browse = useProjectPickerBrowse(settings, browseHost) + const browse = useProjectPickerBrowse(sourceSettings, browseHost) const { loadBrowse } = browse useEffect(() => { @@ -143,7 +149,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setViewPickFor(selection) setViewLoading(true) try { - const result = await listProjectViewsForRuntime(settings, { + const result = await listProjectViewsForRuntime(sourceSettings, { owner: choice.owner, ownerType: choice.ownerType, projectNumber: choice.number, @@ -179,7 +185,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React } } }, - [commitSelection, mountedRef, projectSettings.lastViewByProject, settings] + [commitSelection, mountedRef, projectSettings.lastViewByProject, sourceSettings] ) const handlePaste = useCallback(async () => { @@ -196,7 +202,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setPasteError(null) setPasteBusy(true) try { - const result = await resolveProjectRefForRuntime(settings, input, parsed.host) + const result = await resolveProjectRefForRuntime(sourceSettings, input, parsed.host) if (!mountedRef.current) { return } @@ -218,7 +224,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setPasteBusy(false) } } - }, [handleChooseProject, mountedRef, pasteInput, settings]) + }, [handleChooseProject, mountedRef, pasteInput, sourceSettings]) const filteredBrowse = useMemo( () => diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx index 2b4c799b8612..0f5a0a2e115e 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx @@ -4,6 +4,9 @@ import type { ReactNode } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import ProjectRoadmap from './ProjectRoadmap' +import { i18n, setRendererPluginLanguagePacks, setRendererUiLanguage } from '@/i18n/i18n' +import { DEFAULT_LOCALE } from '@/i18n/supported-languages' +import { pluginLanguageResourceId } from '../../../../shared/plugins/plugin-language-pack-artifact' import type { GitHubProjectField, GitHubProjectFieldValue, @@ -94,13 +97,68 @@ function table( } } -afterEach(() => { +afterEach(async () => { cleanup() vi.useRealTimers() window.localStorage.clear() + setRendererPluginLanguagePacks([]) + await setRendererUiLanguage(DEFAULT_LOCALE) }) describe('ProjectRoadmap', () => { + it.each([ + ['pt-BR', 'pt-BR'], + ['en-US-US', DEFAULT_LOCALE] + ])('renders date ticks and bars with plugin locale %s', async (packLocale, dateLocale) => { + const packId = 'plugin:stablyai.orca-portuguese/pt-BR' as const + const resourceLanguage = pluginLanguageResourceId(packId) + // The resource tag recorded in the Windows crash report on #25021. + expect(resourceLanguage).toBe( + 'plugin0070006c007500670069006e003a0073007400610062006c007900610069002e006f007200630061002d0070006f00720074007500670075006500730065002f00700074002d00420052' + ) + setRendererPluginLanguagePacks([ + { + id: packId, + resourceLanguage, + pluginKey: 'stablyai.orca-portuguese', + locale: packLocale, + catalog: { common: { cancel: 'Cancelar' } } + } + ]) + await setRendererUiLanguage(packId) + expect(i18n.resolvedLanguage).toBe(resourceLanguage) + + render( + list
} + /> + ) + + const month = new Intl.DateTimeFormat(dateLocale, { + month: 'short', + timeZone: 'UTC' + }).format(new Date(Date.UTC(2026, 8, 1))) + expect(screen.getAllByText(month, { exact: false }).length).toBeGreaterThan(0) + const dateFormat = new Intl.DateTimeFormat(dateLocale, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC' + }) + const start = dateFormat.format(new Date(Date.UTC(2026, 8, 1))) + const end = dateFormat.format(new Date(Date.UTC(2026, 9, 15))) + expect(screen.getByRole('button', { name: `Scheduled — ${start} – ${end}` })).toBeTruthy() + }) + it('moves the today marker across local midnight without resetting scroll and cleans up its timer', () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 8, 5, 23, 59, 59)) diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.tsx index 5e06e41d8700..26c0c40824ee 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.tsx @@ -3,7 +3,7 @@ import { CalendarClock } from 'lucide-react' import { Button } from '@/components/ui/button' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import { cn } from '@/lib/utils' -import { i18n, translate } from '@/i18n/i18n' +import { getIntlLocale, translate } from '@/i18n/i18n' import ProjectGroupHeader from './ProjectGroupHeader' import ProjectRoadmapBar from './ProjectRoadmapBar' import { ProjectTitleCell } from './ProjectCellIdentity' @@ -48,7 +48,7 @@ export default function ProjectRoadmap({ }: Props): React.JSX.Element { const view = table.selectedView const prefersReducedMotion = usePrefersReducedMotion() - const locale = i18n.resolvedLanguage ?? i18n.language + const locale = getIntlLocale() // Why: the grid lives on UTC calendar days (parseRoadmapDate), so "today" // must be the viewer's LOCAL calendar date mapped to UTC midnight — the raw // instant would shift the marker into the wrong day off UTC. diff --git a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx index d03489d73894..c9593082915c 100644 --- a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx +++ b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx @@ -19,7 +19,8 @@ export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTabl loading, viewId, doFetch, - setAppliedQueryByView + setAppliedQueryByView, + sourceSettings } = tableState const selectedViewUrl = table ? `${table.project.url}/views/${table.selectedView.number ?? ''}` @@ -58,6 +59,7 @@ export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTabl : null } onSelect={(selection) => void doFetch(selection, true)} + sourceSettings={sourceSettings} /> {currentProjectViewKey ? ( } +type Props = { + selectedRepoIds: ReadonlySet + /** Row-less source host (shared with Linear and Jira): the board, picker and unmatched rows read from it. */ + sourceHostId: ExecutionHostId +} -export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JSX.Element { - const tableState = useProjectViewTable(selectedRepoIds) +export default function ProjectViewWrapper({ + selectedRepoIds, + sourceHostId +}: Props): React.JSX.Element { + const sourceSettings = useMemo( + () => getTaskSourceRuntimeSettings({ hostId: sourceHostId }), + [sourceHostId] + ) + const tableState = useProjectViewTable(selectedRepoIds, sourceSettings) const rowActions = useProjectRowActions({ table: tableState.table, currentCacheKey: tableState.currentCacheKey, selectedRepoIds }) const addRepo = useAppStore((state) => state.addRepo) + 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), + [slugCacheKey] + ) return (
@@ -44,8 +65,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS ) : null} 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 af91cc378201..b3efed48defc 100644 --- a/src/renderer/src/components/github-project/project-picker-runtime.ts +++ b/src/renderer/src/components/github-project/project-picker-runtime.ts @@ -1,4 +1,5 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { githubProjectHost } from '../../../../shared/github/project-identity' import type { GitHubProjectOwnerType } from '../../../../shared/github/project-types' import type { @@ -7,9 +8,13 @@ import type { ResolveProjectRefResult } from '../../../../shared/github/project-result-types' -type RuntimeSettings = Parameters[0] +/** The board's row-less source host, from `getTaskSourceRuntimeSettings`; never the focused server. */ +export type ProjectSourceSettings = Pick -export function getProjectPickerRuntimeScope(settings: RuntimeSettings, host: string): string { +export function getProjectPickerRuntimeScope( + settings: ProjectSourceSettings, + host: string +): string { const target = getActiveRuntimeTarget(settings) const runtimeScope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' return `${runtimeScope}\0${host.toLowerCase()}` @@ -20,7 +25,7 @@ export function getProjectPickerBrowseHost(activeProject: { host?: string } | nu } export async function listAccessibleProjectsForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, host: string ): Promise { const target = getActiveRuntimeTarget(settings) @@ -33,7 +38,7 @@ export async function listAccessibleProjectsForRuntime( } export async function listProjectViewsForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, args: { owner: string ownerType: GitHubProjectOwnerType @@ -50,7 +55,7 @@ export async function listProjectViewsForRuntime( } export async function resolveProjectRefForRuntime( - settings: RuntimeSettings, + settings: ProjectSourceSettings, input: string, host?: string ): Promise { diff --git a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts index b29bd8ef756d..0c49c6370021 100644 --- a/src/renderer/src/components/github-project/useProjectPickerBrowse.ts +++ b/src/renderer/src/components/github-project/useProjectPickerBrowse.ts @@ -9,11 +9,12 @@ import { } from './project-picker-browse-cache' import { getProjectPickerRuntimeScope, - listAccessibleProjectsForRuntime + listAccessibleProjectsForRuntime, + type ProjectSourceSettings } from './project-picker-runtime' export function useProjectPickerBrowse( - settings: Parameters[0], + settings: ProjectSourceSettings, 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 2649dbe19962..5fbd9ee5f088 100644 --- a/src/renderer/src/components/github-project/useProjectViewTable.ts +++ b/src/renderer/src/components/github-project/useProjectViewTable.ts @@ -2,9 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' -import { projectViewCacheKey } from '@/store/github/cache-identity' +import { projectViewCacheKey, projectViewSourceScope } from '@/store/github/cache-identity' import type { GitHubProjectSettings, GitHubProjectTable, @@ -12,14 +11,14 @@ import type { } from '../../../../shared/github/project-types' import type { GetProjectViewTableResult, - GitHubProjectViewError, - ListProjectViewsResult + GitHubProjectViewError } from '../../../../shared/github/project-result-types' import { githubProjectHost, githubProjectIdentityKey } from '../../../../shared/github/project-identity' import type { ResolvedProjectSelection } from './project-picker-selection' +import { listProjectViewsForRuntime, type ProjectSourceSettings } from './project-picker-runtime' import { filterProjectTableRowsBySelectedRepos } from './project-row-filtering' import { getNextVisibleProjectTableCache, @@ -29,21 +28,10 @@ import { } from './project-visible-table-cache' import { useRepoSlugIndex } from '@/lib/repo-slug-index' -type Settings = Parameters[0] - -async function listProjectViewsForRuntime( - settings: Settings, - args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number; host?: string } -): Promise { - const target = getActiveRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'github.project.listViews', args, { - timeoutMs: 30_000 - }) - : window.api.gh.listProjectViews(args) -} - -export function useProjectViewTable(selectedRepoIds: ReadonlySet) { +export function useProjectViewTable( + selectedRepoIds: ReadonlySet, + sourceSettings: ProjectSourceSettings +) { const settings = useAppStore((state) => state.settings) const projectViewCache = useAppStore((state) => state.projectViewCache) const fetchProjectViewTable = useAppStore((state) => state.fetchProjectViewTable) @@ -51,8 +39,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { const lastViewByProject = settings?.githubProjects?.lastViewByProject ?? {} const { lookupSlugMatches, ready: slugIndexReady } = useRepoSlugIndex() const mountedRef = useMountedRef() - const target = getActiveRuntimeTarget(settings) - const sourceScope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' + const sourceScope = projectViewSourceScope(sourceSettings) const [loading, setLoading] = useState(false) const [error, setError] = useState<{ error: GitHubProjectViewError; totalCount?: number } | null>( null @@ -71,6 +58,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { setError(null) try { const result: GetProjectViewTableResult = await fetchProjectViewTable( + sourceSettings, { owner: selection.owner, ownerType: selection.ownerType, @@ -93,7 +81,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { } } }, - [fetchProjectViewTable, mountedRef] + [fetchProjectViewTable, mountedRef, sourceSettings] ) const projectIdentity = activeProject ? githubProjectIdentityKey(activeProject) : null @@ -143,7 +131,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { return } let cancelled = false - void listProjectViewsForRuntime(settings, { + void listProjectViewsForRuntime(sourceSettings, { owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, @@ -164,7 +152,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { return () => { cancelled = true } - }, [activeProject, settings, sourceScope, viewListByProject]) + }, [activeProject, sourceSettings, sourceScope, viewListByProject]) const selectedRepoFingerprint = useMemo( () => getSelectedRepoFingerprint(selectedRepoIds), @@ -247,7 +235,7 @@ export function useProjectViewTable(selectedRepoIds: ReadonlySet) { ) return { - settings, + sourceSettings, activeProject, sourceScope, viewId, diff --git a/src/renderer/src/components/github/PRAssigneesPanel.tsx b/src/renderer/src/components/github/PRAssigneesPanel.tsx index 916c9f5d4131..a2744e82d2b2 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 { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' import { parseOwnerRepoFromItemUrl, resolvePullRequestRepo, @@ -16,10 +16,7 @@ import { } from '@/components/github/github-work-item-identity' import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations' import { ReviewerAvatar } from '@/components/github/work-item-state-presentation' -import { - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../../shared/task-source-context' +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' @@ -47,18 +44,8 @@ export function PRAssigneesPanel({ })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? null, sourceContext)) ) const { isPending, run } = useImmediateMutation() 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 543fb2395e81..d79334fbbe3c 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,7 +1,7 @@ import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { - getGitHubMutationRoutingSettings, + getGitHubRepoRoutingSettings, getGitHubRuntimeRepoId, getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context' @@ -236,7 +236,7 @@ export async function runPullRequestStateUpdate(args: { } // Why: close/reopen must route by the repo owner host like merge (#6957). const target = getActiveRuntimeTarget( - getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext) + getGitHubRepoRoutingSettings(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/gitlab-item-dialog/gitlab-item-dialog-types.ts b/src/renderer/src/components/gitlab-item-dialog/gitlab-item-dialog-types.ts index 8ceadd04d369..8d1f906f6612 100644 --- a/src/renderer/src/components/gitlab-item-dialog/gitlab-item-dialog-types.ts +++ b/src/renderer/src/components/gitlab-item-dialog/gitlab-item-dialog-types.ts @@ -14,4 +14,5 @@ export type GitLabDialogRepoSelector = { repoPath: string repoId?: string | null sourceContext?: TaskSourceContext | null + repoOwnerExecutionHostId?: string } diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-details-editing.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-details-editing.ts index e4fa0f4723d9..063a1b1c5488 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-details-editing.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-details-editing.ts @@ -1,3 +1,4 @@ +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import { useCallback } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -40,7 +41,7 @@ export function useGitLabDetailsEditing( } setLabelOptionsLoading(true) try { - const labels = await window.api.gl.listLabels(repoSelector) + const labels = await gitLabApiFor(repoSelector).listLabels(repoSelector) if (mountedRef.current) { setLabelOptions(normalizeGitLabLabels(labels)) } @@ -127,7 +128,11 @@ export function useGitLabDetailsEditing( setDetailsSaving(true) try { - const res = await window.api.gl.updateMR({ ...repoSelector, iid: item.number, updates }) + const res = await gitLabApiFor(repoSelector).updateMR({ + ...repoSelector, + iid: item.number, + updates + }) if (res.ok) { if (mountedRef.current) { setDetails((current) => diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-item-dialog-effects.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-item-dialog-effects.ts index dd71ef3cb2c4..cea2c0e557f7 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-item-dialog-effects.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-item-dialog-effects.ts @@ -1,4 +1,5 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: GitLab item dialogs reset draft/provider state and hydrate details from GitLab IPC when the selected item identity changes. */ +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import { useEffect } from 'react' import type { GitLabWorkItem, GitLabWorkItemDetails } from '../../../../shared/gitlab-types' import type { GitLabDialogRepoSelector } from './gitlab-item-dialog-types' @@ -21,7 +22,7 @@ export function useGitLabItemDetailsEffect( let stale = false setLoading(true) setError(null) - void window.api.gl + void gitLabApiFor(repoSelector) .workItemDetails({ ...repoSelector, iid: item.number, type: item.type }) .then((data) => { if (stale) { diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-pipeline-actions.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-pipeline-actions.ts index 0229cd4a0e8a..e317e262ca3c 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-pipeline-actions.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-pipeline-actions.ts @@ -1,3 +1,4 @@ +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import { useCallback } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -37,7 +38,7 @@ export function useGitLabPipelineActions( [job.id]: { loading: true } })) try { - const result = await window.api.gl.jobTrace({ + const result = await gitLabApiFor(repoSelector).jobTrace({ ...repoSelector, jobId: job.id, projectRef: details?.item.projectRef ?? item.projectRef ?? null @@ -82,7 +83,7 @@ export function useGitLabPipelineActions( } setRetryingJobId(job.id) try { - const result = await window.api.gl.retryJob({ + const result = await gitLabApiFor(repoSelector).retryJob({ ...repoSelector, jobId: job.id, projectRef: details?.item.projectRef ?? item.projectRef ?? null diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.ts index ae4deb2e9295..ddcbefef63ef 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.ts @@ -1,3 +1,4 @@ +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import { useCallback } from 'react' import { toast } from 'sonner' import { getCommentBodySubmitState } from '@/lib/comment-body-submit-state' @@ -28,7 +29,7 @@ export function useGitLabPrimaryActions( } setActionInFlight('close') try { - const res = await window.api.gl.closeMR({ ...repoSelector, iid: item.number }) + const res = await gitLabApiFor(repoSelector).closeMR({ ...repoSelector, iid: item.number }) if (res.ok) { if (mountedRef.current) { useAppStore.getState().recordFeatureInteraction('gitlab-tasks') @@ -61,7 +62,7 @@ export function useGitLabPrimaryActions( } setActionInFlight('reopen') try { - const res = await window.api.gl.reopenMR({ ...repoSelector, iid: item.number }) + const res = await gitLabApiFor(repoSelector).reopenMR({ ...repoSelector, iid: item.number }) if (res.ok) { if (mountedRef.current) { useAppStore.getState().recordFeatureInteraction('gitlab-tasks') @@ -94,7 +95,7 @@ export function useGitLabPrimaryActions( } setActionInFlight('merge') try { - const res = await window.api.gl.mergeMR({ ...repoSelector, iid: item.number }) + const res = await gitLabApiFor(repoSelector).mergeMR({ ...repoSelector, iid: item.number }) if (res.ok) { if (mountedRef.current) { useAppStore.getState().recordFeatureInteraction('gitlab-tasks') @@ -141,12 +142,12 @@ export function useGitLabPrimaryActions( // Branch on the item type to hit the right channel. const res = item.type === 'mr' - ? await window.api.gl.addMRComment({ + ? await gitLabApiFor(repoSelector).addMRComment({ ...repoSelector, iid: item.number, body: bodyState.body }) - : await window.api.gl.addIssueComment({ + : await gitLabApiFor(repoSelector).addIssueComment({ ...repoSelector, number: item.number, body: bodyState.body diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-review-actions.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-review-actions.ts index e560fa167992..18b1c30cb304 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-review-actions.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-review-actions.ts @@ -1,3 +1,4 @@ +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import { useCallback } from 'react' import { toast } from 'sonner' import { getCommentBodySubmitState } from '@/lib/comment-body-submit-state' @@ -36,7 +37,7 @@ export function useGitLabReviewActions( } setReviewerOptionsLoading(true) try { - const users = await window.api.gl.listAssignableUsers(repoSelector) + const users = await gitLabApiFor(repoSelector).listAssignableUsers(repoSelector) if (mountedRef.current) { setReviewerOptions(dedupeGitLabUsers(users)) } @@ -77,7 +78,7 @@ export function useGitLabReviewActions( } setReviewerUpdating(true) try { - const result = await window.api.gl.updateMRReviewers({ + const result = await gitLabApiFor(repoSelector).updateMRReviewers({ ...repoSelector, iid: item.number, reviewerIds, @@ -156,7 +157,7 @@ export function useGitLabReviewActions( } setInlineCommentSubmitting(true) try { - const result = await window.api.gl.addMRInlineComment({ + const result = await gitLabApiFor(repoSelector).addMRInlineComment({ ...repoSelector, iid: item.number, projectRef: details.item.projectRef ?? item.projectRef ?? null, @@ -214,7 +215,7 @@ export function useGitLabReviewActions( } setResolvingThreadId(threadId) try { - const res = await window.api.gl.resolveMRDiscussion({ + const res = await gitLabApiFor(repoSelector).resolveMRDiscussion({ ...repoSelector, iid: item.number, discussionId: threadId, diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx index 9ad245fde3e6..e3da66df4448 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -144,6 +144,49 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { expect(onStop).toHaveBeenCalledWith('codex-command:exec-1') }) + it('stops a Grok background command and the Grok subagent beside it by their own ids', () => { + const view = (overrides: Partial): AgentChildWorkView => ({ + id: 'child', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: Date.now() - 65_000, + observedAt: Date.now() - 1_000, + stoppable: true, + invocation: { invocationId: 'spawn-1', generation: 1 }, + ...overrides + }) + const onStop = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { expanded: false })) + + fireEvent.click(screen.getByLabelText('Stop Run the dev server')) + fireEvent.click(screen.getByLabelText('Stop Review the diff')) + expect(onStop.mock.calls).toEqual([['acp-task:01a10366'], ['subagent-1']]) + expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() + }) + it('offers no stop at all when the provider exposes none', () => { // An older Codex: a Stop button here would be a control that cannot act. renderStrip({ supportsTaskStop: false, supportsStopAll: false }) diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts index 85da3fe74353..9c0032a71903 100644 --- a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts @@ -17,11 +17,6 @@ export function unnamedSenderLabel(): string { return translate('components.native-chat.agentMessage.unnamedSender', 'an agent') } -/** The name recorded on the message. */ -export function agentMessageSenderLabel(sender: AgentMessageSender): string { - return sender.name ?? unnamedSenderLabel() -} - function findChatTab( state: AppState, sessionId: string, diff --git a/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx b/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx index abeea9ba49cc..c473858ad2fd 100644 --- a/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx +++ b/src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.source.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { NormalizedWorkItemSourceSearchProps } from './smart-workspace-name-field-model' @@ -69,6 +69,8 @@ function props(context?: TaskSourceContext): NormalizedWorkItemSourceSearchProps } describe('smart workspace explicit source connection', () => { + afterEach(cleanup) + beforeEach(() => { vi.clearAllMocks() mocks.state.linearStatus = { diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx index 573dc0b3f94c..a0be9b3a84ff 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx @@ -6,7 +6,6 @@ import { getDefaultSettings } from '../../../../shared/constants' import { getDefaultOnboardingState } from '../../../../shared/onboarding-defaults' import { useAppStore } from '@/store' import OnboardingFlow from './OnboardingFlow' -import { ONBOARDING_SKIP_CONFIRMATION_COPY } from './OnboardingSkipConfirmationDialog' function renderOnboardingFlow(props: ComponentProps): string { return renderToStaticMarkup( @@ -228,13 +227,4 @@ describe('OnboardingFlow', () => { expect(html).not.toContain('min-h-screen') expect(html).not.toContain('background-color:#12181e') }) - - it('renders concise skip confirmation copy', () => { - expect(ONBOARDING_SKIP_CONFIRMATION_COPY).toEqual({ - title: 'Skip onboarding?', - description: "It won't take long!", - skipLabel: 'Skip', - keepGoingLabel: 'No, keep going' - }) - }) }) 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 da43e08f7f31..c6505c3c3273 100644 --- a/src/renderer/src/components/pull-request-page/actions/panel.tsx +++ b/src/renderer/src/components/pull-request-page/actions/panel.tsx @@ -22,7 +22,7 @@ import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getGitHubMutationRoutingSettings } from '@/lib/github-source-runtime-context' +import { getGitHubRepoRoutingSettings } 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' @@ -61,9 +61,7 @@ export function PRActionsPanel({ const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) const sourceSettings = useAppStore( - useShallow((s) => - getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) - ) + useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)) ) const mergeTarget = getActiveRuntimeTarget(sourceSettings) const prRepo = resolvePullRequestRepo(item, projectOrigin) 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 2a7d9e1ba6fd..74d82f78df37 100644 --- a/src/renderer/src/components/pull-request-page/conversation/tab.tsx +++ b/src/renderer/src/components/pull-request-page/conversation/tab.tsx @@ -4,8 +4,10 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { useRepoAssignees } from '@/hooks/useIssueMetadata' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context' +import { + canUseGitHubRepoContext, + getGitHubRepoRoutingSettings +} from '@/lib/github-source-runtime-context' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { filterPRCommentsByAudience, @@ -23,7 +25,6 @@ import { resolveGitHubBodyDraft, shouldSyncGitHubBodyDraft } from '@/components/github-body-draft-state' -import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context' import type { TaskSourceContext } from '../../../../../shared/task-source-context' import type { GitHubAssignableUser, @@ -102,18 +103,8 @@ export function ConversationTab({ const bodyTextareaRef = useRef(null) const bodyTextareaFocusFrameRef = useRef(null) const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)) ) const repoAssignees = useRepoAssignees(repoPath, item.repoId, sourceSettings) const botAuthorOverrides = usePRBotAuthorOverrides() 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 7a2257e6c8e8..2da1ec96bff8 100644 --- a/src/renderer/src/components/pull-request-page/edit/section.tsx +++ b/src/renderer/src/components/pull-request-page/edit/section.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { ArrowRight, ChevronDown, CircleDashed, CircleDot, LoaderCircle } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -7,10 +7,9 @@ import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { useImmediateMutation, useRepoAssignees, useRepoLabels } from '@/hooks/useIssueMetadata' import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' -import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context' import type { TaskSourceContext } from '../../../../../shared/task-source-context' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } 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' @@ -52,18 +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 repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(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. 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 0f9bbec7b457..7862e7112707 100644 --- a/src/renderer/src/components/pull-request-page/reviewers/panel.tsx +++ b/src/renderer/src/components/pull-request-page/reviewers/panel.tsx @@ -3,12 +3,11 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { useRepoAssignees } from '@/hooks/useIssueMetadata' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context' 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 { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } 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' @@ -53,18 +52,8 @@ export function PRReviewersPanel({ reviewRequests: item.reviewRequests })) const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(s, item.repoId ?? null, sourceContext)) ) const submittingRef = useRef(false) const reviewerInputRef = useRef(null) diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.tsx index 571086d65963..6abac438baa6 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.tsx @@ -1,7 +1,12 @@ -import React from 'react' -import { useActiveWorktree, useRepoById } from '@/store/selectors' +import React, { useMemo } from 'react' +import { Server } from 'lucide-react' +import { useAppStore } from '@/store' +import { useActiveWorktree } from '@/store/selectors' +import { translate } from '@/i18n/i18n' import { LocalWorkspacePortsPanel } from './local-workspace-ports-panel' import { SshPortsPanel } from './ssh-ports-panel' +import { HostScopedPortsPanel } from './host-scoped-ports-panel' +import { getPortsPanelOwnerKey, portsPanelHostForOwnerKey } from './ports-panel-host' export { getLocalWorkspacePortSections } from './local-workspace-port-sections' export { @@ -12,11 +17,40 @@ export { export default function PortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element { const activeWorktree = useActiveWorktree() - const activeRepo = useRepoById(activeWorktree?.repoId ?? null) + const workspaceId = activeWorktree?.id ?? null + const ownerKey = useAppStore((state) => getPortsPanelOwnerKey(state, workspaceId)) + const host = useMemo(() => portsPanelHostForOwnerKey(ownerKey), [ownerKey]) + const scopedHost = useMemo( + () => + host.kind === 'host-scoped' && workspaceId + ? { route: host.route, executionHostId: host.executionHostId, worktreeId: workspaceId } + : null, + [host, workspaceId] + ) - if (activeRepo?.connectionId) { - return + if (!workspaceId) { + return + } + if (scopedHost) { + return + } + switch (host.kind) { + case 'direct-ssh': + return + case 'endpoint': + return + case 'host-scoped': + case 'unknown': + return ( +
+ +

+ {translate( + 'auto.components.right.sidebar.PortsPanel.153d3a8026', + 'Workspace host unknown' + )} +

+
+ ) } - - return } diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index caafca96b009..379eb23f6e01 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -14,6 +14,7 @@ import type { TuiAgent } from '../../../../shared/tui-agent' const mocks = vi.hoisted(() => ({ ensureDetectedAgents: vi.fn(), ensureRemoteDetectedAgents: vi.fn(), + ensureRuntimeDetectedAgents: vi.fn(), onOpenChange: vi.fn(), onSaveAgentDefault: vi.fn(), onLaunched: vi.fn(), @@ -60,6 +61,7 @@ vi.mock('sonner', () => ({ import { useAppStore, type AppState } from '@/store' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' let container: HTMLDivElement let root: Root let initialState: AppState @@ -117,7 +119,8 @@ function resetStore(settings: GlobalSettings, repos: Repo[] = []): void { settings, repos, ensureDetectedAgents: mocks.ensureDetectedAgents, - ensureRemoteDetectedAgents: mocks.ensureRemoteDetectedAgents + ensureRemoteDetectedAgents: mocks.ensureRemoteDetectedAgents, + ensureRuntimeDetectedAgents: mocks.ensureRuntimeDetectedAgents }, true ) @@ -180,6 +183,7 @@ describe('SourceControlAgentActionDialog', () => { vi.clearAllMocks() mocks.ensureDetectedAgents.mockResolvedValue(['codex']) mocks.ensureRemoteDetectedAgents.mockResolvedValue(['codex']) + mocks.ensureRuntimeDetectedAgents.mockResolvedValue(['codex']) mocks.onStart.mockResolvedValue(true) mocks.checkSourceControlAgentActionLaunch.mockReturnValue({ ok: true }) container = document.createElement('div') @@ -252,6 +256,21 @@ describe('SourceControlAgentActionDialog', () => { agentArgs: '--model saved' }) }) + // Why (#21429): a paired server's workspace has no SSH connection on this client; its agents + // are on the server, and this client's own list says nothing about them. + it('detects agents on the paired server that owns the workspace', async () => { + mocks.ensureDetectedAgents.mockResolvedValue([]) + resetStore(settingsWithGlobalRecipe(), [repoWithSavedRecipe('', null, 'runtime:env-1')]) + useAppStore.setState({ + worktreesByRepo: { 'repo-1': [makeWorktree({ id: 'repo-1::wt-1', repoId: 'repo-1' })] } + }) + + renderControlledDialog({ repoId: 'repo-1', worktreeId: 'repo-1::wt-1', connectionId: null }) + + await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1)) + expect(mocks.ensureRuntimeDetectedAgents).toHaveBeenCalledWith('env-1', 'repo-1::wt-1') + expect(mocks.ensureDetectedAgents).not.toHaveBeenCalled() + }) it('omits saved arguments from a structured local launch', async () => { setLocalRuntimeCapabilitiesForTests([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) const recipe = { diff --git a/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx b/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx index 938a457ad36c..b5aaf3dfcbdd 100644 --- a/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx @@ -88,6 +88,7 @@ function renderChecksList( detailsStickySurface: 'sidebar' | 'card' checkDetailsContextKey: string checks: PRCheckDetail[] + checksError: string onLoadCheckDetails: (check: PRCheckDetail) => Promise }> = {} ): void { @@ -96,6 +97,7 @@ function renderChecksList( { + it('shows a failed read as an error instead of "No checks configured"', () => { + renderChecksList({ checks: [], checksError: 'Could not load from Build box: 401' }) + + expect(container.textContent).toContain('Could not load from Build box: 401') + expect(container.textContent).not.toContain('No checks configured') + expect(container.querySelector('[role="alert"]')).not.toBeNull() + }) + it('pins a contextual full-details action with the correct sticky surface', async () => { renderChecksList({ worktreeId: 'wt-child-1', detailsStickySurface: 'card' }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts index fadef627d972..12da56b47bf3 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts @@ -19,10 +19,12 @@ export type ChecksPanelActiveContentModel = Pick< | 'activeWorktreeId' | 'agentComposerState' | 'checks' + | 'checksError' | 'checksLoading' | 'claimedCommentResolutionRef' | 'commentResolutionLaunchAcceptedRef' | 'comments' + | 'commentsError' | 'commentsLoading' | 'commentsSelectionClearRequest' | 'conflictDetailsRefreshing' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx index 767913b65593..a19899b69b92 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx @@ -51,11 +51,13 @@ export function ChecksPanelActiveContent({ aiActionDisabledReason, canTargetPRComments, checks, + checksError, checksLoading, claimedCommentResolutionRef, commentResolutionLaunchAcceptedRef, comments, commentsDisabledReason, + commentsError, commentsLoading, commentsSelectionClearRequest, conflictDetailsRefreshing, @@ -242,6 +244,7 @@ export function ChecksPanelActiveContent({ {!(activeConflictReview && checks.length === 0 && !checksLoading) && (
+ ) : checks.length === 0 && checksError ? ( +
+ {checksError} +
) : checks.length === 0 ? (
{translate( diff --git a/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-forge-owner.ts b/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-forge-owner.ts new file mode 100644 index 000000000000..ede7d0c2aed8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-forge-owner.ts @@ -0,0 +1,28 @@ +import { toRuntimeExecutionHostId } from '../../../../../shared/execution-host' +import { translate } from '@/i18n/i18n' +import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' +import { forgeCredentialTarget } from '@/runtime/forge-credential-target' +import { useAppStore } from '@/store' + +/** A failed checks/comments read, naming the machine that answered so a sign-in error is actionable. */ +export function checksPanelLoadErrorMessage(error: unknown, ownerHostId: string): string { + const detail = error instanceof Error ? error.message : String(error) + let machine: string + try { + const target = forgeCredentialTarget({ repoOwnerExecutionHostId: ownerHostId }) + machine = + target.kind === 'environment' + ? selectExecutionHostDisplayLabel( + useAppStore.getState(), + toRuntimeExecutionHostId(target.environmentId) + ) + : translate('auto.runtime.forgeCredentialTarget.thisComputer', 'this computer') + } catch { + return detail + } + return translate( + 'auto.components.right.sidebar.checks.panel.loadFailedOnMachine', + 'Could not load from {{machine}}: {{detail}}', + { machine, detail } + ) +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-polling-test-model.ts b/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-polling-test-model.ts index 221e66cacddb..b01509e3208a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-polling-test-model.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-polling-test-model.ts @@ -37,10 +37,11 @@ export function createModel(overrides: Partial = {}): PollingInput badgeColor: '', addedAt: 1 }, - settings: null, setChecks: vi.fn(), + setChecksError: vi.fn(), setChecksLoading: vi.fn(), setComments: vi.fn(), + setCommentsError: vi.fn(), setCommentsLoading: vi.fn(), gitLabProjectRefRef: { current: null }, ...overrides diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comments-list.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comments-list.tsx index 14d078efba39..50b55594d9e2 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comments-list.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comments-list.tsx @@ -36,6 +36,7 @@ import { export function PRCommentsList(props: PRCommentsListProps): React.JSX.Element { const { comments, + commentsError, commentsLoading, reviewKind = 'PR', commentsDisabled, @@ -315,6 +316,10 @@ export function PRCommentsList(props: PRCommentsListProps): React.JSX.Element {
+ ) : comments.length === 0 && commentsError ? ( +
+ {commentsError} +
) : comments.length === 0 && isAddingComment && onAddComment ? ( renderAddCommentComposer(true) ) : comments.length === 0 ? ( diff --git a/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts b/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts index c5933e7ae2c2..2d895a87251d 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts @@ -1,14 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GitLabWorkItemDetails } from '../../../../../shared/gitlab-types' -const runtime = vi.hoisted(() => ({ - target: { kind: 'environment', environmentId: 'runtime-1' }, - call: vi.fn() -})) +const runtime = vi.hoisted(() => ({ call: vi.fn() })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: () => runtime.target, - callRuntimeRpc: runtime.call + callRuntimeRpc: runtime.call, + runtimeEnvironmentSupportsCapability: vi.fn() })) import { @@ -17,49 +14,57 @@ import { resolveGitLabMRDiscussionForChecks } from './gitlab-review-client' -type AdapterSettings = Parameters[0]['settings'] +const gl = { workItemDetails: vi.fn(), resolveMRDiscussion: vi.fn() } beforeEach(() => { runtime.call.mockReset() runtime.call.mockResolvedValue(null) + gl.workItemDetails.mockReset().mockResolvedValue(null) + gl.resolveMRDiscussion.mockReset().mockResolvedValue({ ok: true }) + vi.stubGlobal('window', { api: { gl } }) }) describe('GitLab checks-panel provider adapter', () => { - it('uses the runtime owner, provider-neutral payload, and 30 second timeout', async () => { - const settings = {} as AdapterSettings - + it('reads an SSH workspace MR on this computer, never the focused server (#24264)', async () => { await fetchGitLabMRDetailsForChecks({ repoPath: '/workspace/repo', repoId: 'repo-1', - settings, - iid: 17 + iid: 17, + repoOwnerExecutionHostId: 'ssh:devbox' }) await resolveGitLabMRDiscussionForChecks({ repoPath: '/workspace/repo', repoId: 'repo-1', - settings, iid: 17, discussionId: 'discussion-4', - resolved: true + resolved: true, + repoOwnerExecutionHostId: 'ssh:devbox' + }) + + expect(runtime.call).not.toHaveBeenCalled() + expect(gl.workItemDetails).toHaveBeenCalledWith({ + repoPath: '/workspace/repo', + repoId: 'repo-1', + repoOwnerExecutionHostId: 'ssh:devbox', + iid: 17, + type: 'mr' + }) + expect(gl.resolveMRDiscussion).toHaveBeenCalledOnce() + }) + + it('reads a server-owned workspace MR on its owner with the 30 second timeout', async () => { + await fetchGitLabMRDetailsForChecks({ + repoPath: '/workspace/repo', + repoId: 'repo-1', + iid: 17, + repoOwnerExecutionHostId: 'runtime:owner-runtime' }) - expect(runtime.call).toHaveBeenNthCalledWith( - 1, - runtime.target, + expect(gl.workItemDetails).not.toHaveBeenCalled() + expect(runtime.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'owner-runtime' }, 'gitlab.workItemDetails', - { repo: 'repo-1', iid: 17, type: 'mr' }, - { timeoutMs: 30_000 } - ) - expect(runtime.call).toHaveBeenNthCalledWith( - 2, - runtime.target, - 'gitlab.resolveMRDiscussion', - { - repo: 'repo-1', - iid: 17, - discussionId: 'discussion-4', - resolved: true - }, + expect.objectContaining({ repo: 'id:repo-1', iid: 17, type: 'mr' }), { timeoutMs: 30_000 } ) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts b/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts index 6c98eff79d63..665f8b3a5eec 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts @@ -3,7 +3,7 @@ import type { GitLabDiscussionResolveResult, GitLabWorkItemDetails } from '../../../../../shared/gitlab-types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import type { ChecksPanelReview } from '../checks-panel-review' export function isGitLabChecksPanelReview( @@ -25,55 +25,27 @@ export function gitLabMRCommentsToPRComments( export async function fetchGitLabMRDetailsForChecks(args: { repoPath: string repoId?: string - settings: Parameters[0] iid: number repoOwnerExecutionHostId?: string }): Promise { - const target = getActiveRuntimeTarget(args.settings) - if (target.kind === 'environment') { - return callRuntimeRpc( - target, - 'gitlab.workItemDetails', - { - repo: args.repoId ?? args.repoPath, - iid: args.iid, - type: 'mr' - }, - { timeoutMs: 30_000 } - ) - } - return (await window.api.gl.workItemDetails({ + return gitLabApiFor(args).workItemDetails({ repoPath: args.repoPath, repoId: args.repoId, repoOwnerExecutionHostId: args.repoOwnerExecutionHostId, iid: args.iid, type: 'mr' - })) as GitLabWorkItemDetails | null + }) } export async function resolveGitLabMRDiscussionForChecks(args: { repoPath: string repoId?: string - settings: Parameters[0] iid: number discussionId: string resolved: boolean + repoOwnerExecutionHostId?: string }): Promise { - const target = getActiveRuntimeTarget(args.settings) - if (target.kind === 'environment') { - return callRuntimeRpc( - target, - 'gitlab.resolveMRDiscussion', - { - repo: args.repoId ?? args.repoPath, - iid: args.iid, - discussionId: args.discussionId, - resolved: args.resolved - }, - { timeoutMs: 30_000 } - ) - } - return window.api.gl.resolveMRDiscussion({ + return gitLabApiFor(args).resolveMRDiscussion({ repoPath: args.repoPath, repoId: args.repoId, iid: args.iid, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx index 5b587fbe4784..bdb154300d84 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx @@ -118,11 +118,13 @@ describe('checks panel concrete content', () => { aiActionDisabledReason: undefined, canTargetPRComments: false, checks: [], + checksError: null, checksLoading: false, claimedCommentResolutionRef: { current: null }, commentResolutionLaunchAcceptedRef: { current: false }, comments: [], commentsDisabledReason: undefined, + commentsError: null, commentsLoading: false, commentsSelectionClearRequest: null, conflictDetailsRefreshing: false, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-list-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-list-state.tsx index 68749963fc17..b6f8e2015f18 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-list-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-list-state.tsx @@ -18,6 +18,8 @@ import { export type ChecksListProps = { checks: PRCheckDetail[] + /** Set when the last read failed; replaces the empty-list copy. */ + checksError?: string | null checksLoading: boolean checkDetailsContextKey: string onLoadCheckDetails?: (check: PRCheckDetail) => Promise diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index d1441f09ff42..9e74a05591e8 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -35,7 +35,6 @@ type ChecksPanelAiAcknowledgementInput = Pick< | 'setCommentResolutionAckBusyNow' | 'setComments' | 'setCommentsSelectionClearRequest' - | 'settings' | 'commentResolutionLaunchAcceptedRef' > & Pick & @@ -56,7 +55,6 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem setCommentResolutionAckBusyNow, setComments, setCommentsSelectionClearRequest, - settings, commentResolutionLaunchAcceptedRef } = model const clearSentCommentSelection = useCallback( @@ -109,8 +107,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem githubResolveTarget: resolution.githubResolveTarget, gitlabTarget: resolution.gitlabTarget, resolveReviewThread, - resolveGitLabDiscussion: (args) => - resolveGitLabMRDiscussionForChecks({ ...args, settings }), + resolveGitLabDiscussion: resolveGitLabMRDiscussionForChecks, isPanelStillOnLaunchReview, onResolvedOptimistically: (threadId) => { setComments((prev) => markPRCommentThreadResolved(prev, threadId, true)) @@ -257,7 +254,6 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem clearSentCommentSelection, refreshCommentsAfterBulkResolve, resolveReviewThread, - settings, commentsRef, setComments, asyncResultKeyRef diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx index e9b94ab4ecd8..764c10b25864 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx @@ -1,3 +1,4 @@ +import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' import { useCallback } from 'react' import { toast } from 'sonner' import { buildResolvePullRequestConflictsPrompt } from '../SourceControl' @@ -20,6 +21,7 @@ type ChecksPanelAiQueueInput = Pick< > & Pick< ChecksPanelControllerState, + | 'activeWorktree' | 'activeWorktreeId' | 'activeWorktreePath' | 'claimedCommentResolutionRef' @@ -37,6 +39,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { const { activeConflictReview, activeReview, + activeWorktree, activeWorktreeId, activeWorktreePath, claimedCommentResolutionRef, @@ -158,7 +161,12 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { // may already be showing a different review by then. const gitlabTarget = activeReview.provider === 'gitlab' - ? { repoPath: repo.path, repoId: repo.id, iid: activeReview.number } + ? { + repoPath: repo.path, + repoId: repo.id, + iid: activeReview.number, + ownerHostId: forgeOwnerHostIdForWorkspace(repo, activeWorktree) + } : undefined const commentResolution = { reviewContextKey: stateRequestKey, @@ -207,6 +215,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { }, [ activeReview, + activeWorktree, activeWorktreeId, activeWorktreePath, pr?.prRepo, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index b9cc54d1a18a..7b3dd1f2dafa 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -1,3 +1,4 @@ +import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' import React, { useCallback } from 'react' import { toast } from 'sonner' import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' @@ -25,6 +26,7 @@ function hasGitHubCheckHandle(check: PRCheckDetail): boolean { export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndReviewActionsInput) { const { activeReview, + activeWorktree, activeWorktreeId, asyncResultKeyRef, branch, @@ -46,7 +48,6 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe pr, prCacheKey, repo, - settings, setChecks, setChecksLoading, setComments, @@ -95,7 +96,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe ? await loadGitLabJobLogDetails({ repoPath: repo.path, repoId: repo.id, - settings, + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repo, activeWorktree), check, projectRef: gitLabProjectRefRef.current }) @@ -156,7 +157,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe isFixingChecksWithAI, pr?.prRepo, repo, - settings, + activeWorktree, sourceControlAiActionsVisible, stateRequestKey, setIsFixingChecksWithAI, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-resolution.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-resolution.tsx index 86163067600c..b3babe0c0093 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-resolution.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-comment-resolution.tsx @@ -1,3 +1,4 @@ +import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' import { useCallback, useEffect } from 'react' import { toast } from 'sonner' import { pickDefaultSourceControlAgent } from '../SourceControl' @@ -17,7 +18,7 @@ import { translate } from '@/i18n/i18n' type ChecksPanelCommentResolutionInput = Pick< ChecksPanelControllerState, - | 'activeConnectionId' + | 'activeWorktree' | 'activeWorktreeId' | 'branch' | 'claimedCommentResolutionRef' @@ -25,7 +26,6 @@ type ChecksPanelCommentResolutionInput = Pick< | 'commentsLoading' | 'detectedAgentIds' | 'pendingCommentResolutionRef' - | 'remoteDetectedAgentIds' | 'repo' | 'resolveReviewThread' | 'setAgentComposerState' @@ -42,9 +42,9 @@ type ChecksPanelCommentResolutionInput = Pick< export function useChecksPanelCommentResolution(model: ChecksPanelCommentResolutionInput) { const { - activeConnectionId, activeGitLabReview, activeReview, + activeWorktree, activeWorktreeId, branch, claimedCommentResolutionRef, @@ -56,7 +56,6 @@ export function useChecksPanelCommentResolution(model: ChecksPanelCommentResolut pr, prCacheKey, prNumber, - remoteDetectedAgentIds, repo, resolveReviewThread, setAgentComposerState, @@ -84,10 +83,10 @@ export function useChecksPanelCommentResolution(model: ChecksPanelCommentResolut const result = await resolveGitLabMRDiscussionForChecks({ repoPath: repo.path, repoId: repo.id, - settings, iid: activeGitLabReview.number, discussionId: threadId, - resolved: resolve + resolved: resolve, + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repo, activeWorktree) }) if (!result.ok) { rollbackThread(previousThreadComments) @@ -143,7 +142,7 @@ export function useChecksPanelCommentResolution(model: ChecksPanelCommentResolut prNumber, repo, resolveReviewThread, - settings, + activeWorktree, setComments ] ) @@ -152,13 +151,11 @@ export function useChecksPanelCommentResolution(model: ChecksPanelCommentResolut const commentsDisabledReason = canTargetPRComments ? undefined : 'Commenting requires a GitHub PR repository target.' - const detectedAgentsForAI = - typeof activeConnectionId === 'string' ? remoteDetectedAgentIds : detectedAgentIds const noEnabledAgentKnown = - detectedAgentsForAI != null && + detectedAgentIds != null && pickDefaultSourceControlAgent( settings?.defaultTuiAgent, - detectedAgentsForAI, + detectedAgentIds, settings?.disabledTuiAgents ) == null const aiActionDisabledReason = !activeWorktreeId @@ -200,7 +197,7 @@ export function useChecksPanelCommentResolution(model: ChecksPanelCommentResolut handleResolve, canTargetPRComments, commentsDisabledReason, - detectedAgentsForAI, + detectedAgentsForAI: detectedAgentIds, noEnabledAgentKnown, aiActionDisabledReason, resolveCommentsWithAIDisabledReason 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 b8b414b60f22..42872c4cdadc 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,8 +1,9 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState, type SetStateAction } from 'react' import { useAppStore, type AppState } from '@/store' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from '../use-checks-panel-terminal-worktree' import { getConnectionId } from '@/lib/connection-context' +import { readDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory' import { useMountedRef } from '@/hooks/useMountedRef' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' @@ -84,16 +85,24 @@ export function useChecksPanelControllerState() { const addPRReviewCommentReply = useAppStore((s) => s.addPRReviewCommentReply) const setPRCommentReaction = useAppStore((s) => s.setPRCommentReaction) const resolveReviewThread = useAppStore((s) => s.resolveReviewThread) - const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) - const remoteDetectedAgentIds = useAppStore((s) => { - return typeof activeConnectionId === 'string' - ? (s.remoteDetectedAgentIds[activeConnectionId] ?? null) - : null - }) + const detectedAgentIds = useAppStore((s) => + activeWorktreeId ? readDetectedAgentsForWorktree(s, activeWorktreeId) : s.detectedAgentIds + ) - const [checks, setChecks] = useState([]) + const [checks, setChecksState] = useState([]) + // Why: a failed read keeps its own state so the list says so instead of "No checks configured". + const [checksError, setChecksError] = useState(null) + const setChecks = useCallback((next: SetStateAction) => { + setChecksError(null) + setChecksState(next) + }, []) const [checksLoading, setChecksLoading] = useState(false) - const [comments, setComments] = useState([]) + const [comments, setCommentsState] = useState([]) + const [commentsError, setCommentsError] = useState(null) + const setComments = useCallback((next: SetStateAction) => { + setCommentsError(null) + setCommentsState(next) + }, []) const [commentsLoading, setCommentsLoading] = useState(false) const commentsRef = useRef([]) const [commentsSelectionClearRequest, setCommentsSelectionClearRequest] = @@ -294,13 +303,16 @@ export function useChecksPanelControllerState() { setPRCommentReaction, resolveReviewThread, detectedAgentIds, - remoteDetectedAgentIds, checks, setChecks, checksLoading, setChecksLoading, + checksError, + setChecksError, comments, setComments, + commentsError, + setCommentsError, commentsLoading, setCommentsLoading, commentsRef, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx index f841968a182a..ef3325bbe3ca 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx @@ -1,3 +1,5 @@ +import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import React, { useCallback, useEffect, useRef } from 'react' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from '../checks-entry-refresh' import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' @@ -294,9 +296,13 @@ export function useChecksPanelEntryRefreshAndTitleActions( setTitleSaving(true) try { if (activeReview.provider === 'gitlab') { - const result = await window.api.gl.updateMR({ + const selector = { repoPath: repo.path, repoId: repo.id, + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repo, activeWorktree) + } + const result = await gitLabApiFor(selector).updateMR({ + ...selector, iid: activeReview.number, updates: { title: nextTitle } }) @@ -329,6 +335,7 @@ export function useChecksPanelEntryRefreshAndTitleActions( } }, [ activeReview, + activeWorktree, repo, pr, titleDraft, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx index 5828cb0473b7..18a937dadb3b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx @@ -4,7 +4,6 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { PRCheckDetail } from '../../../../../shared/github/check-types' -import { getDefaultSettings } from '../../../../../shared/constants' import type * as GitLabReviewClient from './gitlab-review-client' const poller = vi.hoisted<{ @@ -129,11 +128,7 @@ describe('useChecksPanelPolling live behavior', () => { expect(model.fetchPRChecks).not.toHaveBeenCalled() }) - it('uses an explicit owner and missing head override for a replacement MR', async () => { - const ownerSettings = { - ...getDefaultSettings('/tmp'), - activeRuntimeEnvironmentId: 'owner-runtime' - } + it('uses the workspace owner and missing head override for a replacement MR', async () => { const model = createModel({ activeGitLabReview: { provider: 'gitlab', @@ -150,8 +145,7 @@ describe('useChecksPanelPolling live behavior', () => { id: 'worktree-1', repoId: 'repo-1', hostId: 'runtime:owner-runtime' - }), - settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'focused-runtime' } + }) }) const { result } = renderHook(() => useChecksPanelPolling(model)) @@ -159,22 +153,48 @@ describe('useChecksPanelPolling live behavior', () => { result.current.fetchGitLabDetails({ mrNumberOverride: 18, headShaOverride: null, - commitAsCurrent: true, - settingsOverride: ownerSettings + commitAsCurrent: true }) ) expect(gitlab.fetchDetails).toHaveBeenCalledWith( - expect.objectContaining({ - iid: 18, - settings: ownerSettings, - repoOwnerExecutionHostId: 'runtime:owner-runtime' - }) + expect.objectContaining({ iid: 18, repoOwnerExecutionHostId: 'runtime:owner-runtime' }) ) expect(model.asyncResultKeyRef.current).toContain('::18::none') expect(model.asyncResultKeyRef.current).not.toContain('old-head') }) + it('reports a failed MR read as an error naming the machine, not as no checks (#24264)', async () => { + gitlab.fetchDetails.mockRejectedValue(new Error('glab: not logged in')) + const model = createModel({ + activeGitLabReview: { + provider: 'gitlab', + number: 17, + headSha: 'head', + title: 'MR', + state: 'open', + url: '', + status: 'pending', + updatedAt: '', + mergeable: 'UNKNOWN' + }, + activeWorktree: makeWorktree({ id: 'worktree-1', repoId: 'repo-1', hostId: 'ssh:devbox' }) + }) + renderHook(() => useChecksPanelPolling(model)) + + await act(async () => poller.run?.()) + + const message = 'Could not load from this computer: glab: not logged in' + expect(model.setChecksError).toHaveBeenCalledWith(message) + expect(model.setCommentsError).toHaveBeenCalledWith(message) + // Why: a later reset would clear the error; only the scope reset may precede it. + const setChecks = vi.mocked(model.setChecks).mock + const setChecksError = vi.mocked(model.setChecksError).mock + expect(Math.max(0, ...setChecks.invocationCallOrder)).toBeLessThan( + setChecksError.invocationCallOrder[0] + ) + }) + it('drops replacement MR details when the relink scope changes in flight', async () => { let resolveDetails!: (value: { item: { projectRef: null } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx index 0053d7dc17b3..8f701e402510 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx @@ -10,6 +10,8 @@ import type { ChecksPanelContextState } from './use-checks-panel-context-state' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' import type { ChecksPanelComposerState } from './use-checks-panel-composer-state' import { fetchGitLabMRDetailsForChecks, gitLabMRCommentsToPRComments } from './gitlab-review-client' +import { checksPanelLoadErrorMessage } from './checks-panel-forge-owner' +import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' export type ChecksPanelPollingInput = Pick< ChecksPanelContextState, @@ -25,10 +27,11 @@ export type ChecksPanelPollingInput = Pick< | 'pollIntervalRef' | 'prevChecksRef' | 'repo' - | 'settings' | 'setChecks' + | 'setChecksError' | 'setChecksLoading' | 'setComments' + | 'setCommentsError' | 'setCommentsLoading' | 'gitLabProjectRefRef' > & @@ -72,8 +75,10 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { prCacheKey, fetchPRChecks, isCurrentAsyncResult, + activeWorktree, setChecksLoading, setChecks, + setChecksError, pollIntervalRef, prevChecksRef } = modelRef.current @@ -123,6 +128,9 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { return } console.warn('Failed to fetch PR checks:', err) + setChecksError( + checksPanelLoadErrorMessage(err, forgeOwnerHostIdForWorkspace(repo, activeWorktree)) + ) policyRef.current.fail() pollIntervalRef.current = Math.min(Math.max(60_000, pollIntervalRef.current * 2), 900_000) } finally { @@ -143,13 +151,11 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { mrNumberOverride, headShaOverride, commitAsCurrent = false, - settingsOverride, isRequestCurrent }: { mrNumberOverride?: number | null headShaOverride?: string | null commitAsCurrent?: boolean - settingsOverride?: ChecksPanelControllerState['settings'] isRequestCurrent?: () => boolean } = {}) => { const { @@ -158,13 +164,14 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { branch, hostedReviewCacheKey, asyncResultKeyRef, - settings, activeWorktree, isCurrentAsyncResult, gitLabProjectRefRef, setChecks, + setChecksError, setChecksLoading, setComments, + setCommentsError, setCommentsLoading, pollIntervalRef, prevChecksRef @@ -196,9 +203,8 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { const details = await fetchGitLabMRDetailsForChecks({ repoPath: repo.path, repoId: repo.id, - settings: settingsOverride ?? settings, iid: targetMRNumber, - repoOwnerExecutionHostId: activeWorktree?.hostId + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repo, activeWorktree) }) if (isRequestCurrent?.() === false || !isCurrentAsyncResult(requestKey)) { return @@ -221,6 +227,13 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { return } console.warn('Failed to fetch GitLab MR checks:', err) + // Why: one MR details read carries both the pipeline and the discussion. + const message = checksPanelLoadErrorMessage( + err, + forgeOwnerHostIdForWorkspace(repo, activeWorktree) + ) + setChecksError(message) + setCommentsError(message) policyRef.current.fail() pollIntervalRef.current = Math.min(Math.max(60_000, pollIntervalRef.current * 2), 900_000) } finally { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-data.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-data.tsx index 7ccb28d7cc20..18bbfab00be4 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-data.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-data.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect } from 'react' import { checksPanelAsyncResultKey } from '../checks-panel-async-result-key' import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' +import { checksPanelLoadErrorMessage } from './checks-panel-forge-owner' +import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' import type { PRCheckDetail } from '../../../../../shared/github/check-types' import type { PRInfo } from '../../../../../shared/github/pull-request-types' @@ -20,8 +22,9 @@ type ChecksPanelReviewDataInput = Pick< | 'gitLabProjectRefRef' | 'isPanelVisible' | 'repo' - | 'settings' + | 'activeWorktree' | 'setComments' + | 'setCommentsError' | 'setCommentsLoading' > & Pick @@ -39,8 +42,9 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { prCacheKey, prNumber, repo, - settings, + activeWorktree, setComments, + setCommentsError, setCommentsLoading } = model // Fetch comments once when PR changes (no polling — comments change infrequently). @@ -87,6 +91,9 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { } console.warn('Failed to fetch PR comments:', err) setComments([]) + setCommentsError( + checksPanelLoadErrorMessage(err, forgeOwnerHostIdForWorkspace(repo, activeWorktree)) + ) } finally { if ( isCurrentAsyncResult( @@ -107,7 +114,9 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { branch, isCurrentAsyncResult, setCommentsLoading, - setComments + setComments, + setCommentsError, + activeWorktree ] ) @@ -117,12 +126,10 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { return Promise.resolve(null) } if (check.gitlabJobId) { - // Why: `settings` (not ownerSettings) is what fetched the job list, so the - // job id and its trace always resolve against the same host. return loadGitLabJobLogDetails({ repoPath: repo.path, repoId: repo.id, - settings, + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repo, activeWorktree), check, projectRef: gitLabProjectRefRef.current }) @@ -139,7 +146,7 @@ export function useChecksPanelReviewData(model: ChecksPanelReviewDataInput) { { repoId: repo.id } ) }, - [fetchPRCheckDetails, pr?.prRepo, repo, settings, gitLabProjectRefRef] + [fetchPRCheckDetails, pr?.prRepo, repo, activeWorktree, gitLabProjectRefRef] ) // Why: read at call time — the ref is filled by an async MR fetch, so a value prop would be stale. diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-comments-list-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-comments-list-state.tsx index ec2a5d85fa4d..55d5a0cc71c6 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-comments-list-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-comments-list-state.tsx @@ -42,6 +42,8 @@ export function getPRCommentsListDisplayModeLabel(mode: PRCommentsListDisplayMod export type PRCommentsListProps = { comments: PRComment[] + /** Set when the last read failed; replaces the empty-list copy. */ + commentsError?: string | null commentsLoading: boolean reviewKind?: 'PR' | 'MR' commentsDisabled?: boolean diff --git a/src/renderer/src/components/right-sidebar/host-scoped-ports-panel.tsx b/src/renderer/src/components/right-sidebar/host-scoped-ports-panel.tsx new file mode 100644 index 000000000000..17c5525fa75a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/host-scoped-ports-panel.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { RefreshCw, Server } from 'lucide-react' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' +import { + killWorkspacePortOnExecutionHost, + scanWorkspacePortsOnExecutionHost, + type HostScopedPortHost +} from '@/lib/workspace-port-scan-client' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { WorkspacePort, WorkspacePortHostScanResult } from '../../../../shared/workspace-ports' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { translate } from '@/i18n/i18n' +import { LocalPortSection } from './local-port-section' +import { LocalPortDetailsDialog } from './local-port-details-dialog' + +const HOST_SCOPED_PORT_SCAN_INTERVAL_MS = 30_000 + +function hostScopedPortHostKey(host: HostScopedPortHost): string { + return JSON.stringify([host.route.target, host.executionHostId, host.worktreeId]) +} + +/** + * Ports on an SSH host, scanned and stopped only through the endpoint that owns it. No browser + * open: a URL opened here would reach this client's machine, not the SSH host. + */ +export function HostScopedPortsPanel({ + host, + isVisible +}: { + /** Must keep its identity while the host is unchanged; each new identity starts a scan. */ + host: HostScopedPortHost + isVisible: boolean +}): React.JSX.Element { + const hostKey = hostScopedPortHostKey(host) + // Why keyed: a scan that lands after the workspace switched hosts must never show as this host's. + const [scanned, setScanned] = useState<{ key: string; scan: WorkspacePortHostScanResult } | null>( + null + ) + const [refreshing, setRefreshing] = useState(false) + const [collapsed, setCollapsed] = useState(false) + const [detailsPort, setDetailsPort] = useState(null) + const scan = scanned?.key === hostKey ? scanned.scan : null + + const refresh = useCallback(async () => { + setRefreshing(true) + try { + const next = await scanWorkspacePortsOnExecutionHost(host) + setScanned({ key: hostKey, scan: next }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + setScanned({ + key: hostKey, + scan: { + executionHostId: host.executionHostId, + platform: 'unknown', + scannedAt: Date.now(), + ports: [], + unavailableReason: message || 'Workspace port scan failed.' + } + }) + } finally { + setRefreshing(false) + } + }, [host, hostKey]) + + useEffect(() => { + if (!isVisible) { + return + } + return installWindowVisibilityInterval({ + run: () => void refresh(), + intervalMs: HOST_SCOPED_PORT_SCAN_INTERVAL_MS + }) + }, [isVisible, refresh]) + + const handleStopPort = useCallback( + async (port: WorkspacePort) => { + if (!scan || !port.pid) { + return + } + const result = await killWorkspacePortOnExecutionHost(host, { + scannedHostId: scan.executionHostId, + pid: port.pid, + port: port.port + }).catch((error: unknown) => ({ + ok: false as const, + reason: error instanceof Error ? error.message : String(error) + })) + if (!result.ok) { + toast.error(result.reason) + return + } + toast.success( + translate( + 'auto.components.right.sidebar.PortsPanel.97b562d21d', + 'Stopped process on :{{value0}}', + { value0: port.port } + ) + ) + void refresh() + }, + [host, refresh, scan] + ) + + const ports = useMemo(() => scan?.ports ?? [], [scan]) + const parsedHost = parseExecutionHostId(host.executionHostId) + const hostLabel = parsedHost?.kind === 'ssh' ? parsedHost.targetId : host.executionHostId + + return ( +
+
+ + {translate('auto.components.right.sidebar.PortsPanel.6bc058dbe1', 'Ports')} + + + + + + + {translate('auto.components.right.sidebar.PortsPanel.7822e3edc6', 'Refresh Ports')} + + +
+ + {scan?.unavailableReason ? ( +
+ {translate( + 'auto.components.right.sidebar.PortsPanel.f59c783b7a', + 'Port scan unavailable on {{value0}}: {{value1}}', + { value0: hostLabel, value1: scan.unavailableReason } + )} +
+ ) : scan && ports.length === 0 ? ( +
+ +

+ {translate('auto.components.right.sidebar.PortsPanel.38b16cfbef', 'No ports detected')} +

+
+ ) : ( + setCollapsed((value) => !value)} + onStopPort={(port) => void handleStopPort(port)} + onShowDetails={setDetailsPort} + /> + )} + + setDetailsPort(null)} /> +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/hosted-review-gitlab-actions.ts b/src/renderer/src/components/right-sidebar/hosted-review-gitlab-actions.ts index bc36773d199b..a5332b68ceb2 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review-gitlab-actions.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review-gitlab-actions.ts @@ -1,34 +1,15 @@ -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { - GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, - GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE -} from '../../../../shared/protocol-version' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' -import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' -export async function markGitLabHostedReviewReadyForReview(args: { +export function markGitLabHostedReviewReadyForReview(args: { repo: Repo mrNumber: number -}): Promise>> { - const host = parseExecutionHostId(getRepoExecutionHostId(args.repo)) - if (host?.kind === 'runtime') { - await assertRuntimeEnvironmentCapability( - host.environmentId, - GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, - GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE - ) - return callRuntimeRpc>>( - { kind: 'environment', environmentId: host.environmentId }, - 'gitlab.updateMR', - { - repo: args.repo.id, - iid: args.mrNumber, - updates: { readyForReview: true } - }, - { timeoutMs: 30_000 } - ) - } - return window.api.gl.updateMR({ +}): ReturnType { + return gitLabApiFor({ + repoPath: args.repo.path, + repoOwnerExecutionHostId: getRepoExecutionHostId(args.repo) + }).updateMR({ repoPath: args.repo.path, repoId: args.repo.id, iid: args.mrNumber, diff --git a/src/renderer/src/components/right-sidebar/local-port-row.tsx b/src/renderer/src/components/right-sidebar/local-port-row.tsx index 644ed0002977..dc11aa4a4b2c 100644 --- a/src/renderer/src/components/right-sidebar/local-port-row.tsx +++ b/src/renderer/src/components/right-sidebar/local-port-row.tsx @@ -30,7 +30,8 @@ export function LocalPortRow({ port: WorkspacePort onStop: (port: WorkspacePort) => void onShowDetails: (port: WorkspacePort) => void - onOpenInBrowser: (port: WorkspacePort, event?: React.MouseEvent) => void + /** Absent when this client has no route to the port's host. */ + onOpenInBrowser?: (port: WorkspacePort, event?: React.MouseEvent) => void }): React.JSX.Element { const handleCopy = useCallback(() => { void window.api.ui.writeClipboardText(addressForPort(port)) @@ -38,7 +39,7 @@ export function LocalPortRow({ const handleOpenBrowser = useCallback( (event?: React.MouseEvent) => { - void onOpenInBrowser(port, event) + void onOpenInBrowser?.(port, event) }, [onOpenInBrowser, port] ) @@ -127,23 +128,25 @@ export function LocalPortRow({
- - - - - - {getPortOpenBrowserTooltipLabel(openBrowserLabel)} - - + {onOpenInBrowser && ( + + + + + + {getPortOpenBrowserTooltipLabel(openBrowserLabel)} + + + )} + {session.readOnly ? null : ( + + )}
) @@ -182,10 +184,12 @@ export function WorktreeRow({ {/* Why: gate the chip on SSH connectionId, not missing data — warm-reattached local PTYs land here with hasLocalSamples=false. */} {worktree.isRemote && ( - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', - '· remote' - )} + {worktree.hostLabel + ? `· ${worktree.hostLabel}` + : translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', + '· remote' + )} )} diff --git a/src/renderer/src/components/status-bar/resource-workspace-host.ts b/src/renderer/src/components/status-bar/resource-workspace-host.ts index 5ea2e9df33eb..66aeb1019552 100644 --- a/src/renderer/src/components/status-bar/resource-workspace-host.ts +++ b/src/renderer/src/components/status-bar/resource-workspace-host.ts @@ -21,12 +21,16 @@ export function resolveResourceWorkspaceHost( ctx: MergeContext, worktreeId: string, repoId: string -): { isRemote: boolean; isRuntimeScoped: boolean } { +): { isRemote: boolean; runtimeHostId: string | null } { const folder = resolveResourceFolderWorkspace(ctx, worktreeId) const host = folder ? parseExecutionHostId(folder.hostId ?? 'local') : null return { // Folder siblings may execute on different hosts within the same project group. isRemote: host ? host.kind === 'ssh' : ctx.repoConnectionIdById.get(repoId) != null, - isRuntimeScoped: host ? host.kind === 'runtime' : ctx.repoRuntimeScopedById.get(repoId) === true + runtimeHostId: host + ? host.kind === 'runtime' + ? host.id + : null + : (ctx.repoRuntimeHostIdById.get(repoId) ?? null) } } diff --git a/src/renderer/src/components/status-bar/runtime-host-resources.test.ts b/src/renderer/src/components/status-bar/runtime-host-resources.test.ts new file mode 100644 index 000000000000..246269bad6da --- /dev/null +++ b/src/renderer/src/components/status-bar/runtime-host-resources.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +import { + collectRuntimeResourceEnvironmentIds, + getRuntimeHostResourceNotices, + readRuntimeHostResources, + toRuntimeHostResourceSamples +} from './runtime-host-resources' + +describe('runtime host resources', () => { + it('polls only paired servers that host this client’s workspaces', () => { + expect( + collectRuntimeResourceEnvironmentIds([ + 'local', + 'ssh:box', + 'runtime:env%20b', + 'runtime:env-a', + 'runtime:env-a', + undefined + ]) + ).toEqual(['env b', 'env-a']) + }) + + it("keeps a server's well-formed rows and drops malformed ones", async () => { + const call = vi.fn().mockResolvedValue({ + worktrees: [ + { + worktreeId: 'r::/w', + worktreeName: 'w', + repoId: 'r', + repoName: 'R', + cpu: 3, + memory: 30, + history: [10, 'x', 30], + sessions: [ + { sessionId: 's1', paneKey: 'tab:1', pid: 4, cpu: 3, memory: 30 }, + { sessionId: 's2', cpu: 'bad', memory: 1 } + ], + extra: true + }, + { worktreeId: 'r::/broken', repoId: 'r', cpu: Number.NaN, memory: 1 }, + null + ] + }) + + await expect(readRuntimeHostResources('env-a', call)).resolves.toEqual({ + status: 'sampled', + worktrees: [ + { + worktreeId: 'r::/w', + worktreeName: 'w', + repoId: 'r', + repoName: 'R', + cpu: 3, + memory: 30, + history: [10, 30], + sessions: [{ sessionId: 's1', paneKey: 'tab:1', pid: 4, cpu: 3, memory: 30 }] + } + ] + }) + expect(call).toHaveBeenCalledWith('env-a') + }) + + it('says a server that cannot answer is unknown, never idle', async () => { + const missing = Object.assign(new Error('nope'), { code: 'method_not_found' }) + const results = { + old: await readRuntimeHostResources('old', vi.fn().mockRejectedValue(missing)), + gone: await readRuntimeHostResources('gone', vi.fn().mockRejectedValue(new Error('timeout'))), + odd: await readRuntimeHostResources('odd', vi.fn().mockResolvedValue({ worktrees: 'no' })) + } + + expect(results).toEqual({ + old: { status: 'update-required' }, + gone: { status: 'unreachable' }, + odd: { status: 'update-required' } + }) + const label = (id: string): string => `Host ${id}` + expect(toRuntimeHostResourceSamples(results, label)).toEqual([]) + expect(getRuntimeHostResourceNotices(results, label)).toEqual([ + 'Update Host old to see its resources.', + "Couldn't reach Host gone; its resources are unknown.", + 'Update Host odd to see its resources.' + ]) + }) + + it('tags sampled rows with the server host id and name', () => { + expect( + toRuntimeHostResourceSamples({ 'env b': { status: 'sampled', worktrees: [] } }, () => 'Lab') + ).toEqual([{ hostId: 'runtime:env%20b', hostLabel: 'Lab', worktrees: [] }]) + }) +}) diff --git a/src/renderer/src/components/status-bar/runtime-host-resources.ts b/src/renderer/src/components/status-bar/runtime-host-resources.ts new file mode 100644 index 000000000000..21d6e6351b8c --- /dev/null +++ b/src/renderer/src/components/status-bar/runtime-host-resources.ts @@ -0,0 +1,150 @@ +import { translate } from '@/i18n/i18n' +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { SessionMemory, WorktreeMemory } from '../../../../shared/process-stats-types' +import type { RuntimeHostResourceSample } from './resource-usage-merge-types' + +export type RuntimeHostResourceResult = + | { status: 'sampled'; worktrees: WorktreeMemory[] } + // Why: a server that never answered is unverifiable, so the panel names it instead of showing it idle. + | { status: 'unreachable' } + | { status: 'update-required' } + +export type RuntimeHostResourceCall = (environmentId: string) => Promise + +/** Paired servers that host at least one of this client's projects or folder workspaces. */ +export function collectRuntimeResourceEnvironmentIds( + hostIds: Iterable +): string[] { + const environmentIds = new Set() + for (const hostId of hostIds) { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + environmentIds.add(parsed.environmentId) + } + } + return [...environmentIds].sort() +} + +export async function readRuntimeHostResources( + environmentId: string, + call: RuntimeHostResourceCall +): Promise { + try { + const worktrees = parseSnapshotWorktrees(await call(environmentId)) + return worktrees ? { status: 'sampled', worktrees } : { status: 'update-required' } + } catch (error) { + return hasErrorCode(error, 'method_not_found') + ? { status: 'update-required' } + : { status: 'unreachable' } + } +} + +export function toRuntimeHostResourceSamples( + results: Readonly>, + hostLabel: (environmentId: string) => string +): RuntimeHostResourceSample[] { + const samples: RuntimeHostResourceSample[] = [] + for (const [environmentId, result] of Object.entries(results)) { + if (result.status === 'sampled') { + samples.push({ + hostId: toRuntimeExecutionHostId(environmentId), + hostLabel: hostLabel(environmentId), + worktrees: result.worktrees + }) + } + } + return samples +} + +export function getRuntimeHostResourceNotices( + results: Readonly>, + hostLabel: (environmentId: string) => string +): string[] { + return Object.entries(results).flatMap(([environmentId, result]) => { + if (result.status === 'unreachable') { + return [ + translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.8a0472ab54', + "Couldn't reach {{value0}}; its resources are unknown.", + { value0: hostLabel(environmentId) } + ) + ] + } + if (result.status === 'update-required') { + return [ + translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.1ea066b73f', + 'Update {{value0}} to see its resources.', + { value0: hostLabel(environmentId) } + ) + ] + } + return [] + }) +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +// Why: servers ship independently; keep well-formed rows and drop the rest rather than trusting the shape. +function parseSnapshotWorktrees(value: unknown): WorktreeMemory[] | null { + if (!isRecord(value) || !Array.isArray(value.worktrees)) { + return null + } + const worktrees: WorktreeMemory[] = [] + for (const row of value.worktrees) { + const cpu = isRecord(row) ? finiteNumber(row.cpu) : null + const memory = isRecord(row) ? finiteNumber(row.memory) : null + if ( + !isRecord(row) || + typeof row.worktreeId !== 'string' || + typeof row.repoId !== 'string' || + cpu === null || + memory === null + ) { + continue + } + worktrees.push({ + worktreeId: row.worktreeId, + worktreeName: typeof row.worktreeName === 'string' ? row.worktreeName : row.worktreeId, + repoId: row.repoId, + repoName: typeof row.repoName === 'string' ? row.repoName : row.repoId, + cpu, + memory, + sessions: Array.isArray(row.sessions) ? row.sessions.flatMap(parseSessionMemory) : [], + history: Array.isArray(row.history) + ? row.history.flatMap((sample) => finiteNumber(sample) ?? []) + : [] + }) + } + return worktrees +} + +function parseSessionMemory(value: unknown): SessionMemory[] { + if (!isRecord(value) || typeof value.sessionId !== 'string') { + return [] + } + const cpu = finiteNumber(value.cpu) + const memory = finiteNumber(value.memory) + if (cpu === null || memory === null) { + return [] + } + return [ + { + sessionId: value.sessionId, + paneKey: typeof value.paneKey === 'string' ? value.paneKey : null, + pid: finiteNumber(value.pid) ?? 0, + cpu, + memory + } + ] +} 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 3de6be62ccae..60f4addb66ba 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 @@ -87,7 +87,11 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) { const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null // Why: keyed on owner id, not settings identity, so routine settings mutations don't re-run the remote snapshot fetch. const loadAccounts = useCallback(async () => { - const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) + const snapshot = await fetchProviderAccountsSnapshot( + activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } + : { kind: 'local' } + ) // Why: a failed Codex half is a substituted empty roster; keep prior state. if (snapshot.failedProviders?.includes('codex')) { console.error('Codex account list failed; keeping previous status bar state.') @@ -127,7 +131,7 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) { const previousActiveAccountId = getCodexStatusActiveId(accountState, target) setIsSwitching(true) try { - const next = await selectCodexProviderAccount(settings, { + const next = await selectCodexProviderAccount(runtimeTarget, { accountId, runtime: target.runtime, wslDistro: target.wslDistro @@ -157,6 +161,7 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) { nextAccountId: nextActiveAccountId ?? null, // Why: the mutation wrote this row's slot only, so panes on any other // lane still launch under the account they already had. + owner: runtimeTarget, target, // Why: clearing a distro-less WSL row nulls every distro slot at once. clearsEveryWslDistro: accountId === null diff --git a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts index f94237a0bbee..edd1396b3e87 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts @@ -143,6 +143,9 @@ export function useResourceUsageActions({ const handleKillSession = useCallback( (session: UnifiedSessionRow): void => { + if (session.readOnly) { + return + } if (!requiresKillConfirmation(session)) { removeSession(session.sessionId) // Why: await the kill before refreshing, else the refresh re-reads the daemon list before the kill lands and re-adds the row. diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx index f5e1bac9dbb0..58dccb8d3a10 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx +++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx @@ -44,6 +44,7 @@ function derive( useResourceUsageDerivedModel({ open: true, resourceSnapshot: snapshot, + runtimeHostResources: [], sessions, resourceSessionBindings: { tabsByWorktree: {}, diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts index cc56fd7f3578..076f7633ae10 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts @@ -4,7 +4,7 @@ import type { MemorySnapshot } from '../../../../shared/process-stats-types' import type { Worktree } from '../../../../shared/worktree/types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' -import type { DaemonSession } from './resource-usage-merge-types' +import type { DaemonSession, RuntimeHostResourceSample } from './resource-usage-merge-types' import type { ResourceSessionBindingInputs } from './resource-session-bindings' import { countUnboundDaemonSessions } from './resource-session-bindings' import { @@ -22,6 +22,7 @@ import { findAmbiguousWorktreeIds, findDuplicateIds } from '../../lib/unified-ta export function useResourceUsageDerivedModel({ open, resourceSnapshot, + runtimeHostResources, sessions, resourceSessionBindings, runtimePaneTitlesByTabId, @@ -38,6 +39,7 @@ export function useResourceUsageDerivedModel({ }: { open: boolean resourceSnapshot: MemorySnapshot | null + runtimeHostResources: readonly RuntimeHostResourceSample[] sessions: readonly DaemonSession[] resourceSessionBindings: ResourceSessionBindingInputs runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] @@ -78,12 +80,14 @@ export function useResourceUsageDerivedModel({ return map }, [repos]) - // Why: runtime-hosted repos have no local daemon samples or killable sessions; this map drives their per-row exclusion in the merge. - const repoRuntimeScopedById = useMemo(() => { - const map = new Map() + // Why: runtime-hosted repos have no local samples or killable sessions; their rows come only from that server's own samples. + const repoRuntimeHostIdById = useMemo(() => { + const map = new Map() for (const repo of repos) { const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - map.set(repo.id, parsed?.kind === 'runtime') + if (parsed?.kind === 'runtime') { + map.set(repo.id, parsed.id) + } } return map }, [repos]) @@ -108,7 +112,8 @@ export function useResourceUsageDerivedModel({ runtimePaneTitlesByTabId, repoDisplayNameById, repoConnectionIdById, - repoRuntimeScopedById, + repoRuntimeHostIdById, + runtimeHostResources, browserTabsByWorktree, worktreeById, ambiguousWorktreeIds @@ -122,7 +127,8 @@ export function useResourceUsageDerivedModel({ runtimePaneTitlesByTabId, repoDisplayNameById, repoConnectionIdById, - repoRuntimeScopedById, + repoRuntimeHostIdById, + runtimeHostResources, browserTabsByWorktree, worktreeById, ambiguousWorktreeIds diff --git a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts index e565d0cc64bb..786c595a5818 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts @@ -25,6 +25,13 @@ import { import { useResourceSessionInventory } from './use-resource-session-inventory' import { useResourceUsageActions } from './use-resource-usage-actions' import { useResourceUsageDerivedModel } from './use-resource-usage-derived-model' +import { useRuntimeHostResources } from './use-runtime-host-resources' +import { + collectRuntimeResourceEnvironmentIds, + getRuntimeHostResourceNotices, + toRuntimeHostResourceSamples +} from './runtime-host-resources' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' const POLL_MS = 2_000 @@ -87,6 +94,25 @@ export function useResourceUsageStatusController() { getResourceUsageDeferredSshSessionIdsByTabId(s, open) ) const resourceSnapshot = snapshot + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeEnvironmentIds = useMemo( + () => + collectRuntimeResourceEnvironmentIds([ + ...repos.map(getRepoExecutionHostId), + ...allWorktrees.map((worktree) => worktree.hostId) + ]), + [repos, allWorktrees] + ) + const runtimeHostResults = useRuntimeHostResources(open, runtimeEnvironmentIds, POLL_MS) + const { runtimeHostResources, runtimeHostNotices } = useMemo(() => { + const hostLabel = (environmentId: string): string => + runtimeEnvironments.find((environment) => environment.id === environmentId)?.name || + environmentId + return { + runtimeHostResources: toRuntimeHostResourceSamples(runtimeHostResults, hostLabel), + runtimeHostNotices: getRuntimeHostResourceNotices(runtimeHostResults, hostLabel) + } + }, [runtimeHostResults, runtimeEnvironments]) // Why: ptyIdsByTabId tracks mounted/live panes only; Resource Manager reads restored wake hints only for classification. const resourceSessionBindings = useMemo( () => ({ @@ -190,6 +216,7 @@ export function useResourceUsageStatusController() { const derived = useResourceUsageDerivedModel({ open, resourceSnapshot, + runtimeHostResources, sessions, resourceSessionBindings, runtimePaneTitlesByTabId, @@ -243,6 +270,7 @@ export function useResourceUsageStatusController() { setPopoverBodyNode, daemonActions, resourceSnapshot, + runtimeHostNotices, spaceScanReady, recordFeatureInteraction, ...derived, diff --git a/src/renderer/src/components/status-bar/use-runtime-host-resources.ts b/src/renderer/src/components/status-bar/use-runtime-host-resources.ts new file mode 100644 index 000000000000..54c405c553cc --- /dev/null +++ b/src/renderer/src/components/status-bar/use-runtime-host-resources.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { readRuntimeHostResources, type RuntimeHostResourceResult } from './runtime-host-resources' + +const RUNTIME_RESOURCE_TIMEOUT_MS = 5_000 +const EMPTY_RESULTS: Readonly> = {} + +function callDiagnosticsMemory(environmentId: string): Promise { + return callRuntimeRpc( + { kind: 'environment', environmentId }, + 'diagnostics.memory', + undefined, + { + timeoutMs: RUNTIME_RESOURCE_TIMEOUT_MS, + reuseRecentCompatibilityFailure: true + } + ) +} + +/** Polls each paired server's own process samples while the Resource Manager is open. */ +export function useRuntimeHostResources( + open: boolean, + environmentIds: readonly string[], + pollMs: number +): Readonly> { + const environmentKey = environmentIds.join('\n') + const sessionKey = open ? environmentKey : null + // Why keyed and reset on cleanup: a reopened panel or changed host set never shows old samples. + const [state, setState] = useState<{ + key: string | null + results: Readonly> + }>({ key: null, results: {} }) + + useEffect(() => { + if (sessionKey === null || sessionKey === '') { + return + } + let cancelled = false + const inFlight = new Set() + const poll = (): void => { + for (const environmentId of sessionKey.split('\n')) { + // Why: a slow server must not stack requests or delay another server's sample. + if (inFlight.has(environmentId)) { + continue + } + inFlight.add(environmentId) + void readRuntimeHostResources(environmentId, callDiagnosticsMemory).then((result) => { + inFlight.delete(environmentId) + if (!cancelled) { + setState((prev) => ({ + key: sessionKey, + results: { ...(prev.key === sessionKey ? prev.results : {}), [environmentId]: result } + })) + } + }) + } + } + poll() + const timer = window.setInterval(poll, pollMs) + return () => { + cancelled = true + window.clearInterval(timer) + setState({ key: null, results: {} }) + } + }, [sessionKey, pollMs]) + + return state.key === sessionKey && sessionKey !== null ? state.results : EMPTY_RESULTS +} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 5b25e7c54e53..0c45c2c74139 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -23,7 +23,9 @@ const { }, openSettingsPageMock: vi.fn(), openSettingsTargetMock: vi.fn(), - useDetectedAgentsMock: vi.fn(() => ({ detectedIds: ['claude', 'codex', 'gemini'] })) + useDetectedAgentsMock: vi.fn((): { detectedIds: string[] | null; detectionFailed?: boolean } => ({ + detectedIds: ['claude', 'codex', 'gemini'] + })) })) vi.mock('@/hooks/useDetectedAgents', () => ({ @@ -199,6 +201,16 @@ describe('QuickLaunchAgentMenuItems', () => { }) }) + // Why (#25523): a host that never answered is not a host with nothing installed. + it('says the host is unreachable instead of reporting no agents', () => { + useDetectedAgentsMock.mockReturnValueOnce({ detectedIds: null, detectionFailed: true }) + + const html = renderAgentMenuItems() + + expect(html).toContain('reach this workspace') + expect(html).not.toContain('No agents detected') + }) + it('does not label an auto-picked or blank default as configured', () => { shortcutLabelMock.mockReturnValue('⌘⌥T') diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index e23266c9307f..c2b96e182948 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -118,7 +118,8 @@ function QuickLaunchAgentMenuItemsInner({ // instead of the remote server's. Use the same ssh/runtime/local owner // resolution as the rest of the tab bar. const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) - const { detectedIds, needsServerUpdate } = useDetectedAgents(agentDetectionTarget) + const { detectedIds, detectionFailed, needsServerUpdate } = + useDetectedAgents(agentDetectionTarget) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) const disabledAgents = useAppStore( (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS @@ -236,15 +237,20 @@ function QuickLaunchAgentMenuItemsInner({ 'auto.components.tab.bar.QuickLaunchButton.needsServerUpdate', 'Update Orca on this server to list this workspace’s agents' ) - : detectedIds && detectedIds.length > 0 + : detectionFailed ? translate( - 'auto.components.tab.bar.QuickLaunchButton.8dea9b5cdf', - 'No enabled agents' + 'auto.components.tab.bar.QuickLaunchButton.hostUnreachable', + 'Can’t reach this workspace’s host to list its agents' ) - : translate( - 'auto.components.tab.bar.QuickLaunchButton.e518f544b1', - 'No agents detected' - )} + : detectedIds && detectedIds.length > 0 + ? translate( + 'auto.components.tab.bar.QuickLaunchButton.8dea9b5cdf', + 'No enabled agents' + ) + : translate( + 'auto.components.tab.bar.QuickLaunchButton.e518f544b1', + 'No agents detected' + )} ) : null} {agents.map((agent) => { diff --git a/src/renderer/src/components/tab-group/tab-group-host.tsx b/src/renderer/src/components/tab-group/tab-group-host.tsx deleted file mode 100644 index 4c430ffe7746..000000000000 --- a/src/renderer/src/components/tab-group/tab-group-host.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { createContext, useContext } from 'react' -import { useAppStore } from '../../store' -import type { TabBarProps } from '../tab-bar/tab-bar-props' - -/** The tab-strip entries a host can own because only it knows where new things belong. */ -export type TabGroupNewTabActions = Pick< - TabBarProps, - | 'onNewTerminalTab' - | 'onNewTerminalWithShell' - | 'onNewBrowserTab' - | 'onNewSimulatorTab' - | 'onOpenEntry' - | 'onNewFileTab' - | 'onOpenFileTab' - | 'newTabMenuOrder' -> - -/** - * What a surface that mounts tab groups tells them about itself. Groups own layout, panes, selection - * and closing; the host owns only the chrome around its tab strips and where its new tabs go. - */ -export type TabGroupHost = { - /** Rendered at the start of the top-left group's strip, e.g. space under overlaid window chrome. */ - headerStart?: React.ReactNode - /** Rendered at the end of the top-right group's strip. */ - headerEnd?: React.ReactNode - tabStripChrome?: TabBarProps['tabStripChrome'] - markdownAnnotationsEnabled?: boolean - /** Shown in a group's body while it has no tab to render. */ - emptyGroupBody?: React.ReactNode - /** Replaces the group's own new-tab entries; omitted entries fall back to the group's. */ - newTabActions?: (groupId: string) => Partial -} - -// Why a spread: React's CSSProperties has no WebkitAppRegion, and a spread skips the excess-property -// check that would otherwise need a cast. -const NO_DRAG_REGION = { WebkitAppRegion: 'no-drag' } as const - -// Why components rather than values: each subscribes to its own sidebar flag, so a toggle -// re-renders one spacer instead of every group. -// Why no-drag: Electron's drag hit-test honours no-drag only on DOM descendants, not z-index -// siblings, so these keep the overlaid sidebar toggles and window controls clickable. -function CollapsedSidebarHeaderReservation(): React.JSX.Element | null { - const sidebarOpen = useAppStore((state) => state.sidebarOpen) - return sidebarOpen ? null : ( -
- ) -} - -function ClosedExplorerHeaderReservation(): React.JSX.Element | null { - const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen) - return rightSidebarOpen ? null : ( -
- ) -} - -const MAIN_WORKSPACE_HOST: TabGroupHost = { - headerStart: , - headerEnd: -} - -const TabGroupHostContext = createContext(MAIN_WORKSPACE_HOST) - -export const TabGroupHostProvider = TabGroupHostContext.Provider - -export function useTabGroupHost(): TabGroupHost { - return useContext(TabGroupHostContext) -} diff --git a/src/renderer/src/components/task-page-source-context.tsx b/src/renderer/src/components/task-page-source-context.tsx index 9b9cc92ce0cc..5331af0e6852 100644 --- a/src/renderer/src/components/task-page-source-context.tsx +++ b/src/renderer/src/components/task-page-source-context.tsx @@ -10,7 +10,11 @@ import { } from '../../../shared/task-source-context' import { getTaskPageRepoSourceContext } from '../../../shared/task-page-source-context' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' -import type { TaskSourceHostAvailability } from './task-source-context-summary' +import { + getAccountBackedTaskSourceSummary, + type TaskSourceContextSummary, + type TaskSourceHostAvailability +} from './task-source-context-summary' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { cn } from '@/lib/utils' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' @@ -95,6 +99,22 @@ export function getTaskSourceHostAvailabilityForHost( status: host.connectionStatus } } +/** Projects boards have no source row: name the shared row-less host, not the selected repos. */ +export function getGitHubProjectSourceSummary(args: { + providerLabel: string + hostId: TaskSourceContext['hostId'] + host: ExecutionHostRegistryEntry | null | undefined + hostLabelById: ReadonlyMap +}): TaskSourceContextSummary { + const availability = getTaskSourceHostAvailabilityForHost(args.host, args.hostId) + return getAccountBackedTaskSourceSummary(args.providerLabel, { + accountLabel: null, + accountHostId: args.hostId, + hostLabelById: args.hostLabelById, + // Why: boards need no task-source capability, so only host health applies. + hostAvailability: availability && !availability.reason ? [availability] : [] + }) +} export function getTaskPageRepoCacheInput(repo: Repo): { id: string path: string diff --git a/src/renderer/src/components/task-page-task-source-host-availability.test.ts b/src/renderer/src/components/task-page-task-source-host-availability.test.ts index 3fe0261beafb..c3dde4bb742f 100644 --- a/src/renderer/src/components/task-page-task-source-host-availability.test.ts +++ b/src/renderer/src/components/task-page-task-source-host-availability.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' -import { getTaskSourceHostAvailabilityForHost } from './task-page-source-context' +import { + getGitHubProjectSourceSummary, + getTaskSourceHostAvailabilityForHost +} from './task-page-source-context' // Why: a complete typed entry keeps these cases honest when the registry shape changes. function hostEntry( @@ -80,3 +83,32 @@ describe('getTaskSourceHostAvailabilityForHost', () => { }) }) }) + +describe('getGitHubProjectSourceSummary', () => { + const runtimeHost = (overrides: Partial) => + hostEntry({ id: 'runtime:env-1', kind: 'runtime', label: 'Build server', ...overrides }) + + it('names the board host, which a capability check alone never marks unavailable', () => { + const summary = getGitHubProjectSourceSummary({ + providerLabel: 'GitHub', + hostId: 'runtime:env-1', + host: runtimeHost({ health: 'available' }), + hostLabelById: new Map([['runtime:env-1', 'Build server']]) + }) + expect(summary.label).toBe('GitHub · Build server · Current account') + }) + + it('says when the board host is unreachable', () => { + const summary = getGitHubProjectSourceSummary({ + providerLabel: 'GitHub', + hostId: 'runtime:env-1', + host: runtimeHost({ + health: 'disconnected', + capabilities: [TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY] + }), + hostLabelById: new Map([['runtime:env-1', 'Build server']]) + }) + expect(summary.label).toContain('Build server · ') + expect(summary.label).not.toBe('GitHub · Build server · Current account') + }) +}) diff --git a/src/renderer/src/components/task-page/Content.tsx b/src/renderer/src/components/task-page/Content.tsx index ebe40e3ff287..9688056e343f 100644 --- a/src/renderer/src/components/task-page/Content.tsx +++ b/src/renderer/src/components/task-page/Content.tsx @@ -20,6 +20,7 @@ export function TaskPageContent({ dialogWorkItem, dialogRepoPath, dialogSourceContext, + accountBackedTaskSourceHostId, setDialogWorkItem, handleDialogReviewRequestsChange, closeTaskDetailPage, @@ -59,7 +60,10 @@ export function TaskPageContent({ ) ) : taskSource === 'github' && githubMode === 'project' ? (
- +
) : taskSource === 'github' ? ( // Why: bottom of the joined GitHub list card — flush under the filter diff --git a/src/renderer/src/components/task-page/SourceBar.tsx b/src/renderer/src/components/task-page/SourceBar.tsx index 8bcedea82a52..83812b3ed8bb 100644 --- a/src/renderer/src/components/task-page/SourceBar.tsx +++ b/src/renderer/src/components/task-page/SourceBar.tsx @@ -6,6 +6,7 @@ import { X, ExternalLink } from 'lucide-react' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { LinearScopeSelector } from '@/components/linear-scope-selector' +import { getGitHubProjectSourceSummary } from '../task-page-source-context' import { Select, SelectTrigger, @@ -33,6 +34,10 @@ export function TaskPageSourceBar({ taskSource, taskSourceAvailabilityNoticeByProvider, taskSourceContextSummary, + githubMode, + accountBackedTaskSourceHostId, + hostRegistryById, + hostLabelById, taskSourceManuallyChangedRef, setSelectedJiraIssueKey, setSelectedJiraIssueFallback, @@ -48,6 +53,16 @@ export function TaskPageSourceBar({ handleLinearTeamSelectionChange, handleLinearScopeOpen } = model + const sourceSummary = + taskSource === 'github' && githubMode === 'project' + ? getGitHubProjectSourceSummary({ + providerLabel: + visibleSourceOptions.find((source) => source.id === 'github')?.label ?? 'GitHub', + hostId: accountBackedTaskSourceHostId, + host: hostRegistryById.get(accountBackedTaskSourceHostId), + hostLabelById + }) + : taskSourceContextSummary return (
- {taskSourceContextSummary.label} + {sourceSummary.label}
{taskSource === 'linear' && linearConnected ? ( diff --git a/src/renderer/src/components/task-page/github/AssigneesCell.tsx b/src/renderer/src/components/task-page/github/AssigneesCell.tsx index edae0b01e374..0073d9d83929 100644 --- a/src/renderer/src/components/task-page/github/AssigneesCell.tsx +++ b/src/renderer/src/components/task-page/github/AssigneesCell.tsx @@ -1,14 +1,11 @@ import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' import type { Repo } from '../../../../../shared/repo-types' -import { - type TaskSourceContext, - getTaskSourceRuntimeSettings -} from '../../../../../shared/task-source-context' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' import React, { useMemo, useState, useCallback } from 'react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' import { parseGitHubIssueOrPRLink } from '@/lib/github-links' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { translate } from '@/i18n/i18n' @@ -31,18 +28,8 @@ export function GHAssigneesCell({ sourceContext?: TaskSourceContext | null workItemMutation: TaskPageGitHubWorkItemMutationRunner }): React.JSX.Element { - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(s, repo?.id ?? null, sourceContext)) ) const [open, setOpen] = useState(false) const [pendingLogin, setPendingLogin] = useState(null) diff --git a/src/renderer/src/components/task-page/github/MergeCell.tsx b/src/renderer/src/components/task-page/github/MergeCell.tsx index 1213c8062a72..745f674004f5 100644 --- a/src/renderer/src/components/task-page/github/MergeCell.tsx +++ b/src/renderer/src/components/task-page/github/MergeCell.tsx @@ -1,15 +1,12 @@ import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { GitHubPRMergeMethod } from '../../../../../shared/github/pull-request-types' import type { Repo } from '../../../../../shared/repo-types' -import { - type TaskSourceContext, - getTaskSourceRuntimeSettings -} from '../../../../../shared/task-source-context' -import React, { useState, useMemo } from 'react' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import React, { useState } from 'react' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' import { translate } from '@/i18n/i18n' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { @@ -42,18 +39,8 @@ export function PRMergeCell({ }): React.JSX.Element { const [merging, setMerging] = useState(false) const confirm = useConfirmationDialog() - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(s, repo?.id ?? null, sourceContext)) ) if (item.type !== 'pr') { return ( diff --git a/src/renderer/src/components/task-page/github/ReviewCell.tsx b/src/renderer/src/components/task-page/github/ReviewCell.tsx index c21e0909b071..d795cb920281 100644 --- a/src/renderer/src/components/task-page/github/ReviewCell.tsx +++ b/src/renderer/src/components/task-page/github/ReviewCell.tsx @@ -1,14 +1,11 @@ import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' import type { Repo } from '../../../../../shared/repo-types' -import { - type TaskSourceContext, - getTaskSourceRuntimeSettings -} from '../../../../../shared/task-source-context' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' import React, { useState, useMemo, useRef, useCallback } from 'react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { getGitHubPRReviewerQueryState, @@ -59,18 +56,8 @@ export function PRReviewCell({ index: 0 }) const [submitting, setSubmitting] = useState(false) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(s, repo?.id ?? null, sourceContext)) ) const reviewerInputRef = useRef(null) const reviewerTriggerRef = useRef(null) diff --git a/src/renderer/src/components/task-page/github/StatusCell.tsx b/src/renderer/src/components/task-page/github/StatusCell.tsx index 1884d4fd6632..4daf932ad7a3 100644 --- a/src/renderer/src/components/task-page/github/StatusCell.tsx +++ b/src/renderer/src/components/task-page/github/StatusCell.tsx @@ -1,10 +1,7 @@ import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { GitHubIssueUpdate } from '../../../../../shared/issue-mutation-types' import type { Repo } from '../../../../../shared/repo-types' -import { - type TaskSourceContext, - getTaskSourceRuntimeSettings -} from '../../../../../shared/task-source-context' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' import React, { useState, useMemo, useCallback } from 'react' import { createTaskPageGitHubStatusStateDraft, @@ -13,7 +10,7 @@ import { } from '@/components/task-page-github-status-state' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' import { parseGitHubIssueOrPRLink } from '@/lib/github-links' import { getTaskPageGitHubDuplicateCandidates, @@ -53,18 +50,8 @@ export function GHStatusCell({ const [duplicateSearch, setDuplicateSearch] = useState('') const [duplicateError, setDuplicateError] = useState(null) const duplicateIssueCandidates = useGitHubDuplicateIssueCandidates(item, duplicatePickerOpen) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] + const sourceSettings = useAppStore( + useShallow((s) => getGitHubRepoRoutingSettings(s, repo?.id ?? null, sourceContext)) ) const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) const filteredDuplicateCandidates = useMemo( diff --git a/src/renderer/src/components/task-source-context-summary.ts b/src/renderer/src/components/task-source-context-summary.ts index af5e2879d0c9..c99f13fe0ef7 100644 --- a/src/renderer/src/components/task-source-context-summary.ts +++ b/src/renderer/src/components/task-source-context-summary.ts @@ -150,7 +150,7 @@ function getRepoBackedTaskSourceSummary(args: { } } -function getAccountBackedTaskSourceSummary( +export function getAccountBackedTaskSourceSummary( providerLabel: string, args: { accountLabel: string | null | undefined diff --git a/src/renderer/src/components/terminal-agent-tab-shortcut.test.ts b/src/renderer/src/components/terminal-agent-tab-shortcut.test.ts new file mode 100644 index 000000000000..e32869d6ff05 --- /dev/null +++ b/src/renderer/src/components/terminal-agent-tab-shortcut.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' +import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' + +const store: { current: ReturnType | null } = vi.hoisted(() => ({ + current: null +})) + +vi.mock('../store', () => ({ + useAppStore: { getState: () => store.current!.getState() } +})) + +import { resolveTerminalAgentTabShortcut } from './terminal-agent-tab-shortcut' + +// Why (#25804): the paired server's SSH workspace lists its agents under the server, not under +// the server's SSH connection id, which this client has no connection for. +describe('resolveTerminalAgentTabShortcut', () => { + it("picks the default agent from a paired server's SSH workspace inventory", () => { + store.current = createTestStore() + store.current.setState({ + repos: [ + { ...TEST_REPO, connectionId: 'server-ssh-target', executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })] }, + runtimeDetectedAgentIds: { [getRuntimeAgentInventoryKey('env-1', 'wt-1')]: ['claude'] } + }) + + const shortcut = resolveTerminalAgentTabShortcut({ + activeWorktreeId: 'wt-1', + keybindings: {}, + matchShortcut: (actionId) => actionId === 'tab.newAgent' + }) + + expect(shortcut).toEqual({ actionId: 'tab.newAgent', agent: 'claude' }) + }) +}) diff --git a/src/renderer/src/components/terminal-agent-tab-shortcut.ts b/src/renderer/src/components/terminal-agent-tab-shortcut.ts index 7366d2595da8..074d52253c90 100644 --- a/src/renderer/src/components/terminal-agent-tab-shortcut.ts +++ b/src/renderer/src/components/terminal-agent-tab-shortcut.ts @@ -1,7 +1,7 @@ import type { KeybindingActionId, KeybindingOverrides } from '../../../shared/keybindings' import type { TuiAgent } from '../../../shared/tui-agent' import { useAppStore } from '../store' -import { getConnectionId } from '../lib/connection-context' +import { readDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory' import { listBoundAgentTabActions, resolveDefaultAgentForNewTab } from '@/lib/agent-tab-shortcuts' export type TerminalAgentTabShortcut = { @@ -20,15 +20,11 @@ export function resolveTerminalAgentTabShortcut({ }): TerminalAgentTabShortcut { const state = useAppStore.getState() if (matchShortcut('tab.newAgent')) { - const connectionId = getConnectionId(activeWorktreeId) return { actionId: 'tab.newAgent', agent: resolveDefaultAgentForNewTab({ defaultTuiAgent: state.settings?.defaultTuiAgent, - detectedAgentIds: - typeof connectionId === 'string' - ? state.remoteDetectedAgentIds[connectionId] - : state.detectedAgentIds, + detectedAgentIds: readDetectedAgentsForWorktree(state, activeWorktreeId), disabledTuiAgents: state.settings?.disabledTuiAgents }) } diff --git a/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts b/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts index 72b5cd45fc85..c8551538448e 100644 --- a/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts +++ b/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts @@ -28,10 +28,6 @@ export function isAgentTaskCompleteOsNotificationEnabledFromState( return notifications?.enabled !== false && notifications?.agentTaskComplete !== false } -export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean { - return state.settings?.experimentalTerminalAttention === true -} - /** Track completions once settings hydrate; each delivery surface applies its own filter. */ export function isAgentTaskCompleteTrackingEnabledFromState(state: { settings: object | null diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts index 739368a3d841..6b2caedfe6e3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts @@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => { } return { canOpenWithSystemDefault: true, - downloadAndOpen: vi.fn(), openDetectedFilePath: vi.fn(), settings, worktreeRoot: false @@ -30,10 +29,6 @@ vi.mock('./terminal-worktree-path-link', () => ({ resolveKnownWorktreeRootPathLink: () => (mocks.worktreeRoot ? { id: 'wt-2' } : null) })) -vi.mock('./terminal-remote-file-download-open', () => ({ - downloadAndOpenRemoteTerminalFile: mocks.downloadAndOpen -})) - import { handleTerminalFileLink } from './terminal-file-link-actions' const deps = { worktreeId: 'wt-1', worktreePath: '/repo' } @@ -140,11 +135,11 @@ describe('terminal file link actions', () => { expect(actionRequest.alternate.label).toBe('Download & open with default app') actionRequest.alternate.run() - expect(mocks.downloadAndOpen).toHaveBeenCalledWith( - { sourceHostResolved: true }, - '/repo/docs/report.html' - ) - expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + // The open flow downloads a host file and opens a path the host disowns on this computer. + expect(mocks.openDetectedFilePath).toHaveBeenCalledWith('/repo/docs/report.html', null, null, { + ...deps, + openWithSystemDefault: true + }) }) it('keeps row parity between local and remote previewable files', () => { 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 e2e0ad026e15..a76f4f45763a 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 @@ -14,7 +14,6 @@ import { type TerminalLinkActionContext } from './terminal-link-action-request' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' -import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open' import { translate } from '@/i18n/i18n' import { getRevealInFileManagerLabel, revealInFileManager } from '@/lib/reveal-in-file-manager' import { useAppStore } from '@/store' @@ -80,7 +79,7 @@ export function buildFileLinkActions( useAppStore.getState(), fileContext ) - const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext, mappedPath) + const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext) const isMac = navigator.userAgent.includes('Mac') // Why: the OS can only launch a local file, so remote links keep the same row by @@ -122,7 +121,12 @@ export function buildFileLinkActions( 'auto.components.terminal.pane.TerminalLinkActionPopover.downloadOpenWithDefaultApp', 'Download & open with default app' ), - run: () => downloadAndOpenRemoteTerminalFile(fileContext, mappedPath) + // Why the open flow, not a direct download: a path the host disowns may be on this computer. + run: () => + openDetectedFilePath(filePath, line, column, { + ...deps, + openWithSystemDefault: true + }) } // Why omit, not disable: the popover has no disabled rows. The OS file manager can only show a // file on this machine. 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 7282b3600932..582fc02a864e 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 @@ -22,6 +22,11 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { statUserOpenedPath } from '@/lib/user-opened-local-path' +import { + CLIENT_LOCAL_FILE_SETTINGS, + resolveHostWorkspaceFile, + type HostWorkspaceFile +} from './terminal-host-workspace-file' import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' export { @@ -52,7 +57,7 @@ export function isHtmlFilePath(filePath: string): boolean { return /\.html?$/i.test(filePath) } -function openHtmlFileInBrowser(filePath: string, worktreeId: string): void { +function openHtmlFileInBrowser(filePath: string, worktreeId: string, clientLocal: boolean): void { const store = useAppStore.getState() if (worktreeId) { // Why: following an HTML file link changes which worktree is foregrounded, @@ -62,15 +67,17 @@ function openHtmlFileInBrowser(filePath: string, worktreeId: string): void { } const fileUrl = absolutePathToFileUri(filePath) const title = filePath.split(/[/\\]/).pop() ?? filePath - store.createBrowserTab(worktreeId, fileUrl, { title, activate: true }) + store.createBrowserTab(worktreeId, fileUrl, { + title, + activate: true, + // Why: the file is on this computer, so the workspace's paired-server runtime must not adopt the page. + ...(clientLocal ? { browserRuntimeEnvironmentId: null } : {}) + }) } -export function shouldOpenTerminalFileWithSystemDefault( - fileContext: TerminalFileContext, - filePath: string -): boolean { +export function shouldOpenTerminalFileWithSystemDefault(fileContext: TerminalFileContext): boolean { // Why: an unresolved owner has no connectionId either, which would otherwise read as local. - return fileContext.sourceHostResolved && canClientOsOpenWorkspaceFile(fileContext, filePath) + return fileContext.sourceHostResolved && canClientOsOpenWorkspaceFile(fileContext) } let latestOpenDetectedFilePathRequestId = 0 @@ -108,8 +115,9 @@ export function openDetectedFilePath( column: number | null, deps: TerminalFileOpenDeps ): void { - const { openWithSystemDefault = false, runtimeEnvironmentId, worktreeId, worktreePath } = deps - const mappedFilePath = mapTerminalFilePath( + const { openWithSystemDefault = false, runtimeEnvironmentId } = deps + let { worktreeId, worktreePath } = deps + let mappedFilePath = mapTerminalFilePath( filePath, worktreePath, terminalLinkWslDistro(deps.wslDistro, runtimeEnvironmentId) @@ -119,7 +127,9 @@ export function openDetectedFilePath( void (async () => { let statResult - const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + let fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) + let hostWorkspaceFile: HostWorkspaceFile | null = null + let clientLocal = false if (!fileContext.sourceHostResolved) { // Why: with no owner the host is unknown — refuse rather than touch this machine's files. deps.onOpenFailure?.({ @@ -128,11 +138,6 @@ export function openDetectedFilePath( }) return } - const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault( - fileContext, - mappedFilePath - ) - if (!openWithSystemDefault) { const worktreeRootLink = resolveKnownWorktreeRootPathLink( mappedFilePath, @@ -154,7 +159,24 @@ export function openDetectedFilePath( } try { - statResult = await statUserOpenedPath(fileContext, mappedFilePath) + const linkOwner = await resolveHostWorkspaceFile(fileContext, mappedFilePath) + if (linkOwner?.kind === 'client') { + clientLocal = true + fileContext = { + ...fileContext, + settings: CLIENT_LOCAL_FILE_SETTINGS, + connectionId: undefined + } + statResult = { isDirectory: linkOwner.isDirectory, escapesWorktree: false } + } else if (linkOwner) { + hostWorkspaceFile = linkOwner + // Why: the host already stat'ed the path inside the workspace that holds it; open it there. + ;({ worktreeId, worktreePath, absolutePath: mappedFilePath } = hostWorkspaceFile) + fileContext = { ...fileContext, worktreeId, worktreePath } + statResult = { isDirectory: hostWorkspaceFile.isDirectory, escapesWorktree: false } + } else { + statResult = await statUserOpenedPath(fileContext, mappedFilePath) + } } catch (error) { if (requestId === latestOpenDetectedFilePathRequestId && deps.onOpenFailure) { // Why: loss of contact with the host is not evidence the file is gone. @@ -170,6 +192,7 @@ export function openDetectedFilePath( return } + const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext) if (openWithSystemDefault && canOpenWithSystemDefault) { // Why: Shift+Cmd/Ctrl mirrors URL links by escaping Orca and honoring the // user's OS file associations without adding editor-specific settings. @@ -199,8 +222,8 @@ 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 (shouldOpenTerminalFileWithSystemDefault(fileContext, mappedFilePath)) { - openHtmlFileInBrowser(mappedFilePath, worktreeId) + if (canOpenWithSystemDefault) { + openHtmlFileInBrowser(mappedFilePath, worktreeId, clientLocal) return } // Why: the same gesture renders remote HTML too, through the doc preview; only an @@ -215,7 +238,7 @@ export function openDetectedFilePath( const store = useAppStore.getState() let targetWorktreeId = worktreeId - let targetExecutionHostId: ExecutionHostId | undefined + let targetExecutionHostId: ExecutionHostId | undefined = hostWorkspaceFile?.executionHostId let relativePath = mappedFilePath if (worktreePath && isPathInsideWorktree(mappedFilePath, worktreePath)) { const maybeRelative = toWorktreeRelativePath(mappedFilePath, worktreePath) @@ -260,7 +283,9 @@ export function openDetectedFilePath( worktreeId: targetWorktreeId || '', language, mode: 'edit', - runtimeEnvironmentId, + 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: 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-handle-copy.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts index bdba5ca6c284..f533f91e0539 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { copyTerminalHandleForPane } from './terminal-handle-copy' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -58,4 +59,45 @@ describe('copyTerminalHandleForPane', () => { expect(writeClipboardText).not.toHaveBeenCalled() }) + + it('copies the owning server handle for a paired-server pane without asking this runtime', async () => { + // Why: this desktop's runtime has no record of a server-owned pane (#11664, #15553). + const callRuntime = vi.fn().mockResolvedValue({ + id: 'req-1', + ok: false, + error: { code: 'terminal_not_found', message: 'terminal not found' } + }) + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + + await expect( + copyTerminalHandleForPane({ + tabId: 'tab-1', + leafId: LEAF_ID, + ptyId: toRemoteRuntimePtyId('term_on_server', 'env-1'), + callRuntime, + writeClipboardText + }) + ).resolves.toBe('term_on_server') + + expect(callRuntime).not.toHaveBeenCalled() + expect(writeClipboardText).toHaveBeenCalledWith('term_on_server') + }) + + it('refuses an unreadable paired-server pty instead of asking this runtime', async () => { + const callRuntime = vi.fn() + const writeClipboardText = vi.fn() + + await expect( + copyTerminalHandleForPane({ + tabId: 'tab-1', + leafId: LEAF_ID, + ptyId: 'remote:', + callRuntime, + writeClipboardText + }) + ).rejects.toThrow('Terminal ID unavailable') + + expect(callRuntime).not.toHaveBeenCalled() + expect(writeClipboardText).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts b/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts index aaf79719bd2a..76513397fe13 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts @@ -1,9 +1,15 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + isRemoteRuntimePtyId, + parseRemoteRuntimePtyId +} from '../../../../shared/remote-runtime-pty-id' type CopyTerminalHandleDeps = { tabId: string leafId: string + /** The pane's bound PTY; a paired-server PTY id already names the owner's handle. */ + ptyId?: string | null callRuntime: (request: { method: 'terminal.resolvePane' params: { paneKey: string } @@ -14,9 +20,20 @@ type CopyTerminalHandleDeps = { export async function copyTerminalHandleForPane({ tabId, leafId, + ptyId, callRuntime, writeClipboardText }: CopyTerminalHandleDeps): Promise { + // Why: this desktop's runtime does not own a paired server's pane; the owner's handle is what + // its CLI addresses, and the PTY binding already carries it. + if (ptyId && isRemoteRuntimePtyId(ptyId)) { + const ownerHandle = parseRemoteRuntimePtyId(ptyId)?.handle + if (!ownerHandle) { + throw new Error('Terminal ID unavailable') + } + await writeClipboardText(ownerHandle) + return ownerHandle + } const paneKey = makePaneKey(tabId, leafId) const response = await callRuntime({ method: 'terminal.resolvePane', 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 new file mode 100644 index 000000000000..a1f2233fbc3f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts @@ -0,0 +1,123 @@ +import { callHostRoute } from '@/runtime/host-route-call' +import { hostRouteForAuthority } 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' +import { userNamedFileAccess } from '@/lib/local-file-access' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + TERMINAL_PATH_CROSS_WORKSPACE_RUNTIME_CAPABILITY, + type RuntimeTerminalPathResolution +} from '../../../../shared/runtime-file-contracts' + +export type HostWorkspaceFile = { + kind: 'host' + /** The host's spelling of the path, so the tab's relative path agrees with it. */ + absolutePath: string + worktreeId: string + worktreePath: string + executionHostId: ExecutionHostId + isDirectory: boolean +} + +/** 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 } + +/** 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 { + if (relativePath === '') { + return absolutePath + } + return absolutePath.replace(/\\/g, '/').endsWith(`/${relativePath}`) + ? absolutePath.slice(0, absolutePath.length - relativePath.length - 1) + : null +} + +async function statClientLocalFile( + environmentId: string, + absolutePath: string, + hostRefusal: Error +): Promise { + if ( + // Why: a paired web client's filesystem bridge answers from the server, not this computer. + isPairedWebClientWindow() || + !(await runtimeEnvironmentSupportsCapability( + environmentId, + TERMINAL_PATH_CROSS_WORKSPACE_RUNTIME_CAPABILITY, + 15_000 + )) + ) { + throw hostRefusal + } + try { + const stat = await statRuntimePath( + { settings: CLIENT_LOCAL_FILE_SETTINGS, worktreeId: null, worktreePath: null }, + absolutePath, + userNamedFileAccess() + ) + return { kind: 'client', isDirectory: stat.isDirectory } + } catch { + // Why: absent here too, so the host may still hold it outside its workspaces. + throw hostRefusal + } +} + +/** + * 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. + */ +export async function resolveHostWorkspaceFile( + context: RuntimeFileOperationArgs, + absolutePath: string +): Promise { + const environmentId = context.settings?.activeRuntimeEnvironmentId?.trim() + if ( + !environmentId || + !context.worktreeId || + isRemoteRuntimeFileOperation(context, absolutePath) + ) { + return null + } + const resolved = await callHostRoute( + hostRouteForAuthority({ endpoint: { kind: 'environment', environmentId }, at: 'local' }), + 'files.resolveTerminalPath', + { + worktree: toRuntimeWorktreeSelector(context.worktreeId), + pathText: absolutePath, + crossWorkspace: true + }, + { 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') { + // Why: a host grant means the host owns the path; it never becomes a read of this computer. + throw refusal + } + return statClientLocalFile(environmentId, absolutePath, refusal) + } + if (!resolved.exists) { + throw new Error(`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}`) + } + return { + kind: 'host', + absolutePath: resolved.absolutePath, + worktreeId: resolved.worktree, + worktreePath, + executionHostId: toRuntimeExecutionHostId(environmentId), + isDirectory: resolved.isDirectory + } +} 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 new file mode 100644 index 000000000000..b4e708a875f7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from 'vitest' +import { openDetectedFilePath } from './terminal-link-handlers' +import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open' +import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { TERMINAL_PATH_CROSS_WORKSPACE_RUNTIME_CAPABILITY } from '../../../../shared/runtime-file-contracts' +import { + flushDoubleRaf, + installTerminalLinkTestEnvironment +} from './terminal-link-handlers-test-harness' + +const doubles = createTerminalLinkTestDoubles() +const { + storeState, + statMock, + openFileMock, + openFilePathMock, + createBrowserTabMock, + runtimeEnvironmentCallMock, + runtimeEnvironmentTransportCallMock +} = 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) +})) + +vi.mock('./terminal-remote-file-download-open', () => ({ + downloadAndOpenRemoteTerminalFile: vi.fn() +})) + +installTerminalLinkTestEnvironment(doubles) + +const serverWorkspace = { + worktreeId: 'wt-feat', + worktreePath: '/srv/repo', + runtimeEnvironmentId: 'env-1' +} + +function hostDisowns(path: string): void { + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-feat', + relativePath: null, + absolutePath: path, + exists: false, + isDirectory: false + }, + _meta: { runtimeId: 'remote-runtime' } + }) +} + +describe('a link to this computer printed in a paired-server terminal', () => { + it('opens the clicked file from this computer, read-only in the server workspace', async () => { + hostDisowns('/Users/me/notes.md') + + openDetectedFilePath('/Users/me/notes.md', 4, null, serverWorkspace) + await flushDoubleRaf() + + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/Users/me/notes.md', + access: { kind: 'user-file' } + }) + ) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/Users/me/notes.md', + relativePath: '/Users/me/notes.md', + worktreeId: 'wt-feat', + runtimeEnvironmentId: null, + readOnly: true + }), + { forceContentReload: true } + ) + }) + + it('opens a Shift-clicked file with this computer’s default app, not a download', async () => { + hostDisowns('/Users/me/report.pdf') + + openDetectedFilePath('/Users/me/report.pdf', null, null, { + ...serverWorkspace, + openWithSystemDefault: true + }) + await flushDoubleRaf() + + expect(openFilePathMock).toHaveBeenCalledWith('/Users/me/report.pdf', 'local') + expect(downloadAndOpenRemoteTerminalFile).not.toHaveBeenCalled() + }) + + it('renders a clicked HTML file in a browser tab this computer owns', async () => { + hostDisowns('/Users/me/Desktop/review.html') + + openDetectedFilePath('/Users/me/Desktop/review.html', null, null, serverWorkspace) + await flushDoubleRaf() + + expect(createBrowserTabMock).toHaveBeenCalledWith( + 'wt-feat', + 'file:///Users/me/Desktop/review.html', + expect.objectContaining({ browserRuntimeEnvironmentId: null }) + ) + }) + + it('keeps the host refusal when this computer does not have the file either', async () => { + hostDisowns('/etc/orca-missing') + statMock.mockRejectedValueOnce(new Error('ENOENT: no such file')) + const onOpenFailure = vi.fn() + + openDetectedFilePath('/etc/orca-missing', null, null, { + ...serverWorkspace, + onOpenFailure + }) + await flushDoubleRaf() + + expect(openFileMock).not.toHaveBeenCalled() + expect(onOpenFailure).toHaveBeenCalledWith({ + verdict: 'unverifiable', + error: new Error('/etc/orca-missing is outside every workspace on its host') + }) + }) + + it('never reads this computer when the host cannot be reached', async () => { + runtimeEnvironmentCallMock.mockRejectedValueOnce(new Error('socket closed')) + const onOpenFailure = vi.fn() + + openDetectedFilePath('/Users/me/notes.md', null, null, { + ...serverWorkspace, + onOpenFailure + }) + await flushDoubleRaf() + + expect(statMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + expect(onOpenFailure).toHaveBeenCalledWith(expect.objectContaining({ verdict: 'unverifiable' })) + }) + + it('keeps the refusal from a host that cannot search its other workspaces', async () => { + runtimeEnvironmentTransportCallMock.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => { + const status = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (!status?.ok) { + return runtimeEnvironmentCallMock(args) + } + const capabilities = status.result.capabilities?.filter( + (capability) => capability !== TERMINAL_PATH_CROSS_WORKSPACE_RUNTIME_CAPABILITY + ) + return { ...status, result: { ...status.result, capabilities } } + } + ) + hostDisowns('/srv/other-project/config.json') + const onOpenFailure = vi.fn() + + openDetectedFilePath('/srv/other-project/config.json', null, null, { + ...serverWorkspace, + onOpenFailure + }) + await flushDoubleRaf() + + expect(statMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + expect(onOpenFailure).toHaveBeenCalledWith(expect.objectContaining({ verdict: 'unverifiable' })) + }) + + it('never treats a paired web client filesystem as this computer', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + hostDisowns('/Users/me/notes.md') + const onOpenFailure = vi.fn() + + openDetectedFilePath('/Users/me/notes.md', null, null, { ...serverWorkspace, onOpenFailure }) + await flushDoubleRaf() + + expect(statMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + expect(onOpenFailure).toHaveBeenCalledWith(expect.objectContaining({ verdict: 'unverifiable' })) + }) + + it('never reads this computer for a path the host granted as its own', async () => { + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { + worktree: 'wt-feat', + relativePath: null, + absolutePath: '/tmp/artifact.txt', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: '/tmp/artifact.txt', + grantId: 'g-1' + } + }, + _meta: { runtimeId: 'remote-runtime' } + }) + const onOpenFailure = vi.fn() + + openDetectedFilePath('/tmp/artifact.txt', 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 a329e4d19a2e..1b677c8a2fef 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -158,10 +158,8 @@ export function createFilePathLinkProvider( hover: () => { // Why: only local paths can offer the Shift+modifier system // default escape hatch; remote paths may not exist locally. - const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault( - fileContext, - mappedPath - ) + const canOpenWithSystemDefault = + shouldOpenTerminalFileWithSystemDefault(fileContext) const showActions = deps.getLinkActionContext ? deps.getLinkActionContext(paneId) !== null : true diff --git a/src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts new file mode 100644 index 000000000000..c2ce28b467ec --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from 'vitest' +import { openDetectedFilePath } from './terminal-link-handlers' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open' +import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' +import { + flushDoubleRaf, + installTerminalLinkTestEnvironment +} from './terminal-link-handlers-test-harness' + +const doubles = createTerminalLinkTestDoubles() +const { storeState, statMock, openFileMock, openFilePathMock, 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) +})) + +vi.mock('./terminal-remote-file-download-open', () => ({ + downloadAndOpenRemoteTerminalFile: vi.fn() +})) + +installTerminalLinkTestEnvironment(doubles) + +const featureWorktree = { + worktreeId: 'wt-feat', + worktreePath: '/repo/.orca/worktrees/feat-x', + runtimeEnvironmentId: 'env-1' +} + +function hostAnswers(result: unknown): void { + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) +} + +describe('a paired-server link outside its own workspace', () => { + it('opens a main-repo file in the workspace the host says holds it', async () => { + hostAnswers({ + worktree: 'wt-main', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false + }) + + openDetectedFilePath('/repo/src/index.ts', 3, null, featureWorktree) + await flushDoubleRaf() + + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'files.resolveTerminalPath', + params: { worktree: 'id:wt-feat', pathText: '/repo/src/index.ts', crossWorkspace: true } + }) + ) + expect(statMock).not.toHaveBeenCalled() + expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-main', { + providesInitialSurface: true, + executionHostId: 'runtime:env-1' + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/repo/src/index.ts', + relativePath: 'src/index.ts', + worktreeId: 'wt-main', + runtimeEnvironmentId: 'env-1' + }), + { forceContentReload: true } + ) + }) + + it("opens the host's spelling of the path so the tab stays a workspace file", async () => { + hostAnswers({ + worktree: 'wt-main', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false + }) + + openDetectedFilePath('/repo/lib/../src/index.ts', null, null, featureWorktree) + await flushDoubleRaf() + + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/repo/src/index.ts', + relativePath: 'src/index.ts', + worktreeId: 'wt-main' + }), + { forceContentReload: true } + ) + }) + + it('downloads a Shift-clicked host file instead of opening the same path on this computer', async () => { + hostAnswers({ + worktree: 'wt-main', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false + }) + + openDetectedFilePath('/repo/src/index.ts', null, null, { + ...featureWorktree, + openWithSystemDefault: true + }) + await flushDoubleRaf() + + expect(openFilePathMock).not.toHaveBeenCalled() + expect(downloadAndOpenRemoteTerminalFile).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-main', worktreePath: '/repo' }), + '/repo/src/index.ts' + ) + }) + + it('never opens a host directory on this computer', async () => { + hostAnswers({ + worktree: 'wt-main', + relativePath: 'docs', + absolutePath: '/repo/docs', + exists: true, + isDirectory: true + }) + + openDetectedFilePath('/repo/docs', null, null, featureWorktree) + await flushDoubleRaf() + + expect(openFilePathMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + }) + + it('says the host has no workspace for a path outside all of them and this computer', async () => { + statMock.mockRejectedValueOnce(new Error('ENOENT: no such file')) + hostAnswers({ + worktree: 'wt-feat', + relativePath: null, + absolutePath: '/etc/hosts', + exists: false, + isDirectory: false + }) + const onOpenFailure = vi.fn() + + openDetectedFilePath('/etc/hosts', null, null, { ...featureWorktree, onOpenFailure }) + await flushDoubleRaf() + + expect(openFileMock).not.toHaveBeenCalled() + expect(onOpenFailure).toHaveBeenCalledWith({ + verdict: 'unverifiable', + error: new Error('/etc/hosts is outside every workspace on its host') + }) + }) + + it('reports a sibling-workspace file the host verified absent as missing', async () => { + hostAnswers({ + worktree: 'wt-y', + relativePath: 'src/gone.ts', + absolutePath: '/repo/.orca/worktrees/feat-y/src/gone.ts', + exists: false, + isDirectory: false + }) + const onOpenFailure = vi.fn() + + openDetectedFilePath('/repo/.orca/worktrees/feat-y/src/gone.ts', null, null, { + ...featureWorktree, + onOpenFailure + }) + await flushDoubleRaf() + + expect(openFileMock).not.toHaveBeenCalled() + expect(onOpenFailure).toHaveBeenCalledWith(expect.objectContaining({ verdict: 'missing' })) + }) + + it('keeps a file inside its own workspace on the plain stat path', async () => { + hostAnswers({ size: 1, isDirectory: false, mtime: 1 }) + + openDetectedFilePath('/repo/.orca/worktrees/feat-x/a.ts', null, null, featureWorktree) + await flushDoubleRaf() + + expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.stat' }) + ) + expect(runtimeEnvironmentCallMock).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.resolveTerminalPath' }) + ) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ relativePath: 'a.ts', worktreeId: 'wt-feat' }), + { forceContentReload: true } + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts index d8f5d8f0733b..1b30b2156eef 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts @@ -55,7 +55,8 @@ export const copyTerminalPaneMenuPaneId = async ( export const copyTerminalPaneMenuTerminalId = async ( pane: ManagedPane | null, - tabId: string + tabId: string, + ptyId: string | null ): Promise => { if (!pane) { return @@ -64,6 +65,7 @@ export const copyTerminalPaneMenuTerminalId = async ( await copyTerminalHandleForPane({ tabId, leafId: pane.leafId, + ptyId, callRuntime: window.api.runtime.call, writeClipboardText: window.api.ui.writeTerminalClipboardText }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 463a3a7f4154..623dbba05b13 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -174,8 +174,11 @@ export function useTerminalPaneContextMenu({ const onCopyPaneId = async (): Promise => copyTerminalPaneMenuPaneId(resolveMenuPane(), tabId) - const onCopyTerminalId = async (): Promise => - copyTerminalPaneMenuTerminalId(resolveMenuPane(), tabId) + const onCopyTerminalId = async (): Promise => { + const pane = resolveMenuPane() + const ptyId = pane ? (paneTransportsRef.current?.get(pane.id)?.getPtyId() ?? null) : null + return copyTerminalPaneMenuTerminalId(pane, tabId, ptyId) + } const onCopyAgentSessionId = async (): Promise => { const pane = resolveMenuPane() diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandCollapsibleRow.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandCollapsibleRow.tsx deleted file mode 100644 index ab09a287b6a4..000000000000 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandCollapsibleRow.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { ReactNode } from 'react' -import { cn } from '@/lib/utils' - -type TerminalQuickCommandCollapsibleRowProps = { - open: boolean - className?: string - children: ReactNode -} - -/** Why: switching action adds/removes fields; animating the rows keeps the - * dialog from snapping between content heights. */ -export function TerminalQuickCommandCollapsibleRow({ - open, - className, - children -}: TerminalQuickCommandCollapsibleRowProps): React.JSX.Element { - return ( -
-
-
- {children} -
-
-
- ) -} 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 02037238234e..9c869aeb1b06 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 { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' +import { getGitHubRepoRoutingSettings } from '@/lib/github-source-runtime-context' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees } from '@/hooks/useIssueMetadata' import { @@ -37,21 +36,13 @@ export function useTaskPageGitHubIssueDraft(model: TaskPageGitHubCacheReconcilia if (!newIssueTargetRepo?.id) { return null } - const repoOwnerSettings = getSettingsForRepoRuntimeOwner( - { - repos: [newIssueTargetRepo], - settings - }, - newIssueTargetRepo.id + const target = getActiveRuntimeTarget( + getGitHubRepoRoutingSettings( + { repos: [newIssueTargetRepo], settings }, + newIssueTargetRepo.id, + newIssueSourceContext + ) ) - const targetSettings = - newIssueSourceContext?.provider === 'github' - ? { - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(newIssueSourceContext) - } - : repoOwnerSettings - const target = getActiveRuntimeTarget(targetSettings) if (target.kind !== 'environment') { return null } diff --git a/src/renderer/src/components/use-task-page-gitlab-loading.test.tsx b/src/renderer/src/components/use-task-page-gitlab-loading.test.tsx new file mode 100644 index 000000000000..fb1e7fe7e842 --- /dev/null +++ b/src/renderer/src/components/use-task-page-gitlab-loading.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' + +const rpc = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: rpc.call, + runtimeEnvironmentSupportsCapability: vi.fn() +})) + +import { useTaskPageGitLabLoading } from './use-task-page-gitlab-loading' + +const serverRepo: Repo = { + id: 'server-repo', + path: '/srv/app', + displayName: 'app', + badgeColor: '', + addedAt: 1, + executionHostId: 'runtime:build-box' +} +const glListIssues = vi.fn() + +beforeEach(() => { + rpc.call.mockReset().mockResolvedValue({ items: [{ id: 'i1', updatedAt: '2026-10-01' }] }) + glListIssues.mockReset().mockRejectedValue(new Error('Access denied: unknown repository path')) + Object.assign(window, { api: { gl: { listIssues: glListIssues } } }) +}) + +afterEach(cleanup) + +describe('useTaskPageGitLabLoading', () => { + it('lists a server-owned repo on its server (#14603)', async () => { + const setGitlabItems = vi.fn() + const setGitlabError = vi.fn() + renderHook(() => + useTaskPageGitLabLoading({ + selectedRepos: [serverRepo], + selectedReposKey: 'server-repo', + primaryRepo: serverRepo, + taskSource: 'gitlab', + setGitlabItems, + setGitlabLoading: vi.fn(), + setGitlabError, + gitlabRefreshNonce: 0, + gitlabView: 'issues', + setGitlabTodos: vi.fn(), + setGitlabTodosLoading: vi.fn(), + activeGitlabFilter: 'opened' + }) + ) + + await waitFor(() => expect(setGitlabItems).toHaveBeenCalled()) + expect(glListIssues).not.toHaveBeenCalled() + expect(rpc.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'build-box' }, + 'gitlab.listIssues', + expect.objectContaining({ repo: 'id:server-repo', state: 'opened' }), + expect.anything() + ) + expect(setGitlabItems).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: 'i1', repoId: 'server-repo' }) + ]) + expect(setGitlabError).not.toHaveBeenCalledWith(expect.stringContaining('Access denied')) + }) +}) diff --git a/src/renderer/src/components/use-task-page-gitlab-loading.ts b/src/renderer/src/components/use-task-page-gitlab-loading.ts index 9d98feaa8a44..6018dc2cacd9 100644 --- a/src/renderer/src/components/use-task-page-gitlab-loading.ts +++ b/src/renderer/src/components/use-task-page-gitlab-loading.ts @@ -1,12 +1,41 @@ import type { TaskPageProviderMetadataModel } from './use-task-page-provider-metadata' import { useEffect } from 'react' +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' +import type { Repo } from '../../../shared/repo-types' +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import type { GitLabDialogRepoSelector } from './gitlab-item-dialog/gitlab-item-dialog-types' import type { GitLabWorkItem, GitLabTodo } from '../../../shared/gitlab-types' import { getTaskPageRepoSourceContext, isGitLabIssueFilter, isGitLabMRFilter } from './task-page-source-context' -export function useTaskPageGitLabLoading(model: TaskPageProviderMetadataModel) { +function gitLabTaskRepoSelector(repo: Repo): GitLabDialogRepoSelector { + return { + repoPath: repo.path, + repoId: repo.id, + sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'), + repoOwnerExecutionHostId: getRepoExecutionHostId(repo) + } +} + +type GitLabLoadingInput = Pick< + TaskPageProviderMetadataModel, + | 'selectedRepos' + | 'selectedReposKey' + | 'primaryRepo' + | 'taskSource' + | 'setGitlabItems' + | 'setGitlabLoading' + | 'setGitlabError' + | 'gitlabRefreshNonce' + | 'gitlabView' + | 'setGitlabTodos' + | 'setGitlabTodosLoading' + | 'activeGitlabFilter' +> + +export function useTaskPageGitLabLoading(model: Model): Model { const { selectedRepos, selectedReposKey, @@ -39,7 +68,7 @@ export function useTaskPageGitLabLoading(model: TaskPageProviderMetadataModel) { ) { return } - // Why: folder-mode repos lack remotes to derive a GitLab project from; SSH-backed repos use the same provider-aware IPC path. + // Why: folder-mode repos lack remotes to derive a GitLab project from; each repo is read on the machine holding its GitLab sign-in. const eligibleRepos = selectedRepos if (eligibleRepos.length === 0) { setGitlabItems([]) @@ -54,11 +83,10 @@ export function useTaskPageGitLabLoading(model: TaskPageProviderMetadataModel) { gitlabView === 'issues' ? (repo: (typeof eligibleRepos)[0]) => { const isAssignedToMe = activeIssueFilter === 'assigned-to-me' - return window.api.gl + const selector = gitLabTaskRepoSelector(repo) + return gitLabApiFor(selector) .listIssues({ - repoPath: repo.path, - repoId: repo.id, - sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'), + ...selector, state: 'opened', assignee: isAssignedToMe ? '@me' : undefined, limit: 50 @@ -80,12 +108,11 @@ export function useTaskPageGitLabLoading(model: TaskPageProviderMetadataModel) { } }) } - : (repo: (typeof eligibleRepos)[0]) => - window.api.gl + : (repo: (typeof eligibleRepos)[0]) => { + const selector = gitLabTaskRepoSelector(repo) + return gitLabApiFor(selector) .listMRs({ - repoPath: repo.path, - repoId: repo.id, - sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'), + ...selector, state: activeMRFilter ?? 'opened', page: 1, perPage: 50 @@ -105,6 +132,7 @@ export function useTaskPageGitLabLoading(model: TaskPageProviderMetadataModel) { error } }) + } void Promise.allSettled(eligibleRepos.map(fetchItems)) .then((results) => { if (stale) { @@ -157,12 +185,9 @@ export function useTaskPageGitLabLoading(model: TaskPageProviderMetadataModel) { } let stale = false setGitlabTodosLoading(true) - void window.api.gl - .todos({ - repoPath: primaryRepo.path, - repoId: primaryRepo.id, - sourceContext: getTaskPageRepoSourceContext(primaryRepo, 'gitlab') - }) + const selector = gitLabTaskRepoSelector(primaryRepo) + void gitLabApiFor(selector) + .todos(selector) .then((todos) => { if (!stale) { setGitlabTodos(todos as GitLabTodo[]) @@ -191,4 +216,4 @@ export function useTaskPageGitLabLoading(model: TaskPageProviderMetadataModel) { ]) return model } -export type TaskPageGitLabLoadingModel = ReturnType +export type TaskPageGitLabLoadingModel = TaskPageProviderMetadataModel diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts index ad583e444be8..ef2c5139f561 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects' import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects' @@ -43,6 +43,8 @@ function createModel(): TaskPageLinearCollectionEffectsModel { } describe('useTaskPageJiraListEffects refresh wiring', () => { + afterEach(cleanup) + beforeEach(() => { vi.clearAllMocks() listJiraIssues.mockResolvedValue([]) diff --git a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts b/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts deleted file mode 100644 index 8e28677dd39b..000000000000 --- a/src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Repo } from '../../../../shared/repo-types' -import { isFolderRepo } from '../../../../shared/repo-kind' -import type { Worktree } from '../../../../shared/worktree/types' -import { - getPersistedWorkspaceCleanupActivityAt, - isWorkspaceOldForCleanup -} from '../../../../shared/workspace-cleanup' - -/** - * Renderer-side estimate of how many workspaces the cleanup scan would list. It - * sees only Orca's persisted activity record, so it disagrees with the scan by - * design — the scan additionally reads each worktree's git history and cannot - * inspect disconnected remotes. - */ -export function countEstimatedInactiveWorkspaces( - worktrees: readonly Worktree[], - repoById: ReadonlyMap, - now: number -): number { - let count = 0 - for (const worktree of worktrees) { - const repo = repoById.get(worktree.repoId) - if (!repo || isFolderRepo(repo) || worktree.isMainWorktree) { - continue - } - // Why: an unstamped workspace (externally created, or never opened here) reads as - // epoch 0, which counted every one of them as idle and inflated the estimate far - // past what the scan lists. Unknown is not old. - const lastActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) - if ( - lastActivityAt > 0 && - isWorkspaceOldForCleanup({ isArchived: worktree.isArchived, lastActivityAt }, now) - ) { - count += 1 - } - } - return count -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts index 354e3c063e09..542cdca688b6 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts @@ -6,7 +6,6 @@ import { getWorkspaceCleanupReviewRank } from '../../../../shared/workspace-cleanup-facet-rankings' import type { - WorkspaceCleanupSortDirectionState, WorkspaceCleanupSortField, WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model' @@ -42,16 +41,6 @@ export function sortWorkspaceCleanupFacets( ) } -export function compareWorkspaceCleanupFacets( - left: WorkspaceCleanupFacets, - right: WorkspaceCleanupFacets, - field: WorkspaceCleanupSortField, - direction: WorkspaceCleanupSortDirectionState -): number { - const multiplier = direction === 'asc' ? 1 : -1 - return comparePrimary(left, right, field, multiplier) || compareTieBreaks(left, right) -} - function comparePrimary( left: WorkspaceCleanupFacets, right: WorkspaceCleanupFacets, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts index e839de0c2886..d867733d6783 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-sort.ts @@ -122,10 +122,6 @@ export function getWorkspaceCleanupGitState( return candidate.git.clean === false ? 'dirty' : 'unknown' } -export function hasWorkspaceCleanupUnpushedCommits(candidate: WorkspaceCleanupCandidate): boolean { - return hasUnpushedCommits(candidate) -} - function compareWorkspaceCleanupCandidates( left: WorkspaceCleanupCandidate, right: WorkspaceCleanupCandidate, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts index 5b9de7595723..853d2266b398 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts @@ -1,8 +1,5 @@ import { translate } from '@/i18n/i18n' -import type { - WorkspaceCleanupScanError, - WorkspaceCleanupScanProgress -} from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupScanError } from '../../../../shared/workspace-cleanup' function isDisconnectedRemoteScanError(message: string): boolean { return ( @@ -48,22 +45,6 @@ export function formatWorkspaceCleanupScanNotice( ) } -export function formatWorkspaceCleanupScanProgress( - progress: WorkspaceCleanupScanProgress | null -): string { - if (!progress || progress.scannedWorktreeCount === 0) { - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', - 'Finding workspaces...' - ) - } - return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', - 'Checked workspaces so far: {{value0}}', - { value0: progress.scannedWorktreeCount } - ) -} - /** Reports scan size without classifying the user's work. */ export function formatWorkspaceCleanupReadyToast(workspaceCount: number): string { if (workspaceCount === 0) { diff --git a/src/renderer/src/components/workspace-surface-projection.ts b/src/renderer/src/components/workspace-surface-projection.ts index 9b0a5731be23..617879f1fc93 100644 --- a/src/renderer/src/components/workspace-surface-projection.ts +++ b/src/renderer/src/components/workspace-surface-projection.ts @@ -71,7 +71,7 @@ export function projectWorkspaceSurfaces({ // Why: a folder-workspace id is opaque, not path-derived, so colliding hosts // disagree on the path; only the active workspace's resolved host breaks the tie. // Deriving that host from the row alone is sufficient because every stored row is - // stamped with an explicit `executionHostId` by `folderWorkspaceWithFetchedOwner`; + // stamped with an explicit `executionHostId` by `adoptFromEndpoint`; // an unstamped row keeps first-wins rather than guessing. if ( activeWorkspaceResolvedHostId && diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx index 40dd50686b4b..7aa4acd1c90e 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx +++ b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx @@ -53,6 +53,7 @@ function renderDropState(setAttachmentPaths: Dispatch>) promptTextareaRef: createRef(), selectedRepoPath: '/repo', selectedRepoSettings: null, + selectedWorktreeId: null, setAgentPrompt: () => {}, setAttachmentPaths }) @@ -172,6 +173,7 @@ describe('composer upload failures', () => { RUNTIME_SETTINGS, null, '/repo', + null, () => false ) }) diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx index 049cecacffce..6b2c285c3200 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx +++ b/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx @@ -26,6 +26,7 @@ function renderDrop(strict = false) { promptTextareaRef: createRef(), selectedRepoPath: '/folder-workspace', selectedRepoSettings: null, + selectedWorktreeId: null, setAgentPrompt: prompt, setAttachmentPaths: attach }), 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 eb8b2c990c9e..796cd6b2b48a 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -11,7 +11,10 @@ type AttachmentDropStateInput = Pick< | 'selectedRepoSettings' | 'setAgentPrompt' | 'setAttachmentPaths' -> +> & { + /** The selected repo's main worktree id; null for a folder project with no worktree yet. */ + selectedWorktreeId: string | null +} import { useCallback } from 'react' import { toast } from 'sonner' @@ -51,6 +54,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { promptTextareaRef, selectedRepoPath, selectedRepoSettings, + selectedWorktreeId, setAgentPrompt, setAttachmentPaths } = input @@ -124,6 +128,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { targetSettings = selectedRepoSettings, targetConnectionId: string | null | undefined = connectionId, targetRepoPath: string | null | undefined = selectedRepoPath, + targetWorktreeId: string | null | undefined = selectedWorktreeId, canReportFailure: () => boolean = () => true ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => { if (!targetSettings?.activeRuntimeEnvironmentId?.trim() && !targetConnectionId) { @@ -171,7 +176,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { const { results } = await importExternalPathsToRuntime( { settings: targetSettings, - worktreeId: targetRepoPath, + worktreeId: targetWorktreeId ?? targetRepoPath, worktreePath: targetRepoPath, connectionId: targetConnectionId ?? undefined, ...sshExpectation @@ -190,7 +195,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { } return { filePaths: uploadResult.filePaths, folderPaths: uploadResult.folderPaths } }, - [connectionId, selectedRepoPath, selectedRepoSettings] + [connectionId, selectedRepoPath, selectedRepoSettings, selectedWorktreeId] ) const handleAddAttachment = useCallback(async (): Promise => { @@ -259,6 +264,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { selectedRepoSettings, connectionId, selectedRepoPath, + selectedWorktreeId, isCurrentOwner ), applyLocalPaths: applyLocalComposerDrop, @@ -275,6 +281,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { insertComposerFolderPaths, selectedRepoPath, selectedRepoSettings, + selectedWorktreeId, uploadComposerPaths ] ) diff --git a/src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts index c80cad12b2f8..123b8d72956d 100644 --- a/src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts @@ -6,6 +6,7 @@ export function useComposerAttachmentDropState(target: ComposerTargetState) { const destination = resolveComposerAttachmentTarget({ selectedProjectGroup: target.initialTargetState.selectedProjectGroup, selectedRepoPath: target.asyncComposerState.selectedRepoPath, + selectedRepo: target.runtimeTargetSelection.selectedRepo, selectedRepoExecutionHostId: target.runtimeTargetSelection.selectedRepoExecutionHostId, selectedRepoSettings: target.runtimeTargetSelection.selectedRepoSettings, connectionId: target.workspaceIdentityState.connectionId @@ -18,6 +19,7 @@ export function useComposerAttachmentDropState(target: ComposerTargetState) { promptTextareaRef: target.asyncComposerState.promptTextareaRef, selectedRepoPath: destination.path ?? undefined, selectedRepoSettings: destination.settings, + selectedWorktreeId: destination.worktreeId, setAgentPrompt: target.sourceContextState.setAgentPrompt, setAttachmentPaths: target.sourceContextState.setAttachmentPaths }) diff --git a/src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts b/src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts index f9d8973f5e50..53dc63b646f9 100644 --- a/src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts +++ b/src/renderer/src/hooks/composer-state/composer-attachment-target.test.ts @@ -16,6 +16,7 @@ const group: ProjectGroup = { } const selected = { selectedProjectGroup: null, + selectedRepo: { id: 'repo-1' }, selectedRepoPath: '/repo', selectedRepoExecutionHostId: 'local' as const, selectedRepoSettings: { activeRuntimeEnvironmentId: 'focused-runtime' }, @@ -26,6 +27,7 @@ describe('composer attachment destination', () => { expect(resolveComposerAttachmentTarget(selected)).toEqual({ hostId: 'local', path: '/repo', + worktreeId: 'repo-1::/repo', connectionId: null, settings: { activeRuntimeEnvironmentId: null } }) @@ -48,6 +50,7 @@ describe('composer attachment destination', () => { ).toEqual({ hostId: 'ssh:ssh-a', path: '/folder', + worktreeId: null, connectionId: 'ssh-a', settings: { activeRuntimeEnvironmentId: null } }) @@ -62,6 +65,7 @@ describe('composer attachment destination', () => { ).toEqual({ hostId: 'runtime:folder-host', path: '/folder', + worktreeId: null, connectionId: null, settings: { activeRuntimeEnvironmentId: 'folder-host' } }) diff --git a/src/renderer/src/hooks/composer-state/composer-attachment-target.ts b/src/renderer/src/hooks/composer-state/composer-attachment-target.ts index 5a147f9ee7c5..ec96fa312d88 100644 --- a/src/renderer/src/hooks/composer-state/composer-attachment-target.ts +++ b/src/renderer/src/hooks/composer-state/composer-attachment-target.ts @@ -1,4 +1,6 @@ import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { getRepoMainWorktreeId } from '../../../../shared/worktree/id' import type { ComposerModel } from './composer-model' type TargetInput = Pick< @@ -8,15 +10,21 @@ type TargetInput = Pick< | 'selectedRepoExecutionHostId' | 'selectedRepoSettings' | 'connectionId' -> +> & { selectedRepo: Pick | undefined } export function resolveComposerAttachmentTarget(input: TargetInput) { const group = input.selectedProjectGroup const hostId = group ? getRepoExecutionHostId(group) : input.selectedRepoExecutionHostId const host = parseExecutionHostId(hostId) + const repoPath = input.selectedRepoPath ?? null return { hostId, - path: group ? group.parentPath : (input.selectedRepoPath ?? null), + path: group ? group.parentPath : repoPath, + // Why: a paired server resolves files.* by worktree id; a bare path never matches one (#16558). + worktreeId: + !group && input.selectedRepo && repoPath + ? getRepoMainWorktreeId({ id: input.selectedRepo.id, path: repoPath }) + : null, connectionId: group ? host?.kind === 'runtime' ? null diff --git a/src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx b/src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx new file mode 100644 index 000000000000..0458dc74b261 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-remote-server-drop.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { + clearRuntimeCompatibilityCacheForTests, + markRuntimeEnvironmentCompatible +} from '@/runtime/runtime-rpc-client' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from '@/store/slices/runtime-status' +import { + FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../../shared/protocol-version' +import { resolveComposerAttachmentTarget } from './composer-attachment-target' +import { useAttachmentDropState } from './attachment-drop-state' + +const toastError = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastError, message: vi.fn() } })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => undefined, { getState: () => ({}) }) +})) + +const ENVIRONMENT_ID = 'paired-server' +const REPO = { id: 'repo-1', path: '/srv/repo' } +const WORKTREE_SELECTOR = 'id:repo-1::/srv/repo' + +type RuntimeCall = { + selector: string + method: string + params: { worktree?: string; relativePath?: string; finalRelativePath?: string } +} + +/** A paired server that resolves files.* only by a registered worktree id, like the real one. */ +function installPairedServer(options: { onUpload?: () => void } = {}) { + const files = new Set() + const calls: RuntimeCall[] = [] + const meta = { runtimeId: 'server-runtime' } + const call = vi.fn(async (args: RuntimeCall) => { + calls.push(args) + if (args.method === 'status.get') { + return { + id: args.method, + ok: true, + result: { + runtimeId: 'server-runtime', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] + }, + _meta: meta + } + } + if (args.params.worktree !== WORKTREE_SELECTOR) { + return { + id: args.method, + ok: false, + error: { code: 'selector_not_found', message: 'selector_not_found' }, + _meta: meta + } + } + const relativePath = args.params.relativePath ?? '' + if (args.method === 'files.stat') { + return files.has(relativePath) + ? { id: args.method, ok: true, result: { type: 'file', size: 1, mtime: 0 }, _meta: meta } + : { + id: args.method, + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: meta + } + } + if (args.method === 'files.createDir') { + files.add(relativePath) + } + if (args.method === 'files.commitUpload') { + files.add(args.params.finalRelativePath ?? '') + } + return { id: args.method, ok: true, result: { ok: true }, _meta: meta } + }) + const upload = vi.fn(async () => { + options.onUpload?.() + return { byteLength: 1 } + }) + const stage = vi.fn(async ({ sourcePaths }: { sourcePaths: string[] }) => ({ + sources: sourcePaths.map((sourcePath) => ({ + sourcePath, + status: 'staged', + name: sourcePath.slice(sourcePath.lastIndexOf('/') + 1), + kind: 'file', + entries: [ + { relativePath: '', kind: 'file', byteLength: 1, inode: 1, deviceId: 1, modifiedAtMs: 1 } + ] + })) + })) + Object.assign(window, { + api: { + fs: { + importExternalPaths: vi.fn(), + stageExternalPathsForRuntimeUpload: stage, + uploadExternalFileToRuntime: upload + }, + runtimeEnvironments: { call } + } + }) + return { calls, files, upload } +} + +function renderComposerDrop(attached: string[]) { + return renderHook(() => { + const target = resolveComposerAttachmentTarget({ + selectedProjectGroup: null, + selectedRepo: REPO, + selectedRepoPath: REPO.path, + selectedRepoExecutionHostId: `runtime:${ENVIRONMENT_ID}`, + selectedRepoSettings: { activeRuntimeEnvironmentId: null }, + connectionId: null + }) + return useAttachmentDropState({ + agentPromptRef: { current: '' }, + cancelPromptCaretFrame: () => {}, + connectionId: target.connectionId, + promptCaretFrameRef: { current: null }, + promptTextareaRef: createRef(), + selectedRepoPath: target.path ?? undefined, + selectedRepoSettings: target.settings, + selectedWorktreeId: target.worktreeId, + setAgentPrompt: () => {}, + setAttachmentPaths: (next) => { + attached.splice(0, attached.length, ...(typeof next === 'function' ? next([]) : next)) + } + }) + }) +} + +beforeEach(() => { + vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() + replaceRuntimeEnvironmentRevisions([{ id: ENVIRONMENT_ID, createdAt: 1, pairingRevision: 3 }]) + setRuntimeEnvironmentConnectionGenerationForTests(ENVIRONMENT_ID, 1) + markRuntimeEnvironmentCompatible(ENVIRONMENT_ID) +}) + +describe('composer drop on a project owned by a paired server (#16558)', () => { + it('uploads into the project drops folder through the repo worktree', async () => { + const server = installPairedServer() + const attached: string[] = [] + const { result } = renderComposerDrop(attached) + + await act(async () => { + await result.current.applyNativeDrop(['/client/shot.png'], () => true) + }) + + expect(toastError).not.toHaveBeenCalled() + expect(attached).toEqual(['/srv/repo/.orca/drops/shot.png']) + expect(server.files.has('.orca/drops/shot.png')).toBe(true) + for (const call of server.calls.filter(({ method }) => method !== 'status.get')) { + expect(call.selector).toBe(ENVIRONMENT_ID) + expect(call.params.worktree).toBe(WORKTREE_SELECTOR) + } + }) + + it('keeps both files when two drops share a basename', async () => { + const server = installPairedServer() + const attached: string[] = [] + const { result } = renderComposerDrop(attached) + + await act(async () => { + await result.current.applyNativeDrop(['/client/a/shot.png'], () => true) + }) + await act(async () => { + await result.current.applyNativeDrop(['/client/b/shot.png'], () => true) + }) + + expect(server.files.has('.orca/drops/shot.png')).toBe(true) + expect(server.files.has('.orca/drops/shot copy.png')).toBe(true) + expect(attached).toContain('/srv/repo/.orca/drops/shot copy.png') + }) + + it('attaches nothing and commits nothing when the server disconnects mid-drop', async () => { + const server = installPairedServer({ + onUpload: () => setRuntimeEnvironmentConnectionGenerationForTests(ENVIRONMENT_ID, 2) + }) + const attached: string[] = [] + const { result } = renderComposerDrop(attached) + + await act(async () => { + await result.current.applyNativeDrop(['/client/shot.png'], () => true) + }) + + expect(server.upload).toHaveBeenCalledOnce() + expect(server.calls.map(({ method }) => method)).not.toContain('files.commitUpload') + expect(attached).toEqual([]) + expect(toastError).toHaveBeenCalledOnce() + }) + + it('never sends temp cleanup to a replacement server after re-pairing', async () => { + const server = installPairedServer({ + onUpload: () => + replaceRuntimeEnvironmentRevisions([ + { id: ENVIRONMENT_ID, createdAt: 1, pairingRevision: 4 } + ]) + }) + const attached: string[] = [] + const { result } = renderComposerDrop(attached) + + await act(async () => { + await result.current.applyNativeDrop(['/client/shot.png'], () => true) + }) + + const afterUpload = server.calls.map(({ method }) => method) + expect(afterUpload).not.toContain('files.commitUpload') + expect(afterUpload).not.toContain('files.delete') + expect(attached).toEqual([]) + }) +}) 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 1ecef7848792..f2af568f1230 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-model.ts @@ -69,6 +69,7 @@ export type ComposerSourceModel = { targetSettings?: Pick | null, targetConnectionId?: string | null, targetRepoPath?: string | null, + targetWorktreeId?: string | null, canReportFailure?: () => boolean ) => Promise<{ filePaths: string[]; folderPaths: string[] } | null> } 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 ed9e512d664e..52ab98e8e907 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -108,7 +108,7 @@ export function useComposerSubmitOrchestration( resetForNextCreate: multipleCreateReset.resetForNextCreate, resolvedInitialWorkspaceStatus: target.initialTargetState.resolvedInitialWorkspaceStatus, selectedEphemeralVmRecipeId: target.runtimeTargetSelection.selectedEphemeralVmRecipeId, - selectedRepoAgentLaunchPlatform: target.runtimeTargetSelection.selectedRepoAgentLaunchPlatform, + selectedRepoAgentLaunchFact: target.runtimeTargetSelection.selectedRepoAgentLaunchFact, selectedRepoExecutionHostId: target.runtimeTargetSelection.selectedRepoExecutionHostId, selectedRepoIsGit: target.runtimeTargetSelection.selectedRepoIsGit, selectedRepoIsRemote: target.runtimeTargetSelection.selectedRepoIsRemote, 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 c2b5119c8bc0..ea6b7631a600 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -19,7 +19,7 @@ type QuickCreationExecutionInput = Pick< | 'resetForNextCreate' | 'resolvedInitialWorkspaceStatus' | 'selectedEphemeralVmRecipeId' - | 'selectedRepoAgentLaunchPlatform' + | 'selectedRepoAgentLaunchFact' | 'selectedRepoExecutionHostId' | 'selectedRepoIsGit' | 'selectedRepoIsRemote' @@ -65,7 +65,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { resetForNextCreate, resolvedInitialWorkspaceStatus, selectedEphemeralVmRecipeId, - selectedRepoAgentLaunchPlatform, + selectedRepoAgentLaunchFact, selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, @@ -132,7 +132,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { prompt: quickPrompt, draftPrompt: quickDraftPrompt, settings, - platform: selectedRepoAgentLaunchPlatform, + launchHost: selectedRepoAgentLaunchFact, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, telemetrySource @@ -276,7 +276,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { resetForNextCreate, resolvedInitialWorkspaceStatus, selectedEphemeralVmRecipeId, - selectedRepoAgentLaunchPlatform, + selectedRepoAgentLaunchFact, selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index f1f3ab33576e..3c67a5e73c99 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -10,13 +10,18 @@ import { resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { + requireExecutionHostPlatform, + type ExecutionHostPlatformFact +} from '@/lib/execution-host-facts' export type QuickComposerStartupInput = { agent: TuiAgent | null prompt: string draftPrompt: string | null | undefined settings: GlobalSettings | null | undefined - platform: NodeJS.Platform + /** Withheld fails an agent launch; a workspace created without an agent never reads it. */ + launchHost: ExecutionHostPlatformFact shell: AgentStartupShell | null | undefined isRemote: boolean telemetrySource: WorktreeCreationRequest['telemetrySource'] @@ -39,7 +44,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - platform: input.platform, + platform: requireExecutionHostPlatform(input.launchHost), shell: input.shell ?? undefined, isRemote: input.isRemote }) @@ -63,7 +68,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - platform: input.platform, + platform: requireExecutionHostPlatform(input.launchHost), shell: input.shell ?? undefined, isRemote: input.isRemote, allowEmptyPromptLaunch: true 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 85783768acb8..b3ef985701d0 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-model.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-model.ts @@ -6,6 +6,7 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { OrcaVmRecipe } from '../../../../shared/orca-yaml-hook-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' +import type { ExecutionHostPlatformFact } from '@/lib/execution-host-facts' import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' import type { WorkspaceCreationTargetResolution } from '@/lib/project-host-workspace-target' @@ -35,7 +36,7 @@ export type ComposerRuntimeTargetModel = { selectedRepoIsGit: boolean selectedRepoExecutionHostId: ExecutionHostId | null selectedRepoHookContextKey: string | null - selectedRepoAgentLaunchPlatform: NodeJS.Platform + selectedRepoAgentLaunchFact: ExecutionHostPlatformFact selectedRepoIsRemote: boolean selectedRepoStartupShell: AgentStartupShell | undefined selectedRepoProjectId: string | null 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 7d6fe1c4a2df..242fed020e86 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -11,9 +11,13 @@ import { resolveWorkspaceCreationTarget } from '@/lib/project-host-workspace-tar import { isGitRepoKind } from '../../../../shared/repo-kind' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' +import { + resolveExecutionHostAgentStartupShell, + resolveRepoExecutionHostPlatform, + type ExecutionHostPlatformFact +} from '@/lib/execution-host-facts' +import { useAppStore } from '@/store' import { repoIsRemote } from '../../../../shared/agent-launch-remote' -import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' import { buildProjectHostSetupOptions } from '@/lib/project-host-setup-options' import { buildNewWorkspaceCreateTargetOptions } from '@/lib/new-workspace-project-options' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' @@ -127,13 +131,18 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe ? JSON.stringify([selectedRepoExecutionHostId ?? 'local', repoId]) : null - const selectedRepoAgentLaunchPlatform = useMemo(() => { + const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + // Why: the agent runs on the repo's execution host, so its OS decides quoting, not this client's (#22204). + const selectedRepoAgentLaunchFact = useMemo((): ExecutionHostPlatformFact => { if (!selectedRepo) { - return CLIENT_PLATFORM + return { kind: 'withheld', reason: 'Select a project first.' } } - const projectRuntime = selectedRepo.connectionId - ? undefined - : getLocalRepoProjectExecutionRuntimeContext( + return resolveRepoExecutionHostPlatform( + { sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId }, + selectedRepo, + () => + getLocalRepoProjectExecutionRuntimeContext( { activeRepoId, activeWorktreeId: null, @@ -145,17 +154,26 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe selectedRepo.id, CLIENT_PLATFORM ) - return getAgentLaunchPlatformForRepo(selectedRepo, projectRuntime) - }, [activeRepoId, projects, repos, selectedRepo, settings, worktreesByRepo]) + ) + }, [ + activeRepoId, + projects, + repos, + runtimeStatusByEnvironmentId, + selectedRepo, + settings, + sshConnectionStates, + sshStateByEnvironment, + worktreesByRepo + ]) // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only `orca-ide` rename must not apply to remote launch commands. const selectedRepoIsRemote = selectedRepo ? repoIsRemote(selectedRepo) : false - const selectedRepoStartupShell = resolveLocalWindowsAgentStartupShell({ - platform: selectedRepoAgentLaunchPlatform, - isRemote: selectedRepoIsRemote, - terminalWindowsShell: settings?.terminalWindowsShell - }) + const selectedRepoStartupShell = resolveExecutionHostAgentStartupShell( + selectedRepoAgentLaunchFact, + settings?.terminalWindowsShell + ) const selectedRepoProjectId = selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null @@ -265,7 +283,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe selectedRepoIsGit, selectedRepoExecutionHostId, selectedRepoHookContextKey, - selectedRepoAgentLaunchPlatform, + selectedRepoAgentLaunchFact, selectedRepoIsRemote, selectedRepoStartupShell, selectedRepoProjectId, diff --git a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts index 32812f74d717..75882470f1b0 100644 --- a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts +++ b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts @@ -1,14 +1,17 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AppState } from '../../store/types' +import { resolveAgentDashboardMode } from '../../components/dashboard/agent-dashboard-open-mode' export function toggleAgentDashboardFromShortcut( state: Pick< AppState, - | 'activeView' - | 'settings' - | 'agentDashboardDrawerOpen' - | 'setSidebarOpen' - | 'setAgentDashboardDrawerOpen' - >, + 'activeView' | 'agentDashboardDrawerOpen' | 'setSidebarOpen' | 'setAgentDashboardDrawerOpen' + > & { + settings: Pick< + GlobalSettings, + 'experimentalAgentDashboardPopout' | 'experimentalAgentDashboardMode' + > | null + }, openPopout: () => void ): void { if ( @@ -17,7 +20,7 @@ export function toggleAgentDashboardFromShortcut( ) { return } - if (state.settings.experimentalAgentDashboardMode === 'popout') { + if (resolveAgentDashboardMode(state.settings) === 'popout') { openPopout() return } diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 240991417728..4a9905c0285e 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -126,7 +126,7 @@ export function installAppLifetimeIpcEvents( registerUpdaterStatusIpcBridge(unsubs) registerBrowserStateIpcBridge(unsubs, isRuntimeEnvironmentActive) registerContentCreationIpcBridge(unsubs, isRuntimeEnvironmentActive) - registerBrowserRequestIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerBrowserRequestIpcBridge(unsubs) registerTabLifecycleIpcBridge(unsubs) registerRateLimitIpcBridge(unsubs) registerDirectSshStateIpcBridge(unsubs, directSshRuntime) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts new file mode 100644 index 000000000000..915bfcd60426 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type CreateRequest = { requestId: string; worktreeId?: string; url: string } +type CloseRequest = { requestId: string; tabId?: string | null; worktreeId?: string } + +const mocks = vi.hoisted(() => { + const listeners: { + createListener: ((data: CreateRequest) => void) | null + closeListener: ((data: CloseRequest) => void) | null + } = { createListener: null, closeListener: null } + return { + ...listeners, + getState: vi.fn(), + replyTabCreate: vi.fn(), + replyTabClose: vi.fn(), + createBrowserTab: vi.fn(), + closeBrowserTab: vi.fn() + } +}) + +vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({ + destroyPersistentWebview: vi.fn() +})) +vi.mock('../../store', () => ({ useAppStore: { getState: mocks.getState } })) +vi.mock('./browser-automation-bootstrap-lease', () => ({ + acquireBrowserAutomationBootstrapLease: vi.fn() +})) +vi.mock('../../store/pinned-tab-close-guard', () => ({ + guardPinnedTabClose: ({ onClose }: { onClose: () => void }) => onClose(), + isUnifiedTabPinned: vi.fn(() => false), + resolvePinnedTabLabel: vi.fn() +})) + +import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge' + +const LOCAL_WT = 'repo-local::/Users/me/app' +const REMOTE_WT = 'repo-remote::/srv/app' + +function storeState(activeWorktreeId: string) { + return { + // The Active Server is a paired server; neither request below may read it. + settings: { activeRuntimeEnvironmentId: 'env-remote' }, + activeWorktreeId, + repos: [ + { id: 'repo-local', executionHostId: 'local' }, + { id: 'repo-remote', executionHostId: 'runtime:env-remote' } + ], + worktreesByRepo: { + 'repo-local': [{ id: LOCAL_WT, repoId: 'repo-local', hostId: 'local' }], + 'repo-remote': [ + { + id: REMOTE_WT, + repoId: 'repo-remote', + hostId: 'runtime:env-remote', + runtimeOwnerEnvironmentId: 'env-remote' + } + ] + }, + browserTabsByWorktree: { + [LOCAL_WT]: [{ id: 'ws-local' }], + [REMOTE_WT]: [{ id: 'ws-remote' }] + }, + browserPagesByWorkspace: { + 'ws-local': [{ id: 'page-local' }], + 'ws-remote': [{ id: 'page-remote' }] + }, + activeBrowserTabIdByWorktree: {}, + unifiedTabsByWorktree: {}, + createBrowserTab: mocks.createBrowserTab.mockImplementation(() => ({ id: 'ws-new' })), + closeBrowserTab: mocks.closeBrowserTab, + closeBrowserPage: vi.fn() + } +} + +describe('browser requests from this desktop follow the workspace owner (#22275)', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { + onRequestTabCreate: (listener: typeof mocks.createListener) => { + mocks.createListener = listener + return () => {} + }, + replyTabCreate: mocks.replyTabCreate, + onRequestTabSetProfile: () => () => {}, + replyTabSetProfile: vi.fn(), + onRequestTabClose: (listener: typeof mocks.closeListener) => { + mocks.closeListener = listener + return () => {} + }, + replyTabClose: mocks.replyTabClose + } + } + }) + registerBrowserRequestIpcBridge([]) + }) + + it('creates a tab for an explicit local worktree while a paired server is the Active Server', () => { + mocks.getState.mockReturnValue(storeState(REMOTE_WT)) + + mocks.createListener?.({ requestId: 'r1', worktreeId: LOCAL_WT, url: 'about:blank' }) + + expect(mocks.createBrowserTab).toHaveBeenCalledWith(LOCAL_WT, 'about:blank', expect.anything()) + expect(mocks.replyTabCreate).toHaveBeenCalledWith({ requestId: 'r1', browserPageId: 'ws-new' }) + }) + + it("refuses to fall back to a paired server's active workspace", () => { + mocks.getState.mockReturnValue(storeState(REMOTE_WT)) + + mocks.createListener?.({ requestId: 'r2', url: 'about:blank' }) + + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.replyTabCreate).toHaveBeenCalledWith({ + requestId: 'r2', + error: expect.stringContaining('paired server') + }) + }) + + it("closes a local page but refuses a paired server's page", () => { + mocks.getState.mockReturnValue(storeState(LOCAL_WT)) + + mocks.closeListener?.({ requestId: 'c1', tabId: 'ws-local', worktreeId: LOCAL_WT }) + mocks.closeListener?.({ requestId: 'c2', tabId: 'page-remote' }) + + expect(mocks.closeBrowserTab).toHaveBeenCalledExactlyOnceWith('ws-local') + expect(mocks.replyTabClose).toHaveBeenCalledWith({ requestId: 'c1' }) + expect(mocks.replyTabClose).toHaveBeenCalledWith({ + requestId: 'c2', + error: expect.stringContaining('paired server') + }) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts index c062b2258c34..a2454797ee6a 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts @@ -75,7 +75,7 @@ describe('browser profile request teardown', () => { }) it('keeps document-preview guests while rebuilding URL siblings for a profile change', () => { - registerBrowserRequestIpcBridge([], () => false) + registerBrowserRequestIpcBridge([]) mocks.profileListener?.({ requestId: 'request-1', diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts index 39eb3032e273..55ba1d906e77 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -7,31 +7,38 @@ import { import { translate } from '@/i18n/i18n' import { useAppStore } from '../../store' import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' +import { + findBrowserTabWorktreeId, + isBrowserWorkspaceOwnedByPairedServer +} from './browser-request-workspace-owner' + +function pairedServerWorkspaceError(): string { + return translate( + 'browser.request.pairedServerWorkspace', + "This workspace runs on a paired server. Use that server's browser tabs." + ) +} -export function registerBrowserRequestIpcBridge( - unsubs: (() => void)[], - isRuntimeEnvironmentActive: () => boolean -): void { +// Why owner, not the Active Server setting: these requests come from this desktop's own runtime, +// which only names workspaces it runs, so focus on a paired server must not refuse them (#22275). +export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void { unsubs.push( window.api.ui.onRequestTabCreate((data) => { try { - if (isRuntimeEnvironmentActive()) { - // Why: browser automation targets client-local Electron webviews that runtime agents can't see or control. + const store = useAppStore.getState() + const worktreeId = data.worktreeId ?? store.activeWorktreeId + if (!worktreeId) { window.api.ui.replyTabCreate({ requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.291c8ed902', - 'Browser tabs are unavailable while a remote runtime is active' - ) + error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') }) return } - const store = useAppStore.getState() - const worktreeId = data.worktreeId ?? store.activeWorktreeId - if (!worktreeId) { + // Why only the fallback: main resolved an explicit id against its own catalog already. + if (!data.worktreeId && isBrowserWorkspaceOwnedByPairedServer(store, worktreeId)) { window.api.ui.replyTabCreate({ requestId: data.requestId, - error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') + error: pairedServerWorkspaceError() }) return } @@ -72,17 +79,15 @@ export function registerBrowserRequestIpcBridge( unsubs.push( window.api.ui.onRequestTabSetProfile((data) => { try { - if (isRuntimeEnvironmentActive()) { + const store = useAppStore.getState() + const pageWorktreeId = findBrowserTabWorktreeId(store, data.browserPageId) + if (pageWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, pageWorktreeId)) { window.api.ui.replyTabSetProfile({ requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.f45fa2b03c', - 'Browser profiles are unavailable while a remote runtime is active' - ) + error: pairedServerWorkspaceError() }) return } - const store = useAppStore.getState() const owningWorkspace = Object.values(store.browserTabsByWorktree) .flat() .find((workspace) => { @@ -129,16 +134,6 @@ export function registerBrowserRequestIpcBridge( unsubs.push( window.api.ui.onRequestTabClose((data) => { try { - if (isRuntimeEnvironmentActive()) { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.291c8ed902', - 'Browser tabs are unavailable while a remote runtime is active' - ) - }) - return - } const store = useAppStore.getState() const explicitTargetId = data.tabId ?? null const replyBrowserTabNotFound = (tabId: string): void => { @@ -186,6 +181,14 @@ export function registerBrowserRequestIpcBridge( }) return } + const closeWorktreeId = findBrowserTabWorktreeId(store, tabToClose) + if (closeWorktreeId && isBrowserWorkspaceOwnedByPairedServer(store, closeWorktreeId)) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: pairedServerWorkspaceError() + }) + return + } // Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id. // Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace. const isWorkspaceId = Object.values(store.browserTabsByWorktree) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts new file mode 100644 index 000000000000..78a819fbeae3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-request-workspace-owner.ts @@ -0,0 +1,37 @@ +import { resolveOwner } from '@/lib/resolve-owner' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import type { AppState } from '../../store/types' + +/** + * Whether a paired server owns (or may own) `worktreeId`. This desktop's runtime must not create or close browser + * tabs there: that server's own page registry and snapshot are authoritative for them. + */ +export function isBrowserWorkspaceOwnedByPairedServer( + state: WorktreeOperationRouteState, + worktreeId: string +): boolean { + const owner = resolveOwner(state, { workspaceId: worktreeId }) + // Why ambiguous refuses: rows on several hosts cannot prove this desktop owns the tab. + return ( + owner.kind === 'ambiguous' || + (owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment') + ) +} + +/** The workspace holding a browser tab, addressed by its tab (workspace) id or one of its page ids. */ +export function findBrowserTabWorktreeId( + state: Pick, + tabOrPageId: string +): string | null { + for (const [worktreeId, tabs] of Object.entries(state.browserTabsByWorktree)) { + for (const tab of tabs) { + if ( + tab.id === tabOrPageId || + (state.browserPagesByWorkspace[tab.id] ?? []).some((page) => page.id === tabOrPageId) + ) { + return worktreeId + } + } + } + return null +} 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 6c75e473725d..4264c786b866 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 @@ -1,6 +1,7 @@ import { applyWorktreeHeadIdentities } from '../worktree-head-identity-apply' import type { WorktreeChangeRefreshQueue } from '../worktree-change-refresh-queue' import { useAppStore } from '../../store' +import { adoptFromEndpoint } from '../../store/adopt-from-endpoint' export function registerProjectCatalogIpcBridge( unsubs: (() => void)[], @@ -38,10 +39,9 @@ export function registerProjectCatalogIpcBridge( // focus changes; otherwise an unbound repo can refresh from the wrong host. // A folder rename changes the worktree id; handleWorktreesChanged re-keys // state and shields it from the deletion diff. - worktreeChangeRefreshQueue.enqueue({ - ...data, - forceLocalOwner: true - }) + worktreeChangeRefreshQueue.enqueue( + adoptFromEndpoint({ kind: 'local' }, { kind: 'worktreeEvent', row: data }) + ) } ) ) diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index 6a05096b0184..881a4a3f93b6 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -13,10 +13,11 @@ import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' -import { getRepoExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' import { navigationTargetsClients } from '../../../../shared/runtime-navigation' import { useAppStore } from '../../store' +import { adoptFromEndpoint, type OwnedWorktreeEvent } from '../../store/adopt-from-endpoint' import { createRuntimeClientEventsSync } from '../runtime-client-events-sync' import { createRuntimeProjectRefreshScheduler, @@ -42,13 +43,11 @@ export function registerRuntimeClientIpcBridge( }) const ensureRuntimeEventRepoKnown = async ( environmentId: string, - repoId: string + { repoId, executionHostId }: OwnedWorktreeEvent ): Promise => { if ( (useAppStore.getState().repos ?? []).some( - (repo) => - repo.id === repoId && - getRepoExecutionHostId(repo) === toRuntimeExecutionHostId(environmentId) + (repo) => repo.id === repoId && getRepoExecutionHostId(repo) === executionHostId ) ) { return @@ -121,12 +120,14 @@ export function registerRuntimeClientIpcBridge( applyRuntimeEnvironmentSshStateChanged(environmentId, event.targetId, event.state, generation) return } + const endpoint = { kind: 'environment' as const, environmentId } if (event.type === 'worktreesChanged') { - void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => - worktreeChangeRefreshQueue.enqueue({ - repoId: event.repoId, - executionHostId: toRuntimeExecutionHostId(environmentId) - }) + const owned = adoptFromEndpoint(endpoint, { + kind: 'worktreeEvent', + row: { repoId: event.repoId } + }) + void ensureRuntimeEventRepoKnown(environmentId, owned).then(() => + worktreeChangeRefreshQueue.enqueue(owned) ) return } @@ -150,12 +151,16 @@ export function registerRuntimeClientIpcBridge( generation === getEnvironmentSshStateGeneration(environmentId) && runtimeGeneration === getRuntimeEnvironmentConnectionGeneration(environmentId) && runtimeRevision === getRuntimeEnvironmentRevision(environmentId) - void ensureRuntimeEventRepoKnown(environmentId, event.repoId) + const owned = adoptFromEndpoint(endpoint, { + kind: 'worktreeEvent', + row: { repoId: event.repoId } + }) + void ensureRuntimeEventRepoKnown(environmentId, owned) .then(() => { return isCurrent() ? activateNotifiedWorktree(event, { allowRuntimeEnvironment: true, - executionHostId: toRuntimeExecutionHostId(environmentId), + executionHostId: owned.executionHostId, isCurrent }) : undefined diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.ts b/src/renderer/src/hooks/useAgentDetectionTarget.ts index 5323c8786f1f..81bb7ff6b909 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.ts @@ -25,7 +25,7 @@ function getLocalAgentDetectionTargetKey(worktreeId: string): string { : AGENT_DETECTION_LOCAL_TARGET_KEY } -type AgentDetectionOwnerState = Parameters[0] & +export type AgentDetectionOwnerState = Parameters[0] & WorktreeRuntimeOwnerState /** diff --git a/src/renderer/src/hooks/useRetiredWorktreeNames.test.ts b/src/renderer/src/hooks/useRetiredWorktreeNames.test.ts index 16b33fcdc032..1d8ed8797940 100644 --- a/src/renderer/src/hooks/useRetiredWorktreeNames.test.ts +++ b/src/renderer/src/hooks/useRetiredWorktreeNames.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, renderHook, waitFor } from '@testing-library/react' import { useRetiredWorktreeNames } from './useRetiredWorktreeNames' const listRetiredNames = vi.fn() @@ -12,6 +12,8 @@ beforeEach(() => { Object.assign(window, { api: { worktrees: { listRetiredNames } } }) }) +afterEach(cleanup) + describe('useRetiredWorktreeNames', () => { it('keeps the previous names while a refresh is in flight', async () => { // Why: refreshKey changes on every workspace-list mutation, so create-multiple refetches after diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index ba2918c49962..05a02f94ab9d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1164,7 +1164,8 @@ "b2b1aa936d": "Paste your opencode.ai session cookie for rate limit fetching.", "b43e761fe5": "MiniMax cookie update failed.", "b8c2905c2b": "Could not load Codex accounts.", - "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in." + "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.", + "remoteScopeLocalAccountsKept": "Accounts managed on this desktop are unchanged. Switch the default runtime back to Local desktop to view them." }, "AdvancedNetworkSettingsSection": { "d93c7cd531": "Configure app-level network routing.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3b8f0beedc81..5b44591f82e6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -154,6 +154,9 @@ "description": "Per-profile user-agent settings were removed. Choose Cleaned or Native in Browser settings; changes take effect after a restart.", "title": "Browser identity is now app-wide", "openSettings": "Open Settings" + }, + "request": { + "pairedServerWorkspace": "This workspace runs on a paired server. Use that server's browser tabs." } }, "editor": { @@ -1134,7 +1137,8 @@ }, "file": { "preview": { - "pairedOutsideWorktree": "Files outside the workspace can't be previewed on a paired server yet." + "pairedOutsideWorktree": "Files outside the workspace can't be previewed on a paired server yet.", + "webClientUnavailable": "Document previews aren't available in the web client." } }, "clipboardWriteFailure": { @@ -1212,8 +1216,6 @@ "0e3cf53060": "Browser tab {{value0}} not found", "2f6637fe6c": "Browser tab {{value0}} is pinned", "a8d2bf8e9e": "No active browser tab to close", - "291c8ed902": "Browser tabs are unavailable while a remote runtime is active", - "f45fa2b03c": "Browser profiles are unavailable while a remote runtime is active", "f000b2ff76": "No active worktree", "56d3ec4203": "Failed to create untitled markdown file.", "f6300deb8b": "New Browser Tab", @@ -3153,7 +3155,6 @@ "efb3843e75": "Filter and sort workspaces", "93b7381d50": "Filters", "a615e24679": "Sort", - "4cc5b73efe": "Finding workspaces...", "5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned", "searchPlaceholder": "Search workspaces", "ageFilter": "Age", @@ -3165,7 +3166,6 @@ "1d3503357d": "You can close this and come back while deletion continues.", "4c2990886e": "{{value0}}/{{value1}} deleted", "86ba852118": "{{value0}}, {{value1}} failed", - "7b7bde5181": "Checked workspaces so far: {{value0}}", "deletingCount": "Deleting workspaces: {{value0}}", "deleteCount": "Delete workspaces: {{value0}}?", "selectedForDeletionCount": "Selected for deletion: {{value0}}", @@ -3649,7 +3649,8 @@ "465e432ef1": "Could not build launch command for {{value0}}.", "e518f544b1": "No agents detected", "8dea9b5cdf": "No enabled agents", - "needsServerUpdate": "Update Orca on this server to list this workspace’s agents" + "needsServerUpdate": "Update Orca on this server to list this workspace’s agents", + "hostUnreachable": "Can’t reach this workspace’s host to list its agents" }, "RecentTabSwitcher": { "329638ff6f": "Switch Tab", @@ -3877,6 +3878,8 @@ "c9382662bb": "Restart daemon", "59f178fe11": "{{value0}}, daemon unreachable", "21cacb16d1": "· remote", + "8a0472ab54": "Couldn't reach {{value0}}; its resources are unknown.", + "1ea066b73f": "Update {{value0}} to see its resources.", "73a3fd68a9": "Collapse repo", "b12e31dfcb": "Expand repo", "d659d71d2d": "Resume workspace {{value0}}", @@ -5577,7 +5580,9 @@ "b79b39d865": "Remove Project", "removeDescriptionSsh": "This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.", "removeDescriptionLocal": "This only removes {{name}} from Orca. It is still on your disk.", - "removeDescriptionVmRecipe": "This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted." + "removeDescriptionVmRecipe": "This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted.", + "openTerminalWarningOne": "1 open terminal in this project will be closed.", + "openTerminalWarningMany": "{{count}} open terminals in this project will be closed." }, "ScrollToCurrentWorkspaceToolbarButton": { "23989bb663": "Reveal active workspace" @@ -6930,7 +6935,8 @@ "placeholder": "Leave blank to use the key saved by /connect or OPENCODE_API_KEY", "help": "Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts." } - } + }, + "remoteScopeLocalAccountsKeptHostPicker": "Accounts managed on this desktop are unchanged. Choose this computer in the Host menu at the top of Settings to view them." }, "AdvancedPane": { "40b29e0bf3": "Restart", @@ -11858,7 +11864,6 @@ }, "BrowserTerminalLinkActionsSetting": { "title": "Terminal URL clicks", - "description": "Show available actions when you click a link in the terminal or a chat transcript. Turn this off to require {{modifier}}-click in the terminal.", "descriptionV2": "Control clicks on detected URLs printed in terminal panes and chat transcripts.", "plainClickLabel": "Plain click", "plainClickDescription": "Choose whether a left-click shows actions, opens the URL, or leaves it to the terminal. Cmd/Ctrl-click always opens directly.", @@ -12744,6 +12749,12 @@ "offCount": "{{count}} off", "machines": "Machines", "machinesDescription": "Choose which machines can show notifications on this computer. A paired server’s switch also covers work reached through it." + }, + "SettingsHostScopePicker": { + "label": "Host", + "unavailable": "{{value0}} (unavailable)", + "description": "Accounts, detected agents and terminal options below are for this host.", + "unavailableNotice": "This server is no longer saved, so nothing can be shown for it. Choose another host at the top of Settings." } }, "right": { @@ -13010,7 +13021,8 @@ "740aca88ab": "Workspace port scan failed.", "5be4f7f727": "Port {{value0}} menu", "7550998473": "Copy", - "1004af16ab": "Copy {{value0}}" + "1004af16ab": "Copy {{value0}}", + "153d3a8026": "Workspace host unknown" }, "Search": { "1abfb25a66": "Type to search in files", @@ -13336,6 +13348,7 @@ }, "checks": { "panel": { + "loadFailedOnMachine": "Could not load from {{machine}}: {{detail}}", "content": { "3916814392": "behind (base commit:", "755be805f6": "No comments", @@ -18040,6 +18053,10 @@ }, "gitlabIpcTimeout": { "timedOut": "Timed out talking to GitLab." + }, + "forgeCredentialTarget": { + "unknownHost": "The machine that owns this repository is not known yet. Reconnect it and try again.", + "thisComputer": "this computer" } }, "ssh": { @@ -19182,6 +19199,9 @@ "important": "Important", "warning": "Warning", "caution": "Caution" + }, + "automations": { + "runTerminalUnavailable": "Run terminal is unavailable." } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6fe6616d15b3..7f85d1b95ece 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1038,8 +1038,6 @@ "0e3cf53060": "Pestaña del navegador {{value0}} no encontrada", "2f6637fe6c": "La pestaña del navegador {{value0}} está fijada", "a8d2bf8e9e": "No hay ninguna pestaña activa del navegador para cerrar", - "291c8ed902": "Las pestañas del navegador no están disponibles mientras haya un host remoto activo", - "f45fa2b03c": "Los perfiles del navegador no están disponibles mientras haya un host remoto activo", "f000b2ff76": "No hay ningún worktree activo", "56d3ec4203": "No se pudo crear el archivo Markdown sin título.", "f6300deb8b": "Nueva pestaña del navegador", @@ -2933,7 +2931,6 @@ "efb3843e75": "Filtrar y ordenar espacios de trabajo", "93b7381d50": "Filtros", "a615e24679": "Ordenar", - "4cc5b73efe": "Buscando espacios de trabajo inactivos...", "5bf2e88480": "{{value0}}/{{value1}} {{value2}} escaneados", "searchPlaceholder": "Buscar espacios de trabajo", "ageFilter": "Edad", @@ -2945,7 +2942,6 @@ "1d3503357d": "Puedes cerrar esto y volver mientras la eliminación continúa.", "4c2990886e": "{{value0}}/{{value1}} eliminados", "86ba852118": "{{value0}}, {{value1}} fallidos", - "7b7bde5181": "Espacios de trabajo comprobados hasta ahora: {{value0}}", "deletingCount": "Eliminando espacios de trabajo: {{value0}}", "deleteCount": "¿Eliminar espacios de trabajo: {{value0}}?", "selectedForDeletionCount": "Seleccionados para eliminar: {{value0}}", @@ -5320,7 +5316,9 @@ "b79b39d865": "Quitar proyecto", "removeDescriptionSsh": "Esto solo elimina {{name}} de Orca. Sus archivos permanecen en {{host}}; vuelve a añadir ese host SSH para recuperarlo.", "removeDescriptionLocal": "Esto solo elimina {{name}} de Orca. Sigue estando en tu disco.", - "removeDescriptionVmRecipe": "Esto elimina {{name}} de Orca. Su receta de VM determina si el entorno y sus archivos se eliminan permanentemente." + "removeDescriptionVmRecipe": "Esto elimina {{name}} de Orca. Su receta de VM determina si el entorno y sus archivos se eliminan permanentemente.", + "openTerminalWarningOne": "Se cerrará 1 terminal abierto en este proyecto.", + "openTerminalWarningMany": "Se cerrarán {{count}} terminales abiertos en este proyecto." }, "ScrollToCurrentWorkspaceToolbarButton": { "23989bb663": "Mostrar espacio de trabajo activo" @@ -12289,7 +12287,6 @@ }, "BrowserTerminalLinkActionsSetting": { "title": "Clics en la URL del terminal", - "description": "Muestra las acciones disponibles al hacer clic en un enlace en la terminal o en una transcripción de chat. Desactive esto para requerir {{modifier}}-hacer clic en la terminal.", "descriptionV2": "Controle los clics en las URL detectadas impresas en paneles de terminales y transcripciones de chat.", "plainClickLabel": "clic simple", "plainClickDescription": "Elija si un clic izquierdo muestra acciones, abre la URL o la deja en el terminal. Cmd/Ctrl-clic siempre se abre directamente.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 7a995b69d25b..b83e844b2aa7 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1139,8 +1139,6 @@ "0e3cf53060": "Onglet de navigateur {{value0}} introuvable", "2f6637fe6c": "L'onglet de navigateur {{value0}} est épinglé", "a8d2bf8e9e": "Aucun onglet de navigateur actif à fermer", - "291c8ed902": "Les onglets de navigateur sont indisponibles tant qu'un runtime distant est actif", - "f45fa2b03c": "Les profils de navigateur sont indisponibles tant qu'un runtime distant est actif", "f000b2ff76": "Aucun worktree actif", "56d3ec4203": "Échec de la création du fichier markdown sans titre.", "f6300deb8b": "Nouvel onglet de navigateur", @@ -3037,7 +3035,6 @@ "efb3843e75": "Filtrer et trier les espaces de travail", "93b7381d50": "Filtres", "a615e24679": "Trier", - "4cc5b73efe": "Recherche des espaces de travail...", "5bf2e88480": "{{value0}}/{{value1}} {{value2}} analysés", "searchPlaceholder": "Rechercher des espaces de travail", "ageFilter": "Âge", @@ -3049,7 +3046,6 @@ "1d3503357d": "Vous pouvez fermer cette fenêtre et revenir pendant que la suppression continue.", "4c2990886e": "{{value0}}/{{value1}} supprimés", "86ba852118": "{{value0}}, {{value1}} en échec", - "7b7bde5181": "Espaces de travail vérifiés jusqu'à présent : {{value0}}", "deletingCount": "Suppression des espaces de travail : {{value0}}", "deleteCount": "Supprimer les espaces de travail : {{value0}} ?", "selectedForDeletionCount": "Sélectionnés pour suppression : {{value0}}", @@ -5440,7 +5436,9 @@ "b79b39d865": "Retirer le projet", "removeDescriptionSsh": "Ceci retire uniquement {{name}} d'Orca. Ses fichiers restent sur {{host}} — rajoutez cet hôte SSH pour le récupérer.", "removeDescriptionLocal": "Ceci retire uniquement {{name}} d'Orca. Il est toujours sur votre disque.", - "removeDescriptionVmRecipe": "Ceci retire {{name}} d'Orca. Sa recette de VM détermine si l'environnement et ses fichiers sont définitivement supprimés." + "removeDescriptionVmRecipe": "Ceci retire {{name}} d'Orca. Sa recette de VM détermine si l'environnement et ses fichiers sont définitivement supprimés.", + "openTerminalWarningOne": "1 terminal ouvert dans ce projet sera fermé.", + "openTerminalWarningMany": "{{count}} terminaux ouverts dans ce projet seront fermés." }, "ScrollToCurrentWorkspaceToolbarButton": { "23989bb663": "Révéler l'espace de travail actif" @@ -11664,7 +11662,6 @@ }, "BrowserTerminalLinkActionsSetting": { "title": "Afficher les actions de lien du terminal", - "description": "Affiche les actions disponibles quand vous cliquez sur un lien du terminal. Désactivez pour exiger un {{modifier}}+clic.", "descriptionV2": "Contrôlez les clics sur les URL détectées imprimées dans les volets du terminal et les transcriptions de discussion.", "plainClickLabel": "Clic simple", "plainClickDescription": "Choisissez si un clic gauche affiche les actions, ouvre l'URL ou la laisse au terminal. Cmd/Ctrl-clic s'ouvre toujours directement.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5cd9c53b1343..35519254697f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1038,8 +1038,6 @@ "0e3cf53060": "ブラウザタブ {{value0}} が見つかりません", "2f6637fe6c": "ブラウザタブ {{value0}} はピン留めされています", "a8d2bf8e9e": "閉じるためのアクティブなブラウザタブがありません", - "291c8ed902": "リモートランタイムがアクティブな間はブラウザタブは使用できません", - "f45fa2b03c": "リモートランタイムがアクティブな間はブラウザプロファイルを使用できません", "f000b2ff76": "アクティブなワークツリーがありません", "56d3ec4203": "無題の markdown ファイルの作成に失敗しました。", "f6300deb8b": "新規ブラウザタブ", @@ -2932,7 +2930,6 @@ "efb3843e75": "ワークスペースのフィルターと並べ替え", "93b7381d50": "フィルター", "a615e24679": "並べ替え", - "4cc5b73efe": "非アクティブなワークスペースを検索しています…", "5bf2e88480": "{{value0}}/{{value1}} {{value2}} をスキャンしました", "searchPlaceholder": "ワークスペースの検索", "ageFilter": "年", @@ -2944,7 +2941,6 @@ "1d3503357d": "削除の続行中にこれを閉じて後で戻れます。", "4c2990886e": "{{value0}}/{{value1}} 件を削除済み", "86ba852118": "{{value0}}、{{value1}} 件が失敗", - "7b7bde5181": "これまでに確認したワークスペース: {{value0}}", "deletingCount": "削除中のワークスペース: {{value0}}", "deleteCount": "ワークスペースを削除しますか: {{value0}}?", "selectedForDeletionCount": "削除対象として選択済み: {{value0}}", @@ -5300,7 +5296,9 @@ "b79b39d865": "プロジェクトの削除", "removeDescriptionSsh": "この操作は {{name}} を Orca から削除するだけです。ファイルは {{host}} に残っており、その SSH ホストを再追加すれば復元できます。", "removeDescriptionLocal": "この操作は {{name}} を Orca から削除するだけです。ファイルはディスクに残っています。", - "removeDescriptionVmRecipe": "これにより、{{name}} が Orca から削除されます。その VM レシピによって、環境とそのファイルが完全に削除されるかどうかが決まります。" + "removeDescriptionVmRecipe": "これにより、{{name}} が Orca から削除されます。その VM レシピによって、環境とそのファイルが完全に削除されるかどうかが決まります。", + "openTerminalWarningOne": "このプロジェクトで開いているターミナル 1 個が閉じられます。", + "openTerminalWarningMany": "このプロジェクトで開いているターミナル {{count}} 個が閉じられます。" }, "ScrollToCurrentWorkspaceToolbarButton": { "23989bb663": "アクティブなワークスペースを表示する" @@ -11956,7 +11954,6 @@ }, "BrowserTerminalLinkActionsSetting": { "title": "ターミナル URL のクリック数", - "description": "ターミナル内のリンクまたはチャット記録をクリックしたときに利用可能な操作を表示します。ターミナルで {{modifier}} クリックを要求するには、これをオフにします。", "descriptionV2": "ターミナルペインおよびチャットトランスクリプトに出力される検出された URL のクリックを制御します。", "plainClickLabel": "プレーンクリック", "plainClickDescription": "左クリックで操作を表示するか、URL を開くか、ターミナルに任せるかを選択します。 Cmd/Ctrl キーを押しながらクリックすると、常に直接開きます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4fe0d6b66d45..5cfa3a7c8f1d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1038,8 +1038,6 @@ "0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다", "2f6637fe6c": "브라우저 탭 {{value0}}이(가) 고정되어 있습니다", "a8d2bf8e9e": "닫을 활성 브라우저 탭이 없습니다.", - "291c8ed902": "원격 런타임이 활성화된 동안에는 브라우저 탭을 사용할 수 없습니다.", - "f45fa2b03c": "원격 런타임이 활성화된 동안에는 브라우저 프로필을 사용할 수 없습니다.", "f000b2ff76": "활성 작업 트리 없음", "56d3ec4203": "제목 없는 markdown 파일을 생성하지 못했습니다.", "f6300deb8b": "새 브라우저 탭", @@ -2932,7 +2930,6 @@ "efb3843e75": "워크스페이스 필터링 및 정렬", "93b7381d50": "필터", "a615e24679": "종류", - "4cc5b73efe": "비활성 워크스페이스를 찾는 중...", "5bf2e88480": "{{value0}}/{{value1}} {{value2}} 스캔됨", "searchPlaceholder": "워크스페이스 검색", "ageFilter": "나이", @@ -2944,7 +2941,6 @@ "1d3503357d": "삭제가 계속되는 동안 이 창을 닫고 나중에 돌아올 수 있습니다.", "4c2990886e": "{{value0}}/{{value1}}개 삭제됨", "86ba852118": "{{value0}}, {{value1}}개 실패", - "7b7bde5181": "지금까지 확인한 워크스페이스: {{value0}}", "deletingCount": "삭제 중인 워크스페이스: {{value0}}", "deleteCount": "워크스페이스 삭제: {{value0}}?", "selectedForDeletionCount": "삭제 대상으로 선택됨: {{value0}}", @@ -5300,7 +5296,9 @@ "b79b39d865": "프로젝트 제거", "removeDescriptionSsh": "이 작업은 {{name}}을(를) Orca에서 제거만 합니다. 파일은 {{host}}에 그대로 남아 있으며, 해당 SSH 호스트를 다시 추가하면 복구할 수 있습니다.", "removeDescriptionLocal": "이 작업은 {{name}}을(를) Orca에서 제거만 합니다. 파일은 디스크에 그대로 남아 있습니다.", - "removeDescriptionVmRecipe": "이렇게 하면 Orca에서 {{name}}이(가) 제거됩니다. 해당 VM 레시피에 따라 환경과 해당 파일이 영구적으로 삭제되는지 여부가 결정됩니다." + "removeDescriptionVmRecipe": "이렇게 하면 Orca에서 {{name}}이(가) 제거됩니다. 해당 VM 레시피에 따라 환경과 해당 파일이 영구적으로 삭제되는지 여부가 결정됩니다.", + "openTerminalWarningOne": "이 프로젝트에서 열려 있는 터미널 1개가 닫힙니다.", + "openTerminalWarningMany": "이 프로젝트에서 열려 있는 터미널 {{count}}개가 닫힙니다." }, "ScrollToCurrentWorkspaceToolbarButton": { "23989bb663": "활성 워크스페이스로 이동" @@ -12004,7 +12002,6 @@ }, "BrowserTerminalLinkActionsSetting": { "title": "터미널 URL 클릭", - "description": "터미널의 링크나 채팅 내용을 클릭하면 사용 가능한 작업이 표시됩니다. 터미널에서 {{modifier}}-클릭을 요구하려면 이 기능을 끄십시오.", "descriptionV2": "터미널 창과 채팅 기록에 인쇄된 감지된 URL에 대한 클릭을 제어합니다.", "plainClickLabel": "일반 클릭", "plainClickDescription": "왼쪽 클릭으로 작업을 표시할지, URL을 열지, 터미널에 그대로 둘지 선택하세요. Cmd/Ctrl 키를 누른 채 클릭하면 항상 바로 열립니다.", @@ -12589,7 +12586,7 @@ "e887fa4b2e": "터미널에서 열기", "1d8e182c32": "파일 보기", "clipboardStagingUnavailable": "Orca의 임시 저장소를 사용할 수 없기 때문에 파일을 복사할 수 없습니다.", - "revealInFinder": "Finder에 공개", + "revealInFinder": "Finder에서 보기", "openContainingFolder": "포함된 폴더 열기", "revealInFileExplorer": "파일 탐색기에 표시" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a59378025d68..1d2ea1bd5458 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1038,8 +1038,6 @@ "0e3cf53060": "未找到浏览器选项卡 {{value0}}", "2f6637fe6c": "浏览器选项卡 {{value0}} 已固定", "a8d2bf8e9e": "没有可关闭的活动浏览器选项卡", - "291c8ed902": "当远程运行时处于活动状态时,浏览器选项卡不可用", - "f45fa2b03c": "当远程运行时处于活动状态时,浏览器配置文件不可用", "f000b2ff76": "没有活动的工作树", "56d3ec4203": "无法创建无标题 Markdown 文件。", "f6300deb8b": "新浏览器选项卡", @@ -2932,7 +2930,6 @@ "efb3843e75": "筛选并排序工作区", "93b7381d50": "筛选", "a615e24679": "排序", - "4cc5b73efe": "正在查找非活动工作区...", "5bf2e88480": "已扫描 {{value0}}/{{value1}} 个{{value2}}", "searchPlaceholder": "搜索工作区", "ageFilter": "时间", @@ -2944,7 +2941,6 @@ "1d3503357d": "删除继续期间,你可以关闭此窗口并稍后回来。", "4c2990886e": "已删除 {{value0}}/{{value1}}", "86ba852118": "{{value0}},{{value1}} 个失败", - "7b7bde5181": "到目前为止已检查工作区:{{value0}}", "deletingCount": "正在删除工作区:{{value0}}", "deleteCount": "删除工作区:{{value0}}?", "selectedForDeletionCount": "已选择删除:{{value0}}", @@ -5300,7 +5296,9 @@ "b79b39d865": "删除项目", "removeDescriptionSsh": "此操作仅将 {{name}} 从 Orca 中移除。文件仍保留在 {{host}} 上 — 重新添加该 SSH 主机即可恢复。", "removeDescriptionLocal": "此操作仅将 {{name}} 从 Orca 中移除。文件仍保留在您的磁盘上。", - "removeDescriptionVmRecipe": "这会从 Orca 中删除 {{name}}。其VM配方决定是否永久删除环境及其文件。" + "removeDescriptionVmRecipe": "这会从 Orca 中删除 {{name}}。其VM配方决定是否永久删除环境及其文件。", + "openTerminalWarningOne": "此项目中 1 个打开的终端将被关闭。", + "openTerminalWarningMany": "此项目中 {{count}} 个打开的终端将被关闭。" }, "ScrollToCurrentWorkspaceToolbarButton": { "23989bb663": "显示活动工作区" @@ -11969,7 +11967,6 @@ }, "BrowserTerminalLinkActionsSetting": { "title": "终端 URL 点击次数", - "description": "单击终端或聊天记录中的链接时显示可用的操作。关闭此功能需要在终端中单击 {{modifier}}。", "descriptionV2": "控制点击终端窗格和聊天记录中打印的已检测的 URL。", "plainClickLabel": "简单的点击", "plainClickDescription": "选择左键单击是显示操作、打开 URL 还是将其留给终端。 Cmd/Ctrl-单击始终直接打开。", diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index 47de5eaaf0f2..a10f7e8fd971 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -5,7 +5,6 @@ import fr from './locales/fr.json' import ja from './locales/ja.json' import ko from './locales/ko.json' import zh from './locales/zh.json' -import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex' const localizedCatalogs = { es, ja, ko, zh } const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat @@ -13,17 +12,6 @@ const englishSearch = en.auto.components.settings.experimental.search.nativeChat const englishComposer = en.components['native-chat'].composer const localizedEffortValues = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const -const codexEffortValues = new Set( - [ - ...CODEX_SESSION_OPTION_CATALOG.models.flatMap((model) => model.options), - ...(CODEX_SESSION_OPTION_CATALOG.unknownModelOptions ?? []) - ].flatMap((option) => - option.id === 'effort' && option.kind.type === 'select' - ? option.kind.choices.map((choice) => choice.value) - : [] - ) -) - describe('native chat locale copy', () => { it.each(Object.entries({ en, es, fr, ja, ko, zh }))( '%s covers chat naming controls and ordinary labels', @@ -40,10 +28,6 @@ describe('native chat locale copy', () => { } ) - it('covers every Codex effort choice', () => { - expect([...codexEffortValues].sort()).toEqual([...localizedEffortValues].sort()) - }) - it.each(Object.entries(localizedCatalogs))( '%s falls back to current Chat UI copy until it is translated', (_code, catalog) => { diff --git a/src/renderer/src/lib/agent-background-session-launch-host.test.ts b/src/renderer/src/lib/agent-background-session-launch-host.test.ts index ef00efa51458..e4f72247b38a 100644 --- a/src/renderer/src/lib/agent-background-session-launch-host.test.ts +++ b/src/renderer/src/lib/agent-background-session-launch-host.test.ts @@ -1,9 +1,38 @@ import { describe, expect, it } from 'vitest' import { resolveAgentBackgroundLaunchHost } from './agent-background-session-launch-host' +function sshState(remotePlatform: 'linux' | 'win32') { + return { status: 'connected', error: null, reconnectAttempt: 0, remotePlatform } +} + +function runtimeStatus(hostPlatform: NodeJS.Platform) { + return { + checkedAt: 1, + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + hostPlatform + } + } +} + +type LaunchStore = Parameters[0]['store'] +type LaunchRepo = Parameters[0]['repo'] + +function asLaunchInputs(store: unknown, repo: unknown): { store: LaunchStore; repo: LaunchRepo } { + const withRepo = repo ? Object.assign({}, store, { repos: [repo] }) : store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixtures carry every slice and repo field the host resolver reads. + return { store: withRepo as LaunchStore, repo: repo as LaunchRepo } +} + function makeFolderHostState(args: { connectionId: string | null folderPath: string + folderExecutionHostId?: string repos?: { id: string connectionId: string | null @@ -17,17 +46,29 @@ function makeFolderHostState(args: { id: 'folder-1', projectGroupId: 'group-1', folderPath: args.folderPath, - connectionId: args.connectionId + connectionId: args.connectionId, + executionHostId: args.folderExecutionHostId ?? null } ], projectGroups: [ { id: 'group-1', parentGroupId: null, - connectionId: args.connectionId + connectionId: args.connectionId, + executionHostId: args.folderExecutionHostId ?? null } ], - repos: args.repos ?? [] + repos: args.repos ?? [], + worktreesByRepo: {}, + sshConnectionStates: new Map([ + ['m4air', sshState('linux')], + ['openclaw', sshState('linux')], + ['nested', sshState('win32')] + ]), + sshStateByEnvironment: new Map([ + ['vm-1', { connectionStates: new Map([['nested', sshState('linux')]]) }] + ]), + runtimeStatusByEnvironmentId: new Map([['linux-box', runtimeStatus('linux')]]) } } @@ -142,7 +183,91 @@ describe('resolveAgentBackgroundLaunchHost', () => { } as never }) - expect(host).toMatchObject({ connectionId: 'nested', isRemote: true }) + // Why linux: the runtime's own SSH state for the target wins over the client's same-named one. + expect(host).toMatchObject({ connectionId: 'nested', isRemote: true, platform: 'linux' }) + }) + + it('quotes for the Orca server that owns a repo, not the client (#22204)', () => { + const host = resolveAgentBackgroundLaunchHost({ + ...asLaunchInputs(makeFolderHostState({ connectionId: null, folderPath: '/project' }), { + id: 'repo-1', + connectionId: null, + executionHostId: 'runtime:linux-box', + path: '/srv/repo' + }), + worktreeId: 'repo-1::/srv/repo', + worktreePath: '/srv/repo' + }) + + expect(host).toMatchObject({ connectionId: null, isRemote: false, platform: 'linux' }) + }) + + it("uses a worktree's nested SSH host over its repo's runtime default", () => { + const state = { + ...makeFolderHostState({ connectionId: null, folderPath: '/project' }), + worktreesByRepo: { + 'repo-1': [ + { + id: 'repo-1::/srv/repo', + repoId: 'repo-1', + hostId: 'ssh:nested', + runtimeOwnerEnvironmentId: 'vm-1' + } + ] + }, + runtimeStatusByEnvironmentId: new Map([['vm-1', runtimeStatus('win32')]]) + } + const host = resolveAgentBackgroundLaunchHost({ + ...asLaunchInputs(state, { + id: 'repo-1', + connectionId: null, + executionHostId: 'runtime:vm-1', + path: '/srv/repo' + }), + worktreeId: 'repo-1::/srv/repo', + worktreePath: '/srv/repo' + }) + + // Why isRemote: the SSH target runs the relay shim as `orca`, not the Linux desktop `orca-ide`. + expect(host).toMatchObject({ platform: 'linux', isLocalHost: false, isRemote: true }) + }) + + it('quotes for the Orca server that owns a folder workspace', () => { + const host = resolveAgentBackgroundLaunchHost({ + ...asLaunchInputs( + makeFolderHostState({ + connectionId: null, + folderPath: '/project', + folderExecutionHostId: 'runtime:linux-box' + }), + null + ), + worktreeId: 'folder:folder-1', + worktreePath: '/project' + }) + + expect(host.platform).toBe('linux') + }) + + it('refuses a launch whose SSH host never reported its OS', () => { + expect(() => + resolveAgentBackgroundLaunchHost({ + ...asLaunchInputs( + { + ...makeFolderHostState({ connectionId: null, folderPath: '/project' }), + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'ssh:silent' }] + }, + { + id: 'repo-1', + connectionId: null, + executionHostId: 'ssh:silent', + path: '/srv/repo' + } + ), + worktreeId: 'repo-1::/srv/repo', + worktreePath: '/srv/repo' + }) + ).toThrow('has not reported its operating system') }) it('uses Linux startup quoting for a local WSL folder', () => { diff --git a/src/renderer/src/lib/agent-background-session-launch-host.ts b/src/renderer/src/lib/agent-background-session-launch-host.ts index 300dec7f6ff5..e0830e881e10 100644 --- a/src/renderer/src/lib/agent-background-session-launch-host.ts +++ b/src/renderer/src/lib/agent-background-session-launch-host.ts @@ -1,13 +1,12 @@ import type { useAppStore } from '@/store' -import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + requireExecutionHostPlatform, + resolveWorktreeExecutionHostPlatform +} from '@/lib/execution-host-facts' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { getRepoSshConnectionId } from '../../../shared/execution-host' -import { isWslUncPath } from '../../../shared/wsl-paths' type LaunchStore = ReturnType type LaunchRepo = LaunchStore['repos'][number] @@ -18,6 +17,8 @@ export type AgentBackgroundLaunchHost = { /** Platform whose shell quoting and CLI naming the startup plan must target. */ platform: NodeJS.Platform isRemote: boolean + /** Only a local host's shell is the one this client's terminal setting describes. */ + isLocalHost: boolean /** Accepted status connection; undefined preserves unknown-owner behavior. */ expectedConnectionId: string | null | undefined } @@ -33,6 +34,21 @@ function resolveFolderWorkspaceConnectionIdForLaunch( return getFolderWorkspaceConnectionId(store, parsed.folderWorkspaceId) } +// Why the worktree, not the repo: a per-worktree host (nested SSH) outranks the repo's default. +function resolveLaunchHostFact( + store: LaunchStore, + worktreeId: string, + worktreePath: string | undefined +): Pick & { isSshHost: boolean } { + const fact = resolveWorktreeExecutionHostPlatform(store, worktreeId, worktreePath) + return { + platform: requireExecutionHostPlatform(fact), + isLocalHost: fact.kind === 'known' && fact.hostKind === 'local', + // Why: an SSH target (nested ones too) runs the relay shim as plain `orca`. + isSshHost: fact.kind === 'known' && fact.hostKind === 'ssh' + } +} + /** Resolves folder launch ownership from workspace scope when no repo row exists. */ export function resolveAgentBackgroundLaunchHost(args: { store: LaunchStore @@ -46,13 +62,11 @@ export function resolveAgentBackgroundLaunchHost(args: { // repo on the client with a remote path. One resolution feeds the route, the trust write and the // launch shape, which must not disagree about the host. const sshConnectionId = getRepoSshConnectionId(repo) + const { isSshHost, ...hostFact } = resolveLaunchHostFact(store, worktreeId, worktreePath) return { connectionId: sshConnectionId, - platform: getAgentLaunchPlatformForRepo( - repo, - sshConnectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) - ), - isRemote: repoIsRemote(repo), + ...hostFact, + isRemote: repoIsRemote(repo) || isSshHost, expectedConnectionId: sshConnectionId } } @@ -61,16 +75,11 @@ export function resolveAgentBackgroundLaunchHost(args: { if (isFolderWorkspace && folderWorkspaceConnectionId === undefined) { throw new Error('The target folder workspace host is unavailable or ambiguous.') } + const { isSshHost, ...hostFact } = resolveLaunchHostFact(store, worktreeId, worktreePath) return { connectionId: folderWorkspaceConnectionId ?? null, - platform: folderWorkspaceConnectionId - ? isWindowsAbsolutePathLike(worktreePath ?? '') - ? 'win32' - : 'linux' - : isWslUncPath(worktreePath ?? '') - ? 'linux' - : CLIENT_PLATFORM, - isRemote: Boolean(folderWorkspaceConnectionId), + ...hostFact, + isRemote: Boolean(folderWorkspaceConnectionId) || isSshHost, expectedConnectionId: isFolderWorkspace ? (folderWorkspaceConnectionId ?? null) : undefined } } diff --git a/src/renderer/src/lib/agent-catalog-icon.tsx b/src/renderer/src/lib/agent-catalog-icon.tsx index cc18491736ee..e653e9920a54 100644 --- a/src/renderer/src/lib/agent-catalog-icon.tsx +++ b/src/renderer/src/lib/agent-catalog-icon.tsx @@ -54,8 +54,9 @@ export function AgentIcon({ return } const catalogEntry = getAgentCatalog().find((entry) => entry.id === agent) - const iconSrc = - catalogEntry?.iconUrl ?? (agent === 'dsb' ? undefined : AGENT_FAVICON_ASSETS[agent]) + // Why: recognition-only DSB shares the bundled DeepSeek mark. + const iconAgent = agent === 'dsb' ? 'dsh' : agent + const iconSrc = catalogEntry?.iconUrl ?? AGENT_FAVICON_ASSETS[iconAgent] if (iconSrc) { return ( `. const iconSrc = catalogEntry?.iconUrl ?? bundledFaviconUrl diff --git a/src/renderer/src/lib/agent-detection-target-inventory.test.ts b/src/renderer/src/lib/agent-detection-target-inventory.test.ts new file mode 100644 index 000000000000..bd4ab72fb20e --- /dev/null +++ b/src/renderer/src/lib/agent-detection-target-inventory.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' +import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' +import type { Repo } from '../../../shared/repo-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import { + ensureDetectedAgentsForWorktree, + readDetectedAgentsForWorktree +} from './agent-detection-target-inventory' + +function storeWithRepo(repo: Partial) { + const store = createTestStore() + const ensureDetectedAgents = vi.fn(async (): Promise => ['codex']) + const ensureRemoteDetectedAgents = vi.fn(async (): Promise => ['gemini']) + const ensureRuntimeDetectedAgents = vi.fn(async (): Promise => ['claude']) + store.setState({ + repos: [{ ...TEST_REPO, ...repo }], + worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })] }, + ensureDetectedAgents, + ensureRemoteDetectedAgents, + ensureRuntimeDetectedAgents + }) + return { store, ensureDetectedAgents, ensureRemoteDetectedAgents, ensureRuntimeDetectedAgents } +} + +// Why (#21429, #25804): a paired server's workspace, including one on that server's own SSH +// target, lists the agents the server finds there; never this client's or its SSH map's. +describe('agent inventory for a worktree', () => { + it("reads and loads a paired server's SSH workspace from that server", async () => { + const harness = storeWithRepo({ + connectionId: 'server-ssh-target', + executionHostId: 'runtime:env-1' + }) + harness.store.setState({ + detectedAgentIds: ['codex'], + remoteDetectedAgentIds: { 'server-ssh-target': ['gemini'] }, + runtimeDetectedAgentIds: { [getRuntimeAgentInventoryKey('env-1', 'wt-1')]: ['claude'] } + }) + const state = harness.store.getState() + + expect(readDetectedAgentsForWorktree(state, 'wt-1')).toEqual(['claude']) + await expect(ensureDetectedAgentsForWorktree(state, 'wt-1')).resolves.toEqual(['claude']) + expect(harness.ensureRuntimeDetectedAgents).toHaveBeenCalledWith('env-1', 'wt-1') + expect(harness.ensureRemoteDetectedAgents).not.toHaveBeenCalled() + expect(harness.ensureDetectedAgents).not.toHaveBeenCalled() + }) + + it('loads a local workspace in its own project runtime', async () => { + const harness = storeWithRepo({}) + + await expect( + ensureDetectedAgentsForWorktree(harness.store.getState(), 'wt-1') + ).resolves.toEqual(['codex']) + expect(harness.ensureDetectedAgents).toHaveBeenCalledWith('wt-1') + }) + + it("loads a direct SSH workspace from this client's connection", async () => { + const harness = storeWithRepo({ connectionId: 'ssh-1' }) + + await expect( + ensureDetectedAgentsForWorktree(harness.store.getState(), 'wt-1') + ).resolves.toEqual(['gemini']) + expect(harness.ensureRemoteDetectedAgents).toHaveBeenCalledWith('ssh-1') + }) + + it('probes nothing while the workspace owner is unknown', async () => { + const harness = storeWithRepo({}) + harness.store.setState({ repos: [], worktreesByRepo: {} }) + const state = harness.store.getState() + + expect(readDetectedAgentsForWorktree(state, 'repo-missing::wt')).toBeNull() + await expect(ensureDetectedAgentsForWorktree(state, 'repo-missing::wt')).resolves.toEqual([]) + expect(harness.ensureDetectedAgents).not.toHaveBeenCalled() + expect(harness.ensureRemoteDetectedAgents).not.toHaveBeenCalled() + expect(harness.ensureRuntimeDetectedAgents).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/agent-detection-target-inventory.ts b/src/renderer/src/lib/agent-detection-target-inventory.ts index c2231df22609..6e023f15711a 100644 --- a/src/renderer/src/lib/agent-detection-target-inventory.ts +++ b/src/renderer/src/lib/agent-detection-target-inventory.ts @@ -1,5 +1,10 @@ import type { AppState } from '@/store/types' import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' +import { + getAgentDetectionTargetKeyForWorktree, + parseAgentDetectionTargetKey, + type AgentDetectionOwnerState +} from '@/hooks/useAgentDetectionTarget' import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' import type { TuiAgent } from '../../../shared/tui-agent' @@ -16,6 +21,10 @@ type DetectionActionState = Pick< 'ensureDetectedAgents' | 'ensureRemoteDetectedAgents' | 'ensureRuntimeDetectedAgents' > +export type WorktreeAgentInventoryState = AgentDetectionOwnerState & + DetectionReadState & + DetectionActionState + /** The agent list a detection target's host last reported; null while it has not loaded. */ export function readDetectedAgentsForTarget( state: DetectionReadState, @@ -46,3 +55,37 @@ export function ensureDetectedAgentsForTarget( } return state.ensureDetectedAgents(target.worktreeId) } + +function detectionTargetForWorktree( + state: AgentDetectionOwnerState, + worktreeId: string | null +): AgentDetectionTarget | undefined { + const target = parseAgentDetectionTargetKey( + getAgentDetectionTargetKeyForWorktree(state, worktreeId) + ) + // Why: a local project's own runtime (native vs WSL) decides its PATH, not the focused one. + return target?.kind === 'local' && !target.worktreeId && worktreeId + ? { ...target, worktreeId } + : target +} + +/** The worktree host's agent list; null while unloaded or while its owner is unresolved. */ +export function readDetectedAgentsForWorktree( + state: AgentDetectionOwnerState & DetectionReadState, + worktreeId: string | null +): TuiAgent[] | null { + const target = detectionTargetForWorktree(state, worktreeId) + return target ? readDetectedAgentsForTarget(state, target) : null +} + +/** + * Loads the worktree's agents from the host that runs them: a hub-owned SSH workspace asks the hub, + * never this client's SSH map. An unresolved owner lists nothing rather than probing this machine. + */ +export function ensureDetectedAgentsForWorktree( + state: WorktreeAgentInventoryState, + worktreeId: string | null +): Promise { + const target = detectionTargetForWorktree(state, worktreeId) + return target ? ensureDetectedAgentsForTarget(state, target) : Promise.resolve([]) +} diff --git a/src/renderer/src/lib/agent-hibernation-pane-age.ts b/src/renderer/src/lib/agent-hibernation-pane-age.ts index 21b940b46000..936554d9ce17 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-age.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-age.ts @@ -96,11 +96,6 @@ export function getHibernationBoundaryResolvedAtByPaneKey(): Record ({ function serverReports(capabilities: readonly string[] | null): void { Object.assign(store, { runtimeStatusByEnvironmentId: new Map( - capabilities ? [['web-runtime', { status: { capabilities } }]] : [] + capabilities ? [['web-runtime', { status: { capabilities, hostPlatform: 'linux' } }]] : [] ) }) } diff --git a/src/renderer/src/lib/agent-launch-platform.ts b/src/renderer/src/lib/agent-launch-platform.ts deleted file mode 100644 index 0788610892bf..000000000000 --- a/src/renderer/src/lib/agent-launch-platform.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import type { AppState } from '@/store' -import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' - -export function getAgentLaunchPlatformForRepo( - repo: Pick, - projectRuntime?: ProjectExecutionRuntimeResolution -): NodeJS.Platform { - if (!repo.connectionId) { - if (projectRuntime?.status === 'repair-required') { - return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM - } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { - return 'linux' - } - return CLIENT_PLATFORM - } - return isWindowsAbsolutePathLike(repo.path) ? 'win32' : 'linux' -} diff --git a/src/renderer/src/lib/client-login-shell.ts b/src/renderer/src/lib/client-login-shell.ts deleted file mode 100644 index b0e0cca1fb88..000000000000 --- a/src/renderer/src/lib/client-login-shell.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Login shell (`$SHELL`) of the machine running this client, or '' when - * unknown (web build, preload unavailable). - * - * Why: commands Orca hands the user to paste — or types into a locally - * spawned pane — are parsed by that shell, and fish does not share the sh - * grammar for every construct. - */ -export function getClientLoginShell(): string { - try { - return window.api?.platform?.get?.().shell ?? '' - } catch { - return '' - } -} 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 c6729d291ae8..8ffae69187b7 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.test.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts @@ -387,14 +387,14 @@ describe('resolveCodexPaneSelectionLane', () => { describe('getCodexAccountSwitchLaneMatcher', () => { it('scopes a local switch to the runtime slot it wrote', () => { const hostSwitch = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'host' } }) expect(hostSwitch('host')).toBe(true) expect(hostSwitch('wsl:Ubuntu')).toBe(false) const ubuntuSwitch = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'wsl', wslDistro: 'Ubuntu' } }) expect(ubuntuSwitch('wsl:Ubuntu')).toBe(true) @@ -406,7 +406,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { // matching only `wsl:__default__` would leave those panes stranded, unnoticed. it('claims every WSL distro when the change cleared them all', () => { const wslDefaultSwitch = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'wsl', wslDistro: null }, clearsEveryWslDistro: true }) @@ -425,7 +425,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { // would card — and mute — every sibling distro's healthy Codex pane. it('keeps a distro-less WSL selection off sibling distro panes', () => { const wslDefaultSelect = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'wsl', wslDistro: null } }) expect(wslDefaultSelect('wsl:__default__')).toBe(true) @@ -438,7 +438,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { // that slot alone. Without the null-distro condition it would mute them all. it('keeps a cleared concrete-distro row off the other distros', () => { const ubuntuClear = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, clearsEveryWslDistro: true }) @@ -449,7 +449,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { it('scopes a switch made against a runtime environment to that machine', () => { const environmentSwitch = getCodexAccountSwitchLaneMatcher({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + owner: { kind: 'environment', environmentId: 'env-1' }, target: { runtime: 'host' } }) expect(environmentSwitch('env:env-1')).toBe(true) @@ -459,7 +459,7 @@ describe('getCodexAccountSwitchLaneMatcher', () => { it('never lets a local host switch claim a remote or SSH pane', () => { const hostSwitch = getCodexAccountSwitchLaneMatcher({ - settings: null, + owner: { kind: 'local' }, target: { runtime: 'host' } }) const state = laneState() diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts index 56ea2108074d..27bee15e5392 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 } from '@/runtime/runtime-client-target' +import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-client-target' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { getCodexSelectionLaneKey, @@ -14,7 +14,6 @@ import { } from '../../../shared/local-windows-terminal-runtime' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' import { resolveTerminalStartupCwd } from '../../../shared/terminal-startup-cwd' -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { parseWslUncPath } from '../../../shared/wsl-paths' @@ -25,8 +24,6 @@ import { hasCachedWindowsTerminalCapabilities } from './windows-terminal-capabilities' -type RuntimeEnvironmentSettings = Pick - /** Everything the pane lane needs: the workspace path plus the project runtime inputs. */ type CodexPaneLaneState = Pick< AppState, @@ -72,7 +69,8 @@ export function isForeignMachineCodexPtyId(ptyId: string): boolean { /** Matches the panes a Codex account mutation could have re-pointed. */ export function getCodexAccountSwitchLaneMatcher(args: { - settings: RuntimeEnvironmentSettings | null | undefined + /** Host whose roster the mutation wrote. */ + owner: RuntimeClientTarget target?: CodexAccountSelectionTarget | null /** * True only when the mutation cleared every WSL distro slot at once, which @@ -82,8 +80,8 @@ export function getCodexAccountSwitchLaneMatcher(args: { */ clearsEveryWslDistro?: boolean }): (laneKey: string) => boolean { - const runtimeTarget = getActiveRuntimeTarget(args.settings) - // Why: with an environment active the mutation is RPC'd to that machine's + const runtimeTarget = args.owner + // Why: a server-owned mutation is RPC'd to that machine's // roster and local GlobalSettings are never touched, so the local host/WSL // panes are exactly the ones the switch cannot have affected. if (runtimeTarget.kind === 'environment') { 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 74c15995e43a..8260bceccdb8 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,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' + +// Why: these cases model a switch made on the focused host, as the status bar does. +const focusedOwner = () => getActiveRuntimeTarget(useAppStore.getState().settings) import { markLiveCodexSessionsForRestart } from './codex-session-restart' const ACCOUNT_A = 'account-a@example.com' @@ -65,6 +69,7 @@ describe('Codex account switch recheck against main', () => { useAppStore.getState().queueCodexPaneRestarts(['pty-1']) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_B, nextAccountLabel: ACCOUNT_C, previousAccountId: 'account-b', @@ -80,6 +85,7 @@ describe('Codex account switch recheck against main', () => { listStalePanes.mockRejectedValue(new Error('ipc failed')) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, previousAccountId: 'account-a', 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 c913cfe060db..1fd132600283 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,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' + +// Why: these cases model a switch made on the focused host, as the status bar does. +const focusedOwner = () => getActiveRuntimeTarget(useAppStore.getState().settings) import { markLiveCodexSessionsForRestart, markRestoredStaleCodexSessionsForRestart @@ -99,6 +103,7 @@ describe('spurious shell readings on Codex-launched panes', () => { vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex-aarch64-ap') await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -114,6 +119,7 @@ describe('spurious shell readings on Codex-launched panes', () => { vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -125,6 +131,7 @@ describe('spurious shell readings on Codex-launched panes', () => { vi.mocked(window.api.pty.confirmForegroundProcess).mockRejectedValue(new Error('daemon busy')) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -138,6 +145,7 @@ describe('spurious shell readings on Codex-launched panes', () => { ).confirmForegroundProcess = undefined await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -149,6 +157,7 @@ describe('spurious shell readings on Codex-launched panes', () => { seedPane() await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -164,6 +173,7 @@ describe('spurious shell readings on Codex-launched panes', () => { }) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -183,6 +193,7 @@ describe('spurious shell readings on Codex-launched panes', () => { }) await markLiveCodexSessionsForRestart({ + owner: focusedOwner(), previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index d858f3140587..67a418acffea 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -1,5 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' + +// 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), + ...args + }) import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../shared/tui-agent' import { @@ -120,7 +130,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -170,7 +180,7 @@ describe('markLiveCodexSessionsForRestart', () => { return { foregroundProcess, hasChildProcesses: false } }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -193,7 +203,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -209,7 +219,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: true }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -227,7 +237,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -245,7 +255,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: true }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -262,7 +272,7 @@ describe('markLiveCodexSessionsForRestart', () => { return { foregroundProcess: 'codex', hasChildProcesses: true } }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -281,7 +291,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -298,7 +308,7 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -315,13 +325,13 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) useAppStore.getState().queueCodexPaneRestarts(['pty-1']) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_B, nextAccountLabel: ACCOUNT_A }) @@ -336,12 +346,12 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) useAppStore.getState().queueCodexPaneRestarts(['pty-1']) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_B, nextAccountLabel: 'account-c@example.com' }) @@ -362,12 +372,12 @@ describe('markLiveCodexSessionsForRestart', () => { hasChildProcesses: false }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_B, nextAccountLabel: ACCOUNT_C }) @@ -412,7 +422,7 @@ describe('markLiveCodexSessionsForRestart', () => { _meta: { runtimeId: 'remote-runtime' } }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -532,7 +542,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('leaves a live remote Codex pane alone on a host switch, and never inspects it', async () => { seedPanes([{ ptyId: 'remote:env-1@@term-1' }]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -545,7 +555,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('leaves a live SSH-connection Codex pane alone on a host switch', async () => { seedPanes([{ ptyId: 'ssh:my-box@@pty-7' }]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -558,7 +568,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('still marks the local host pane while sparing the remote one beside it', async () => { seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -571,10 +581,27 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('marks the local pane for a switch made on this computer while a server is focused', async () => { + seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) + useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await markLiveCodexSessionsForRestart({ + owner: { kind: 'local' }, + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + // Why: the notice follows the host that was written, never the focused one. + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-1': { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B } + }) + }) + it('still marks a local host pane when the switch names no target at all', async () => { seedPanes([{ ptyId: 'pty-1' }]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) @@ -590,7 +617,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('leaves a WSL Codex pane alone on a host switch even when its foreground is codex', async () => { seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -603,7 +630,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('marks that same WSL pane when its own distro is the lane that changed', async () => { seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'wsl', wslDistro: 'Ubuntu' } @@ -617,7 +644,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { it('keeps one distro switch off another distro pane', async () => { seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'wsl', wslDistro: 'Debian' } @@ -630,7 +657,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -662,7 +689,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { 'pty-1': 'wsl:Ubuntu' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -687,7 +714,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { } ]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, previousAccountId: 'account-a', @@ -709,7 +736,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { 'pty-1': 'host' }) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: 'System default', nextAccountLabel: ACCOUNT_A, previousAccountId: null, @@ -737,7 +764,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { } ]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, previousAccountId: 'account-a', @@ -784,7 +811,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockResolvedValue(recorded()) } - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } @@ -803,7 +830,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { { ptyId: 'ssh:my-box@@pty-7' } ]) - await markLiveCodexSessionsForRestart({ + await markForFocusedHost({ previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B, target: { runtime: 'host' } diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index 7fff41a088ec..ab6a83ce0cf0 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -23,6 +23,7 @@ import { resolveCodexPaneSelectionLane } from './codex-pane-selection-lane' import type { CodexAccountSelectionTarget } from '../../../shared/codex-selection-lane' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { TuiAgent } from '../../../shared/tui-agent' // Why: prompt integrations such as Starship can outlast the daemon's 300ms @@ -202,6 +203,8 @@ export async function markLiveCodexSessionsForRestart(args: { previousAccountId?: string | null nextAccountId?: string | null target?: CodexAccountSelectionTarget | null + /** Host whose roster the change wrote; never inferred from focus. */ + owner: RuntimeClientTarget /** Set when the change cleared the selection rather than pointing it somewhere. */ clearsEveryWslDistro?: boolean }): Promise { @@ -209,7 +212,7 @@ export async function markLiveCodexSessionsForRestart(args: { const scans = await scanCodexPanes(state, { ptyIdFilter: null, isLaneInScope: getCodexAccountSwitchLaneMatcher({ - settings: state.settings, + owner: args.owner, target: args.target, clearsEveryWslDistro: args.clearsEveryWslDistro }) diff --git a/src/renderer/src/lib/connection-context.test.ts b/src/renderer/src/lib/connection-context.test.ts index fae6c74cdd81..7031a368a9b2 100644 --- a/src/renderer/src/lib/connection-context.test.ts +++ b/src/renderer/src/lib/connection-context.test.ts @@ -621,7 +621,7 @@ describe('getConnectionIdFromState', () => { }) it('keeps a runtime host nested SSH target, which decides local readability', () => { - // `repoWithFetchedOwner` stamps the runtime host and spreads the nested target through. The + // `adoptFromEndpoint` stamps the runtime host and spreads the nested target through. The // pane pairs it with the environment (`selectRuntimeAwareSshStatus`) for reconnect state, and // `isNativeChatTranscriptLocalReadable` treats a null here as "this client can read it" — so // dropping it would send a transcript read to the wrong machine. diff --git a/src/renderer/src/lib/doc-preview-web-client.ts b/src/renderer/src/lib/doc-preview-web-client.ts new file mode 100644 index 000000000000..9fc50e02db43 --- /dev/null +++ b/src/renderer/src/lib/doc-preview-web-client.ts @@ -0,0 +1,20 @@ +import { translate } from '@/i18n/i18n' +import { isPairedWebClientWindow } from './desktop-window-chrome' + +/** The document preview is a desktop webview; a paired web client has no window to host it in. */ +export function webClientDocPreviewRefusal(): { + status: 'unsupported' + message: string + reason: 'no-channel' +} | null { + return isPairedWebClientWindow() + ? { + status: 'unsupported', + message: translate( + 'auto.lib.file.preview.webClientUnavailable', + "Document previews aren't available in the web client." + ), + reason: 'no-channel' + } + : null +} diff --git a/src/renderer/src/lib/execution-host-facts.ts b/src/renderer/src/lib/execution-host-facts.ts new file mode 100644 index 000000000000..d66359311616 --- /dev/null +++ b/src/renderer/src/lib/execution-host-facts.ts @@ -0,0 +1,172 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ParsedExecutionHost +} from '../../../shared/execution-host' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import { isWslUncPath } from '../../../shared/wsl-paths' +import type { AppState } from '@/store/types' +import { getConnectionIdFromState } from './connection-owner-resolution' +import { getLocalProjectExecutionRuntimeContext } from './local-preflight-context' +import { CLIENT_PLATFORM } from './new-workspace' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from './worktree-runtime-owner' + +/** + * The OS of the machine an agent runs on. `withheld` means no host has reported it; a launch must + * stop there, because the client's or the endpoint's OS would quote the command for the wrong shell. + */ +export type ExecutionHostPlatformFact = + | { kind: 'known'; platform: NodeJS.Platform; hostKind: ParsedExecutionHost['kind'] } + | { kind: 'withheld'; reason: string } + +type HostFactsState = Pick< + AppState, + 'sshConnectionStates' | 'sshStateByEnvironment' | 'runtimeStatusByEnvironmentId' +> + +type WorktreeHostFactsState = HostFactsState & + Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'detectedWorktreesByRepo' + | 'folderWorkspaces' + | 'projectGroups' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' + > + +const SSH_PLATFORM_WITHHELD = + 'The SSH host has not reported its operating system yet. Connect to it and try again.' +const RUNTIME_PLATFORM_WITHHELD = + 'The Orca server has not reported its operating system yet. Reconnect to it and try again.' +const RUNTIME_PLATFORM_UPDATE = + 'This Orca server does not report its operating system. Update the server to launch agents on it.' +const HOST_UNRESOLVED_WITHHELD = 'Orca cannot tell which host owns this workspace yet.' + +function resolveLocalPlatform( + projectRuntime: ProjectExecutionRuntimeResolution | undefined, + workspacePath: string | null | undefined +): NodeJS.Platform { + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (projectRuntime?.status === 'resolved') { + return projectRuntime.runtime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + return workspacePath && isWslUncPath(workspacePath) ? 'linux' : CLIENT_PLATFORM +} + +function resolveHostPlatform( + state: HostFactsState, + args: { + host: ParsedExecutionHost | null + /** SSH target reached through a runtime host (`runtime:E` + target T). */ + nestedSshTargetId: string | null + /** Runtime host owning an `ssh:T` workspace, when it is nested rather than direct. */ + sshOwnerEnvironmentId: string | null + workspacePath: string | null | undefined + projectRuntime: () => ProjectExecutionRuntimeResolution | undefined + } +): ExecutionHostPlatformFact { + const { host } = args + if (!host) { + return { kind: 'withheld', reason: HOST_UNRESOLVED_WITHHELD } + } + if (host.kind === 'local') { + const platform = resolveLocalPlatform(args.projectRuntime(), args.workspacePath) + return { kind: 'known', platform, hostKind: 'local' } + } + const sshTargetId = host.kind === 'ssh' ? host.targetId : args.nestedSshTargetId + if (sshTargetId) { + const ownerEnvironmentId = + host.kind === 'runtime' ? host.environmentId : args.sshOwnerEnvironmentId + const sshState = ownerEnvironmentId + ? state.sshStateByEnvironment.get(ownerEnvironmentId)?.connectionStates.get(sshTargetId) + : state.sshConnectionStates.get(sshTargetId) + return sshState?.remotePlatform + ? { kind: 'known', platform: sshState.remotePlatform, hostKind: 'ssh' } + : { kind: 'withheld', reason: SSH_PLATFORM_WITHHELD } + } + if (host.kind !== 'runtime') { + return { kind: 'withheld', reason: HOST_UNRESOLVED_WITHHELD } + } + const status = lastVerifiedRuntimeStatus( + state.runtimeStatusByEnvironmentId.get(host.environmentId) + ) + const hostPlatform = status?.hostPlatform + if (!hostPlatform) { + const reason = status ? RUNTIME_PLATFORM_UPDATE : RUNTIME_PLATFORM_WITHHELD + return { kind: 'withheld', reason } + } + // Why: a WSL UNC workspace on a Windows server runs its agents inside that distro. + const platform = + hostPlatform === 'win32' && args.workspacePath && isWslUncPath(args.workspacePath) + ? 'linux' + : hostPlatform + return { kind: 'known', platform, hostKind: 'runtime' } +} + +/** Platform of the host that runs agents for a repo that has no workspace yet. */ +export function resolveRepoExecutionHostPlatform( + state: HostFactsState, + repo: { connectionId?: string | null; executionHostId?: string | null; path: string }, + projectRuntime: () => ProjectExecutionRuntimeResolution | undefined +): ExecutionHostPlatformFact { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + return resolveHostPlatform(state, { + host, + nestedSshTargetId: host?.kind === 'runtime' ? repo.connectionId?.trim() || null : null, + sshOwnerEnvironmentId: null, + workspacePath: repo.path, + projectRuntime + }) +} + +/** Platform of the host that runs agents for a worktree or folder workspace. */ +export function resolveWorktreeExecutionHostPlatform( + state: WorktreeHostFactsState, + worktreeId: string, + workspacePath: string | null | undefined +): ExecutionHostPlatformFact { + // Why the routing default: an owner-less row launches where routing sends it, so read that host. + const host = parseExecutionHostId(getExecutionHostIdForWorktree(state, worktreeId)) + return resolveHostPlatform(state, { + host, + nestedSshTargetId: + host?.kind === 'runtime' ? (getConnectionIdFromState(state, worktreeId) ?? null) : null, + sshOwnerEnvironmentId: + host?.kind === 'ssh' ? getRuntimeEnvironmentIdForWorktree(state, worktreeId) : null, + workspacePath, + projectRuntime: () => getLocalProjectExecutionRuntimeContext(state, worktreeId) + }) +} + +export function requireExecutionHostPlatform(fact: ExecutionHostPlatformFact): NodeJS.Platform { + if (fact.kind === 'withheld') { + throw new Error(fact.reason) + } + return fact.platform +} + +/** `terminalWindowsShell` describes this client's shell, so it only shapes local launches. */ +export function resolveExecutionHostAgentStartupShell( + fact: ExecutionHostPlatformFact, + terminalWindowsShell: string | null | undefined +): AgentStartupShell | undefined { + return fact.kind === 'known' + ? resolveLocalWindowsAgentStartupShell({ + platform: fact.platform, + isRemote: fact.hostKind !== 'local', + terminalWindowsShell + }) + : undefined +} diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index c6a58edec220..a945c574bfaf 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -592,3 +592,38 @@ describe('convertBrowserPageToWorkspaceDoc', () => { } ) }) + +// #22618: the doc preview is a desktop webview, so a web client must not create a tab it can +// never materialize; the action hides instead of failing silently. +describe('document previews in a paired web client', () => { + it('hides Open in Orca Browser and refuses with a reason instead of a silent no-op', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + try { + mocks.environmentId = 'runtime-1' + mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' } + + expect( + canShowWorkspaceFileBrowserAction(browserActionState(), 'wt-1', '/repo/report.html') + ).toBe(false) + const plan = openFileInBrowserTab({ + filePath: '/srv/repo/docs/example.html', + worktreeId: 'wt-1' + }) + expect(plan).toMatchObject({ status: 'unsupported', reason: 'no-channel' }) + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + + mocks.browserPagesByWorkspace = { 'browser-1': [{ id: 'page-1', worktreeId: 'wt-1' }] } + expect( + convertBrowserPageToWorkspaceDoc('page-1', { + kind: 'workspace-doc', + worktreeId: 'wt-1', + filePath: '/srv/repo/docs/example.html' + }) + ).toBe('failed') + expect(mocks.convertBrowserPage).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + } + }) +}) diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index cb36ac814adc..4a1890e55efa 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -1,6 +1,7 @@ import { toast } from 'sonner' import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { webClientDocPreviewRefusal } from '@/lib/doc-preview-web-client' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' import { basename, getRelativePathInsideRoot } from '@/lib/path' @@ -61,7 +62,7 @@ export function getWorkspaceFilePreviewPlan( } } if (connectionId !== null) { - return { status: 'doc-preview' } + return webClientDocPreviewRefusal() ?? { status: 'doc-preview' } } // Why: the doc preview needs no browser at all, so a paired runtime without the // screencast capability still previews documents. @@ -76,7 +77,7 @@ export function getWorkspaceFilePreviewPlan( reason: 'outside-worktree' } } - return { status: 'doc-preview' } + return webClientDocPreviewRefusal() ?? { status: 'doc-preview' } } const availability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] if (availability.state !== 'enabled') { @@ -252,6 +253,11 @@ export function convertBrowserPageToWorkspaceDoc( docLocation: BrowserPageDocLocation, options?: { leg?: BrowserPageConversionLeg } ): 'activated-existing' | 'opened-in-owning-worktree' | 'converted' | 'failed' { + const webRefusal = webClientDocPreviewRefusal() + if (webRefusal) { + toast.error(webRefusal.message) + return 'failed' + } const state = useAppStore.getState() // Why a history leg skips reuse: Back and Forward both mean "this tab, as it was" — activating // another tab showing the document would leave this one a web page with live provenance, so diff --git a/src/renderer/src/lib/fix-checks-agent-launch.test.ts b/src/renderer/src/lib/fix-checks-agent-launch.test.ts index 32c636f13f7c..9d1c71bfaf9e 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.test.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.test.ts @@ -213,6 +213,7 @@ describe('startFixChecksAgent', () => { }) it('rejects without launching when remote agent detection fails', async () => { + mocks.store.repos = [{ ...mocks.store.repos[0], connectionId: 'conn-1' }] mocks.getConnectionId.mockReturnValue('conn-1') mocks.store.ensureRemoteDetectedAgents.mockRejectedValue(new Error('detection failed')) const { startFixChecksAgent } = await import('./fix-checks-agent-launch') diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index daa59baad3dd..f2e78e715519 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' +import { ensureDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' @@ -81,11 +82,9 @@ async function resolveSavedAgentOverride( async function pickExistingWorktreeAgent( worktreeId: string, - savedAgent: TuiAgent | null | undefined, - repoConnectionId: string | null | undefined + savedAgent: TuiAgent | null | undefined ): Promise { - const connectionId = getConnectionId(worktreeId) ?? repoConnectionId ?? null - const detectedAgents = await detectAgentsForConnection(connectionId) + const detectedAgents = await ensureDetectedAgentsForWorktree(useAppStore.getState(), worktreeId) if (savedAgent) { if (isAgentAvailable(savedAgent, detectedAgents)) { return savedAgent @@ -160,11 +159,7 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis return false } const targetConnectionId = getConnectionId(targetWorktreeId) ?? repo?.connectionId ?? null - const agent = await pickExistingWorktreeAgent( - targetWorktreeId, - savedAgentId, - repo?.connectionId - ) + const agent = await pickExistingWorktreeAgent(targetWorktreeId, savedAgentId) if (!agent) { return false } diff --git a/src/renderer/src/lib/folder-workspace-runtime-owner.ts b/src/renderer/src/lib/folder-workspace-runtime-owner.ts index b9b0266ff07d..4ee299282cc1 100644 --- a/src/renderer/src/lib/folder-workspace-runtime-owner.ts +++ b/src/renderer/src/lib/folder-workspace-runtime-owner.ts @@ -11,6 +11,7 @@ import { getSingleFocusedRuntimeEnvironmentId, type SingleRuntimeLegacyOwnerState } from './single-runtime-legacy-owner' +import { getPairedWebClientEnvironmentId } from './paired-web-client-host' type RuntimeExecutionHost = Extract @@ -106,7 +107,7 @@ export function getRuntimeEnvironmentIdForFolderWorkspace( folderWorkspace?.connectionId?.trim() || projectGroup?.connectionId?.trim() ) { - return null + return getPairedWebClientEnvironmentId(state) } const restoredRuntimeHost = getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId) if (restoredRuntimeHost) { @@ -134,6 +135,21 @@ export function getExplicitRuntimeEnvironmentIdForFolderWorkspace( return getRestoredRuntimeHostForFolderWorkspace(state, folderWorkspaceId)?.environmentId ?? null } +/** The SSH target behind the paired server that owns this folder, when the owning row names one. */ +export function getNestedSshTargetIdForFolderWorkspace( + state: FolderWorkspaceRuntimeOwnerState, + folderWorkspaceId: string, + executionHostId?: ExecutionHostId +): string | null { + const folderWorkspace = findFolderWorkspaceOwner(state, folderWorkspaceId, executionHostId) + const owner = folderWorkspace?.executionHostId + ? folderWorkspace + : findFolderProjectGroup(state, folderWorkspaceId, executionHostId) + return owner && parseExecutionHostId(owner.executionHostId)?.kind === 'runtime' + ? owner.connectionId?.trim() || null + : null +} + export function getExecutionHostIdForFolderWorkspace( state: FolderWorkspaceRuntimeOwnerState, folderWorkspaceId: string, 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 b0cc6c505ab9..dc690948430d 100644 --- a/src/renderer/src/lib/github-source-runtime-context.test.ts +++ b/src/renderer/src/lib/github-source-runtime-context.test.ts @@ -1,9 +1,11 @@ +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 { canUseGitHubRepoContext, - getGitHubMutationRoutingSettings, + getGitHubRepoRoutingSettings, getGitHubRuntimeRepoId, getGitHubSourceRuntimeHost, getGitHubSourceRuntimeTarget @@ -56,7 +58,7 @@ describe('GitHub source runtime context', () => { }) }) -describe('getGitHubMutationRoutingSettings', () => { +describe('getGitHubRepoRoutingSettings', () => { const runtimeOwnedRepo: RepoRuntimeOwnerState = { settings: { activeRuntimeEnvironmentId: null }, repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] @@ -67,7 +69,7 @@ describe('getGitHubMutationRoutingSettings', () => { repoId: string | null, sourceContext: TaskSourceContext | null ): ReturnType { - return getActiveRuntimeTarget(getGitHubMutationRoutingSettings(state, repoId, sourceContext)) + return getActiveRuntimeTarget(getGitHubRepoRoutingSettings(state, repoId, sourceContext)) } it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => { @@ -111,6 +113,23 @@ describe('getGitHubMutationRoutingSettings', () => { ).toEqual({ kind: 'local' }) }) + it('keeps a local source local when the repo id also exists on a server', () => { + const duplicateIds: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }, + { id: 'repo-1', connectionId: null, executionHostId: 'local' } + ] + } + expect( + resolveTarget(duplicateIds, 'repo-1', { + ...runtimeSourceContext, + hostId: 'local', + repoId: 'repo-1' + }) + ).toEqual({ kind: 'local' }) + }) + it('ignores runtime hosts on non-GitHub sources', () => { expect( resolveTarget(runtimeOwnedRepo, 'repo-1', { @@ -143,3 +162,24 @@ describe('getGitHubMutationRoutingSettings', () => { ).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/github-source-runtime-context.ts b/src/renderer/src/lib/github-source-runtime-context.ts index 0267497f0b79..86d314231866 100644 --- a/src/renderer/src/lib/github-source-runtime-context.ts +++ b/src/renderer/src/lib/github-source-runtime-context.ts @@ -7,6 +7,7 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getExplicitRuntimeOwnerEnvironmentId, + getHostlessSourceRepoOwnerEnvironmentId, type RepoRuntimeOwnerState } from './repo-runtime-owner' @@ -30,19 +31,24 @@ export function getGitHubSourceRuntimeTarget( ) } -// Why: PR mutations must run on the repo's explicit owner host (#6957); a -// local or absent source never downgrades a runtime-owned repo to local IPC, +// 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 getGitHubMutationRoutingSettings( +export function getGitHubRepoRoutingSettings( state: RepoRuntimeOwnerState, repoId: string | null | undefined, sourceContext: TaskSourceContext | null | undefined ): Pick { const sourceHost = getGitHubSourceRuntimeHost(sourceContext) + if (sourceHost) { + return { activeRuntimeEnvironmentId: sourceHost.environmentId } + } return { activeRuntimeEnvironmentId: - sourceHost?.environmentId ?? getExplicitRuntimeOwnerEnvironmentId(state, repoId) + sourceContext?.provider === 'github' + ? getHostlessSourceRepoOwnerEnvironmentId(state.repos, repoId) + : getExplicitRuntimeOwnerEnvironmentId(state, repoId) } } diff --git a/src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts b/src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts index 88c37cf128c6..4b13a04cfa2b 100644 --- a/src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts +++ b/src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts @@ -71,7 +71,13 @@ describe('GitLab source lookup routing', () => { expect(callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'env-1' }, 'gitlab.workItemByPath', - { repo: 'runtime-repo', host: 'gitlab.com', path: 'acme/app', iid: 7, type: 'mr' }, + expect.objectContaining({ + repo: 'id:runtime-repo', + host: 'gitlab.com', + path: 'acme/app', + iid: 7, + type: 'mr' + }), { timeoutMs: 30_000 } ) expect(window.api.gl.workItemByPath).not.toHaveBeenCalled() @@ -104,7 +110,13 @@ describe('GitLab source lookup routing', () => { expect(callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'env-1' }, 'gitlab.listMRs', - { repo: 'runtime-repo', state: 'opened', page: 1, perPage: 12, query: 'fix login' }, + expect.objectContaining({ + repo: 'id:runtime-repo', + state: 'opened', + page: 1, + perPage: 12, + query: 'fix login' + }), { timeoutMs: 30_000 } ) expect(window.api.gl.listMRs).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/gitlab-work-item-source-lookup.ts b/src/renderer/src/lib/gitlab-work-item-source-lookup.ts index c848a84b7715..0ebfee4ab90d 100644 --- a/src/renderer/src/lib/gitlab-work-item-source-lookup.ts +++ b/src/renderer/src/lib/gitlab-work-item-source-lookup.ts @@ -1,7 +1,6 @@ import type { GitLabWorkItem, ListMergeRequestsResult } from '../../../shared/gitlab-types' import type { TaskSourceContext } from '../../../shared/task-source-context' -import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { gitLabApiFor } from '@/runtime/gitlab-owner-api' type GitLabSourceLookupArgs = { repoPath: string @@ -23,10 +22,6 @@ type GitLabMRListLookupArgs = GitLabSourceLookupArgs & { query?: string } -function runtimeRepoId(args: Pick): string { - return args.sourceContext?.repoId ?? args.repoId -} - function withRendererRepoId(item: Omit | GitLabWorkItem, repoId: string) { return { ...item, repoId } as GitLabWorkItem } @@ -34,60 +29,14 @@ function withRendererRepoId(item: Omit | GitLabWorkIte export async function lookupGitLabWorkItemByPathForSource( args: GitLabWorkItemByPathLookupArgs ): Promise { - const target = getActiveRuntimeTarget(getTaskSourceRuntimeSettings(args.sourceContext)) - const item = - target.kind === 'environment' - ? await callRuntimeRpc | null>( - target, - 'gitlab.workItemByPath', - { - repo: runtimeRepoId(args), - host: args.host, - path: args.path, - iid: args.iid, - type: args.type - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gl.workItemByPath({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - host: args.host, - path: args.path, - iid: args.iid, - type: args.type - })) as Omit | GitLabWorkItem | null) + const item = await gitLabApiFor(args).workItemByPath(args) return item ? withRendererRepoId(item, args.repoId) : null } export async function listGitLabMRsForSource( args: GitLabMRListLookupArgs ): Promise { - const target = getActiveRuntimeTarget(getTaskSourceRuntimeSettings(args.sourceContext)) - const result = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'gitlab.listMRs', - { - repo: runtimeRepoId(args), - state: args.state, - page: args.page, - perPage: args.perPage, - query: args.query - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gl.listMRs({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - state: args.state, - page: args.page, - perPage: args.perPage, - query: args.query - })) as ListMergeRequestsResult) + const result = await gitLabApiFor(args).listMRs(args) return { ...result, items: result.items.map((item) => withRendererRepoId(item, args.repoId)) diff --git a/src/renderer/src/lib/launch-agent-background-session-extra-args.test.ts b/src/renderer/src/lib/launch-agent-background-session-extra-args.test.ts index 4c9233e588f6..5dbab2e5772c 100644 --- a/src/renderer/src/lib/launch-agent-background-session-extra-args.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-extra-args.test.ts @@ -49,9 +49,18 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) -vi.mock('@/lib/agent-launch-platform', () => ({ - getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo -})) +vi.mock('@/lib/execution-host-facts', async (importOriginal) => { + const hostFact = () => ({ + kind: 'known' as const, + platform: mockGetAgentLaunchPlatformForRepo(), + hostKind: 'local' as const + }) + return { + ...(await importOriginal>()), + resolveRepoExecutionHostPlatform: hostFact, + resolveWorktreeExecutionHostPlatform: hostFact + } +}) vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index e6480d197f7a..a4ca1bc9ce36 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -51,9 +51,18 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) -vi.mock('@/lib/agent-launch-platform', () => ({ - getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo -})) +vi.mock('@/lib/execution-host-facts', async (importOriginal) => { + const hostFact = () => ({ + kind: 'known' as const, + platform: mockGetAgentLaunchPlatformForRepo(), + hostKind: 'local' as const + }) + return { + ...(await importOriginal>()), + resolveRepoExecutionHostPlatform: hostFact, + resolveWorktreeExecutionHostPlatform: hostFact + } +}) vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index d665133b910d..c086d175d035 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -27,6 +27,7 @@ const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockDispatchEvent = vi.fn() const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>() +const mockHostKind = vi.fn<() => 'local' | 'runtime'>(() => 'local') const state = createAgentBackgroundSessionTestState({ createTab: mockCreateTab, setTabCustomTitle: mockSetTabCustomTitle, @@ -53,9 +54,18 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) -vi.mock('@/lib/agent-launch-platform', () => ({ - getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo -})) +vi.mock('@/lib/execution-host-facts', async (importOriginal) => { + const hostFact = () => ({ + kind: 'known' as const, + platform: mockGetAgentLaunchPlatformForRepo(), + hostKind: mockHostKind() + }) + return { + ...(await importOriginal>()), + resolveRepoExecutionHostPlatform: hostFact, + resolveWorktreeExecutionHostPlatform: hostFact + } +}) vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ registerEagerPtyBuffer: mockRegisterEagerPtyBuffer, @@ -613,4 +623,23 @@ describe('launchAgentBackgroundSession', () => { }) ) }) + + it("keeps this client's Git Bash setting off a Windows Orca server's launch", async () => { + mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + mockHostKind.mockReturnValueOnce('runtime') + Object.assign(state.settings, { terminalWindowsShell: 'bash.exe' }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'hermes', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('powershell.exe -NoProfile -EncodedCommand') + }) + ) + }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index db874a124deb..29be211524b9 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -55,7 +55,7 @@ export async function launchAgentBackgroundSession( worktreePath: worktree.path, repo }) - const { platform: launchPlatform, isRemote } = launchHost + const { platform: launchPlatform, isRemote, isLocalHost } = launchHost // Route by the worktree's owner host, not the focused runtime. const runtimeTarget = getActiveRuntimeTarget( getSettingsForWorktreeRuntimeOwner(store, worktreeId) @@ -67,7 +67,10 @@ export async function launchAgentBackgroundSession( // Why before any tab or PTY: an invalid launch must fail the run without creating a terminal. const planInputs = resolveAgentStartupPlanInputs({ agent, - settings: store.settings ?? {}, + // Why: the client's Windows shell setting describes this machine, not a remote host. + settings: isLocalHost + ? (store.settings ?? {}) + : { ...store.settings, terminalWindowsShell: undefined }, platform: launchPlatform, isRemote, extraAgentArgs: args.extraAgentArgs, diff --git a/src/renderer/src/lib/launch-agent-execution-context.test.ts b/src/renderer/src/lib/launch-agent-execution-context.test.ts new file mode 100644 index 000000000000..1094f73d566b --- /dev/null +++ b/src/renderer/src/lib/launch-agent-execution-context.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { SshConnectionState } from '../../../shared/ssh-types' +import type { AppState } from '@/store/types' +import { resolveAgentLaunchExecutionContext } from './launch-agent-execution-context' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +vi.mock('@/lib/new-workspace', async (importOriginal) => ({ + ...(await importOriginal>()), + CLIENT_PLATFORM: 'win32' +})) + +type Store = Parameters[0] + +function runtimeStatus(hostPlatform: NodeJS.Platform): RuntimeEnvironmentStatus { + return { + checkedAt: 1, + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + hostPlatform + } + } +} + +function sshState(remotePlatform?: 'linux' | 'win32'): SshConnectionState { + return { + targetId: 'box', + status: 'connected', + error: null, + reconnectAttempt: 0, + ...(remotePlatform ? { remotePlatform } : {}) + } +} + +function store(args: { + repo: Partial & { path: string } + worktreePath: string + sshConnectionStates?: Map + sshStateByEnvironment?: AppState['sshStateByEnvironment'] + runtimeStatusByEnvironmentId?: Map +}): Store { + const repo = { + id: 'repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + ...args.repo + } + const worktree = { + id: `repo::${args.worktreePath}`, + repoId: 'repo', + path: args.worktreePath + } + const state = { + repos: [repo], + worktreesByRepo: { repo: [worktree] }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + projects: [], + activeRepoId: null, + activeWorktreeId: null, + settings: { activeRuntimeEnvironmentId: null, terminalWindowsShell: 'powershell.exe' }, + sshConnectionStates: args.sshConnectionStates ?? new Map(), + sshStateByEnvironment: args.sshStateByEnvironment ?? new Map(), + runtimeStatusByEnvironmentId: args.runtimeStatusByEnvironmentId ?? new Map(), + restoredRuntimeHostIdByWorkspaceSessionKey: {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test store carries every slice the resolver reads. + return { ...state, allWorktrees: () => [worktree] } as unknown as Store +} + +describe('resolveAgentLaunchExecutionContext from a Windows client', () => { + it('quotes for the Linux Orca server that runs the agent, not the client (#22204, #12252)', () => { + const context = resolveAgentLaunchExecutionContext( + store({ + repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' }, + worktreePath: '/srv/repo', + runtimeStatusByEnvironmentId: new Map([['linux-box', runtimeStatus('linux')]]) + }), + { worktreeId: 'repo::/srv/repo' } + ) + expect(context).toMatchObject({ + resolvedLaunchPlatform: 'linux', + isRemote: false, + queuedShell: undefined + }) + }) + + it('does not apply the client PowerShell setting to a Windows Orca server', () => { + const context = resolveAgentLaunchExecutionContext( + store({ + repo: { path: 'D:\\repo', executionHostId: 'runtime:win-box' }, + worktreePath: 'D:\\repo', + runtimeStatusByEnvironmentId: new Map([['win-box', runtimeStatus('win32')]]) + }), + { worktreeId: 'repo::D:\\repo' } + ) + expect(context).toMatchObject({ + resolvedLaunchPlatform: 'win32', + queuedShell: undefined + }) + }) + + it('withholds the launch when the Orca server has never reported its OS', () => { + const context = resolveAgentLaunchExecutionContext( + store({ + repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' }, + worktreePath: '/srv/repo' + }), + { worktreeId: 'repo::/srv/repo' } + ) + expect(context).toBeNull() + }) + + it('uses the SSH host OS reported by the relay for a direct SSH workspace', () => { + const context = resolveAgentLaunchExecutionContext( + store({ + repo: { path: '/home/u/repo', connectionId: 'box' }, + worktreePath: '/home/u/repo', + sshConnectionStates: new Map([['box', sshState('win32')]]) + }), + { worktreeId: 'repo::/home/u/repo' } + ) + expect(context).toMatchObject({ + resolvedLaunchPlatform: 'win32', + isRemote: true + }) + }) + + it('withholds a direct SSH launch until the relay reports the host OS', () => { + const context = resolveAgentLaunchExecutionContext( + store({ + repo: { path: 'C:\\repo', connectionId: 'box' }, + worktreePath: 'C:\\repo', + sshConnectionStates: new Map([['box', sshState()]]) + }), + { worktreeId: 'repo::C:\\repo' } + ) + expect(context).toBeNull() + }) + + it('reads a nested SSH target from the Orca server that reaches it', () => { + const context = resolveAgentLaunchExecutionContext( + store({ + repo: { + path: 'C:\\repo', + executionHostId: 'runtime:linux-box', + connectionId: 'box' + }, + worktreePath: 'C:\\repo', + sshConnectionStates: new Map([['box', sshState('win32')]]), + sshStateByEnvironment: new Map([ + [ + 'linux-box', + { + connectionStates: new Map([['box', sshState('linux')]]), + targetLabels: new Map(), + targetGenerations: new Map(), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]), + runtimeStatusByEnvironmentId: new Map([['linux-box', runtimeStatus('win32')]]) + }), + { worktreeId: 'repo::C:\\repo' } + ) + expect(context).toMatchObject({ + resolvedLaunchPlatform: 'linux', + isRemote: true + }) + }) + + it('keeps the client shell setting for a local workspace', () => { + const context = resolveAgentLaunchExecutionContext( + store({ repo: { path: 'C:\\repo' }, worktreePath: 'C:\\repo' }), + { worktreeId: 'repo::C:\\repo' } + ) + expect(context).toMatchObject({ + resolvedLaunchPlatform: 'win32', + queuedShell: 'powershell' + }) + }) + + it('reads a folder workspace path so a WSL folder on a Windows server quotes for Linux', () => { + const base = store({ + repo: { path: '/unused' }, + worktreePath: '/unused', + runtimeStatusByEnvironmentId: new Map([['win-box', runtimeStatus('win32')]]) + }) + const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\project' + const context = resolveAgentLaunchExecutionContext( + Object.assign(base, { + folderWorkspaces: [ + { id: 'f1', projectGroupId: 'g1', connectionId: null, executionHostId: 'runtime:win-box' } + ], + projectGroups: [{ id: 'g1', connectionId: null, executionHostId: 'runtime:win-box' }], + getKnownWorktreeById: () => ({ path: folderPath }) + }), + { worktreeId: 'folder:f1' } + ) + expect(context).toMatchObject({ resolvedLaunchPlatform: 'linux', queuedShell: undefined }) + }) + + it("ignores a caller's client platform for a workspace on a Linux server", () => { + const context = resolveAgentLaunchExecutionContext( + store({ + repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' }, + worktreePath: '/srv/repo', + runtimeStatusByEnvironmentId: new Map([['linux-box', runtimeStatus('linux')]]) + }), + { worktreeId: 'repo::/srv/repo', launchPlatform: 'win32' } + ) + expect(context).toMatchObject({ resolvedLaunchPlatform: 'linux', queuedShell: undefined }) + }) + + it("keeps a caller's platform for a local launch", () => { + const context = resolveAgentLaunchExecutionContext( + store({ repo: { path: 'C:\\repo' }, worktreePath: 'C:\\repo' }), + { worktreeId: 'repo::C:\\repo', launchPlatform: 'linux' } + ) + expect(context).toMatchObject({ resolvedLaunchPlatform: 'linux', queuedShell: undefined }) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-execution-context.ts b/src/renderer/src/lib/launch-agent-execution-context.ts index 4d9cf4b41c8e..0ec03f3cdd3c 100644 --- a/src/renderer/src/lib/launch-agent-execution-context.ts +++ b/src/renderer/src/lib/launch-agent-execution-context.ts @@ -1,9 +1,10 @@ +import { toast } from 'sonner' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' -import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { getConnectionIdFromState } from '@/lib/connection-context' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + resolveExecutionHostAgentStartupShell, + resolveWorktreeExecutionHostPlatform +} from '@/lib/execution-host-facts' import type { useAppStore } from '@/store' /** Where a new-tab agent launch runs, and the quoting rules that follow from it. */ @@ -16,39 +17,36 @@ export type AgentLaunchExecutionContext = { queuedShell: AgentStartupShell | undefined } +/** `null` means the host's OS is unknown; the user has been told why the launch stopped. */ export function resolveAgentLaunchExecutionContext( store: ReturnType, args: { worktreeId: string; launchPlatform?: NodeJS.Platform } -): AgentLaunchExecutionContext { - const worktree = store - .allWorktrees?.() - .find((entry: { id: string }) => entry.id === args.worktreeId) - const repo = worktree ? store.repos?.find((entry) => entry.id === worktree.repoId) : null - // Why: `store.repos.find` is host-blind and the same repo id can exist on local, SSH and runtime - // hosts, so the row it returns can belong to a different host than the worktree names (#11163). - // The shared resolver answers from the worktree's own host; `undefined` (rival rows disagree) is - // not evidence of a remote, and main rejects that launch anyway. +): AgentLaunchExecutionContext | null { + // Why: `undefined` (rival host rows disagree) is not evidence of a remote; main rejects it anyway. const worktreeSshConnectionId = getConnectionIdFromState(store, args.worktreeId) - const resolvedLaunchPlatform = - args.launchPlatform ?? - (repo - ? getAgentLaunchPlatformForRepo( - repo, - worktreeSshConnectionId - ? undefined - : getLocalProjectExecutionRuntimeContext(store, args.worktreeId) - ) - : CLIENT_PLATFORM) // Why: SSH remotes deploy the shim as plain `orca`, so skip the Linux-only `orca-ide` rename for remote launches. const isRemote = Boolean(worktreeSshConnectionId) + // Why getKnownWorktreeById: folder workspaces are not in allWorktrees, and their path marks WSL. + const workspacePath = ( + store.getKnownWorktreeById?.(args.worktreeId) ?? + store.allWorktrees?.().find((entry) => entry.id === args.worktreeId) + )?.path + // Why: the agent runs on the workspace's execution host, so its OS and shell decide quoting (#22204). + const fact = resolveWorktreeExecutionHostPlatform(store, args.worktreeId, workspacePath) + if (fact.kind === 'withheld') { + toast.error(fact.reason) + return null + } + // Why: a caller's platform only refines a local launch (e.g. WSL); a remote host's own OS wins. + const resolvedLaunchPlatform = + fact.hostKind === 'local' && args.launchPlatform ? args.launchPlatform : fact.platform return { worktreeSshConnectionId, resolvedLaunchPlatform, isRemote, - queuedShell: resolveLocalWindowsAgentStartupShell({ - platform: resolvedLaunchPlatform, - isRemote, - terminalWindowsShell: store.settings?.terminalWindowsShell - }) + queuedShell: resolveExecutionHostAgentStartupShell( + { ...fact, platform: resolvedLaunchPlatform }, + store.settings?.terminalWindowsShell + ) } } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts index 156b8622ee66..8826ca1ac07c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts @@ -35,7 +35,14 @@ const store = { repos: [] as StoreRepo[], folderWorkspaces: [] as unknown[], projectGroups: [] as unknown[], - sshConnectionStates: new Map(), + sshConnectionStates: new Map([ + ['openclaw', { status: 'connected', remotePlatform: 'linux' }], + ['m4air', { status: 'connected', remotePlatform: 'linux' }] + ]), + sshStateByEnvironment: new Map([ + ['vm-1', { connectionStates: new Map([['nested', { remotePlatform: 'linux' }]]) }] + ]), + runtimeStatusByEnvironmentId: new Map([['vm-1', { status: { hostPlatform: 'linux' } }]]), transientClearedAgentStatusConnectionIds: {} as Record, worktreesByRepo: {} as Record, allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1'] ?? []), diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts index a78947376a81..09bce656be29 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts @@ -36,6 +36,8 @@ const store = { terminalLayoutsByTabId: {}, ptyIdsByTabId: {}, sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + runtimeStatusByEnvironmentId: new Map([['web-runtime', { status: { hostPlatform: 'linux' } }]]), transientClearedAgentStatusConnectionIds: {}, allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), createTab: mocks.createTab, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts index 8704b28ad157..22d9d04b126f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts @@ -38,8 +38,10 @@ const store = { | { kind: 'wsl'; distro: string | null } }[], repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], - sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]), + sshConnectionStates: new Map([['ssh-a', { status: 'connected', remotePlatform: 'linux' }]]), transientClearedAgentStatusConnectionIds: {} as Record, + sshStateByEnvironment: new Map(), + runtimeStatusByEnvironmentId: new Map([['web-runtime', { status: { hostPlatform: 'linux' } }]]), worktreesByRepo: { 'repo-1': [ { @@ -135,7 +137,9 @@ describe('launchAgentInNewTab Windows shell quoting', () => { } ] store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] - store.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) + store.sshConnectionStates = new Map([ + ['ssh-a', { status: 'connected', remotePlatform: 'linux' }] + ]) store.transientClearedAgentStatusConnectionIds = {} store.worktreesByRepo = { 'repo-1': [ @@ -286,6 +290,9 @@ describe('launchAgentInNewTab Windows shell quoting', () => { it('does not use the local Windows shell setting for remote Windows launches', async () => { store.settings.terminalWindowsShell = 'cmd.exe' store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }] + store.sshConnectionStates = new Map([ + ['ssh-1', { status: 'connected', remotePlatform: 'win32' }] + ]) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') launchAgentInNewTab({ requestId: 'request-7', agent: 'claude', worktreeId: 'wt-1' }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index fa5ff2a1aecc..aa2e661143af 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -52,8 +52,13 @@ const store = { | { kind: 'wsl'; distro: string | null } }[], repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], - sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]), + sshConnectionStates: new Map([ + ['ssh-a', { status: 'connected', remotePlatform: 'linux' }], + ['ssh-target-1', { status: 'connected', remotePlatform: 'linux' }] + ]), transientClearedAgentStatusConnectionIds: {} as Record, + sshStateByEnvironment: new Map(), + runtimeStatusByEnvironmentId: new Map([['web-runtime', { status: { hostPlatform: 'linux' } }]]), worktreesByRepo: { 'repo-1': [ { @@ -173,7 +178,10 @@ describe('launchAgentInNewTab', () => { } ] store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] - store.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) + store.sshConnectionStates = new Map([ + ['ssh-a', { status: 'connected', remotePlatform: 'linux' }], + ['ssh-target-1', { status: 'connected', remotePlatform: 'linux' }] + ]) store.transientClearedAgentStatusConnectionIds = {} store.worktreesByRepo = { 'repo-1': [ diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 827469cb7cc0..a1426578be4f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -135,13 +135,11 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent activate } = args const store = useAppStore.getState() - const { resolvedLaunchPlatform, isRemote, queuedShell } = resolveAgentLaunchExecutionContext( - store, - { - worktreeId, - ...(launchPlatform ? { launchPlatform } : {}) - } - ) + const executionContext = resolveAgentLaunchExecutionContext(store, { worktreeId, launchPlatform }) + if (!executionContext) { + return null + } + const { resolvedLaunchPlatform, isRemote, queuedShell } = executionContext const cmdOverrides = store.settings?.agentCmdOverrides ?? {} const effectiveAgentArgs = agentArgs !== undefined diff --git a/src/renderer/src/lib/launch-agent-session-continuation.test.ts b/src/renderer/src/lib/launch-agent-session-continuation.test.ts index 0634680cf520..df00b7635043 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.test.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.test.ts @@ -2,14 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const launchAgentInNewTab = vi.hoisted(() => vi.fn()) const writeClipboardText = vi.hoisted(() => vi.fn(async () => undefined)) -const connectionId = vi.hoisted(() => ({ value: null as string | null })) -const runtimeEnvironmentId = vi.hoisted(() => ({ value: null as string | null })) +const ensureDetectedAgentsForWorktree = vi.hoisted(() => vi.fn()) const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn() })) -const store = vi.hoisted(() => ({ - settings: { disabledTuiAgents: [] as string[] }, - ensureDetectedAgents: vi.fn(async () => ['claude', 'codex']), - ensureRemoteDetectedAgents: vi.fn(async () => ['claude', 'codex']), - ensureRuntimeDetectedAgents: vi.fn(async () => ['claude', 'codex']) +const store: { settings: { disabledTuiAgents: string[] } } = vi.hoisted(() => ({ + settings: { disabledTuiAgents: [] } })) vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) @@ -17,12 +13,7 @@ vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab })) vi.mock('@/lib/agent-catalog', () => ({ getAgentLabel: (agent: string) => (agent === 'codex' ? 'Codex' : 'Claude') })) -vi.mock('@/lib/connection-context', () => ({ - getConnectionIdFromState: () => connectionId.value -})) -vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: () => runtimeEnvironmentId.value -})) +vi.mock('@/lib/agent-detection-target-inventory', () => ({ ensureDetectedAgentsForWorktree })) vi.mock('sonner', () => ({ toast })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => @@ -35,12 +26,8 @@ vi.mock('@/i18n/i18n', () => ({ describe('launchAgentSessionContinuation', () => { beforeEach(() => { vi.clearAllMocks() - connectionId.value = null - runtimeEnvironmentId.value = null store.settings.disabledTuiAgents = [] - store.ensureDetectedAgents.mockResolvedValue(['claude', 'codex']) - store.ensureRemoteDetectedAgents.mockResolvedValue(['claude', 'codex']) - store.ensureRuntimeDetectedAgents.mockResolvedValue(['claude', 'codex']) + ensureDetectedAgentsForWorktree.mockResolvedValue(['claude', 'codex']) launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 'tab-new' }, promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false }) @@ -80,27 +67,16 @@ describe('launchAgentSessionContinuation', () => { ) }) - it('detects the target Agent on the SSH host that owns the workspace', async () => { - connectionId.value = 'ssh-1' + it("detects the target Agent in the workspace host's inventory", async () => { const { detectAgentSessionContinuationAgents } = await import('./launch-agent-session-continuation') await expect(detectAgentSessionContinuationAgents('wt-1')).resolves.toEqual(['claude', 'codex']) - expect(store.ensureRemoteDetectedAgents).toHaveBeenCalledWith('ssh-1') - expect(store.ensureDetectedAgents).not.toHaveBeenCalled() - }) - - it('detects local Agents in the target worktree runtime', async () => { - const { detectAgentSessionContinuationAgents } = - await import('./launch-agent-session-continuation') - - await detectAgentSessionContinuationAgents('wt-1') - - expect(store.ensureDetectedAgents).toHaveBeenCalledWith('wt-1') + expect(ensureDetectedAgentsForWorktree).toHaveBeenCalledWith(store, 'wt-1') }) it('stops before launch when the selected Agent is unavailable', async () => { - store.ensureDetectedAgents.mockResolvedValue(['claude']) + ensureDetectedAgentsForWorktree.mockResolvedValue(['claude']) const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation') await expect( diff --git a/src/renderer/src/lib/launch-agent-session-continuation.ts b/src/renderer/src/lib/launch-agent-session-continuation.ts index 5bac99ec2ccd..080830781f78 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.ts @@ -1,9 +1,8 @@ import { toast } from 'sonner' import { getAgentLabel } from '@/lib/agent-catalog' -import { getConnectionIdFromState } from '@/lib/connection-context' +import { ensureDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import type { LaunchSource } from '../../../shared/telemetry-events' @@ -22,14 +21,7 @@ type LaunchAgentSessionContinuationArgs = { export async function detectAgentSessionContinuationAgents( worktreeId: string ): Promise { - const state = useAppStore.getState() - const connectionId = getConnectionIdFromState(state, worktreeId) - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - return connectionId - ? state.ensureRemoteDetectedAgents(connectionId) - : runtimeEnvironmentId - ? state.ensureRuntimeDetectedAgents(runtimeEnvironmentId, worktreeId) - : state.ensureDetectedAgents(worktreeId) + return ensureDetectedAgentsForWorktree(useAppStore.getState(), worktreeId) } async function ensureAgentAvailable(agent: TuiAgent, worktreeId: string): Promise { diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index c1fcc5b5b074..79c431515da8 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -91,7 +91,12 @@ const store = { }, projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }], repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], - sshConnectionStates: new Map(), + sshConnectionStates: new Map([ + ['ssh-a', { status: 'connected', remotePlatform: 'linux' }], + ['runtime-ssh-a', { status: 'connected', remotePlatform: 'linux' }] + ]), + sshStateByEnvironment: new Map(), + runtimeStatusByEnvironmentId: new Map([['web-runtime', { status: { hostPlatform: 'linux' } }]]), transientClearedAgentStatusConnectionIds: {}, worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', projectId: 'repo-1', path: '/repo/worktree' }] diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 37c3d7db6250..276810168f43 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -95,6 +95,17 @@ describe('editorTabFileAccess', () => { }, 'user-file' ], + [ + 'a read-only link to this computer opened in a server workspace backed by SSH', + { + filePath: '/Users/me/Desktop/review.md', + relativePath: '/Users/me/Desktop/review.md', + worktreeId: 'repo-ssh::/work/project', + runtimeEnvironmentId: null, + readOnly: true + }, + 'user-file' + ], [ 'a project tab, which stays inside its root', { filePath: '/Users/me/project/a.ts', relativePath: 'a.ts', worktreeId: localWorktreeId }, @@ -208,6 +219,7 @@ 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' diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index d93b1d30e845..b3480ecb26b9 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -45,6 +45,13 @@ type EditorTabAccessFile = Pick< | 'liveTail' > +/** 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 +): boolean { + return file.readOnly === true && (file.liveTail === true || file.relativePath === file.filePath) +} + /** * The file access a persisted editor tab reads and saves with. A tab the user opened outside its owner's * root (a floating-workspace tab, or one stored with an absolute path) is user-named, so it reads @@ -54,8 +61,8 @@ export function editorTabFileAccess( state: Pick, file: EditorTabAccessFile ): LocalFileAccess | undefined { - // Why: AI Vault logs are client-local files the user opened, whatever the worktree's host. - if (file.readOnly === true && file.liveTail === true) { + // 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) diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 3bce56d5340d..cc5627de5871 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import type { AppState } from '@/store/types' @@ -375,18 +375,25 @@ describe('local preflight context', () => { expect(localPreflightContextKey(context)).toBe('local-project:wsl:Ubuntu') }) - it('does not use the global runtime default for active SSH projects', () => { - const state = { + // Why (#18837): the Windows host is neither this machine's default nor the SSH host's context. + it('uses the global runtime default for this machine while an SSH project is active', () => { + const state: AppState = { ...makeState({ repoPath: '/home/alice/repo', + worktreePath: '/home/alice/repo', repo: { connectionId: 'builder', executionHostId: 'ssh:builder' } }), settings: { + ...getDefaultSettings('/home/alice'), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } + const noProjectState = { ...state, activeRepoId: null, activeWorktreeId: null } - expect(getLocalAgentPreflightContext(state, 'win32')).toBeUndefined() + const context = getLocalAgentPreflightContext(state, 'win32') + + expect(localPreflightContextKey(context)).toBe('local-project:wsl:Ubuntu') + expect(context).toEqual(getLocalAgentPreflightContext(noProjectState, 'win32')) }) it('uses the project override over legacy agent location for local agent checks', () => { diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index e508879aafee..2eb0d9133dfe 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -200,8 +200,11 @@ export function getLocalAgentPreflightContext( // Why: Settings -> Agents is global and can mount before any project is // active; still respect the Windows/WSL runtime default for PATH detection. + // An active workspace on another host says nothing about this machine's context (#18837). const globalRuntime = getGlobalWindowsExecutionRuntimeContext( - state, + !worktreeId && isActiveWorkspaceOnAnotherHost(state) + ? { ...state, activeRepoId: null, activeWorktreeId: null } + : state, worktreeId, appPlatform, wslContext @@ -256,6 +259,12 @@ export function getLocalAgentPreflightContext( return undefined } +function isActiveWorkspaceOnAnotherHost(state: AppState): boolean { + const activeWorktree = getLocalProjectRuntimeWorkspace(state, undefined) + const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree) + return Boolean(repo) && (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) +} + function getCachedLocalProjectRuntimeWslContext(): LocalProjectRuntimeWslContext { // Why: preflight selectors are synchronous. Reuse an existing capability // answer when available without spawning WSL probes from store reads. diff --git a/src/renderer/src/lib/paired-web-client-host.test.ts b/src/renderer/src/lib/paired-web-client-host.test.ts new file mode 100644 index 000000000000..08254e9dc7be --- /dev/null +++ b/src/renderer/src/lib/paired-web-client-host.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from './worktree-runtime-owner' +import { resolveWorktreeOperationRoute } from './worktree-operation-route' +import { resolveTerminalHostOwnership } from './terminal-worktree-route' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' + +const ENV = 'web-env' +const ENV_HOST = `runtime:${ENV}` as const +const WT = 'repo-1::/srv/proj' + +function webState(overrides: WorktreeRuntimeOwnerState = {}): WorktreeRuntimeOwnerState { + return { + settings: { activeRuntimeEnvironmentId: null }, + runtimeEnvironments: [{ id: ENV }], + runtimeEnvironmentCatalogHydrated: true, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: ENV_HOST }], + worktreesByRepo: { + 'repo-1': [{ id: WT, repoId: 'repo-1', hostId: ENV_HOST, runtimeOwnerEnvironmentId: ENV }] + }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + ...overrides + } +} + +const UNSTAMPED_LOCAL_ROW: WorktreeRuntimeOwnerState = { + worktreesByRepo: { 'repo-1': [{ id: WT, repoId: 'repo-1', hostId: 'local' }] } +} + +describe('a paired web client routes every owner through its server (#9047)', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('routes a workspace the server calls local to the server, never to the browser', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + for (const state of [ + webState(), + webState({ activeWorktreeId: WT, activeWorkspaceExecutionHostId: 'local' }), + webState(UNSTAMPED_LOCAL_ROW) + ]) { + expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBe(ENV) + expect(resolveTerminalHostOwnership(state, WT, 'spawn')).toEqual({ + kind: 'runtime', + runtimeEnvironmentId: ENV + }) + expect(resolveWorktreeOperationRoute(state, WT)).toEqual({ + executionHostId: ENV_HOST, + runtimeEnvironmentId: ENV + }) + } + }) + + it('starts setup terminals and local folder workspaces on the server', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + expect( + resolveTerminalHostOwnership(webState(), 'ephemeral-setup-terminal:panel-1', 'spawn') + ).toEqual({ kind: 'runtime', runtimeEnvironmentId: ENV }) + const folderState = webState({ + folderWorkspaces: [ + { id: 'folder-1', projectGroupId: 'group-1', executionHostId: 'local', connectionId: null } + ] + }) + expect(getRuntimeEnvironmentIdForWorktree(folderState, folderWorkspaceKey('folder-1'))).toBe( + ENV + ) + }) + + it('keeps an SSH target as the place and the server as the transport', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + const state = webState({ activeWorktreeId: WT, activeWorkspaceExecutionHostId: 'ssh:target-1' }) + expect(resolveWorktreeOperationRoute(state, WT)).toEqual({ + executionHostId: 'ssh:target-1', + runtimeEnvironmentId: ENV + }) + }) + + it('still refuses an id two hosts publish instead of guessing one', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + const state = webState({ + worktreesByRepo: { + 'repo-1': [ + { id: WT, repoId: 'repo-1', hostId: 'local' }, + { id: WT, repoId: 'repo-1', hostId: 'ssh:target-1' } + ] + } + }) + expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBeNull() + }) + + it('leaves a desktop window local workspace on this computer', () => { + const state = webState(UNSTAMPED_LOCAL_ROW) + expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBeNull() + expect(resolveTerminalHostOwnership(state, WT, 'spawn').kind).toBe('local-or-ssh') + }) +}) diff --git a/src/renderer/src/lib/paired-web-client-host.ts b/src/renderer/src/lib/paired-web-client-host.ts new file mode 100644 index 000000000000..58e0e79e9ece --- /dev/null +++ b/src/renderer/src/lib/paired-web-client-host.ts @@ -0,0 +1,37 @@ +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { isPairedWebClientWindow } from './desktop-window-chrome' + +type HostRouteState = { runtimeEnvironments?: readonly { id: string }[] } + +/** + * The paired server is a web client's only host: a browser tab has no PTYs, files or browser of its + * own, so an owner this client would call "local" can only be reached through that server. + */ +export function getPairedWebClientEnvironmentId(state: HostRouteState): string | null { + if (!isPairedWebClientWindow()) { + return null + } + const environments = state.runtimeEnvironments ?? [] + return environments.length === 1 ? environments[0].id.trim() || null : null +} + +/** A route with no server, on a web client, goes through its server; `local` there is the server's own. */ +export function onPairedWebClientHost< + T extends { executionHostId: ExecutionHostId | null; runtimeEnvironmentId: string | null } +>(state: HostRouteState, route: T): T { + const environmentId = route.runtimeEnvironmentId ? null : getPairedWebClientEnvironmentId(state) + const host = parseExecutionHostId(route.executionHostId) + // Why: an unparseable host is the never-routable sentinel; it stays refused. + if (!environmentId || (route.executionHostId && !host)) { + return route + } + return { + ...route, + executionHostId: host?.kind === 'ssh' ? host.id : toRuntimeExecutionHostId(environmentId), + runtimeEnvironmentId: environmentId + } +} diff --git a/src/renderer/src/lib/palette-match/match-quality.ts b/src/renderer/src/lib/palette-match/match-quality.ts index d400489b6bee..62bed7be116d 100644 --- a/src/renderer/src/lib/palette-match/match-quality.ts +++ b/src/renderer/src/lib/palette-match/match-quality.ts @@ -12,14 +12,6 @@ export const PALETTE_MATCH_QUALITIES = [ export type PaletteMatchQuality = (typeof PALETTE_MATCH_QUALITIES)[number] -const QUALITY_RANK = new Map( - PALETTE_MATCH_QUALITIES.map((quality, index) => [quality, index]) -) - -export function paletteMatchQualityRank(quality: PaletteMatchQuality): number { - return QUALITY_RANK.get(quality) ?? PALETTE_MATCH_QUALITIES.length -} - export function isFuzzyPaletteMatchQuality(quality: PaletteMatchQuality): boolean { return quality === 'typo' } diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 5eb2113454e9..a8326eb0e758 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -1,10 +1,8 @@ import { - IDLE, collectTabPaneInputs, resolveAttention, type SmartClass, - type TabPaneInputSources, - type WorktreeAttention + type TabPaneInputSources } from '@/components/sidebar/smart-attention' import { tabHasLivePty } from './tab-has-live-pty' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' @@ -49,20 +47,6 @@ const STATUS_BY_ATTENTION_CLASS: Record = { 5: null } -export function resolveRecentWorkspaceTabAttention( - row: RecentWorkspaceTabRow, - paneSources: TabPaneInputSources, - now: number -): WorktreeAttention { - if (!row.terminalTab) { - return IDLE - } - return resolveAttention( - collectTabPaneInputs(row.terminalTab, row.worktreeLastActivityAt, paneSources, now), - now - ) -} - /** Live status dot for a hero row — re-read on agent churn, unlike the frozen ordering. */ export function resolveRecentWorkspaceTabStatus( row: RecentWorkspaceTabRow, diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 81930d72a331..49e9e7239146 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -66,6 +66,20 @@ export function getExplicitRuntimeOwnerEnvironmentId( return parsed?.kind === 'runtime' ? parsed.environmentId : null } +// Why: a source without a runtime host inherits a repo's runtime owner only when exactly one +// row has that id; duplicate ids across hosts fail closed to the source's own host (#7623). +export function getHostlessSourceRepoOwnerEnvironmentId( + repos: RepoRuntimeOwnerState['repos'], + repoId: string | null | undefined +): string | null { + const matches = repoId ? (repos?.filter((entry) => entry.id === repoId) ?? []) : [] + if (matches.length !== 1) { + return null + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(matches[0])) + return parsed?.kind === 'runtime' ? parsed.environmentId : null +} + type OwnerRoutedSettingsCacheEntry = { settingsSource: RepoRuntimeOwnerState['settings'] reposSource: RepoRuntimeOwnerState['repos'] diff --git a/src/renderer/src/lib/resolve-owner.test.ts b/src/renderer/src/lib/resolve-owner.test.ts new file mode 100644 index 000000000000..dab7faced3db --- /dev/null +++ b/src/renderer/src/lib/resolve-owner.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { HostAuthority } from '../../../shared/host-authority' +import type { ExecutionHostOwnerMatch } from '../../../shared/worktree-execution-host-resolution' +import { + getLegacyFocusFallbackCount, + resetLegacyFocusFallbackCountForTest, + resolveOwner, + resolveOwnerWithLegacyFocus, + type WorkspaceOwnerRef +} from './resolve-owner' +import type { + WorktreeOperationOwnerRecord, + WorktreeOperationRouteState +} from './worktree-operation-route' + +const WORKTREE_ID = 'repo-1::/srv/w' + +type Match = ExecutionHostOwnerMatch +const missing: Match = { kind: 'missing' } +const ambiguous: Match = { kind: 'ambiguous' } +const selfAt = (at: HostAuthority['at']): Match => ({ + kind: 'resolved', + owner: { endpoint: { kind: 'self' }, at } +}) +const envAt = (environmentId: string, at: HostAuthority['at'] = 'local'): Match => ({ + kind: 'resolved', + owner: { endpoint: { kind: 'environment', environmentId }, at } +}) + +function worktree( + hostId?: WorktreeOperationOwnerRecord['hostId'], + runtimeOwnerEnvironmentId?: string +): WorktreeOperationOwnerRecord { + return { id: WORKTREE_ID, repoId: 'repo-1', hostId, runtimeOwnerEnvironmentId } +} + +function folder(executionHostId: `runtime:${string}` | 'local' | null, connectionId?: string) { + return { + id: 'f1', + projectGroupId: 'g1', + connectionId: connectionId ?? null, + executionHostId, + diffComments: [] + } +} + +type ActiveServer = 'local' | 'other' | 'deleted' +const ACTIVE_SERVERS: readonly ActiveServer[] = ['local', 'other', 'deleted'] +const FOCUS: Record = { + local: null, + other: 'env-other', + deleted: 'env-gone' +} +const SAVED_SERVERS = ['env-a', 'env-b', 'env-orcad', 'env-other'] + +type Row = { + name: string + rows: WorktreeOperationRouteState + ref: WorkspaceOwnerRef + owner: Match + /** Today's focus-reading answer where it differs from `owner`. */ + legacy?: Partial> + savedServers?: string[] +} + +const ROWS: Row[] = [ + { + name: 'local, stamped', + rows: { repos: [{ id: 'repo-1' }], worktreesByRepo: { 'repo-1': [worktree('local')] } }, + ref: { workspaceId: WORKTREE_ID }, + owner: selfAt('local') + }, + { + name: 'local, unstamped legacy row', + rows: { repos: [{ id: 'repo-1' }], worktreesByRepo: { 'repo-1': [worktree()] } }, + ref: { workspaceId: WORKTREE_ID }, + owner: selfAt('local'), + legacy: { other: missing, deleted: missing } + }, + { + name: 'local, unstamped legacy row, one saved server', + rows: { repos: [{ id: 'repo-1' }], worktreesByRepo: { 'repo-1': [worktree()] } }, + ref: { workspaceId: WORKTREE_ID }, + owner: selfAt('local'), + savedServers: ['env-other'], + legacy: { other: envAt('env-other'), deleted: missing } + }, + { + name: 'direct SSH', + rows: { + repos: [{ id: 'repo-1', connectionId: 'box' }], + worktreesByRepo: { 'repo-1': [worktree('ssh:box')] } + }, + ref: { workspaceId: WORKTREE_ID }, + owner: selfAt('ssh:box') + }, + { + name: 'managed server on an SSH host keeps its own id', + rows: { + repos: [{ id: 'repo-1', executionHostId: 'runtime:env-orcad' }], + worktreesByRepo: { 'repo-1': [worktree('runtime:env-orcad', 'env-orcad')] } + }, + ref: { workspaceId: WORKTREE_ID }, + owner: envAt('env-orcad') + }, + { + name: 'paired server', + rows: { + repos: [{ id: 'repo-1', executionHostId: 'runtime:env-a' }], + worktreesByRepo: { 'repo-1': [worktree('runtime:env-a', 'env-a')] } + }, + ref: { workspaceId: WORKTREE_ID }, + owner: envAt('env-a') + }, + { + name: 'SSH target behind a paired server', + rows: { worktreesByRepo: { 'repo-1': [worktree('ssh:t', 'env-a')] } }, + ref: { workspaceId: WORKTREE_ID }, + owner: envAt('env-a', 'ssh:t') + }, + { + name: 'SSH target behind a paired server, row names the host', + rows: { worktreesByRepo: { 'repo-1': [worktree('ssh:t', 'env-a')] } }, + ref: { workspaceId: WORKTREE_ID, hostId: 'ssh:t' }, + owner: envAt('env-a', 'ssh:t') + }, + { + name: 'SSH target behind a paired server, known only from its repo', + rows: { + repos: [{ id: 'repo-1', executionHostId: 'runtime:env-a', connectionId: 't' }], + worktreesByRepo: { 'repo-1': [worktree('runtime:env-a', 'env-a')] } + }, + ref: { workspaceId: WORKTREE_ID }, + owner: envAt('env-a', 'ssh:t') + }, + { + name: 'same ssh:t behind two servers', + rows: { + worktreesByRepo: { 'repo-1': [worktree('ssh:t', 'env-a'), worktree('ssh:t', 'env-b')] } + }, + ref: { workspaceId: WORKTREE_ID }, + owner: ambiguous + }, + { + name: 'same ssh:t behind two servers, row names the host', + rows: { + worktreesByRepo: { 'repo-1': [worktree('ssh:t', 'env-a'), worktree('ssh:t', 'env-b')] } + }, + ref: { workspaceId: WORKTREE_ID, hostId: 'ssh:t' }, + owner: ambiguous + }, + { + name: 'same ssh:t behind two servers, selected as the active workspace', + rows: { + worktreesByRepo: { 'repo-1': [worktree('ssh:t', 'env-a'), worktree('ssh:t', 'env-b')] }, + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:t' + }, + ref: { workspaceId: WORKTREE_ID }, + owner: ambiguous + }, + { + name: 'recipe VM SSH target', + rows: { + repos: [{ id: 'repo-1', connectionId: 'runtime-ssh-vm1' }], + worktreesByRepo: { 'repo-1': [worktree('ssh:runtime-ssh-vm1')] } + }, + ref: { workspaceId: WORKTREE_ID }, + owner: selfAt('ssh:runtime-ssh-vm1') + }, + { + name: 'floating workspace', + rows: {}, + ref: { workspaceId: FLOATING_TERMINAL_WORKTREE_ID }, + owner: selfAt('local') + }, + { + name: 'folder, local', + rows: { folderWorkspaces: [folder('local')] }, + ref: { workspaceId: 'folder:f1' }, + owner: selfAt('local') + }, + { + name: 'folder, paired server', + rows: { folderWorkspaces: [folder('runtime:env-a')] }, + ref: { workspaceId: 'folder:f1' }, + owner: envAt('env-a') + }, + { + name: 'folder, SSH target behind a paired server', + rows: { folderWorkspaces: [folder('runtime:env-a', 't')] }, + ref: { workspaceId: 'folder:f1' }, + owner: envAt('env-a', 'ssh:t') + }, + { + name: 'folder, SSH target behind a paired server, stamped on its group', + rows: { + folderWorkspaces: [folder(null)], + projectGroups: [{ id: 'g1', connectionId: 't', executionHostId: 'runtime:env-a' }] + }, + ref: { workspaceId: 'folder:f1' }, + owner: envAt('env-a', 'ssh:t') + }, + { + name: 'folder, unstamped, one saved server', + rows: { folderWorkspaces: [folder(null)] }, + ref: { workspaceId: 'folder:f1' }, + owner: selfAt('local'), + savedServers: ['env-other'], + legacy: { other: envAt('env-other') } + }, + { + name: 'unknown id', + rows: {}, + ref: { workspaceId: WORKTREE_ID }, + owner: missing + } +] + +function withActiveServer(row: Row, active: ActiveServer): WorktreeOperationRouteState { + return { + ...row.rows, + settings: { activeRuntimeEnvironmentId: FOCUS[active] }, + runtimeEnvironments: (row.savedServers ?? SAVED_SERVERS).map((id) => ({ id })), + runtimeEnvironmentCatalogHydrated: true + } +} + +describe('resolveOwner characterization', () => { + beforeEach(() => resetLegacyFocusFallbackCountForTest()) + + describe.each(ROWS)('$name', (row) => { + it.each(ACTIVE_SERVERS)('Active Server = %s', (active) => { + const state = withActiveServer(row, active) + expect(resolveOwner(state, row.ref)).toEqual(row.owner) + + const legacy = row.legacy?.[active] + expect(resolveOwnerWithLegacyFocus(state, row.ref)).toEqual(legacy ?? row.owner) + expect(getLegacyFocusFallbackCount()).toBe(legacy ? 1 : 0) + }) + }) +}) diff --git a/src/renderer/src/lib/resolve-owner.ts b/src/renderer/src/lib/resolve-owner.ts new file mode 100644 index 000000000000..6462f79ac896 --- /dev/null +++ b/src/renderer/src/lib/resolve-owner.ts @@ -0,0 +1,142 @@ +/** + * The owner of a workspace as a {@link HostAuthority}, decided from the rows alone. It never reads + * the "Active Server" focus setting; the one transitional path that does is + * {@link resolveOwnerWithLegacyFocus}, which counts every answer focus changed. + */ +import { + getRepoSshConnectionId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { findIndexedRepoOwnerForHost } from './worktree-runtime-owner-index' +import { + hostAuthorityFromOperationRoute, + SELF_LOCAL_AUTHORITY, + type HostAuthority +} from '../../../shared/host-authority' +import type { ExecutionHostOwnerMatch } from '../../../shared/worktree-execution-host-resolution' +import { + getFloatingWorkspaceOperationRoute, + resolveStrictWorktreeOperationRouteResultForHost, + resolveWorktreeOperationRouteResult, + type WorktreeOperationRouteResolution, + type WorktreeOperationRouteState +} from './worktree-operation-route' +import { getNestedSshTargetIdForFolderWorkspace } from './folder-workspace-runtime-owner' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' + +/** A worktree id, a `folder:` workspace key, or the floating workspace; `hostId` when the row names one. */ +export type WorkspaceOwnerRef = { workspaceId: string; hostId?: ExecutionHostId } + +function toOwnerMatch( + resolution: WorktreeOperationRouteResolution +): ExecutionHostOwnerMatch { + if (resolution.kind !== 'resolved') { + return resolution + } + const authority = hostAuthorityFromOperationRoute(resolution.route) + if (authority === 'contradictory') { + return { kind: 'ambiguous' } + } + return authority ? { kind: 'resolved', owner: authority } : { kind: 'missing' } +} + +function resolveRoute( + state: WorktreeOperationRouteState, + ref: WorkspaceOwnerRef +): ExecutionHostOwnerMatch { + if (getFloatingWorkspaceOperationRoute(ref.workspaceId)) { + return { kind: 'resolved', owner: SELF_LOCAL_AUTHORITY } + } + const hostId = + ref.hostId ?? + (state.activeWorktreeId === ref.workspaceId ? state.activeWorkspaceExecutionHostId : null) + // Why strict: rival servers proxying the same `ssh:t` must not read as a directly dialed target. + const match = toOwnerMatch( + hostId + ? resolveStrictWorktreeOperationRouteResultForHost(state, ref.workspaceId, hostId) + : resolveWorktreeOperationRouteResult(state, ref.workspaceId) + ) + return match.kind === 'resolved' + ? { kind: 'resolved', owner: withNestedPlace(state, ref, match.owner) } + : match +} + +/** + * A route names only the server; the owning folder or repo row's `connectionId` is the SSH target + * behind it, the same reading `resolveWorktreeExecutionHost` applies. + */ +function withNestedPlace( + state: WorktreeOperationRouteState, + ref: WorkspaceOwnerRef, + authority: HostAuthority +): HostAuthority { + if (authority.endpoint.kind !== 'environment' || authority.at !== 'local') { + return authority + } + const scope = parseWorkspaceKey(ref.workspaceId) + const targetId = + scope?.type === 'folder' + ? getNestedSshTargetIdForFolderWorkspace(state, scope.folderWorkspaceId, ref.hostId) + : nestedRepoTargetId(state, ref.workspaceId, authority.endpoint.environmentId) + return targetId ? { ...authority, at: toSshExecutionHostId(targetId) } : authority +} + +function nestedRepoTargetId( + state: WorktreeOperationRouteState, + worktreeId: string, + environmentId: string +): string | null { + const repo = findIndexedRepoOwnerForHost( + state.repos, + getRepoIdFromWorktreeId(worktreeId), + toRuntimeExecutionHostId(environmentId) + ) + return repo ? getRepoSshConnectionId(repo) : null +} + +export function resolveOwner( + state: WorktreeOperationRouteState, + ref: WorkspaceOwnerRef +): ExecutionHostOwnerMatch { + // Why: focus is the only settings field the route resolvers read; blanking it makes this focus-free. + return resolveRoute({ ...state, settings: null }, ref) +} + +let legacyFocusFallbackCount = 0 + +/** How many answers focus has changed since start; a slice drops its fallback once this stays 0. */ +export function getLegacyFocusFallbackCount(): number { + return legacyFocusFallbackCount +} + +export function resetLegacyFocusFallbackCountForTest(): void { + legacyFocusFallbackCount = 0 +} + +function sameOwnerMatch( + a: ExecutionHostOwnerMatch, + b: ExecutionHostOwnerMatch +): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + +/** + * {@link resolveOwner} plus today's focus fallback, which lets a single focused server claim rows + * that predate owner stamping. Transitional: callers move to {@link resolveOwner} slice by slice. + */ +export function resolveOwnerWithLegacyFocus( + state: WorktreeOperationRouteState, + ref: WorkspaceOwnerRef +): ExecutionHostOwnerMatch { + const legacy = resolveRoute(state, ref) + if ( + state.settings?.activeRuntimeEnvironmentId?.trim() && + !sameOwnerMatch(legacy, resolveOwner(state, ref)) + ) { + legacyFocusFallbackCount += 1 + } + return legacy +} diff --git a/src/renderer/src/lib/single-runtime-legacy-owner.ts b/src/renderer/src/lib/single-runtime-legacy-owner.ts index 00b39751b1b2..381d8a922779 100644 --- a/src/renderer/src/lib/single-runtime-legacy-owner.ts +++ b/src/renderer/src/lib/single-runtime-legacy-owner.ts @@ -1,3 +1,5 @@ +import { getPairedWebClientEnvironmentId } from './paired-web-client-host' + export type SingleRuntimeLegacyOwnerState = { settings?: { activeRuntimeEnvironmentId?: string | null } | null runtimeEnvironments?: readonly { id: string }[] @@ -6,6 +8,10 @@ export type SingleRuntimeLegacyOwnerState = { export function getSingleFocusedRuntimeEnvironmentId( state: SingleRuntimeLegacyOwnerState ): string | null { + const webHost = getPairedWebClientEnvironmentId(state) + if (webHost) { + return webHost + } const focused = state.settings?.activeRuntimeEnvironmentId?.trim() if (!focused) { return null diff --git a/src/renderer/src/lib/terminal-tab-owner-activation.test.ts b/src/renderer/src/lib/terminal-tab-owner-activation.test.ts new file mode 100644 index 000000000000..2dca85c14965 --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-owner-activation.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + activateWebRuntimeSessionTab: vi.fn(), + getRuntimeEnvironmentIdForWorktree: vi.fn(), + resolveWebSessionVisibleTabId: vi.fn() +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: mocks.getRuntimeEnvironmentIdForWorktree +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab, + isWebRuntimeSessionActive: (environmentId: string | null | undefined) => + Boolean(environmentId?.trim()) +})) + +vi.mock('@/runtime/web-session-focus-intent', () => ({ + resolveWebSessionVisibleTabId: mocks.resolveWebSessionVisibleTabId +})) + +import { activateTerminalTabOnOwner } from './terminal-tab-owner-activation' + +describe('activateTerminalTabOnOwner', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('tells the paired server that owns the workspace, not the focused one', () => { + mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue('env-owner') + mocks.resolveWebSessionVisibleTabId.mockReturnValue('unified-visible') + + activateTerminalTabOnOwner('wt-1', 'web-terminal-host-tab', 'leaf-b') + + expect(mocks.getRuntimeEnvironmentIdForWorktree).toHaveBeenCalledWith({}, 'wt-1') + expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab', + environmentId: 'env-owner', + leafId: 'leaf-b', + // Why: a later navigation away from this tab must cancel the deferred focus. + expectedCurrentLocalTabId: 'unified-visible' + }) + }) + + it('makes no owner round trip for a workspace this client runs', () => { + mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null) + + activateTerminalTabOnOwner('wt-local', 'tab-1', null) + + expect(mocks.activateWebRuntimeSessionTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/terminal-tab-owner-activation.ts b/src/renderer/src/lib/terminal-tab-owner-activation.ts new file mode 100644 index 000000000000..3682aca1bcbe --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-owner-activation.ts @@ -0,0 +1,33 @@ +import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + activateWebRuntimeSessionTab, + isWebRuntimeSessionActive +} from '@/runtime/web-runtime-session' +import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent' + +/** + * Tells the paired server that owns `worktreeId` which tab and pane this client revealed, as a + * tab-strip click does: the owner stores it as this client's selection and wakes a parked pane, + * and a tab that has not reached this renderer yet is focused when it arrives. + * Call after the local activation; navigating away before the tab arrives cancels that focus. + * A workspace this client runs itself (local or SSH) needs no owner round trip. + */ +export function activateTerminalTabOnOwner( + worktreeId: string, + tabId: string, + leafId: string | null +): void { + const state = useAppStore.getState() + const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (!environmentId || !isWebRuntimeSessionActive(environmentId)) { + return + } + void activateWebRuntimeSessionTab({ + worktreeId, + tabId, + environmentId, + leafId, + expectedCurrentLocalTabId: resolveWebSessionVisibleTabId(state, worktreeId) + }) +} diff --git a/src/renderer/src/lib/unstamped-non-local-row-counter.ts b/src/renderer/src/lib/unstamped-non-local-row-counter.ts new file mode 100644 index 000000000000..2fca51b2559f --- /dev/null +++ b/src/renderer/src/lib/unstamped-non-local-row-counter.ts @@ -0,0 +1,18 @@ +/** + * Dev counter for rows that reached the store without an owner stamp yet were attributed to a + * non-local host by inference. Every ingest path stamps through `adoptFromEndpoint`, so this + * should stay 0; a reader that has to guess a non-local owner reports it here. + */ +let unstampedNonLocalRowCount = 0 + +export function noteUnstampedNonLocalRow(): void { + unstampedNonLocalRowCount += 1 +} + +export function getUnstampedNonLocalRowCount(): number { + return unstampedNonLocalRowCount +} + +export function resetUnstampedNonLocalRowCountForTest(): void { + unstampedNonLocalRowCount = 0 +} diff --git a/src/renderer/src/lib/workspace-file-host-routing.ts b/src/renderer/src/lib/workspace-file-host-routing.ts index 7f1f29d82e72..16350e4880c9 100644 --- a/src/renderer/src/lib/workspace-file-host-routing.ts +++ b/src/renderer/src/lib/workspace-file-host-routing.ts @@ -1,8 +1,6 @@ import { getConnectionId, getConnectionIdForFile } from '@/lib/connection-context' -import { - isRemoteRuntimeFileOperation, - type RuntimeFileOperationArgs -} from '@/runtime/runtime-file-client' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { hasRemoteRuntimeOwner } from '@/runtime/runtime-file-routing' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' @@ -39,12 +37,10 @@ export function buildWorkspaceFileContextForFile( } /** - * Whether the client's OS can launch this path at all. False for anything an SSH connection or a - * runtime environment owns — those have to be downloaded before the OS has a file to open. + * Whether the client's OS can launch the workspace's paths at all. False for anything an SSH + * connection or a runtime environment owns — those have to be downloaded before the OS has a file + * to open. Decided by owner, not path: a server path outside its workspace is still the server's. */ -export function canClientOsOpenWorkspaceFile( - fileContext: RuntimeFileOperationArgs, - filePath: string -): boolean { - return !fileContext.connectionId && !isRemoteRuntimeFileOperation(fileContext, filePath) +export function canClientOsOpenWorkspaceFile(fileContext: RuntimeFileOperationArgs): boolean { + return !fileContext.connectionId && !hasRemoteRuntimeOwner(fileContext) } diff --git a/src/renderer/src/lib/workspace-port-host-scoped-scan.test.ts b/src/renderer/src/lib/workspace-port-host-scoped-scan.test.ts new file mode 100644 index 000000000000..d25eb023da6e --- /dev/null +++ b/src/renderer/src/lib/workspace-port-host-scoped-scan.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { callRuntimeRpc, supportsCapability } = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + supportsCapability: vi.fn() +})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + callRuntimeRpc, + runtimeEnvironmentSupportsCapability: supportsCapability +})) + +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { + killWorkspacePortOnExecutionHost, + scanWorkspacePortsOnExecutionHost, + WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON, + type HostScopedPortHost +} from './workspace-port-scan-client' +import { WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' + +const host: HostScopedPortHost = { + route: { target: { kind: 'environment', environmentId: 'env-e' }, at: 'ssh:t' }, + executionHostId: 'ssh:t', + worktreeId: 'repo-1::/srv/w' +} + +const rowOnT = { + id: '0.0.0.0:3020:4242', + kind: 'external', + bindHost: '0.0.0.0', + connectHost: '127.0.0.1', + port: 3020, + pid: 4242, + protocol: 'unknown' +} + +beforeEach(() => { + callRuntimeRpc.mockReset() + supportsCapability.mockReset() + supportsCapability.mockResolvedValue(true) +}) + +describe('scanWorkspacePortsOnExecutionHost', () => { + it('asks the server to scan the workspace host and keeps the host it names', async () => { + callRuntimeRpc.mockResolvedValue({ + executionHostId: 'ssh:t', + platform: 'linux', + scannedAt: 1, + ports: [rowOnT] + }) + + const scan = await scanWorkspacePortsOnExecutionHost(host) + + expect(supportsCapability).toHaveBeenCalledWith( + 'env-e', + WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY + ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + host.route.target, + 'workspacePorts.scanHost', + { worktree: 'id:repo-1::/srv/w' }, + expect.objectContaining({ timeoutMs: 15_000 }) + ) + expect(scan).toMatchObject({ executionHostId: 'ssh:t', ports: [rowOnT] }) + expect(scan.unavailableReason).toBeUndefined() + }) + + it('never asks an older server, whose scan would show the server instead of its SSH host', async () => { + supportsCapability.mockResolvedValue(false) + + const scan = await scanWorkspacePortsOnExecutionHost(host) + + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(scan).toMatchObject({ + executionHostId: 'ssh:t', + ports: [], + unavailableReason: WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON + }) + }) + + it('says "update server" when the server lacks the method despite advertising it', async () => { + callRuntimeRpc.mockRejectedValue( + new RuntimeRpcCallError({ + id: 'r', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-e' } + }) + ) + + await expect(scanWorkspacePortsOnExecutionHost(host)).resolves.toMatchObject({ + ports: [], + unavailableReason: WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON + }) + }) + + it("scans a recipe VM through this app's own runtime without a capability probe", async () => { + const recipeVm: HostScopedPortHost = { + route: { target: { kind: 'local' }, at: 'ssh:runtime-ssh-vm1' }, + executionHostId: 'ssh:runtime-ssh-vm1', + worktreeId: 'repo-1::/srv/w' + } + callRuntimeRpc.mockResolvedValue({ + executionHostId: 'ssh:runtime-ssh-vm1', + platform: 'unknown', + scannedAt: 1, + ports: [], + unavailableReason: 'The SSH host is not connected.' + }) + + const scan = await scanWorkspacePortsOnExecutionHost(recipeVm) + + expect(supportsCapability).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'workspacePorts.scanHost', + { worktree: 'id:repo-1::/srv/w' }, + expect.anything() + ) + // An unreachable VM stays "could not look", never an empty "no ports". + expect(scan.unavailableReason).toBe('The SSH host is not connected.') + }) + + it("drops rows the server scanned on a host other than the workspace's", async () => { + callRuntimeRpc.mockResolvedValue({ + executionHostId: 'local', + platform: 'linux', + scannedAt: 1, + ports: [rowOnT] + }) + + const scan = await scanWorkspacePortsOnExecutionHost(host) + + expect(scan.ports).toEqual([]) + expect(scan.unavailableReason).toMatch(/different host/) + }) +}) + +describe('killWorkspacePortOnExecutionHost', () => { + it('stops a row only through the server, naming the host the row was scanned on', async () => { + callRuntimeRpc.mockResolvedValue({ ok: true }) + + await expect( + killWorkspacePortOnExecutionHost(host, { scannedHostId: 'ssh:t', pid: 4242, port: 3020 }) + ).resolves.toEqual({ ok: true }) + + // Same pid and port may be listening on the server itself; the old repo-scoped kill would + // signal that one, so it must never be used for a row from the SSH host. + expect(callRuntimeRpc).toHaveBeenCalledOnce() + expect(callRuntimeRpc).toHaveBeenCalledWith( + host.route.target, + 'workspacePorts.killHost', + { worktree: 'id:repo-1::/srv/w', executionHostId: 'ssh:t', pid: 4242, port: 3020 }, + expect.objectContaining({ timeoutMs: 15_000 }) + ) + }) + + it('reports "update server" instead of falling back to the server-local kill', async () => { + callRuntimeRpc.mockRejectedValue( + new RuntimeRpcCallError({ + id: 'r', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-e' } + }) + ) + + await expect( + killWorkspacePortOnExecutionHost(host, { scannedHostId: 'ssh:t', pid: 4242, port: 3020 }) + ).resolves.toEqual({ ok: false, reason: WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON }) + expect(callRuntimeRpc).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/workspace-port-scan-client.ts b/src/renderer/src/lib/workspace-port-scan-client.ts index 0cfb6715b964..8d9f4a4b6196 100644 --- a/src/renderer/src/lib/workspace-port-scan-client.ts +++ b/src/renderer/src/lib/workspace-port-scan-client.ts @@ -1,9 +1,24 @@ import { callRuntimeRpc, + runtimeEnvironmentSupportsCapability, RuntimeRpcCallError, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/workspace-ports' +import { callHostRoute } from '@/runtime/host-route-call' +import type { HostRoute } from '@/runtime/runtime-client-target' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import type { + WorkspacePort, + WorkspacePortHostScanResult, + WorkspacePortKillResult, + WorkspacePortScanResult +} from '../../../shared/workspace-ports' +import { normalizeExecutionHostId } from '../../../shared/execution-host' +import { WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' + +export const WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON = + 'Update the server to see ports on its SSH hosts.' +const WORKSPACE_PORT_RPC_TIMEOUT_MS = 15_000 const WORKSPACE_PORT_PLATFORMS = new Set([ 'aix', @@ -96,9 +111,7 @@ export async function runWorkspacePortScanForTarget( target, 'workspacePorts.scan', params, - { - timeoutMs: 15_000 - } + { timeoutMs: WORKSPACE_PORT_RPC_TIMEOUT_MS } ) return requireWorkspacePortScanResult(result) } catch (error) { @@ -113,3 +126,93 @@ export async function runWorkspacePortScanForTarget( throw error } } + +/** An SSH host scanned by the endpoint that owns it, addressed as the panel resolved it. */ +export type HostScopedPortHost = { + route: HostRoute + executionHostId: string + worktreeId: string +} + +function unavailableHostScan(executionHostId: string, reason: string): WorkspacePortHostScanResult { + return { + executionHostId, + platform: 'unknown', + scannedAt: Date.now(), + ports: [], + unavailableReason: reason + } +} + +function isMethodNotFound(error: unknown): boolean { + return error instanceof RuntimeRpcCallError && error.code === 'method_not_found' +} + +/** + * Asks the owning endpoint to scan the host the workspace runs on. An older server only knows how to + * scan itself, so it is never asked: its answer would show the server's ports for the SSH workspace. + */ +export async function scanWorkspacePortsOnExecutionHost( + host: HostScopedPortHost +): Promise { + const { target } = host.route + if ( + target.kind === 'environment' && + !(await runtimeEnvironmentSupportsCapability( + target.environmentId, + WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY + )) + ) { + return unavailableHostScan(host.executionHostId, WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON) + } + let response: unknown + try { + response = await callHostRoute( + host.route, + 'workspacePorts.scanHost', + { worktree: toRuntimeWorktreeSelector(host.worktreeId) }, + { timeoutMs: WORKSPACE_PORT_RPC_TIMEOUT_MS } + ) + } catch (error) { + if (isMethodNotFound(error)) { + return unavailableHostScan(host.executionHostId, WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON) + } + throw error + } + const scan = requireWorkspacePortScanResult(response) + const scannedHostId = isRecord(response) ? response.executionHostId : undefined + const expected = normalizeExecutionHostId(host.executionHostId) + // Why: rows belong to one host; a server that resolved the workspace elsewhere must not fill them. + if (typeof scannedHostId !== 'string' || normalizeExecutionHostId(scannedHostId) !== expected) { + return unavailableHostScan( + host.executionHostId, + 'The server scanned a different host than this workspace runs on.' + ) + } + return { ...scan, executionHostId: scannedHostId } +} + +/** Stops a row on the host it was scanned on; the server refuses when the workspace moved. */ +export async function killWorkspacePortOnExecutionHost( + host: HostScopedPortHost, + row: { scannedHostId: string; pid: number; port: number } +): Promise { + try { + return await callHostRoute( + host.route, + 'workspacePorts.killHost', + { + worktree: toRuntimeWorktreeSelector(host.worktreeId), + executionHostId: row.scannedHostId, + pid: row.pid, + port: row.port + }, + { timeoutMs: WORKSPACE_PORT_RPC_TIMEOUT_MS } + ) + } catch (error) { + if (isMethodNotFound(error)) { + return { ok: false, reason: WORKSPACE_PORTS_SERVER_SSH_UPDATE_REASON } + } + throw error + } +} diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index 98132ebe54c9..6d89b75adca1 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -14,6 +14,7 @@ import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' +import { onPairedWebClientHost } from './paired-web-client-host' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, @@ -188,15 +189,18 @@ function resolveSelectedHostRoute( } // Why: only an `ssh:` selection can hide a paired HUB owner, so local stays an O(1) hot path. if (selectedHost.kind !== 'ssh') { - return { executionHostId: selectedHost.id, runtimeEnvironmentId: null } + return onPairedWebClientHost(state, { + executionHostId: selectedHost.id, + runtimeEnvironmentId: null + }) } const { environmentIds } = collectHubOwnerEnvironmentIds(state, worktreeId, selectedHost.id) const environmentId = environmentIds.values().next().value - return { + return onPairedWebClientHost(state, { executionHostId: selectedHost.id, // Why: rival HUBs projecting the same host cannot be disambiguated by the host selection alone. runtimeEnvironmentId: environmentIds.size === 1 && environmentId ? environmentId : null - } + }) } /** @@ -246,6 +250,16 @@ export function resolveWorktreeOperationRoute( export function resolveWorktreeOperationRouteResult( state: WorktreeOperationRouteState, worktreeId: string +): WorktreeOperationRouteResolution { + const resolution = resolveOwnerRouteResult(state, worktreeId) + return resolution.kind === 'resolved' + ? { kind: 'resolved', route: onPairedWebClientHost(state, resolution.route) } + : resolution +} + +function resolveOwnerRouteResult( + state: WorktreeOperationRouteState, + worktreeId: string ): WorktreeOperationRouteResolution { const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) if (activeRoute) { diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 9942cb0e106e..3c04be33ab1b 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -17,6 +17,7 @@ import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' import { getSingleFocusedRuntimeEnvironmentId } from './single-runtime-legacy-owner' +import { getPairedWebClientEnvironmentId } from './paired-web-client-host' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, @@ -89,9 +90,12 @@ export function getRuntimeEnvironmentIdForWorktree( const projectedRuntimeOwner = getProjectedRuntimeOwnerEnvironmentId(owner) const parsedHost = parseExecutionHostId(owner.hostId) const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) + // Why: a row on a web client that names no server is still reached through its only one. + const onlyHost = getPairedWebClientEnvironmentId(state) if (!hasDetectedOwner && (projectedRuntimeOwner || parsedHost)) { return ( - projectedRuntimeOwner || (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) + projectedRuntimeOwner || + (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : onlyHost) ) } if (!hasDetectedOwner) { @@ -105,7 +109,7 @@ export function getRuntimeEnvironmentIdForWorktree( ) { const repoHost = parseExecutionHostId(getRepoExecutionHostId(repoResolution.owner)) if (repoHost) { - return repoHost.kind === 'runtime' ? repoHost.environmentId : null + return repoHost.kind === 'runtime' ? repoHost.environmentId : onlyHost } } } diff --git a/src/renderer/src/lib/worktree-visit-recency.ts b/src/renderer/src/lib/worktree-visit-recency.ts index 0fa9533ce33c..915c6e098c90 100644 --- a/src/renderer/src/lib/worktree-visit-recency.ts +++ b/src/renderer/src/lib/worktree-visit-recency.ts @@ -54,10 +54,6 @@ export function getWorktreeIdFromVisitKey(key: string): string { return key } -export function isHostQualifiedVisitKey(key: string): boolean { - return getWorktreeIdFromVisitKey(key) !== key -} - /** Remove all recency entries belonging to one or more raw worktree ids. */ export function removeWorktreeVisitEntries( timestamps: Readonly>, diff --git a/src/renderer/src/runtime/forge-credential-target.ts b/src/renderer/src/runtime/forge-credential-target.ts new file mode 100644 index 000000000000..b08275c22b64 --- /dev/null +++ b/src/renderer/src/runtime/forge-credential-target.ts @@ -0,0 +1,55 @@ +import { getRepoExecutionHostId, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { parseHostAuthorityKey } from '../../../shared/host-authority' +import type { Repo } from '../../../shared/repo-types' +import type { Worktree } from '../../../shared/worktree/types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import type { RuntimeClientTarget } from './runtime-client-target' + +export type ForgeRepoSelector = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + /** The repo's or workspace's own host, from its row. */ + repoOwnerExecutionHostId?: string +} + +/** + * Where a forge call for this repo runs (`credential` ownership): a server-owned repo asks its + * server; a local or SSH repo uses this computer's CLI. Never the focused server. + */ +export function forgeCredentialTarget( + selector: Pick +): RuntimeClientTarget { + const hostId = selector.sourceContext?.hostId || selector.repoOwnerExecutionHostId + if (!hostId) { + return { kind: 'local' } + } + const authority = parseHostAuthorityKey(hostId) + if (!authority) { + throw new Error( + translate( + 'auto.runtime.forgeCredentialTarget.unknownHost', + 'The machine that owns this repository is not known yet. Reconnect it and try again.' + ) + ) + } + return authority.endpoint.kind === 'environment' + ? { kind: 'environment', environmentId: authority.endpoint.environmentId } + : { kind: 'local' } +} + +/** + * A workspace's forge owner. An SSH workspace behind a paired server keeps `ssh:t` as its host and + * names the server separately; that server holds the credentials, not this computer. + */ +export function forgeOwnerHostIdForWorkspace( + repo: Pick, + worktree: Pick | null | undefined +): string { + const serverId = worktree?.runtimeOwnerEnvironmentId?.trim() + if (serverId) { + return toRuntimeExecutionHostId(serverId) + } + return worktree?.hostId || getRepoExecutionHostId(repo) +} diff --git a/src/renderer/src/runtime/gitlab-job-trace-client.test.ts b/src/renderer/src/runtime/gitlab-job-trace-client.test.ts index e48c6ac1aba5..6ca96b73a75d 100644 --- a/src/renderer/src/runtime/gitlab-job-trace-client.test.ts +++ b/src/renderer/src/runtime/gitlab-job-trace-client.test.ts @@ -3,7 +3,10 @@ import type { PRCheckDetail } from '../../../shared/github/check-types' import { loadGitLabJobLogDetails } from './gitlab-job-trace-client' const callRuntimeRpc = vi.hoisted(() => vi.fn()) -vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc })) +vi.mock('./runtime-rpc-client', () => ({ + callRuntimeRpc, + runtimeEnvironmentSupportsCapability: vi.fn() +})) const jobTrace = vi.fn() @@ -32,7 +35,7 @@ describe('loadGitLabJobLogDetails', () => { } await expect( - loadGitLabJobLogDetails({ repoPath: '/repo', settings: null, check: githubCheck }) + loadGitLabJobLogDetails({ repoPath: '/repo', check: githubCheck }) ).resolves.toBeNull() expect(jobTrace).not.toHaveBeenCalled() expect(callRuntimeRpc).not.toHaveBeenCalled() @@ -44,7 +47,6 @@ describe('loadGitLabJobLogDetails', () => { const details = await loadGitLabJobLogDetails({ repoPath: '/repo', repoId: 'repo-1', - settings: { activeRuntimeEnvironmentId: null }, check: gitLabCheck }) @@ -64,7 +66,6 @@ describe('loadGitLabJobLogDetails', () => { await loadGitLabJobLogDetails({ repoPath: '/repo', repoId: 'repo-1', - settings: { activeRuntimeEnvironmentId: null }, check: gitLabCheck, projectRef: { host: 'gitlab.example.test', path: 'contributor/orca' } }) @@ -87,7 +88,6 @@ describe('loadGitLabJobLogDetails', () => { const details = await loadGitLabJobLogDetails({ repoPath: '/repo', - settings: { activeRuntimeEnvironmentId: null }, check: manualCheck }) @@ -112,7 +112,6 @@ describe('loadGitLabJobLogDetails', () => { const details = await loadGitLabJobLogDetails({ repoPath: '/repo', - settings: { activeRuntimeEnvironmentId: null }, check: canceledCheck }) @@ -126,7 +125,7 @@ describe('loadGitLabJobLogDetails', () => { await loadGitLabJobLogDetails({ repoPath: '/repo', repoId: 'repo-1', - settings: { activeRuntimeEnvironmentId: 'env-9' }, + repoOwnerExecutionHostId: 'runtime:env-9', check: gitLabCheck }) @@ -135,7 +134,7 @@ describe('loadGitLabJobLogDetails', () => { { kind: 'environment', environmentId: 'env-9' }, 'gitlab.jobTrace', // Why: bounding in main keeps the response under the 1 MB transport frame cap. - { repo: 'repo-1', jobId: 42, projectRef: undefined, logExcerpt: true }, + expect.objectContaining({ repo: 'id:repo-1', jobId: 42, projectRef: null, logExcerpt: true }), { timeoutMs: 65_000 } ) }) @@ -146,7 +145,6 @@ describe('loadGitLabJobLogDetails', () => { await expect( loadGitLabJobLogDetails({ repoPath: '/repo', - settings: { activeRuntimeEnvironmentId: null }, check: gitLabCheck }) ).rejects.toThrow('403 Forbidden') @@ -159,7 +157,6 @@ describe('loadGitLabJobLogDetails', () => { const pending = loadGitLabJobLogDetails({ repoPath: '/repo', - settings: { activeRuntimeEnvironmentId: null }, check: gitLabCheck }) const assertion = expect(pending).rejects.toThrow('Timed out loading the GitLab job log.') @@ -176,7 +173,6 @@ describe('loadGitLabJobLogDetails', () => { await expect( loadGitLabJobLogDetails({ repoPath: '/repo', - settings: { activeRuntimeEnvironmentId: null }, check: gitLabCheck }) ).rejects.toThrow('Failed to load the GitLab job log.') diff --git a/src/renderer/src/runtime/gitlab-job-trace-client.ts b/src/renderer/src/runtime/gitlab-job-trace-client.ts index 6b277eb15d7a..9250e880da2f 100644 --- a/src/renderer/src/runtime/gitlab-job-trace-client.ts +++ b/src/renderer/src/runtime/gitlab-job-trace-client.ts @@ -3,16 +3,11 @@ import { gitLabJobCanHaveTrace, gitLabJobTraceToCheckRunDetails } from '../../../shared/gitlab-job-trace-check-details' -import type { GitLabJobTraceResult, GitLabProjectRef } from '../../../shared/gitlab-types' +import type { GitLabProjectRef } from '../../../shared/gitlab-types' import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/github/check-types' -import { getActiveRuntimeTarget } from './runtime-client-target' -import { callRuntimeRpc } from './runtime-rpc-client' +import { gitLabApiFor, JOB_TRACE_TIMEOUT_MS } from './gitlab-owner-api' import { withGitLabIpcTimeout } from './gitlab-ipc-timeout' -// Why: job logs run longer than a generic GitLab call, so this sits above main's 60s -// `glab` timeout — otherwise the caller aborts first and hides main's classified error. -const JOB_TRACE_TIMEOUT_MS = 65_000 - /** * Load a GitLab pipeline job log as provider-neutral check details. * @@ -23,7 +18,7 @@ const JOB_TRACE_TIMEOUT_MS = 65_000 export async function loadGitLabJobLogDetails(args: { repoPath: string repoId?: string - settings: Parameters[0] + repoOwnerExecutionHostId?: string check: PRCheckDetail /** Fork/cross-project MRs run their pipeline outside the repo's own project. */ projectRef?: GitLabProjectRef | null @@ -35,29 +30,15 @@ export async function loadGitLabJobLogDetails(args: { if (!gitLabJobCanHaveTrace(args.check)) { return gitLabJobTraceToCheckRunDetails(args.check, '', emptyTraceStrings()) } - const target = getActiveRuntimeTarget(args.settings) - const result = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'gitlab.jobTrace', - { - repo: args.repoId ?? args.repoPath, - jobId, - projectRef: args.projectRef ?? undefined, - logExcerpt: true - }, - { timeoutMs: JOB_TRACE_TIMEOUT_MS } - ) - : await withJobTraceTimeout( - window.api.gl.jobTrace({ - repoPath: args.repoPath, - repoId: args.repoId, - jobId, - projectRef: args.projectRef ?? null, - logExcerpt: true - }) - ) + const result = await withJobTraceTimeout( + gitLabApiFor(args).jobTrace({ + repoPath: args.repoPath, + repoId: args.repoId, + jobId, + projectRef: args.projectRef ?? null, + logExcerpt: true + }) + ) if (!result?.ok) { throw new Error( result?.error?.trim() || diff --git a/src/renderer/src/runtime/gitlab-owner-api.test.ts b/src/renderer/src/runtime/gitlab-owner-api.test.ts new file mode 100644 index 000000000000..db2bce6deb08 --- /dev/null +++ b/src/renderer/src/runtime/gitlab-owner-api.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const rpc = vi.hoisted(() => ({ call: vi.fn(), supports: vi.fn() })) + +vi.mock('./runtime-rpc-client', () => ({ + callRuntimeRpc: rpc.call, + runtimeEnvironmentSupportsCapability: rpc.supports +})) + +import { gitLabApiFor } from './gitlab-owner-api' +import { forgeCredentialTarget, forgeOwnerHostIdForWorkspace } from './forge-credential-target' + +const glListIssues = vi.fn() + +beforeEach(() => { + rpc.call.mockReset().mockResolvedValue({ items: [] }) + rpc.supports.mockReset().mockResolvedValue(true) + glListIssues.mockReset().mockResolvedValue({ items: [] }) + vi.stubGlobal('window', { api: { gl: { listIssues: glListIssues } } }) +}) + +describe('gitLabApiFor', () => { + it('reads a server-owned repo on that server', async () => { + const selector = { + repoPath: '/work/app', + repoId: 'server-repo', + repoOwnerExecutionHostId: 'runtime:owner-server' + } + await gitLabApiFor(selector).listIssues({ ...selector, state: 'opened' }) + + expect(glListIssues).not.toHaveBeenCalled() + expect(rpc.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'owner-server' }, + 'gitlab.listIssues', + expect.objectContaining({ repo: 'id:server-repo', state: 'opened' }), + { timeoutMs: 30_000 } + ) + }) + + it.each(['ssh:devbox', 'local'])( + 'uses this computer for a %s repo', + async (repoOwnerExecutionHostId) => { + const selector = { repoPath: '/work/app', repoId: 'repo', repoOwnerExecutionHostId } + await gitLabApiFor(selector).listIssues(selector) + + expect(rpc.call).not.toHaveBeenCalled() + expect(glListIssues).toHaveBeenCalledOnce() + } + ) + + it('lets a task source name the host and the server-side repo id', async () => { + const selector = { + repoPath: '/work/app', + repoId: 'renderer-id', + repoOwnerExecutionHostId: 'local', + sourceContext: { + kind: 'task-source' as const, + provider: 'gitlab' as const, + projectId: 'p', + hostId: 'runtime:owner-server' as const, + repoId: 'server-side-id' + } + } + await gitLabApiFor(selector).listIssues(selector) + + expect(rpc.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'owner-server' }, + 'gitlab.listIssues', + expect.objectContaining({ repo: 'id:server-side-id' }), + expect.anything() + ) + }) + + it('rejects instead of guessing when the owner is unresolved', async () => { + const selector = { + repoPath: '/work/app', + repoOwnerExecutionHostId: 'runtime:unresolved-owner' + } + + expect(() => forgeCredentialTarget(selector)).toThrow() + await expect(gitLabApiFor(selector).listIssues(selector)).rejects.toThrow( + 'The machine that owns this repository is not known yet' + ) + expect(rpc.call).not.toHaveBeenCalled() + expect(glListIssues).not.toHaveBeenCalled() + }) + + it('sends an SSH workspace behind a server to that server, a direct SSH one to this computer', async () => { + const repo = { executionHostId: 'runtime:owner-server' as const } + const nested = forgeOwnerHostIdForWorkspace(repo, { + hostId: 'ssh:devbox', + runtimeOwnerEnvironmentId: 'owner-server' + }) + const selector = { repoPath: '/work/app', repoId: 'repo', repoOwnerExecutionHostId: nested } + await gitLabApiFor(selector).listIssues(selector) + + expect(rpc.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'owner-server' }, + 'gitlab.listIssues', + expect.anything(), + expect.anything() + ) + expect(forgeOwnerHostIdForWorkspace({ connectionId: 'devbox' }, { hostId: 'ssh:devbox' })).toBe( + 'ssh:devbox' + ) + expect(forgeOwnerHostIdForWorkspace({ connectionId: 'devbox' }, null)).toBe('ssh:devbox') + }) +}) diff --git a/src/renderer/src/runtime/gitlab-owner-api.ts b/src/renderer/src/runtime/gitlab-owner-api.ts new file mode 100644 index 000000000000..f161b60cd372 --- /dev/null +++ b/src/renderer/src/runtime/gitlab-owner-api.ts @@ -0,0 +1,43 @@ +import { createRuntimeGitLabApi, type GitLabApi } from './runtime-gitlab-api' +import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' +import { forgeCredentialTarget, type ForgeRepoSelector } from './forge-credential-target' + +// Why: job logs outlive main's 60s `glab` timeout so its classified error reaches the caller. +export const JOB_TRACE_TIMEOUT_MS = 65_000 +const GITLAB_RPC_TIMEOUT_MS = 30_000 + +// Why: a task source names the repo by its id on the server that published it. +function withSourceRepo(params: unknown, sourceRepoId: string | null): unknown { + return sourceRepoId && params && typeof params === 'object' && 'repo' in params + ? { ...params, repo: `id:${sourceRepoId}` } + : params +} + +function runtimeGitLabApi(environmentId: string, sourceRepoId: string | null): GitLabApi { + const target = { kind: 'environment', environmentId } as const + return createRuntimeGitLabApi({ + call: (method, params) => + callRuntimeRpc(target, method, withSourceRepo(params, sourceRepoId), { + timeoutMs: method === 'gitlab.jobTrace' ? JOB_TRACE_TIMEOUT_MS : GITLAB_RPC_TIMEOUT_MS + }), + supportsCapability: (capability) => + runtimeEnvironmentSupportsCapability(environmentId, capability) + }) +} + +/** The `gl` API on the machine that holds the repo's GitLab credentials. */ +export function gitLabApiFor(selector: ForgeRepoSelector): GitLabApi { + let target: ReturnType + try { + target = forgeCredentialTarget(selector) + } catch (error) { + // Why: callers chain `.then`/`await`; an unknown owner must reject, not throw synchronously. + return createRuntimeGitLabApi({ + call: () => Promise.reject(error), + supportsCapability: () => Promise.resolve(false) + }) + } + return target.kind === 'environment' + ? runtimeGitLabApi(target.environmentId, selector.sourceContext?.repoId?.trim() || null) + : window.api.gl +} diff --git a/src/renderer/src/runtime/host-route-call.test.ts b/src/renderer/src/runtime/host-route-call.test.ts new file mode 100644 index 000000000000..e1f6cedc9e62 --- /dev/null +++ b/src/renderer/src/runtime/host-route-call.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callRuntimeRpc = vi.hoisted(() => vi.fn()) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc })) + +import { callHostRoute, HostRouteUnverifiableError } from './host-route-call' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from '@/store/slices/runtime-status' + +const route = { target: { kind: 'environment', environmentId: 'env-1' }, at: 'local' } as const + +beforeEach(() => { + callRuntimeRpc.mockReset() + clearRuntimeEnvironmentConnectionGenerationsForTests() + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) +}) + +describe('callHostRoute', () => { + it('calls the route target with the pairing revision captured before the call', async () => { + callRuntimeRpc.mockResolvedValue({ ok: true }) + + await expect( + callHostRoute(route, 'folderWorkspace.update', { id: 'f' }, { timeoutMs: 5 }) + ).resolves.toEqual({ ok: true }) + expect(callRuntimeRpc).toHaveBeenCalledWith( + route.target, + 'folderWorkspace.update', + { id: 'f' }, + { timeoutMs: 5, expectedEnvironmentPairingRevision: 7 } + ) + }) + + it('reports a reply from a replaced runtime as unverifiable instead of applying it', async () => { + callRuntimeRpc.mockImplementation(async () => { + setRuntimeEnvironmentConnectionGenerationForTests('env-1', 1) + return { ok: true } + }) + + const call = callHostRoute(route, 'folderWorkspace.update', {}) + await expect(call).rejects.toBeInstanceOf(HostRouteUnverifiableError) + await expect(call).rejects.toMatchObject({ verdict: 'unverifiable' }) + expect(callRuntimeRpc).toHaveBeenCalledOnce() + }) + + it('refuses client effects and unclassified methods without calling a host', async () => { + await expect(callHostRoute(route, 'shell.openPath', {})).rejects.toThrow( + 'not a host-owned operation' + ) + await expect(callHostRoute(route, 'mystery.call', {})).rejects.toThrow( + 'not a host-owned operation' + ) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/host-route-call.ts b/src/renderer/src/runtime/host-route-call.ts new file mode 100644 index 000000000000..728e4a8d46da --- /dev/null +++ b/src/renderer/src/runtime/host-route-call.ts @@ -0,0 +1,44 @@ +import { getHostOperationOwnership } from '../../../shared/host-operation-ownership' +import { callRuntimeRpc } from './runtime-rpc-client' +import type { HostRoute } from './runtime-client-target' +import { captureHostIdentityFence, isHostIdentityFenceCurrent } from '@/store/host-catalog-fencing' + +/** The host behind the route changed while the call was in flight; it may or may not have run. */ +export class HostRouteUnverifiableError extends Error { + readonly verdict = 'unverifiable' + constructor(readonly method: string) { + super(`The host changed while ${method} was in flight; refresh to see whether it ran.`) + this.name = 'HostRouteUnverifiableError' + } +} + +type HostRouteCallOptions = Pick< + NonNullable[3]>, + 'timeoutMs' | 'signal' | 'expectedEnvironmentRuntimeId' +> + +/** + * Calls a host-owned method on the host the route names. Params stay endpoint-relative: the domain + * adapter derives them from the same resource ref as the route. A reply whose identity fence moved + * is never applied or retried; snapshot readers add their own data fence on top. + */ +export async function callHostRoute( + route: HostRoute, + method: string, + params?: unknown, + options: HostRouteCallOptions = {} +): Promise { + const ownership = getHostOperationOwnership(method) + if (ownership === null || ownership === 'client') { + throw new Error(`${method} is not a host-owned operation`) + } + const fence = captureHostIdentityFence(route) + const result = await callRuntimeRpc(route.target, method, params, { + ...options, + expectedEnvironmentPairingRevision: fence.pairingRevision + }) + if (!isHostIdentityFenceCurrent(fence)) { + throw new HostRouteUnverifiableError(method) + } + return result +} diff --git a/src/renderer/src/runtime/remote-browser-tab-ownership.ts b/src/renderer/src/runtime/remote-browser-tab-ownership.ts index 1e895ee488e0..c5a7120fbb53 100644 --- a/src/renderer/src/runtime/remote-browser-tab-ownership.ts +++ b/src/renderer/src/runtime/remote-browser-tab-ownership.ts @@ -45,14 +45,6 @@ export function getBrowserWorkspaceRemoteOwnership( return { kind: 'exact', environmentId: environmentIds[0]! } } -export function getBrowserWorkspaceRemoteOwnerEnvironmentId( - state: RemoteBrowserTabOwnershipState, - workspaceId: string -): string | null { - const ownership = getBrowserWorkspaceRemoteOwnership(state, workspaceId) - return ownership.kind === 'exact' ? ownership.environmentId : null -} - export function browserWorkspaceHasRemoteOwner( state: RemoteBrowserTabOwnershipState, workspaceId: string, diff --git a/src/renderer/src/runtime/runtime-client-target.test.ts b/src/renderer/src/runtime/runtime-client-target.test.ts new file mode 100644 index 000000000000..7519bd898b3d --- /dev/null +++ b/src/renderer/src/runtime/runtime-client-target.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { UNRESOLVED_OWNER_HOST_ID } from '../../../shared/execution-host' +import { hostRouteForAuthority, runtimeTargetForExecutionHostId } from './runtime-client-target' + +describe('runtimeTargetForExecutionHostId', () => { + it('keeps the answers for plain host ids', () => { + expect(runtimeTargetForExecutionHostId('local')).toEqual({ kind: 'local' }) + expect(runtimeTargetForExecutionHostId('runtime:env%2Fa')).toEqual({ + kind: 'environment', + environmentId: 'env/a' + }) + expect(runtimeTargetForExecutionHostId('ssh:box')).toBeNull() + expect(runtimeTargetForExecutionHostId(UNRESOLVED_OWNER_HOST_ID)).toBeNull() + }) + + it('routes a nested SSH key to the server that owns the target', () => { + expect(runtimeTargetForExecutionHostId('nested:env-a/box')).toEqual({ + kind: 'environment', + environmentId: 'env-a' + }) + }) +}) + +describe('hostRouteForAuthority', () => { + it('keeps the place beside the target instead of inside it', () => { + expect( + hostRouteForAuthority({ + endpoint: { kind: 'environment', environmentId: 'env-a' }, + at: 'ssh:box' + }) + ).toEqual({ target: { kind: 'environment', environmentId: 'env-a' }, at: 'ssh:box' }) + expect(hostRouteForAuthority({ endpoint: { kind: 'self' }, at: 'ssh:box' })).toEqual({ + target: { kind: 'local' }, + at: 'ssh:box' + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-client-target.ts b/src/renderer/src/runtime/runtime-client-target.ts index 07a5f25574a9..47bc13c150cb 100644 --- a/src/renderer/src/runtime/runtime-client-target.ts +++ b/src/renderer/src/runtime/runtime-client-target.ts @@ -1,5 +1,9 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' -import { parseRoutableExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { + parseHostAuthorityKey, + type HostAuthority, + type HostAuthorityKey +} from '../../../shared/host-authority' export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } @@ -10,18 +14,38 @@ export function getActiveRuntimeTarget( return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } } -/** RPC target for a dispatchable host; direct SSH and the unresolved-owner sentinel have none. */ +/** + * 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. + */ export function runtimeTargetForExecutionHostId( - hostId: ExecutionHostId + hostId: HostAuthorityKey ): RuntimeClientTarget | null { - const parsed = parseRoutableExecutionHostId(hostId) - if (parsed?.kind === 'local') { - return { kind: 'local' } + const authority = parseHostAuthorityKey(hostId) + if (!authority) { + return null } - if (parsed?.kind === 'runtime') { - return { kind: 'environment', environmentId: parsed.environmentId } + if (authority.endpoint.kind === 'environment') { + return { kind: 'environment', environmentId: authority.endpoint.environmentId } + } + return authority.at === 'local' ? { kind: 'local' } : null +} + +/** + * Transport plus place. `at` stays off `RuntimeClientTarget` because fences and caches key on the + * target; folding it in would merge `(E, ssh:t)` into `runtime:E`. + */ +export type HostRoute = { target: RuntimeClientTarget; at: HostAuthority['at'] } + +export function hostRouteForAuthority(authority: HostAuthority): HostRoute { + const { endpoint } = authority + return { + target: + endpoint.kind === 'self' + ? { kind: 'local' } + : { kind: 'environment', environmentId: endpoint.environmentId }, + at: authority.at } - return null } export function settingsForRuntimeOwner( diff --git a/src/renderer/src/runtime/runtime-gitlab-api.ts b/src/renderer/src/runtime/runtime-gitlab-api.ts new file mode 100644 index 000000000000..3ce66e4110a3 --- /dev/null +++ b/src/renderer/src/runtime/runtime-gitlab-api.ts @@ -0,0 +1,90 @@ +import type { PreloadApi } from '../../../preload/api-types' +import { + GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, + GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE, + type RuntimeCapability +} from '../../../shared/protocol-version' +import { + GITLAB_WEB_RPC_METHODS, + type WebGitLabRuntimeMethod +} from '@/web/preload-api/web-gitlab-routes' +import { mapRepoPathArg } from './runtime-repo-selector-params' + +export type GitLabApi = NonNullable + +type GitLabApiResult = Awaited> + +export type RuntimeGitLabTransport = { + call: (method: WebGitLabRuntimeMethod, params?: unknown) => Promise + supportsCapability: (capability: RuntimeCapability) => Promise +} + +/** The `gl` API spoken over runtime RPC to the server that owns the repo. */ +export function createRuntimeGitLabApi(transport: RuntimeGitLabTransport): GitLabApi { + const route = (method: WebGitLabRuntimeMethod, args?: unknown): Promise => + transport.call(method, mapRepoPathArg(args)) + + return { + viewer: () => Promise.resolve(null), + diagnoseAuth: () => route>(GITLAB_WEB_RPC_METHODS.diagnoseAuth), + rateLimit: (args) => + route>(GITLAB_WEB_RPC_METHODS.rateLimit, args), + projectSlug: () => Promise.resolve(null), + mrForBranch: () => Promise.resolve(null), + mr: () => Promise.resolve(null), + listMRs: (args) => route>(GITLAB_WEB_RPC_METHODS.listMRs, args), + listWorkItems: (args) => + route>(GITLAB_WEB_RPC_METHODS.listWorkItems, args), + issue: () => Promise.resolve(null), + listIssues: (args) => + route>(GITLAB_WEB_RPC_METHODS.listIssues, args), + createIssue: (args) => + route>(GITLAB_WEB_RPC_METHODS.createIssue, args), + updateIssue: (args) => + route>(GITLAB_WEB_RPC_METHODS.updateIssue, args), + addIssueComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addIssueComment, args), + listLabels: (args) => + route>(GITLAB_WEB_RPC_METHODS.listLabels, args), + listAssignableUsers: () => Promise.resolve([]), + todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), + // Why: the owner guard names a host as this client sees it; the server resolves the repo itself. + workItemDetails: ({ repoOwnerExecutionHostId: _owner, ...args }) => + route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), + closeMR: (args) => + route>(GITLAB_WEB_RPC_METHODS.closeMR, { + ...args, + state: 'closed' + }), + reopenMR: (args) => + route>(GITLAB_WEB_RPC_METHODS.reopenMR, { + ...args, + state: 'opened' + }), + mergeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.mergeMR, args), + updateMR: async (args) => { + if ( + args.updates.readyForReview && + !(await transport.supportsCapability(GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY)) + ) { + return { ok: false, error: GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE } + } + return route>(GITLAB_WEB_RPC_METHODS.updateMR, args) + }, + updateMRReviewers: (args) => + route>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args), + addMRComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addMRComment, args), + addMRInlineComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args), + resolveMRDiscussion: (args) => + route>( + GITLAB_WEB_RPC_METHODS.resolveMRDiscussion, + args + ), + jobTrace: (args) => route>(GITLAB_WEB_RPC_METHODS.jobTrace, args), + retryJob: (args) => route>(GITLAB_WEB_RPC_METHODS.retryJob, args), + workItemByPath: (args) => + route>(GITLAB_WEB_RPC_METHODS.workItemByPath, args) + } satisfies GitLabApi +} diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts index decdaf86e8a0..388a41baf0c6 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts @@ -18,8 +18,8 @@ import { } from './runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' -const LOCAL = { activeRuntimeEnvironmentId: null } -const REMOTE = { activeRuntimeEnvironmentId: 'env-1' } +const LOCAL = { kind: 'local' } as const +const REMOTE = { kind: 'environment', environmentId: 'env-1' } as const function emptyClaudeState(): ClaudeRateLimitAccountsState { return { accounts: [], activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } } @@ -312,8 +312,8 @@ describe('fetchProviderAccountsSnapshot', () => { }) it('isolates in-flight snapshots by remote account owner', async () => { - const first = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-1' }) - const second = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-2' }) + const first = fetchProviderAccountsSnapshot({ kind: 'environment', environmentId: 'env-1' }) + const second = fetchProviderAccountsSnapshot({ kind: 'environment', environmentId: 'env-2' }) await flushMicrotasks() expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(2) @@ -343,7 +343,7 @@ describe('fetchProviderAccountsSnapshot', () => { ) const local = fetchProviderAccountsSnapshot(LOCAL) - const remote = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'local' }) + const remote = fetchProviderAccountsSnapshot({ kind: 'environment', environmentId: 'local' }) await flushMicrotasks() expect(remote).not.toBe(local) diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts index 2620a55cf2f8..6db5a4cc603b 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -1,11 +1,10 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../../shared/managed-account-types' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError, type RuntimeClientTarget } from './runtime-rpc-client' // Mirrors OrcaRuntime.getAccountsSnapshot() / the accounts.subscribe payload. export type ProviderAccountsSnapshot = { @@ -35,21 +34,12 @@ const REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS = 15_000 const REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS = 30_000 const pendingProviderAccountsSnapshots = new Map>() -function getProviderAccountsOwnerKey( - settings: Pick | null | undefined -): string { - const target = getActiveRuntimeTarget(settings) +export function getProviderAccountsOwnerKey(target: RuntimeClientTarget): string { // Why: environment ids are user-controlled strings; prefix the target kind // so a remote id such as “local” cannot share the desktop's pending read. return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } -export function hasRemoteProviderAccountOwner( - settings: Pick | null | undefined -): boolean { - return getActiveRuntimeTarget(settings).kind === 'environment' -} - export type ProviderAccountsWatcher = { close: () => void } @@ -75,13 +65,12 @@ function providerAccountsLoadError(provider: 'Claude' | 'Codex', cause: unknown) // deliberately avoided: it blocks behind provider usage refreshes on the // server and can hang for minutes behind broken auth. export function watchProviderAccounts( - settings: Pick | null | undefined, + target: RuntimeClientTarget, handlers: { onSnapshot: (snapshot: ProviderAccountsSnapshot) => void onError: (error: unknown) => void } ): ProviderAccountsWatcher { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { let closed = false void Promise.allSettled([ @@ -205,16 +194,16 @@ export function watchProviderAccounts( // One-shot convenience over watchProviderAccounts for surfaces that only need // the current snapshot (status-bar switcher menus). export function fetchProviderAccountsSnapshot( - settings: Pick | null | undefined + target: RuntimeClientTarget ): Promise { - const ownerKey = getProviderAccountsOwnerKey(settings) + const ownerKey = getProviderAccountsOwnerKey(target) const pending = pendingProviderAccountsSnapshots.get(ownerKey) if (pending) { return pending } const request = new Promise((resolve, reject) => { - const watcher = watchProviderAccounts(settings, { + const watcher = watchProviderAccounts(target, { onSnapshot: (snapshot) => { watcher.close() resolve(snapshot) @@ -238,10 +227,9 @@ export function fetchProviderAccountsSnapshot( } export async function selectClaudeProviderAccount( - settings: Pick | null | undefined, + target: RuntimeClientTarget, selection: ProviderAccountSelection ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { return callRuntimeRpc( target, @@ -254,10 +242,9 @@ export async function selectClaudeProviderAccount( } export async function selectCodexProviderAccount( - settings: Pick | null | undefined, + target: RuntimeClientTarget, selection: ProviderAccountSelection ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { return callRuntimeRpc( target, @@ -270,10 +257,9 @@ export async function selectCodexProviderAccount( } export async function removeClaudeProviderAccount( - settings: Pick | null | undefined, + target: RuntimeClientTarget, accountId: string ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { return callRuntimeRpc( target, @@ -286,10 +272,9 @@ export async function removeClaudeProviderAccount( } export async function removeCodexProviderAccount( - settings: Pick | null | undefined, + target: RuntimeClientTarget, accountId: string ): Promise { - const target = getActiveRuntimeTarget(settings) if (target.kind === 'environment') { return callRuntimeRpc( target, diff --git a/src/renderer/src/runtime/runtime-repo-selector-params.ts b/src/renderer/src/runtime/runtime-repo-selector-params.ts new file mode 100644 index 000000000000..53d1ca280cb9 --- /dev/null +++ b/src/renderer/src/runtime/runtime-repo-selector-params.ts @@ -0,0 +1,12 @@ +export function mapRepoPathArg(args: unknown): unknown { + if (!args || typeof args !== 'object' || !('repoPath' in args)) { + return args + } + const record = args as Record + const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null + return { + ...record, + // Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes. + repo: repoId ? `id:${repoId}` : record.repoPath + } +} diff --git a/src/renderer/src/runtime/web-runtime-browser-creation-e2e-fault.ts b/src/renderer/src/runtime/web-runtime-browser-creation-e2e-fault.ts index 44a422a5d6a2..c2503530b873 100644 --- a/src/renderer/src/runtime/web-runtime-browser-creation-e2e-fault.ts +++ b/src/renderer/src/runtime/web-runtime-browser-creation-e2e-fault.ts @@ -154,22 +154,6 @@ function exposeFaultApi(): void { exposeFaultApi() -export function observeE2eWebRuntimeBrowserCreation(result: Promise): void { - if (!e2eConfig.exposeStore || !settleCreation) { - return - } - const settle = settleCreation - settleCreation = null - void result.then( - (created) => settle({ status: 'fulfilled', created }), - (error) => - settle({ - status: 'rejected', - error: error instanceof Error ? error.message : String(error) - }) - ) -} - export function throwIfE2eWebRuntimeBrowserCapabilityUnavailable(): void { if (!e2eConfig.exposeStore || !capabilityRejectionArmed) { return diff --git a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts index da2ca9224e3e..8051cd07a549 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts @@ -182,6 +182,31 @@ describe('web runtime session tab actions', () => { expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalled() }) + it('carries the picked split pane and the visible tab guard to the owner and focus intent', async () => { + mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue('host-terminal') + const runtimeCall = vi.fn().mockResolvedValue({ id: 'activate', ok: true, result: {} }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await activateWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-terminal', + leafId: 'leaf-b', + expectedCurrentLocalTabId: 'unified-local-terminal' + }) + + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'session.tabs.activate', + params: expect.objectContaining({ tabId: 'host-terminal', leafId: 'leaf-b' }) + }) + ) + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toEqual({ + hostTabId: 'host-terminal', + leafId: 'leaf-b', + expectedCurrentLocalTabId: 'unified-local-terminal' + }) + }) + it('supersedes browser focus intent when a terminal is activated next', async () => { mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( (_state, args: { tabId: string }) => diff --git a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts index 0fbb18fb534d..059a53346e4e 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts @@ -28,6 +28,10 @@ export async function activateWebRuntimeSessionTab(args: { worktreeId: string tabId: string environmentId?: string | null + /** The split pane the user picked; without it the host and focus intent pick the first. */ + leafId?: string | null + /** The local tab visible at the gesture; later navigation away cancels the deferred focus. */ + expectedCurrentLocalTabId?: string | null }): Promise { return (await callWebRuntimeSessionTabMethod('session.tabs.activate', args)) === 'applied' } @@ -60,6 +64,8 @@ async function callWebRuntimeSessionTabMethod( reason?: RuntimeSessionTabCloseReason publicationEpoch?: string | null terminalHandle?: string | null + leafId?: string | null + expectedCurrentLocalTabId?: string | null } ): Promise { const environmentId = @@ -110,7 +116,13 @@ async function callWebRuntimeSessionTabMethod( recordWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId, Date.now()) } else { activationHostTabId = hostTabId - recordWebSessionFocusIntent(intentOwner, args.worktreeId, hostTabId) + recordWebSessionFocusIntent( + intentOwner, + args.worktreeId, + hostTabId, + args.leafId ?? undefined, + args.expectedCurrentLocalTabId + ) } const response = await callEnvironment({ // Why: old hosts cannot route this additive method, so a generation @@ -121,6 +133,7 @@ async function callWebRuntimeSessionTabMethod( tabId: hostTabId, ...(method === 'session.tabs.activate' ? { + ...(args.leafId ? { leafId: args.leafId } : {}), // Why: the additive navigation target protects new hosts while notifyClients:false protects old hosts. notifyClients: false, navigation: 'caller' as const, diff --git a/src/renderer/src/store/adopt-from-endpoint.test.ts b/src/renderer/src/store/adopt-from-endpoint.test.ts new file mode 100644 index 000000000000..1634de3dab3f --- /dev/null +++ b/src/renderer/src/store/adopt-from-endpoint.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { ProjectHostSetup } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import type { RuntimeClientTarget } from '../runtime/runtime-client-target' +import { adoptFromEndpoint } from './adopt-from-endpoint' +import { getFolderWorkspaceHostId } from './folder-workspaces/folder-workspace-catalog' + +const LOCAL: RuntimeClientTarget = { kind: 'local' } +const SERVER: RuntimeClientTarget = { kind: 'environment', environmentId: 'env-a' } + +function repo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/srv/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + ...overrides + } +} + +function group(overrides: Partial = {}): ProjectGroup { + return { + id: 'group-1', + name: 'group', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function setup(overrides: Partial = {}): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/srv/repo', + displayName: 'repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function workspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'folder', + folderPath: '/srv/folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + createdAt: 1, + lastActivityAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('adoptFromEndpoint', () => { + describe('repo', () => { + it.each([ + ['local row', LOCAL, repo(), 'local'], + [ + 'local row keeping an existing stamp', + LOCAL, + repo({ executionHostId: 'ssh:kept' }), + 'ssh:kept' + ], + ['direct SSH row', LOCAL, repo({ connectionId: 'conn-1' }), 'ssh:conn-1'], + ['server row', SERVER, repo(), 'runtime:env-a'], + ['server row naming a host', SERVER, repo({ executionHostId: 'local' }), 'runtime:env-a'] + ])('%s', (_name, target, row, expectedHostId) => { + const adopted = adoptFromEndpoint(target, { kind: 'repo', row }) + expect(adopted.executionHostId).toBe(expectedHostId) + }) + + it('keeps a server row nested SSH target', () => { + const adopted = adoptFromEndpoint(SERVER, { + kind: 'repo', + row: repo({ connectionId: 'nested' }) + }) + expect(adopted).toMatchObject({ executionHostId: 'runtime:env-a', connectionId: 'nested' }) + }) + }) + + describe('projectGroup', () => { + it.each([ + ['local row', LOCAL, group(), 'local'], + [ + 'local row overwrites a stale stamp', + LOCAL, + group({ executionHostId: 'runtime:old' }), + 'local' + ], + ['SSH row', LOCAL, group({ connectionId: 'conn-1' }), 'ssh:conn-1'], + ['server row', SERVER, group({ connectionId: 'nested' }), 'runtime:env-a'] + ])('%s', (_name, target, row, expectedHostId) => { + const adopted = adoptFromEndpoint(target, { kind: 'projectGroup', row }) + expect(adopted.executionHostId).toBe(expectedHostId) + }) + }) + + describe('projectHostSetup', () => { + it('leaves a local row as the host published it', () => { + const row = setup({ hostId: 'ssh:conn-1', connectionId: 'conn-1' }) + expect(adoptFromEndpoint(LOCAL, { kind: 'projectHostSetup', row })).toEqual(row) + }) + + it.each([ + ['server-local row', setup(), 'runtime:env-a'], + ['server row naming its SSH target', setup({ hostId: 'ssh:t', connectionId: 't' }), 'ssh:t'], + ['server row with explicit execution host', setup({ executionHostId: 'ssh:x' }), 'ssh:x'] + ])('%s', (_name, row, expectedExecutionHostId) => { + const adopted = adoptFromEndpoint(SERVER, { kind: 'projectHostSetup', row }) + expect(adopted).toMatchObject({ + hostId: 'runtime:env-a', + executionHostId: expectedExecutionHostId, + runtimeOwnerEnvironmentId: 'env-a', + connectionId: null + }) + }) + }) + + describe('folderWorkspace', () => { + const groups = [group({ connectionId: 'conn-1' })] + const resolveOwnHostId = (row: FolderWorkspace) => getFolderWorkspaceHostId(row, groups) + + it.each([ + ['row inheriting its group host', LOCAL, workspace(), 'ssh:conn-1'], + ['row with its own SSH target', LOCAL, workspace({ connectionId: 'own' }), 'ssh:own'], + ['row with an explicit host', LOCAL, workspace({ executionHostId: 'local' }), 'local'], + ['server row', SERVER, workspace({ connectionId: 'nested' }), 'runtime:env-a'] + ])('%s', (_name, target, row, expectedHostId) => { + const adopted = adoptFromEndpoint(target, { kind: 'folderWorkspace', row, resolveOwnHostId }) + expect(adopted.executionHostId).toBe(expectedHostId) + }) + }) + + describe('worktreeEvent', () => { + const renamed = { oldWorktreeId: 'repo-1::/a', newWorktreeId: 'repo-1::/b' } + + it('pins an event from this app to this app, keeping the rename', () => { + expect( + adoptFromEndpoint(LOCAL, { kind: 'worktreeEvent', row: { repoId: 'repo-1', renamed } }) + ).toEqual({ repoId: 'repo-1', renamed, forceLocalOwner: true }) + }) + + it('stamps an event from a server with that server', () => { + expect( + adoptFromEndpoint(SERVER, { kind: 'worktreeEvent', row: { repoId: 'repo-1' } }) + ).toEqual({ repoId: 'repo-1', executionHostId: 'runtime:env-a' }) + }) + }) + + describe('createdWorktree', () => { + const resolveOwnHostId = (): 'ssh:own' => 'ssh:own' + + it.each([ + ['this app, no requested host', LOCAL, undefined, 'ssh:own'], + ['this app, requested SSH host', LOCAL, 'ssh:t', 'ssh:t'], + ['server, no requested host', SERVER, undefined, 'runtime:env-a'], + ['server, requested host', SERVER, 'runtime:env-a', 'runtime:env-a'] + ] as const)('%s', (_name, target, requestedHostId, expectedHostId) => { + expect( + adoptFromEndpoint(target, { kind: 'createdWorktree', requestedHostId, resolveOwnHostId }) + ).toBe(expectedHostId) + }) + }) +}) diff --git a/src/renderer/src/store/adopt-from-endpoint.ts b/src/renderer/src/store/adopt-from-endpoint.ts new file mode 100644 index 000000000000..e1507480e50b --- /dev/null +++ b/src/renderer/src/store/adopt-from-endpoint.ts @@ -0,0 +1,153 @@ +/** + * The one place a row or event from an endpoint gets its owner stamped. A row a paired server + * returns is owned by that server (`runtime:E`); a row this app returns keeps the host it names. + * Per-kind arms differ only in which fields carry that owner. + */ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { ProjectHostSetup } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { normalizeProjectHostSetupRow } from '../../../shared/project-catalog-row-normalization' +import type { RuntimeClientTarget } from '../runtime/runtime-client-target' +import { getRuntimeTargetHostId } from './runtime-target-host' + +export type FetchedRepo = { kind: 'repo'; row: Repo } +export type FetchedProjectGroup = { kind: 'projectGroup'; row: ProjectGroup } +export type FetchedProjectHostSetup = { kind: 'projectHostSetup'; row: ProjectHostSetup } +export type FetchedFolderWorkspace = { + kind: 'folderWorkspace' + row: FolderWorkspace + /** The row's own host on this app (explicit host, SSH connection, then its project group). */ + resolveOwnHostId: (workspace: FolderWorkspace) => ExecutionHostId +} +type WorktreeEventRow = { + repoId: string + renamed?: { oldWorktreeId: string; newWorktreeId: string } +} +/** A worktree change or activation an endpoint pushed; the endpoint is its only owner fact. */ +export type EndpointWorktreeEvent = { kind: 'worktreeEvent'; row: WorktreeEventRow } +export type OwnedWorktreeEvent = WorktreeEventRow & + ( + | { executionHostId: ExecutionHostId; forceLocalOwner?: never } + | { executionHostId?: never; forceLocalOwner: true } + ) +/** A worktree an endpoint just created, stamped before any listing has seen it. */ +export type CreatedWorktree = { + kind: 'createdWorktree' + /** The host the caller asked for; it wins, since the endpoint was derived from it. */ + requestedHostId: ExecutionHostId | undefined + /** The repo's own host in this app's catalog. */ + resolveOwnHostId: () => ExecutionHostId +} +export type FetchedRow = + | FetchedRepo + | FetchedProjectGroup + | FetchedProjectHostSetup + | FetchedFolderWorkspace + | EndpointWorktreeEvent + | CreatedWorktree + +export function adoptFromEndpoint(target: RuntimeClientTarget, fetched: FetchedRepo): Repo +export function adoptFromEndpoint( + target: RuntimeClientTarget, + fetched: FetchedProjectGroup +): ProjectGroup +export function adoptFromEndpoint( + target: RuntimeClientTarget, + fetched: FetchedProjectHostSetup +): ProjectHostSetup +export function adoptFromEndpoint( + target: RuntimeClientTarget, + fetched: FetchedFolderWorkspace +): FolderWorkspace +export function adoptFromEndpoint( + target: RuntimeClientTarget, + fetched: EndpointWorktreeEvent +): OwnedWorktreeEvent +export function adoptFromEndpoint( + target: RuntimeClientTarget, + fetched: CreatedWorktree +): ExecutionHostId +export function adoptFromEndpoint( + target: RuntimeClientTarget, + fetched: FetchedRow +): Repo | ProjectGroup | ProjectHostSetup | FolderWorkspace | OwnedWorktreeEvent | ExecutionHostId { + switch (fetched.kind) { + case 'repo': + return adoptRepo(target, fetched.row) + case 'projectGroup': + return adoptProjectGroup(target, fetched.row) + case 'projectHostSetup': + return adoptProjectHostSetup(target, fetched.row) + case 'folderWorkspace': + return { + ...fetched.row, + executionHostId: + target.kind === 'environment' + ? getRuntimeTargetHostId(target) + : fetched.resolveOwnHostId(fetched.row) + } + case 'worktreeEvent': + // Why: this app's own events can name any repo in its catalog (local or direct SSH), so they + // pin the refresh to this app instead of to one host. + return target.kind === 'environment' + ? { ...fetched.row, executionHostId: getRuntimeTargetHostId(target) } + : { ...fetched.row, forceLocalOwner: true } + case 'createdWorktree': + return ( + fetched.requestedHostId ?? + (target.kind === 'environment' + ? getRuntimeTargetHostId(target) + : fetched.resolveOwnHostId()) + ) + } +} + +function adoptRepo(target: RuntimeClientTarget, repo: Repo): Repo { + if (target.kind === 'environment') { + // `connectionId` survives: on a server's row it names that server's nested SSH target. + return { ...repo, executionHostId: getRuntimeTargetHostId(target) } + } + if (repo.connectionId) { + return { ...repo, executionHostId: getRepoExecutionHostId(repo) } + } + return repo.executionHostId ? repo : { ...repo, executionHostId: LOCAL_EXECUTION_HOST_ID } +} + +function adoptProjectGroup(target: RuntimeClientTarget, projectGroup: ProjectGroup): ProjectGroup { + if (target.kind === 'environment') { + return { ...projectGroup, executionHostId: getRuntimeTargetHostId(target) } + } + if (projectGroup.connectionId) { + return { ...projectGroup, executionHostId: toSshExecutionHostId(projectGroup.connectionId) } + } + return { ...projectGroup, executionHostId: LOCAL_EXECUTION_HOST_ID } +} + +function adoptProjectHostSetup( + target: RuntimeClientTarget, + setup: ProjectHostSetup +): ProjectHostSetup { + // Why here: every setup row crossing IPC or RPC into the renderer passes through this + // adoption step, so it is where the declared field types stop being aspirational. + const adopted = normalizeProjectHostSetupRow(setup) + if (target.kind !== 'environment') { + return adopted + } + const hostId = getRuntimeTargetHostId(target) + const executionHostId = adopted.executionHostId ?? adopted.hostId + return { + ...adopted, + hostId, + executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, + runtimeOwnerEnvironmentId: target.environmentId, + // Why: paired clients route through the HUB and must not treat its private SSH target as client-local configuration. + connectionId: null + } +} diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts index 343a776e64a5..c334c7d309ae 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts @@ -21,6 +21,7 @@ import { mergeFolderWorkspaceUpdateResponse } from './folder-workspace-routing' import { getRuntimeTargetHostId } from '../runtime-target-host' +import { adoptFromEndpoint } from '../adopt-from-endpoint' import { mergeByIdentity, unchangedMergeSource } from '../catalog-identity' export type FetchedFolderWorkspaceCatalog = { @@ -184,20 +185,6 @@ export function clearRestoredFolderWorkspaceSessionOwners( return next } -export function folderWorkspaceWithFetchedOwner( - workspace: FolderWorkspace, - target: RuntimeClientTarget, - projectGroups: readonly ProjectGroup[] -): FolderWorkspace { - return { - ...workspace, - executionHostId: - target.kind === 'environment' - ? getRuntimeTargetHostId(target) - : getFolderWorkspaceHostId(workspace, projectGroups) - } -} - export async function fetchFolderWorkspaceCatalogForTarget( target: RuntimeClientTarget, projectGroups: readonly ProjectGroup[] @@ -213,18 +200,10 @@ export async function fetchFolderWorkspaceCatalogForTarget( { timeoutMs: 15_000, reuseRecentCompatibilityFailure: true } ) ).folderWorkspaces - let ownedFolderWorkspaces: FolderWorkspace[] - if (target.kind === 'local') { - const resolveHostId = createFolderWorkspaceHostResolver(projectGroups) - ownedFolderWorkspaces = fetchedFolderWorkspaces.map((workspace) => ({ - ...workspace, - executionHostId: resolveHostId(workspace) - })) - } else { - ownedFolderWorkspaces = fetchedFolderWorkspaces.map((workspace) => - folderWorkspaceWithFetchedOwner(workspace, target, projectGroups) - ) - } + const resolveOwnHostId = createFolderWorkspaceHostResolver(projectGroups) + const ownedFolderWorkspaces = fetchedFolderWorkspaces.map((row) => + adoptFromEndpoint(target, { kind: 'folderWorkspace', row, resolveOwnHostId }) + ) return { folderWorkspaces: ownedFolderWorkspaces, hostId: getRuntimeTargetHostId(target) 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 cfef51e4716d..e80047064cf8 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts @@ -19,13 +19,13 @@ import { 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 { folderWorkspaceUpdateInvalidatesPathStatus, getFolderWorkspacePathStatusRouteSettings, mergeFolderWorkspaceUpdateResponse } from './folder-workspace-routing' import { - folderWorkspaceWithFetchedOwner, getFolderWorkspaceHostId, getFolderWorkspaceUpdateIdentity, reconcileFailedFolderWorkspaceUpdate @@ -93,11 +93,12 @@ export function createFolderWorkspaceMutationActions( { timeoutMs: 15_000 } ) ).folderWorkspace - const ownedWorkspace = folderWorkspaceWithFetchedOwner( - workspace, - target, - get().projectGroups - ) + const { projectGroups } = get() + const ownedWorkspace = adoptFromEndpoint(target, { + kind: 'folderWorkspace', + row: workspace, + resolveOwnHostId: (row) => getFolderWorkspaceHostId(row, projectGroups) + }) set((s) => ({ folderWorkspaces: [ownedWorkspace, ...s.folderWorkspaces], folderWorkspacePathStatuses: {} diff --git a/src/renderer/src/store/github/check-actions.ts b/src/renderer/src/store/github/check-actions.ts index a364b0610282..a942757ba487 100644 --- a/src/renderer/src/store/github/check-actions.ts +++ b/src/renderer/src/store/github/check-actions.ts @@ -35,11 +35,7 @@ export const createCheckActions = ( options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, @@ -209,11 +205,7 @@ export const createCheckActions = ( options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const requestContext = getGitHubWorkItemRequestContext( get(), requestSettings, diff --git a/src/renderer/src/store/github/comment-fetch-actions.ts b/src/renderer/src/store/github/comment-fetch-actions.ts index 09ba5103213d..1971dc4f7181 100644 --- a/src/renderer/src/store/github/comment-fetch-actions.ts +++ b/src/renderer/src/store/github/comment-fetch-actions.ts @@ -17,11 +17,7 @@ export const createCommentFetchActions = ( options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, diff --git a/src/renderer/src/store/github/comment-mutation-actions.ts b/src/renderer/src/store/github/comment-mutation-actions.ts index 5de7c86f75f9..2762a95333fd 100644 --- a/src/renderer/src/store/github/comment-mutation-actions.ts +++ b/src/renderer/src/store/github/comment-mutation-actions.ts @@ -19,11 +19,7 @@ export const createCommentMutationActions = ( options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, @@ -117,11 +113,7 @@ export const createCommentMutationActions = ( options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, diff --git a/src/renderer/src/store/github/conversation-comment-actions.ts b/src/renderer/src/store/github/conversation-comment-actions.ts index d92a51baa398..383c27eaa7a6 100644 --- a/src/renderer/src/store/github/conversation-comment-actions.ts +++ b/src/renderer/src/store/github/conversation-comment-actions.ts @@ -18,11 +18,7 @@ export const createConversationCommentActions = ( options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, diff --git a/src/renderer/src/store/github/issue-actions.ts b/src/renderer/src/store/github/issue-actions.ts index d360457a235a..57638369c93e 100644 --- a/src/renderer/src/store/github/issue-actions.ts +++ b/src/renderer/src/store/github/issue-actions.ts @@ -17,11 +17,7 @@ export const createIssueActions = ( fetchIssue: async (repoPath, number, options) => { const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, diff --git a/src/renderer/src/store/github/project-actions.ts b/src/renderer/src/store/github/project-actions.ts index ca1e5b24da04..f6943566b4e9 100644 --- a/src/renderer/src/store/github/project-actions.ts +++ b/src/renderer/src/store/github/project-actions.ts @@ -25,9 +25,9 @@ export const createProjectActions = ( GitHubSlice, 'fetchProjectViewTable' | 'updateProjectFieldValue' | 'clearProjectFieldValue' > => ({ - fetchProjectViewTable: async (args, options) => { - const target = getActiveRuntimeTarget(get().settings) - const sourceScope = projectViewSourceScope(get().settings) + fetchProjectViewTable: async (sourceSettings, args, options) => { + const target = getActiveRuntimeTarget(sourceSettings) + const sourceScope = projectViewSourceScope(sourceSettings) 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/provider-request-coalescing.test.ts b/src/renderer/src/store/github/provider-request-coalescing.test.ts index f012999ce65d..5367fb1989d5 100644 --- a/src/renderer/src/store/github/provider-request-coalescing.test.ts +++ b/src/renderer/src/store/github/provider-request-coalescing.test.ts @@ -308,8 +308,10 @@ describe('GitHub provider request upgrade coalescing', () => { .mockReturnValueOnce(replacementWeak.promise) .mockReturnValue(forcedResult.promise) - const first = store.getState().fetchProjectViewTable(projectViewRequest) - const forcedFetch = store.getState().fetchProjectViewTable(projectViewRequest, { force: true }) + const first = store.getState().fetchProjectViewTable(null, projectViewRequest) + const forcedFetch = store + .getState() + .fetchProjectViewTable(null, 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( @@ -318,7 +320,7 @@ describe('GitHub provider request upgrade coalescing', () => { expect(pending).toBeDefined() let replacementFetch: Promise | undefined void pending?.promise.then(() => { - replacementFetch = store.getState().fetchProjectViewTable(projectViewRequest) + replacementFetch = store.getState().fetchProjectViewTable(null, projectViewRequest) }) const staleError = { type: 'network_error' as const, message: 'first attempt offline' } diff --git a/src/renderer/src/store/github/review-thread-actions.ts b/src/renderer/src/store/github/review-thread-actions.ts index 727465919e20..299e61d0ba4f 100644 --- a/src/renderer/src/store/github/review-thread-actions.ts +++ b/src/renderer/src/store/github/review-thread-actions.ts @@ -14,11 +14,7 @@ export const createReviewThreadActions = ( options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath ) const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) + const requestSettings = getGitHubRepoSourceSettings(get(), repo, options?.sourceContext) const cacheKey = sourceScopedRepoCacheKey( repoPath, repoId, diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts index 6a5116804d3d..6943d07ab5d1 100644 --- a/src/renderer/src/store/github/slice-types.ts +++ b/src/renderer/src/store/github/slice-types.ts @@ -26,6 +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 { CacheEntry, FetchOptions, @@ -253,7 +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. */ fetchProjectViewTable: ( + sourceSettings: Pick | null, args: GetProjectViewTableArgs, options?: FetchOptions ) => Promise 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 3bb9d20e872c..3fb7738650f2 100644 --- a/src/renderer/src/store/github/work-item-aggregate-actions.ts +++ b/src/renderer/src/store/github/work-item-aggregate-actions.ts @@ -114,11 +114,7 @@ export const createWorkItemAggregateActions = ( repos.map(async (r) => { const requestState = get() const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - r.sourceContext - ) + const requestSettings = getGitHubWorkItemSourceSettings(requestState, repo, r.sourceContext) const requestContext = getGitHubWorkItemRequestContext( requestState, requestSettings, @@ -199,7 +195,7 @@ export const createWorkItemAggregateActions = ( const requestState = get() const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, + requestState, repo, r.sourceContext ) @@ -241,7 +237,7 @@ export const createWorkItemAggregateActions = ( : getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath) const cached = get().workItemsCache[key] const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, + requestState, repo, 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 c14eca173630..305529d51603 100644 --- a/src/renderer/src/store/github/work-item-fetch-actions.ts +++ b/src/renderer/src/store/github/work-item-fetch-actions.ts @@ -90,7 +90,7 @@ export const createWorkItemFetchActions = ( const requestState = get() const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, + requestState, repo, 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 new file mode 100644 index 000000000000..af2b98b989cf --- /dev/null +++ b/src/renderer/src/store/github/work-item-routing.test.ts @@ -0,0 +1,67 @@ +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' + +const settings = { + ...getDefaultSettings('/home/user'), + activeRuntimeEnvironmentId: 'focused-runtime' +} +const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1' +} +function makeRepo(executionHostId: Repo['executionHostId']): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '', + addedAt: 0, + connectionId: null, + executionHostId + } +} +const runtimeRepo = makeRepo('runtime:owner-runtime') +const localRepo = makeRepo('local') + +function stateWith(repos: Repo[]): Parameters[0] { + return { settings, repos } +} + +describe.each([ + ['getGitHubRepoSourceSettings', getGitHubRepoSourceSettings], + ['getGitHubWorkItemSourceSettings', getGitHubWorkItemSourceSettings] +])('%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( + resolve(stateWith([runtimeRepo]), runtimeRepo, localSource)?.activeRuntimeEnvironmentId + ).toBe('owner-runtime') + }) + + it('lets a runtime source override the repo owner', () => { + expect( + resolve(stateWith([runtimeRepo]), runtimeRepo, { + ...localSource, + hostId: 'runtime:source-runtime' + })?.activeRuntimeEnvironmentId + ).toBe('source-runtime') + }) + + it('never routes a local source to the focused runtime', () => { + expect( + resolve(stateWith([localRepo]), localRepo, localSource)?.activeRuntimeEnvironmentId + ).toBeNull() + expect(resolve(stateWith([]), undefined, localSource)?.activeRuntimeEnvironmentId).toBeNull() + }) + + it('keeps a local source local when the repo id also exists on a server', () => { + const state = stateWith([runtimeRepo, localRepo]) + expect(resolve(state, runtimeRepo, localSource)?.activeRuntimeEnvironmentId).toBeNull() + expect(resolve(state, localRepo, localSource)?.activeRuntimeEnvironmentId).toBeNull() + }) +}) diff --git a/src/renderer/src/store/github/work-item-routing.ts b/src/renderer/src/store/github/work-item-routing.ts index b47ee0501d8b..10d5479efd0b 100644 --- a/src/renderer/src/store/github/work-item-routing.ts +++ b/src/renderer/src/store/github/work-item-routing.ts @@ -3,7 +3,6 @@ import type { Repo } from '../../../../shared/repo-types' import type { GitHubWorkItem, ListWorkItemsResult } from '../../../../shared/github/work-item-types' import { getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, type TaskSourceContext } from '../../../../shared/task-source-context' import { @@ -14,6 +13,7 @@ import { } from '../../../../shared/execution-host' import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' import { callRuntimeRpc, RuntimeRpcCallError } from '../../runtime/runtime-rpc-client' +import { getHostlessSourceRepoOwnerEnvironmentId } from '@/lib/repo-runtime-owner' import { workItemsCacheKey } from './cache-identity' import { findRepoForGitHubOwner, @@ -31,6 +31,8 @@ export type GitHubWorkItemRequestTarget = | { kind: 'environment'; environmentId: string; runtimeRepoId: string } | { kind: 'local' } +type GitHubSourceRoutingState = Pick + export type GitHubWorkItemsListArgs = { limit: number query?: string @@ -46,15 +48,35 @@ export function settingsForGitHubRepoOwner( 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 GitHub lookups run on the desktop client; host focus must not redirect them to the selected runtime. + return withActiveRuntimeEnvironmentId( + settings, + parsed?.kind === 'runtime' ? parsed.environmentId : null + ) +} + +function withActiveRuntimeEnvironmentId( + settings: AppState['settings'], + activeRuntimeEnvironmentId: string | null +): AppState['settings'] { return settings - ? { ...settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) + ? { ...settings, activeRuntimeEnvironmentId } + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: routing readers only consume activeRuntimeEnvironmentId before settings hydrate. + ({ activeRuntimeEnvironmentId } as AppState['settings']) +} + +function settingsForGitHubTaskSource( + state: GitHubSourceRoutingState, + repo: Pick | undefined, + sourceContext: TaskSourceContext +): AppState['settings'] { + const sourceHost = parseExecutionHostId(sourceContext.hostId) + return withActiveRuntimeEnvironmentId( + state.settings, + sourceHost?.kind === 'runtime' + ? sourceHost.environmentId + : getHostlessSourceRepoOwnerEnvironmentId(state.repos, repo?.id) + ) } export function settingsForGitHubFocusedRepoOwner( @@ -107,31 +129,25 @@ export function getGitHubWorkItemSourceCacheScope( } export function getGitHubWorkItemSourceSettings( - settings: AppState['settings'], - repo: Pick | undefined, + state: GitHubSourceRoutingState, + repo: Pick | undefined, sourceContext?: TaskSourceContext | null ): AppState['settings'] { if (sourceContext?.provider === 'github') { - return { - ...settings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as AppState['settings'] + return settingsForGitHubTaskSource(state, repo, sourceContext) } - return settingsForGitHubFocusedRepoOwner(settings, repo) + return settingsForGitHubFocusedRepoOwner(state.settings, repo) } export function getGitHubRepoSourceSettings( - settings: AppState['settings'], - repo: Pick | undefined, + state: GitHubSourceRoutingState, + repo: Pick | undefined, sourceContext?: TaskSourceContext | null ): AppState['settings'] { if (sourceContext?.provider === 'github') { - return { - ...settings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as AppState['settings'] + return settingsForGitHubTaskSource(state, repo, sourceContext) } - return settingsForGitHubRepoOwner(settings, repo) + return settingsForGitHubRepoOwner(state.settings, repo) } export function getGitHubWorkItemRequestContext( diff --git a/src/renderer/src/store/host-catalog-fencing.test.ts b/src/renderer/src/store/host-catalog-fencing.test.ts new file mode 100644 index 000000000000..d11a65221c50 --- /dev/null +++ b/src/renderer/src/store/host-catalog-fencing.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { replaceRuntimeEnvironmentRevisions } from '../runtime/runtime-environment-revision' + +const generations = vi.hoisted(() => ({ + connection: new Map(), + sshState: new Map(), + nestedTarget: new Map(), + localTarget: new Map() +})) + +vi.mock('./slices/runtime-status', () => ({ + getRuntimeEnvironmentConnectionGeneration: (id: string) => generations.connection.get(id) ?? 0 +})) +vi.mock('./slices/runtime-environment-ssh', () => ({ + getEnvironmentSshStateGeneration: (id: string) => generations.sshState.get(id) ?? 0, + getEnvironmentSshTargetConnectionGeneration: (id: string, targetId: string) => + generations.nestedTarget.get(`${id}/${targetId}`) ?? 0 +})) +vi.mock('./slices/ssh', () => ({ + getLocalSshTargetConnectionGeneration: (targetId: string) => + generations.localTarget.get(targetId) ?? 0 +})) + +import { + captureHostIdentityFence, + claimHostCatalogFence, + isHostCatalogFenceCurrent, + isHostIdentityFenceCurrent +} from './host-catalog-fencing' + +const env = { kind: 'environment', environmentId: 'env-1' } as const +const store = { removedRuntimeEnvironmentIds: new Set() } +const get = (): typeof store => store + +beforeEach(() => { + for (const map of Object.values(generations)) { + map.clear() + } + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 1 }]) +}) + +describe('host identity fence', () => { + it('holds through catalog and SSH-state churn that the data fence rejects', () => { + const identity = captureHostIdentityFence({ target: env, at: 'local' }) + const catalog = claimHostCatalogFence(get, 'repos', env) + generations.sshState.set('env-1', 1) + claimHostCatalogFence(get, 'repos', env) + + expect(isHostIdentityFenceCurrent(identity)).toBe(true) + expect(isHostCatalogFenceCurrent(get, catalog)).toBe(false) + }) + + it('moves when the server reconnects or is re-paired to another machine', () => { + const reconnected = captureHostIdentityFence({ target: env, at: 'local' }) + generations.connection.set('env-1', 1) + expect(isHostIdentityFenceCurrent(reconnected)).toBe(false) + + const repaired = captureHostIdentityFence({ target: env, at: 'local' }) + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 2 }], { + retired: ['env-1'], + sameHost: [] + }) + expect(isHostIdentityFenceCurrent(repaired)).toBe(false) + }) + + it('holds across a re-pair proven to be the same machine', () => { + const fence = captureHostIdentityFence({ target: env, at: 'local' }) + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 2 }], { + retired: [], + sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }] + }) + expect(isHostIdentityFenceCurrent(fence)).toBe(true) + }) + + it('moves when the server is removed', () => { + const fence = captureHostIdentityFence({ target: env, at: 'local' }) + replaceRuntimeEnvironmentRevisions([]) + expect(isHostIdentityFenceCurrent(fence)).toBe(false) + }) + + it('tracks only the nested SSH target the route names', () => { + const fence = captureHostIdentityFence({ target: env, at: 'ssh:box' }) + generations.nestedTarget.set('env-1/other', 3) + expect(isHostIdentityFenceCurrent(fence)).toBe(true) + generations.nestedTarget.set('env-1/box', 1) + expect(isHostIdentityFenceCurrent(fence)).toBe(false) + }) + + it('tracks a directly dialed SSH target by its local connection generation', () => { + const fence = captureHostIdentityFence({ target: { kind: 'local' }, at: 'ssh:box' }) + generations.nestedTarget.set('env-1/box', 1) + expect(isHostIdentityFenceCurrent(fence)).toBe(true) + generations.localTarget.set('box', 1) + expect(isHostIdentityFenceCurrent(fence)).toBe(false) + }) +}) diff --git a/src/renderer/src/store/host-catalog-fencing.ts b/src/renderer/src/store/host-catalog-fencing.ts index 346b7292ce2a..02219e1635c0 100644 --- a/src/renderer/src/store/host-catalog-fencing.ts +++ b/src/renderer/src/store/host-catalog-fencing.ts @@ -1,24 +1,85 @@ import type { AppState } from './types' -import type { RuntimeClientTarget } from '../runtime/runtime-rpc-client' +import type { HostRoute, RuntimeClientTarget } from '../runtime/runtime-client-target' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { + getRuntimeEnvironmentRevision, + resolveContinuedRuntimeEnvironmentRevision +} from '../runtime/runtime-environment-revision' import { isRemovedRuntimeHostId } from './slices/stale-runtime-host-rows' -import { getEnvironmentSshStateGeneration } from './slices/runtime-environment-ssh' +import { + getEnvironmentSshStateGeneration, + getEnvironmentSshTargetConnectionGeneration +} from './slices/runtime-environment-ssh' +import { getLocalSshTargetConnectionGeneration } from './slices/ssh' import { getRuntimeEnvironmentConnectionGeneration } from './slices/runtime-status' import { getRuntimeTargetHostId } from './runtime-target-host' +/** + * Who answered: pairing, runtime incarnation (connection generation) and, for an SSH place, that + * target's connection generation. Guards mutations; unrelated catalog or SSH churn never moves it. + */ +export type HostIdentityFence = { + route: HostRoute + pairingRevision: number | undefined + runtimeConnectionGeneration: number | null + sshTargetConnectionGeneration: number | null +} + +function sshTargetConnectionGeneration(route: HostRoute): number | null { + const place = parseExecutionHostId(route.at) + if (place?.kind !== 'ssh') { + return null + } + return route.target.kind === 'environment' + ? getEnvironmentSshTargetConnectionGeneration(route.target.environmentId, place.targetId) + : getLocalSshTargetConnectionGeneration(place.targetId) +} + +export function captureHostIdentityFence(route: HostRoute): HostIdentityFence { + const environmentId = route.target.kind === 'environment' ? route.target.environmentId : null + return { + route, + pairingRevision: environmentId ? getRuntimeEnvironmentRevision(environmentId) : undefined, + runtimeConnectionGeneration: environmentId + ? getRuntimeEnvironmentConnectionGeneration(environmentId) + : null, + sshTargetConnectionGeneration: sshTargetConnectionGeneration(route) + } +} + +export function isHostIdentityFenceCurrent(fence: HostIdentityFence): boolean { + const { route } = fence + if (route.target.kind === 'environment') { + const { environmentId } = route.target + // Why continuity: a re-pair proven to be the same machine keeps the identity. + if ( + resolveContinuedRuntimeEnvironmentRevision(environmentId, fence.pairingRevision) !== + getRuntimeEnvironmentRevision(environmentId) || + getRuntimeEnvironmentConnectionGeneration(environmentId) !== fence.runtimeConnectionGeneration + ) { + return false + } + } + return sshTargetConnectionGeneration(route) === fence.sshTargetConnectionGeneration +} + export type HostCatalogKind = 'repos' | 'project-groups' | 'folder-workspaces' +/** Identity fence plus data generations: guards reads that apply a host's catalog snapshot. */ export type HostCatalogFence = { key: string generation: number target: RuntimeClientTarget + identity: HostIdentityFence sshStateGeneration: number | null - runtimeConnectionGeneration: number | null } -const latestHostCatalogGenerationByStore = new WeakMap<() => AppState, Map>() +type CatalogFenceStore = () => Pick + +const latestHostCatalogGenerationByStore = new WeakMap>() export function claimHostCatalogFence( - get: () => AppState, + get: CatalogFenceStore, kind: HostCatalogKind, target: RuntimeClientTarget ): HostCatalogFence { @@ -34,16 +95,16 @@ export function claimHostCatalogFence( key, generation, target, + identity: captureHostIdentityFence({ target, at: 'local' }), sshStateGeneration: - target.kind === 'environment' ? getEnvironmentSshStateGeneration(target.environmentId) : null, - runtimeConnectionGeneration: - target.kind === 'environment' - ? getRuntimeEnvironmentConnectionGeneration(target.environmentId) - : null + target.kind === 'environment' ? getEnvironmentSshStateGeneration(target.environmentId) : null } } -export function isHostCatalogFenceCurrent(get: () => AppState, fence: HostCatalogFence): boolean { +export function isHostCatalogFenceCurrent( + get: CatalogFenceStore, + fence: HostCatalogFence +): boolean { if (latestHostCatalogGenerationByStore.get(get)?.get(fence.key) !== fence.generation) { return false } @@ -56,7 +117,6 @@ export function isHostCatalogFenceCurrent(get: () => AppState, fence: HostCatalo get().removedRuntimeEnvironmentIds ) && getEnvironmentSshStateGeneration(fence.target.environmentId) === fence.sshStateGeneration && - getRuntimeEnvironmentConnectionGeneration(fence.target.environmentId) === - fence.runtimeConnectionGeneration + isHostIdentityFenceCurrent(fence.identity) ) } diff --git a/src/renderer/src/store/project-groups/project-group-catalog.ts b/src/renderer/src/store/project-groups/project-group-catalog.ts index 25a404398e2b..ce87bd5d70a4 100644 --- a/src/renderer/src/store/project-groups/project-group-catalog.ts +++ b/src/renderer/src/store/project-groups/project-group-catalog.ts @@ -2,7 +2,7 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' import { callRuntimeRpc, type RuntimeClientTarget } from '../../runtime/runtime-rpc-client' import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing' import { getRuntimeTargetHostId } from '../runtime-target-host' -import { projectGroupWithFetchedOwner } from './project-group-owner-stamping' +import { adoptFromEndpoint } from '../adopt-from-endpoint' import { mergeByIdentity, unchangedMergeSource } from '../catalog-identity' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -48,7 +48,9 @@ export async function fetchProjectGroupCatalogForTarget( }) ).groups return { - projectGroups: fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)), + projectGroups: fetchedGroups.map((group) => + adoptFromEndpoint(target, { kind: 'projectGroup', row: group }) + ), hostId: getRuntimeTargetHostId(target) } } 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 3c1e79288cce..f5fd46d7e5ec 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -15,8 +15,8 @@ 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 { repoWithFetchedOwner, settingsForRepoOwner } from '../repos/owner-routing' -import { projectGroupWithFetchedOwner } from './project-group-owner-stamping' +import { settingsForRepoOwner } from '../repos/owner-routing' +import { adoptFromEndpoint } from '../adopt-from-endpoint' export function createProjectGroupMutationActions( set: Parameters>[0], @@ -47,7 +47,7 @@ export function createProjectGroupMutationActions( { timeoutMs: 15_000 } ) ).group - const ownedGroup = projectGroupWithFetchedOwner(group, target) + const ownedGroup = adoptFromEndpoint(target, { kind: 'projectGroup', row: group }) const ownerHostId = getProjectGroupHostId(ownedGroup) set((s) => { // An overlapping catalog refresh may have already inserted a newer copy. @@ -92,7 +92,7 @@ export function createProjectGroupMutationActions( if (!updated) { return false } - const ownedGroup = projectGroupWithFetchedOwner(updated, target) + const ownedGroup = adoptFromEndpoint(target, { kind: 'projectGroup', row: updated }) set((s) => ({ projectGroups: s.projectGroups.map((group) => projectGroupMatchesOwnerHost(group, groupId, ownerHostId) ? ownedGroup : group @@ -252,7 +252,7 @@ export function createProjectGroupMutationActions( if (!moved) { return false } - const ownedMoved = repoWithFetchedOwner(moved, target) + const ownedMoved = adoptFromEndpoint(target, { kind: 'repo', row: moved }) const movedHostId = getRepoExecutionHostId(ownedMoved) set((s) => { const nextRepos = s.repos.map((repo) => diff --git a/src/renderer/src/store/project-groups/project-group-owner-stamping.ts b/src/renderer/src/store/project-groups/project-group-owner-stamping.ts deleted file mode 100644 index 32ac7888d984..000000000000 --- a/src/renderer/src/store/project-groups/project-group-owner-stamping.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ProjectGroup } from '../../../../shared/project-group-types' -import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' -import type { RuntimeClientTarget } from '../../runtime/runtime-rpc-client' -import { getRuntimeTargetHostId } from '../runtime-target-host' - -export function projectGroupWithFetchedOwner( - projectGroup: ProjectGroup, - target: RuntimeClientTarget -): ProjectGroup { - if (target.kind === 'environment') { - return { ...projectGroup, executionHostId: getRuntimeTargetHostId(target) } - } - if (projectGroup.connectionId) { - return { ...projectGroup, executionHostId: toSshExecutionHostId(projectGroup.connectionId) } - } - return { ...projectGroup, executionHostId: LOCAL_EXECUTION_HOST_ID } -} diff --git a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts index 1d57bde1b875..c808ff5f4ab1 100644 --- a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts +++ b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { getProjectHostSetupProjectionFromState } from '../project-host-setup-selector' -import { fetchProjectHostSetupCompatibility, setupWithFetchedOwner } from './project-host-routing' +import { fetchProjectHostSetupCompatibility } from './project-host-routing' +import { adoptFromEndpoint } from '../adopt-from-endpoint' // Crash 3bcc5be3 (v1.4.188, Linux, page.settings boundary): a setup row whose repoId arrived // null reached Settings' projectByRepoId useMemo and threw "Cannot read properties of null @@ -88,10 +89,10 @@ describe('project catalog ingest with non-string row fields', () => { // Why: a remote host on a different Orca version is a first-class source of these rows, and // decoders hand them over verbatim — the client cannot assume the host already repaired them. it('coerces on the remote adoption boundary too', () => { - const adopted = setupWithFetchedOwner(badSetups()[1]!, { - kind: 'environment', - environmentId: 'env-1' - }) + const adopted = adoptFromEndpoint( + { kind: 'environment', environmentId: 'env-1' }, + { kind: 'projectHostSetup', row: badSetups()[1]! } + ) expect(adopted.repoId).toBe('') expect(adopted.path).toBe('') expect(adopted.hostId).toBe('runtime:env-1') diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index 1a4a45ea03ac..2db4a0bc64c5 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -10,7 +10,6 @@ import { type ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import { - normalizeProjectHostSetupRow, normalizeProjectHostSetupRows, normalizeProjectRows } from '../../../../shared/project-catalog-row-normalization' @@ -18,7 +17,7 @@ import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { parseExecutionHostId } from '../../../../shared/execution-host' import { assertRuntimeEnvironmentCapability, callRuntimeRpc, @@ -26,6 +25,7 @@ import { type RuntimeClientTarget } from '../../runtime/runtime-rpc-client' import { getRuntimeTargetHostId } from '../runtime-target-host' +import { adoptFromEndpoint } from '../adopt-from-endpoint' export function getProjectSetupRuntimeTarget( hostId: ProjectHostSetupExistingFolderArgs['hostId'] @@ -52,28 +52,6 @@ export function getProjectUpdateRuntimeTarget( : { kind: 'local' } } -export function setupWithFetchedOwner( - setup: ProjectHostSetup, - target: RuntimeClientTarget -): ProjectHostSetup { - // Why here: every setup row crossing IPC or RPC into the renderer passes through this - // adoption step, so it is where the declared field types stop being aspirational. - const adopted = normalizeProjectHostSetupRow(setup) - const hostId = getRuntimeTargetHostId(target) - if (target.kind !== 'environment') { - return adopted - } - const executionHostId = adopted.executionHostId ?? adopted.hostId - return { - ...adopted, - hostId, - executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, - runtimeOwnerEnvironmentId: target.environmentId, - // Why: paired clients route through the HUB and must not treat its private SSH target as client-local configuration. - connectionId: null - } -} - function normalizeProjectCatalogProjection( projection: ProjectHostSetupProjection ): ProjectHostSetupProjection { @@ -130,7 +108,9 @@ export async function fetchProjectHostSetupCompatibility( // Why projects too: the same wire response carries them, and a remote host on another // Orca version can publish a row whose declared field types do not hold. projects: normalizeProjectRows([...projectResponse.projects]), - setups: setupResponse.setups.map((setup) => setupWithFetchedOwner(setup, target)) + setups: setupResponse.setups.map((setup) => + adoptFromEndpoint(target, { kind: 'projectHostSetup', row: setup }) + ) } } catch { // Why: newer clients must hydrate against older runtimes that only know repo.list; derive the transitional model locally. diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts index dbc106a6afa9..e2d787408cfb 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -15,12 +15,11 @@ import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import type { RepoSlice } from '../repos/repo-state' import { ERROR_TOAST_DURATION } from '../repos/repo-state' -import { repoWithFetchedOwner } from '../repos/owner-routing' +import { adoptFromEndpoint } from '../adopt-from-endpoint' import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' import { assertProjectHostSetupMutationRuntimeCapabilities, - getProjectSetupRuntimeTarget, - setupWithFetchedOwner + getProjectSetupRuntimeTarget } from './project-host-routing' export function createProjectHostSetupActions( @@ -55,9 +54,9 @@ export function createProjectHostSetupActions( { timeoutMs: 15_000 } ) ).result - const repo = repoWithFetchedOwner(result.repo, target) + const repo = adoptFromEndpoint(target, { kind: 'repo', row: result.repo }) const repoHostId = getRepoExecutionHostId(repo) - const setup = setupWithFetchedOwner(result.setup, target) + const setup = adoptFromEndpoint(target, { kind: 'projectHostSetup', row: result.setup }) const project = normalizeProjectRow(result.project) set((s) => { const nextRepos = s.repos.some((entry) => @@ -109,7 +108,7 @@ export function createProjectHostSetupActions( { timeoutMs: 15_000 } ) ).result - const setup = setupWithFetchedOwner(result.setup, target) + const setup = adoptFromEndpoint(target, { kind: 'projectHostSetup', row: result.setup }) const project = normalizeProjectRow(result.project) set((s) => ({ projects: s.projects.some((entry) => entry.id === project.id) @@ -149,9 +148,11 @@ export function createProjectHostSetupActions( { timeoutMs: 15_000 } ) ).result - const setup = setupWithFetchedOwner(result.setup, target) + const setup = adoptFromEndpoint(target, { kind: 'projectHostSetup', row: result.setup }) const project = normalizeProjectRow(result.project) - const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repo = result.repo + ? adoptFromEndpoint(target, { kind: 'repo', row: result.repo }) + : undefined const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => ({ repos: repo @@ -198,7 +199,9 @@ export function createProjectHostSetupActions( { timeoutMs: 15_000 } ) ).result - const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repo = result.repo + ? adoptFromEndpoint(target, { kind: 'repo', row: result.repo }) + : undefined const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => { const projectHostSetups = s.projectHostSetups.filter( diff --git a/src/renderer/src/store/repos/owner-routing.ts b/src/renderer/src/store/repos/owner-routing.ts index 631083d4d04f..52ce1d87f3d9 100644 --- a/src/renderer/src/store/repos/owner-routing.ts +++ b/src/renderer/src/store/repos/owner-routing.ts @@ -1,6 +1,5 @@ import type { AppState } from '../types' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { Repo } from '../../../../shared/repo-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' @@ -10,27 +9,9 @@ import { } from '../../runtime/runtime-rpc-client' import type { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { translate } from '@/i18n/i18n' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId -} from '../../../../shared/execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { AddRepoPathOptions } from './repo-state' -import { getRuntimeTargetHostId } from '../runtime-target-host' - -export function repoWithFetchedOwner( - repo: Repo, - target: ReturnType -): Repo { - if (target.kind === 'environment') { - return { ...repo, executionHostId: getRuntimeTargetHostId(target) } - } - if (repo.connectionId) { - return { ...repo, executionHostId: getRepoExecutionHostId(repo) } - } - return repo.executionHostId ? repo : { ...repo, executionHostId: LOCAL_EXECUTION_HOST_ID } -} export function settingsForRepoOwner( state: Pick, diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index 95d81bcb86e2..5629a55b0406 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -17,9 +17,9 @@ import { ERROR_TOAST_DURATION } from './repo-state' import { fetchRuntimeAddProjectPathStatus, getAddRepoPathRouteSettings, - getRuntimeEnvironmentDisplayName, - repoWithFetchedOwner + getRuntimeEnvironmentDisplayName } from './owner-routing' +import { adoptFromEndpoint } from '../adopt-from-endpoint' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' import { warnIfProjectKnownInAnotherProfile } from '../projects/project-profile-presence' import { warnIfProjectCrossesWslFilesystemBoundary } from '../projects/project-wsl-filesystem-boundary-advisory' @@ -90,7 +90,7 @@ export function createRepoAddActions( }) return null } - repo = repoWithFetchedOwner(repo, target) + repo = adoptFromEndpoint(target, { kind: 'repo', row: repo }) const repoIdentity = getRepoHostIdentity(repo) const alreadyAdded = get().repos.some((r) => getRepoHostIdentity(r) === repoIdentity) if (alreadyAdded) { diff --git a/src/renderer/src/store/repos/repo-catalog-merge.ts b/src/renderer/src/store/repos/repo-catalog-merge.ts index f08f944d6ef8..1aa5aca87e22 100644 --- a/src/renderer/src/store/repos/repo-catalog-merge.ts +++ b/src/renderer/src/store/repos/repo-catalog-merge.ts @@ -10,7 +10,7 @@ import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import type { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { RepoSlice } from './repo-state' -import { repoWithFetchedOwner } from './owner-routing' +import { adoptFromEndpoint } from '../adopt-from-endpoint' import { getRuntimeTargetHostId } from '../runtime-target-host' import { fetchProjectHostSetupCompatibility } from '../projects/project-host-routing' import { mergeFetchedReposForHost } from './repo-catalog-identity' @@ -37,7 +37,7 @@ export async function fetchRepoCatalogForTarget( reuseRecentCompatibilityFailure: true }) ).repos - const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target)) + const repos = fetchedRepos.map((repo) => adoptFromEndpoint(target, { kind: 'repo', row: repo })) return { repos, projectHostSetupCompatibility: await fetchProjectHostSetupCompatibility(target, repos), diff --git a/src/renderer/src/store/repos/repo-removal.test.ts b/src/renderer/src/store/repos/repo-removal.test.ts new file mode 100644 index 000000000000..f368cf853fb2 --- /dev/null +++ b/src/renderer/src/store/repos/repo-removal.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { countOpenRepoTerminalTabs } from './repo-removal' + +describe('countOpenRepoTerminalTabs', () => { + it('counts tabs with a PTY on the removed host only', () => { + const state = { + worktreesByRepo: { + 'repo-1': [ + { id: 'repo-1::/a', hostId: 'local' }, + { id: 'repo-1::/srv/a', hostId: 'ssh:box' } + ] + }, + detectedWorktreesByRepo: {}, + tabsByWorktree: { + 'repo-1::/a': [{ id: 'tab-live' }, { id: 'tab-no-pty' }], + 'repo-1::/srv/a': [{ id: 'tab-ssh' }] + }, + ptyIdsByTabId: { 'tab-live': ['pty-1'], 'tab-no-pty': [], 'tab-ssh': ['pty-2'] } + } + + expect(countOpenRepoTerminalTabs(state, 'repo-1', 'local')).toBe(1) + expect(countOpenRepoTerminalTabs(state, 'repo-1', 'ssh:box')).toBe(1) + expect(countOpenRepoTerminalTabs(state, 'repo-2', 'local')).toBe(0) + }) +}) diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index c042d46493f0..e1dde15d7cf2 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -32,8 +32,17 @@ export function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: str return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId } +type HostScopedWorktreeRef = { id: string; hostId?: string } + +type RepoWorktreeCatalog = { + worktreesByRepo: Readonly> + detectedWorktreesByRepo: Readonly< + Record + > +} + export function getKnownRepoWorktreeIds( - state: AppState, + state: RepoWorktreeCatalog, projectId: string, hostId?: string ): string[] { @@ -51,6 +60,26 @@ export function getKnownRepoWorktreeIds( return [...ids] } +/** Terminal tabs that removing this project's host row will close. */ +export function countOpenRepoTerminalTabs( + state: RepoWorktreeCatalog & { + tabsByWorktree: Readonly> + ptyIdsByTabId: Readonly> + }, + projectId: string, + hostId: string +): number { + let count = 0 + for (const worktreeId of getKnownRepoWorktreeIds(state, projectId, hostId)) { + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + if ((state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0) { + count += 1 + } + } + } + return count +} + export function createRepoRemovalActions( set: Parameters>[0], get: Parameters>[1] diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index 93da54840a43..175dcfe827d4 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -16,7 +16,8 @@ import { normalizeWorktreeVisibilitySourcePreferences } from '../../../../shared/worktree/visibility-sources' import type { RepoSlice, RepoUpdate } from './repo-state' -import { repoWithFetchedOwner, settingsForRepoOwner } from './owner-routing' +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' @@ -148,7 +149,7 @@ export function createRepoUpdateActions( return r } if (updatedRepo) { - return repoWithFetchedOwner(updatedRepo, target) + return adoptFromEndpoint(target, { kind: 'repo', row: updatedRepo }) } let mergedRepo: Repo = r const { diff --git a/src/renderer/src/store/slices/diff-comment-persistence.ts b/src/renderer/src/store/slices/diff-comment-persistence.ts index 4d28d7b7f1de..e0f97a2ce790 100644 --- a/src/renderer/src/store/slices/diff-comment-persistence.ts +++ b/src/renderer/src/store/slices/diff-comment-persistence.ts @@ -12,6 +12,8 @@ import { getExecutionHostIdForFolderWorkspace, getRuntimeEnvironmentIdForFolderWorkspace } from '@/lib/folder-workspace-runtime-owner' +import { callHostRoute } from '@/runtime/host-route-call' +import { hostRouteForAuthority } from '@/runtime/runtime-client-target' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' export function normalizeDiffComment(comment: DiffComment): DiffComment { @@ -65,21 +67,23 @@ async function persist( scope.folderWorkspaceId, executionHostId ) - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) - const updated = - target.kind === 'local' - ? await window.api.folderWorkspaces.update({ - folderWorkspaceId: scope.folderWorkspaceId, - updates: { diffComments } - }) - : ( - await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>( - target, - 'folderWorkspace.update', - { folderWorkspaceId: scope.folderWorkspaceId, updates: { diffComments } }, - { timeoutMs: 15_000 } - ) - ).folderWorkspace + const updated = runtimeEnvironmentId + ? ( + await callHostRoute<{ folderWorkspace: FolderWorkspace | null }>( + // Why local: the folder's catalog row lives on the server even when it names an SSH target. + hostRouteForAuthority({ + endpoint: { kind: 'environment', environmentId: runtimeEnvironmentId }, + at: 'local' + }), + 'folderWorkspace.update', + { folderWorkspaceId: scope.folderWorkspaceId, updates: { diffComments } }, + { timeoutMs: 15_000 } + ) + ).folderWorkspace + : await window.api.folderWorkspaces.update({ + folderWorkspaceId: scope.folderWorkspaceId, + updates: { diffComments } + }) if (!updated?.diffComments) { throw new Error('Failed to persist folder workspace review notes') } diff --git a/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts b/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts index 3fbf9e891d00..a7499ec83f53 100644 --- a/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts @@ -11,7 +11,7 @@ import { type OpenCheckRunDetailsState } from '@/components/editor/check-run-details-tab' import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' -import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { forgeOwnerHostIdForWorkspace } from '@/runtime/forge-credential-target' import { findWorktreeById, getRepoIdFromWorktreeId } from '../../worktree-helpers' import type { OpenFile } from '../types/open-file' import { openWorkspaceEditorItem } from '../tabs/workspace-editor-item' @@ -174,7 +174,7 @@ export function createCheckRunDetailsActions( ? await loadGitLabJobLogDetails({ repoPath: repo.path, repoId: repo.id, - settings: getSettingsForWorktreeRuntimeOwner(state, file.worktreeId), + repoOwnerExecutionHostId: forgeOwnerHostIdForWorkspace(repo, worktree), check, // Why: a fork MR's job lives in the source project, not the repo's own. projectRef: checkRunDetails.gitlabProjectRef ?? null diff --git a/src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts b/src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts index c11ed39868a9..4ea285c89e13 100644 --- a/src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts +++ b/src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts @@ -10,6 +10,10 @@ import { type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from './runtime-status' import { createDiffCommentsSlice } from './diffComments' const runtimeEnvironmentCall = vi.fn() @@ -93,6 +97,7 @@ function bodies(store: ReturnType, workspaceKey: string) beforeEach(() => { vi.clearAllMocks() clearRuntimeCompatibilityCacheForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) @@ -593,6 +598,42 @@ describe('folder workspace diff comment rollback convergence', () => { expect(folderWorkspacesUpdate).not.toHaveBeenCalled() }) + it('rolls back a runtime note whose server was replaced while the write was in flight', async () => { + const store = createTestStore() + const runtimeWorkspace = makeFolderWorkspace({ + executionHostId: 'runtime:env-owner', + connectionId: 'nested-box' + }) + const key = folderWorkspaceKey(runtimeWorkspace.id) + store.setState({ + activeWorktreeId: key, + activeWorkspaceExecutionHostId: 'runtime:env-owner', + folderWorkspaces: [runtimeWorkspace] + }) + runtimeEnvironmentCall.mockImplementation(async (args: { params: { updates: object } }) => { + // Why: the reply may come from the old runtime; whether the write landed is unknown. + setRuntimeEnvironmentConnectionGenerationForTests('env-owner', 1) + return { + id: 'rpc-folder-update', + ok: true, + result: { + folderWorkspace: { + ...runtimeWorkspace, + ...args.params.updates + } + }, + _meta: { runtimeId: 'remote-runtime' } + } + }) + + await expect(addNote(store, key, 'A', 1)).resolves.toBeNull() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-owner', method: 'folderWorkspace.update' }) + ) + expect(store.getState().getDiffComments(key)).toEqual([]) + expect(folderWorkspacesUpdate).not.toHaveBeenCalled() + }) + it('keeps a failing burst scoped to its own execution host', async () => { const store = createTestStore() const workspaceId = 'shared-folder-id' 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 40a64f77ce75..8b77d5a02a2e 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,7 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { const table = makeTable() store.setState({ projectViewCache: { [cacheKey]: { data: table, fetchedAt: Date.now() } } }) - await expect(store.getState().fetchProjectViewTable(request)).resolves.toEqual({ + await expect(store.getState().fetchProjectViewTable(null, request)).resolves.toEqual({ ok: true, data: table }) @@ -62,8 +62,8 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { const pending = Promise.withResolvers() mockApi.gh.getProjectViewTable.mockReturnValueOnce(pending.promise) - const first = store.getState().fetchProjectViewTable(request) - const duplicate = store.getState().fetchProjectViewTable(request) + const first = store.getState().fetchProjectViewTable(null, request) + const duplicate = store.getState().fetchProjectViewTable(null, request) pending.resolve({ ok: true, data: makeTable() }) await expect(Promise.all([first, duplicate])).resolves.toEqual([ @@ -80,9 +80,9 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { .mockReturnValueOnce(weak.promise) .mockResolvedValue({ ok: true, data: makeTable('forced') }) - const first = store.getState().fetchProjectViewTable(request) + const first = store.getState().fetchProjectViewTable(null, request) const forced = Array.from({ length: 20 }, () => - store.getState().fetchProjectViewTable(request, { force: true }) + store.getState().fetchProjectViewTable(null, request, { force: true }) ) weak.resolve({ ok: true, data: makeTable('weak') }) @@ -100,17 +100,17 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { store.setState({ projectViewCache: { [cacheKey]: { data: stale, fetchedAt: 1 } } }) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) - await expect(store.getState().fetchProjectViewTable(request, { force: true })).resolves.toEqual( - { - ok: false, - error - } - ) + await expect( + store.getState().fetchProjectViewTable(null, request, { force: true }) + ).resolves.toEqual({ + ok: false, + error + }) expect(store.getState().projectViewCache[cacheKey]).toMatchObject({ data: stale, error }) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) await expect( - store.getState().fetchProjectViewTable({ + store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1 @@ -123,7 +123,7 @@ describe('createGitHubSlice.fetchProjectViewTable coordination', () => { mockApi.gh.getProjectViewTable.mockRejectedValueOnce(new Error('transport offline')) await expect( - store.getState().fetchProjectViewTable({ + store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1 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 faa589824c30..43e51bf0dce0 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 @@ -23,7 +23,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) - it('routes project table fetches through the active runtime environment', async () => { + it('routes project table fetches through the board source, not the focused server', async () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } @@ -60,17 +60,20 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) - const result = await store.getState().fetchProjectViewTable({ - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1' - }) + const result = await store.getState().fetchProjectViewTable( + { activeRuntimeEnvironmentId: 'env-board' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + } + ) expect(result.ok).toBe(true) expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', + selector: 'env-board', method: 'github.project.viewTable', params: { owner: 'acme', @@ -119,16 +122,16 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) - await store.getState().fetchProjectViewTable({ - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - viewId: 'view-1' - }) + await store.getState().fetchProjectViewTable( + { activeRuntimeEnvironmentId: 'env-1' }, + { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + } + ) - store.setState({ - settings: { activeRuntimeEnvironmentId: null } - } as Partial) mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: true, data: { @@ -156,7 +159,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { } }) - const localResult = await store.getState().fetchProjectViewTable({ + const localResult = await store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, @@ -207,7 +210,7 @@ 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({ + await store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, @@ -483,7 +486,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { try { const store = createTestStore() mockApi.gh.getProjectViewTable.mockImplementation( - async (args: Parameters[0]) => ({ + async (args: Parameters[1]) => ({ ok: true, data: { project: { @@ -514,6 +517,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { for (let i = 0; i <= 500; i++) { vi.setSystemTime(1_000 + i) await store.getState().fetchProjectViewTable( + null, { 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 30717fca0d96..64a51027d590 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,7 +30,7 @@ describe('GitHub provider request concurrency', () => { .fetchWorkItems(`repo-${index}`, `/repo/${index}`, 20, '') .catch(() => []) ) - const queuedProject = store.getState().fetchProjectViewTable({ + const queuedProject = store.getState().fetchProjectViewTable(null, { owner: 'acme', ownerType: 'organization', projectNumber: 1, 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 c4be9da838f1..f0afe39d3516 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-state.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-state.ts @@ -146,10 +146,6 @@ export function shouldRefetchGitHubScopedResultForNoHint( ) } -export function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean { - return inflightHintKey === nextHintKey -} - export function isStaleMergedGitHubReviewForHead( cached: HostedReviewCacheEntry | undefined, currentHeadOid: string | null | undefined diff --git a/src/renderer/src/store/slices/runtime-detected-agents.test.ts b/src/renderer/src/store/slices/runtime-detected-agents.test.ts index 83bc3b6e8906..042fa52069f5 100644 --- a/src/renderer/src/store/slices/runtime-detected-agents.test.ts +++ b/src/renderer/src/store/slices/runtime-detected-agents.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTestStore } from './store-test-helpers' +import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' import { getRuntimeAgentInventoryKey } from './runtime-agent-inventory-key' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, @@ -134,6 +135,35 @@ describe('workspace agent detection on a host without workspace-scoped detection expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBeUndefined() }) + // Why (#25804): the host's own SSH workspace runs on that target, not on the host machine. + it.each([ + ['detect', 'ensureRuntimeDetectedAgents'], + ['refresh', 'refreshRuntimeDetectedAgents'] + ] as const)( + 'asks an old host to %s on its SSH target for a workspace that lives there', + async (_label, action) => { + serveHost({ hostPlatform: 'win32', scoped: false }) + const store = createTestStore() + store.setState({ + repos: [ + { ...TEST_REPO, connectionId: 'server-ssh-target', executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })] } + }) + + await expect(store.getState()[action]('env-1', 'wt-1')).resolves.toEqual(['codex']) + + expect(agentCalls()).toEqual([ + expect.objectContaining({ + method: 'preflight.detectRemoteAgents', + params: { connectionId: 'server-ssh-target' } + }) + ]) + expect(store.getState().runtimeDetectedAgentIds[KEY]).toEqual(['codex']) + expect(store.getState().runtimeAgentDetectionNeedsServerUpdate[KEY]).toBeUndefined() + } + ) + it('drops the notice with its environment', async () => { serveHost({ hostPlatform: 'win32', scoped: false }) const store = createTestStore() diff --git a/src/renderer/src/store/slices/runtime-detected-agents.ts b/src/renderer/src/store/slices/runtime-detected-agents.ts index 6b5c8df21dd7..d900f6b5a7fa 100644 --- a/src/renderer/src/store/slices/runtime-detected-agents.ts +++ b/src/renderer/src/store/slices/runtime-detected-agents.ts @@ -7,6 +7,8 @@ import { RuntimeRpcCallError, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' import { PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { getRuntimeAgentInventoryEnvironmentId, @@ -58,6 +60,12 @@ export class RuntimeAgentDetectionNeedsServerUpdateError extends Error { } } +/** A paired host's workspace that lives on one of that host's own SSH targets. */ +type NestedSshAgentDetection = { + connectionId: string + call: (params: { connectionId: string }) => Promise +} + /** * Sends `preflight.detectAgents` / `refreshAgents` scoped to a workspace. A host without the * workspace-scoped capability can only answer its own default, which is the workspace's list only @@ -66,7 +74,8 @@ export class RuntimeAgentDetectionNeedsServerUpdateError extends Error { export function callRuntimeAgentDetection( environmentId: string, worktreeId: string | null | undefined, - call: (params: { worktreeId: string } | undefined) => Promise + call: (params: { worktreeId: string } | undefined) => Promise, + nestedSsh?: NestedSshAgentDetection ): Promise { if (!worktreeId) { return call(undefined) @@ -81,6 +90,10 @@ export function callRuntimeAgentDetection( if (status.capabilities?.includes(capability)) { return call({ worktreeId }) } + // Why: the host's SSH workspace runs on its SSH target, which old hosts already probe by id. + if (nestedSsh) { + return nestedSsh.call({ connectionId: nestedSsh.connectionId }) + } // Why: an old Windows host's default omits a WSL workspace's agents; an absent platform may be one. if (!status.hostPlatform || status.hostPlatform === 'win32') { throw new RuntimeAgentDetectionNeedsServerUpdateError() @@ -144,6 +157,20 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< }) } + const nestedSshDetection = ( + target: RuntimeClientTarget, + worktreeId: string | null | undefined + ): NestedSshAgentDetection | undefined => { + const connectionId = worktreeId ? getConnectionIdFromState(get(), worktreeId) : null + return typeof connectionId === 'string' + ? { + connectionId, + call: (params) => + callRuntimeRpc(target, 'preflight.detectRemoteAgents', params) + } + : undefined + } + // Commits a settled detection; an old host commits an empty list, never its default. const commitDetection = (key: string, agents: TuiAgent[], needsServerUpdate: boolean): void => { set((s) => ({ @@ -185,8 +212,11 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< })) const target = { kind: 'environment', environmentId } as const - const pending = callRuntimeAgentDetection(environmentId, worktreeId, (params) => - callRuntimeRpc(target, 'preflight.detectAgents', params) + const pending = callRuntimeAgentDetection( + environmentId, + worktreeId, + (params) => callRuntimeRpc(target, 'preflight.detectAgents', params), + nestedSshDetection(target, worktreeId) ) .then((typed) => { // Why: skip committing if the environment was removed (retained out) @@ -238,17 +268,21 @@ export const createRuntimeDetectedAgentsSlice: StateCreator< })) const target = { kind: 'environment', environmentId } as const - const pending = callRuntimeAgentDetection(environmentId, worktreeId, (params) => - callRuntimeRpc<{ agents: TuiAgent[] }>(target, 'preflight.refreshAgents', params) - .then((result) => result.agents) - .catch((error) => { - if (!isRuntimeMethodNotFoundError(error)) { - throw error - } - // Why: only older servers need the fallback; retrying disconnects and - // runtime failures doubles remote work without any chance of recovery. - return callRuntimeRpc(target, 'preflight.detectAgents', params) - }) + const pending = callRuntimeAgentDetection( + environmentId, + worktreeId, + (params) => + callRuntimeRpc<{ agents: TuiAgent[] }>(target, 'preflight.refreshAgents', params) + .then((result) => result.agents) + .catch((error) => { + if (!isRuntimeMethodNotFoundError(error)) { + throw error + } + // Why: only older servers need the fallback; retrying disconnects and + // runtime failures doubles remote work without any chance of recovery. + return callRuntimeRpc(target, 'preflight.detectAgents', params) + }), + nestedSshDetection(target, worktreeId) ) .then((typed) => { // Why: same guard as ensureRuntimeDetectedAgents — if the environment 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 41673f84d395..465c81a6d18d 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -1,9 +1,7 @@ import { runtimeTargetForExecutionHostId } from '@/runtime/runtime-client-target' import { repoHostId } from '../listing/worktree-host-ownership' -import { - isUnresolvedOwnerHostId, - toRuntimeExecutionHostId -} from '../../../../../../shared/execution-host' +import { isUnresolvedOwnerHostId } 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' import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types' @@ -183,11 +181,11 @@ export function createCreateWorktree( const target = options?.executionHostId ? (runtimeTargetForExecutionHostId(options.executionHostId) ?? { kind: 'local' as const }) : getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) - const creationHostId = - options?.executionHostId ?? - (target.kind === 'environment' - ? toRuntimeExecutionHostId(target.environmentId) - : repoHostId(get(), repoId)) + const creationHostId = adoptFromEndpoint(target, { + kind: 'createdWorktree', + requestedHostId: options?.executionHostId, + resolveOwnHostId: () => repoHostId(get(), repoId) + }) if ( target.kind === 'environment' && (options?.linkedWorkItem?.provider === 'jira' || diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts new file mode 100644 index 000000000000..66f5eddecc86 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + getUnstampedNonLocalRowCount, + resetUnstampedNonLocalRowCountForTest +} from '@/lib/unstamped-non-local-row-counter' +import { worktreeMatchesHost } from './worktree-host-ownership' + +describe('worktreeMatchesHost unstamped non-local rows', () => { + beforeEach(() => resetUnstampedNonLocalRowCountForTest()) + + it.each([ + ['stamped SSH row', { hostId: 'ssh:t' as const }, 'ssh:t' as const, true, true, 0], + ['stamped server row', { runtimeOwnerEnvironmentId: 'e' }, 'runtime:e' as const, true, true, 0], + ['unstamped row claimed by local', {}, 'local' as const, true, true, 0], + ['unstamped row claimed by SSH', {}, 'ssh:t' as const, true, true, 1], + ['unstamped row claimed by a server', {}, 'runtime:e' as const, true, true, 1], + ['unstamped row refused by SSH', {}, 'ssh:t' as const, false, false, 0], + ['unstamped row, no hint, SSH', {}, 'ssh:t' as const, undefined, false, 0], + ['unstamped row, no hint, local', {}, 'local' as const, undefined, true, 0] + ])('%s', (_name, row, hostId, unhostedWorktreesMatchHost, matches, count) => { + expect(worktreeMatchesHost(row, hostId, { unhostedWorktreesMatchHost })).toBe(matches) + expect(getUnstampedNonLocalRowCount()).toBe(count) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts index a70e6b28af06..a7a19a9cb96a 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts @@ -13,6 +13,7 @@ import { type ExecutionHostId } from '../../../../../../shared/execution-host' import { toVisibleWorktree } from './worktree-catalog-visibility' +import { noteUnstampedNonLocalRow } from '@/lib/unstamped-non-local-row-counter' import type { WorktreeHostMatchOptions, RepoHostSummary } from './worktree-slice-types' export function withRepoHostOwnership< @@ -188,7 +189,7 @@ export function worktreeMatchesHost( if (worktree.hostId) { return worktree.hostId === hostId } - return options.unhostedWorktreesMatchHost ?? false + return unhostedRowMatchesNonLocalHost(options.unhostedWorktreesMatchHost ?? false) } if (worktree.runtimeOwnerEnvironmentId) { return false @@ -196,7 +197,17 @@ export function worktreeMatchesHost( if (worktree.hostId) { return worktree.hostId === hostId } - return options.unhostedWorktreesMatchHost ?? hostId === LOCAL_EXECUTION_HOST_ID + if (hostId === LOCAL_EXECUTION_HOST_ID) { + return options.unhostedWorktreesMatchHost ?? true + } + return unhostedRowMatchesNonLocalHost(options.unhostedWorktreesMatchHost ?? false) +} + +function unhostedRowMatchesNonLocalHost(matches: boolean): boolean { + if (matches) { + noteUnstampedNonLocalRow() + } + return matches } export function mergeWorktreesForHost< diff --git a/src/renderer/src/web/preload-api/web-github-api.ts b/src/renderer/src/web/preload-api/web-github-api.ts index 55f964716be9..0e63b3066481 100644 --- a/src/renderer/src/web/preload-api/web-github-api.ts +++ b/src/renderer/src/web/preload-api/web-github-api.ts @@ -6,7 +6,7 @@ import { import { translate } from '@/i18n/i18n' import { GITHUB_WEB_RPC_METHODS } from './web-github-routes' import type { WebGitHubRuntimeMethod } from './web-github-routes' -import { mapRepoPathArg } from './web-review-api' +import { mapRepoPathArg } from '@/runtime/runtime-repo-selector-params' import { callRuntimeResult, getRemoteRuntimeStatus } from './web-runtime-calls' import { noopUnsubscribe } from './web-storage' diff --git a/src/renderer/src/web/preload-api/web-gitlab-api.ts b/src/renderer/src/web/preload-api/web-gitlab-api.ts index dd6fcc1b6324..1e3aa5b5b258 100644 --- a/src/renderer/src/web/preload-api/web-gitlab-api.ts +++ b/src/renderer/src/web/preload-api/web-gitlab-api.ts @@ -1,83 +1,15 @@ import type { PreloadApi } from '../../../../preload/api-types' -import { - GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, - GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE -} from '../../../../shared/protocol-version' -import { GITLAB_WEB_RPC_METHODS } from './web-gitlab-routes' -import type { WebGitLabRuntimeMethod } from './web-gitlab-routes' -import { mapRepoPathArg } from './web-review-api' +import { createRuntimeGitLabApi } from '@/runtime/runtime-gitlab-api' import { callRuntimeResult, getRemoteRuntimeStatus } from './web-runtime-calls' export type WebGitLabApi = NonNullable -export type WebGitLabResult = Awaited> - export function createGitLabApi(): WebGitLabApi { - const route = (method: WebGitLabRuntimeMethod, args?: unknown): Promise => - callRuntimeResult(method, mapRepoPathArg(args)) - - const gitLabApi = { - viewer: () => Promise.resolve(null), - diagnoseAuth: () => route>(GITLAB_WEB_RPC_METHODS.diagnoseAuth), - rateLimit: (args) => - route>(GITLAB_WEB_RPC_METHODS.rateLimit, args), - projectSlug: () => Promise.resolve(null), - mrForBranch: () => Promise.resolve(null), - mr: () => Promise.resolve(null), - listMRs: (args) => route>(GITLAB_WEB_RPC_METHODS.listMRs, args), - listWorkItems: (args) => - route>(GITLAB_WEB_RPC_METHODS.listWorkItems, args), - issue: () => Promise.resolve(null), - listIssues: (args) => - route>(GITLAB_WEB_RPC_METHODS.listIssues, args), - createIssue: (args) => - route>(GITLAB_WEB_RPC_METHODS.createIssue, args), - updateIssue: (args) => - route>(GITLAB_WEB_RPC_METHODS.updateIssue, args), - addIssueComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addIssueComment, args), - listLabels: (args) => - route>(GITLAB_WEB_RPC_METHODS.listLabels, args), - listAssignableUsers: () => Promise.resolve([]), - todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), - workItemDetails: ({ repoOwnerExecutionHostId: _owner, ...args }) => - route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), - closeMR: (args) => - route>(GITLAB_WEB_RPC_METHODS.closeMR, { - ...args, - state: 'closed' - }), - reopenMR: (args) => - route>(GITLAB_WEB_RPC_METHODS.reopenMR, { - ...args, - state: 'opened' - }), - mergeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.mergeMR, args), - updateMR: async (args) => { - if (args.updates.readyForReview) { - const status = await getRemoteRuntimeStatus().catch(() => null) - if (!status?.capabilities?.includes(GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY)) { - return { ok: false, error: GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE } - } - } - return route>(GITLAB_WEB_RPC_METHODS.updateMR, args) - }, - updateMRReviewers: (args) => - route>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args), - addMRComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addMRComment, args), - addMRInlineComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args), - resolveMRDiscussion: (args) => - route>( - GITLAB_WEB_RPC_METHODS.resolveMRDiscussion, - args - ), - jobTrace: (args) => route>(GITLAB_WEB_RPC_METHODS.jobTrace, args), - retryJob: (args) => route>(GITLAB_WEB_RPC_METHODS.retryJob, args), - workItemByPath: (args) => - route>(GITLAB_WEB_RPC_METHODS.workItemByPath, args) - } satisfies WebGitLabApi - - return gitLabApi + return createRuntimeGitLabApi({ + call: (method, params) => callRuntimeResult(method, params), + supportsCapability: async (capability) => { + const status = await getRemoteRuntimeStatus().catch(() => null) + return status?.capabilities?.includes(capability) === true + } + }) } diff --git a/src/renderer/src/web/preload-api/web-review-api.ts b/src/renderer/src/web/preload-api/web-review-api.ts index 4c4541d48d19..d77eb5191015 100644 --- a/src/renderer/src/web/preload-api/web-review-api.ts +++ b/src/renderer/src/web/preload-api/web-review-api.ts @@ -1,6 +1,7 @@ import type { PreloadApi } from '../../../../preload/api-types' import { createFallbackProxy } from './web-fallback-api' import { callRuntimeResult } from './web-runtime-calls' +import { mapRepoPathArg } from '@/runtime/runtime-repo-selector-params' export function createRuntimeNamespaceApi(prefix: string): never { return createFallbackProxy([prefix], (path, args) => { @@ -23,19 +24,6 @@ export function createHooksApi(): NonNullable['hooks']> { } } -export function mapRepoPathArg(args: unknown): unknown { - if (!args || typeof args !== 'object' || !('repoPath' in args)) { - return args - } - const record = args as Record - const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null - return { - ...record, - // Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes. - repo: repoId ? `id:${repoId}` : record.repoPath - } -} - export function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown { if (prefix !== 'hostedReview') { return args diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index a50f182e4211..64f4f28e8e1d 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -91,6 +91,10 @@ export function getClientForEnvironment( if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { throw new Error('runtime_manually_disconnected') } + // Why: a request captured before a re-pair must not reopen a client with the replaced token. + if (!isActivePairing(environment)) { + throw new Error(PAIRING_CHANGED_MESSAGE) + } if ( !webRuntimeState.activeClient || webRuntimeState.activeClientEnvironmentId !== environment.id @@ -173,17 +177,32 @@ export function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | return webRuntimeState.activeEnvironment } +const PAIRING_CHANGED_MESSAGE = 'The paired Orca server changed while the request was in progress.' + export function assertActiveEnvironment(environmentId: string): void { if (requireActiveEnvironment().id !== environmentId) { - throw new Error('The paired Orca server changed while the request was in progress.') + throw new Error(PAIRING_CHANGED_MESSAGE) } } +function pairingRevisionOf(environment: StoredWebRuntimeEnvironment): number { + return environment.pairingRevision ?? environment.createdAt +} + +/** Re-pairing the same server keeps its id, so only the revision tells the pairings apart. */ +function isActivePairing(environment: StoredWebRuntimeEnvironment): boolean { + const active = webRuntimeState.activeEnvironment + return ( + active?.id === environment.id && pairingRevisionOf(active) === pairingRevisionOf(environment) + ) +} + export function updateEnvironmentFromResponse( environment: StoredWebRuntimeEnvironment, response: RuntimeRpcResponse ): void { - if (webRuntimeState.activeEnvironment?.id !== environment.id) { + const active = webRuntimeState.activeEnvironment + if (!active || !isActivePairing(environment)) { return } const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) @@ -194,8 +213,9 @@ export function updateEnvironmentFromResponse( typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' ? (response.result as { pairedDeviceId: string }).pairedDeviceId : undefined + // Why the active record: the captured one may predate another reply's update of this pairing. webRuntimeState.activeEnvironment = updateStoredEnvironmentRuntimeId( - environment, + active, runtimeId, pairedDeviceId ) diff --git a/src/renderer/src/web/web-pairing.test.ts b/src/renderer/src/web/web-pairing.test.ts index 42721c3c821c..367b2af949e0 100644 --- a/src/renderer/src/web/web-pairing.test.ts +++ b/src/renderer/src/web/web-pairing.test.ts @@ -49,6 +49,17 @@ describe('web pairing input', () => { expect(parseWebPairingInput(encodeOffer({ endpoint }))).toMatchObject({ endpoint: expected }) }) + it('keeps a readable host descriptor and drops an unreadable one without refusing', () => { + const hostDescriptor = { installationId: '11111111-1111-4111-8111-111111111111' } + expect(parseWebPairingInput(encodeOffer({ hostDescriptor }))).toEqual({ + ...offer, + hostDescriptor + }) + expect(parseWebPairingInput(encodeOffer({ hostDescriptor: { installationId: 7 } }))).toEqual( + offer + ) + }) + it('treats invalid device scope metadata as unknown', () => { expect(parseWebPairingInput(`orca://pair?code=${encodeOffer({ scope: 'admin' })}`)).toEqual( offer diff --git a/src/renderer/src/web/web-pairing.ts b/src/renderer/src/web/web-pairing.ts index fc58bf9ea084..54a0312c7028 100644 --- a/src/renderer/src/web/web-pairing.ts +++ b/src/renderer/src/web/web-pairing.ts @@ -1,3 +1,7 @@ +import { + RuntimeHostDescriptorSchema, + type RuntimeHostDescriptor +} from '../../../shared/runtime-host-descriptor' import type { DeviceScope } from '../../../shared/runtime-types' const PAIRING_OFFER_VERSION = 2 @@ -9,6 +13,7 @@ export type WebPairingOffer = { publicKeyB64: string pairedDeviceId?: string scope?: DeviceScope + hostDescriptor?: RuntimeHostDescriptor } export type WebPairingStartupDecision = @@ -104,13 +109,16 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null { typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.length > 0 ? parsed.pairedDeviceId : null + // Why safeParse: a descriptor this build cannot read is dropped, never a reason to refuse pairing. + const hostDescriptor = RuntimeHostDescriptorSchema.safeParse(parsed.hostDescriptor).data return { v: PAIRING_OFFER_VERSION, endpoint: normalizeWebSocketEndpoint(parsed.endpoint), deviceToken: parsed.deviceToken, publicKeyB64: parsed.publicKeyB64, ...(pairedDeviceId ? { pairedDeviceId } : {}), - ...(scope ? { scope } : {}) + ...(scope ? { scope } : {}), + ...(hostDescriptor ? { hostDescriptor } : {}) } } diff --git a/src/renderer/src/web/web-runtime-environment-repair.test.ts b/src/renderer/src/web/web-runtime-environment-repair.test.ts new file mode 100644 index 000000000000..abcbd68d5c58 --- /dev/null +++ b/src/renderer/src/web/web-runtime-environment-repair.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + encodePairingCode, + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +const ENVIRONMENT_KEY = 'orca.web.runtimeEnvironment.v1' +const INSTALL_A = '11111111-1111-4111-8111-111111111111' +const INSTALL_B = '22222222-2222-4222-8222-222222222222' + +function readStored(storage: Storage): Record { + const stored: unknown = JSON.parse(storage.getItem(ENVIRONMENT_KEY) ?? '{}') + return typeof stored === 'object' && stored !== null + ? Object.fromEntries(Object.entries(stored)) + : {} +} + +async function installWithStoredServer( + pin?: Record +): Promise> { + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + if (pin) { + globals.storage.setItem( + ENVIRONMENT_KEY, + JSON.stringify({ ...readStored(globals.storage), hostDescriptor: pin }) + ) + } + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + return globals +} + +describe('web re-pairing the same server', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('never lets a reply from the replaced pairing overwrite the new one', async () => { + const tokens: string[] = [] + const pending: { answer?: () => void } = {} + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + constructor(offer: { deviceToken: string }) { + tokens.push(offer.deviceToken) + } + + call(method: string): Promise> { + const reply = { id: method, ok: true as const, result: {}, _meta: { runtimeId: 'r-1' } } + return new Promise((resolve) => { + pending.answer = () => resolve(reply) + }) + } + + close(): void {} + } + })) + const globals = await installWithStoredServer() + const staleCall = globals.window.api.runtimeEnvironments.call({ + selector: 'web-server-a', + method: 'repos.list' + }) + await vi.waitFor(() => expect(pending.answer).toBeDefined()) + const answerStale = pending.answer + + await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Server A', + pairingCode: encodePairingCode({ publicKeyB64: 'public-key', deviceToken: 'fresh-token' }) + }) + answerStale?.() + await staleCall + + expect(readStored(globals.storage)).toMatchObject({ + id: 'web-server-a', + endpoints: [{ deviceToken: 'fresh-token' }] + }) + void globals.window.api.runtimeEnvironments.call({ + selector: 'web-server-a', + method: 'repos.list' + }) + await vi.waitFor(() => expect(tokens).toEqual(['token', 'fresh-token'])) + }) + + it('keeps the execution-host id and refreshes the credentials (#11574)', async () => { + const globals = await installWithStoredServer() + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Server A again', + pairingCode: encodePairingCode({ + publicKeyB64: 'public-key', + deviceToken: 'fresh-token', + hostDescriptor: { installationId: INSTALL_A } + }) + }) + + expect(paired.environment).toMatchObject({ id: 'web-server-a', createdAt: 1 }) + expect(paired.environment.pairingRevision).toBeGreaterThan(1) + expect(paired.environment).not.toHaveProperty('hostDescriptor') + expect(readStored(globals.storage)).toMatchObject({ + id: 'web-server-a', + hostDescriptor: { installationId: INSTALL_A }, + endpoints: [{ id: 'ws-web-server-a', deviceToken: 'fresh-token' }] + }) + expect(readStored(globals.storage)).not.toHaveProperty('compatibleEnvironmentIds') + }) + + it('mints a new id for a same-key server whose pinned installation changed', async () => { + const globals = await installWithStoredServer({ installationId: INSTALL_A }) + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Clone', + pairingCode: encodePairingCode({ + publicKeyB64: 'public-key', + hostDescriptor: { installationId: INSTALL_B } + }) + }) + + expect(paired.environment.id).not.toBe('web-server-a') + expect(readStored(globals.storage)).toMatchObject({ + hostDescriptor: { installationId: INSTALL_B } + }) + }) + + it('keeps the old pin when a downgraded server sends no descriptor', async () => { + const globals = await installWithStoredServer({ installationId: INSTALL_A }) + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Server A', + pairingCode: encodePairingCode({ publicKeyB64: 'public-key' }) + }) + + expect(paired.environment.id).toBe('web-server-a') + expect(readStored(globals.storage)).toMatchObject({ + hostDescriptor: { installationId: INSTALL_A } + }) + }) + + it('drops a malformed pinned descriptor instead of trusting it', async () => { + const globals = await installWithStoredServer({ installationId: 'not-a-uuid' }) + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Server A', + pairingCode: encodePairingCode({ + publicKeyB64: 'public-key', + hostDescriptor: { installationId: INSTALL_B } + }) + }) + + expect(paired.environment.id).toBe('web-server-a') + expect(readStored(globals.storage)).toMatchObject({ + hostDescriptor: { installationId: INSTALL_B } + }) + }) +}) diff --git a/src/renderer/src/web/web-runtime-environment.ts b/src/renderer/src/web/web-runtime-environment.ts index 6093a88aed3c..1dcc123c00bf 100644 --- a/src/renderer/src/web/web-runtime-environment.ts +++ b/src/renderer/src/web/web-runtime-environment.ts @@ -1,10 +1,18 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import { + RuntimeHostDescriptorSchema, + type RuntimeHostDescriptor +} from '../../../shared/runtime-host-descriptor' +import { classifyRuntimeHostRePair } from '../../../shared/runtime-host-pairing-identity' import type { WebPairingOffer } from './web-pairing' import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' export type StoredWebRuntimeEnvironment = Omit & { + /** Ids minted by re-pairs before re-pairing kept the id; read-only, never extended. */ compatibleEnvironmentIds?: string[] + /** The server's descriptor pinned at pairing; see classifyRuntimeHostRePair. */ + hostDescriptor?: RuntimeHostDescriptor endpoints: { id: string kind: 'websocket' @@ -41,15 +49,18 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment | typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.trim().length > 0 ? parsed.pairedDeviceId.trim() : null + const hostDescriptor = RuntimeHostDescriptorSchema.safeParse(parsed.hostDescriptor).data const { compatibleEnvironmentIds: _unvalidatedIds, pairedDeviceId: _unvalidatedDeviceId, + hostDescriptor: _unvalidatedDescriptor, ...environment } = parsed return { ...environment, ...(pairedDeviceId ? { pairedDeviceId } : {}), - ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}) + ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}), + ...(hostDescriptor ? { hostDescriptor } : {}) } } catch { return null @@ -70,19 +81,37 @@ export function createStoredWebRuntimeEnvironment(args: { previousEnvironment?: StoredWebRuntimeEnvironment | null connectionDependency?: 'ssh-tunnel' }): StoredWebRuntimeEnvironment { - const id = `web-${createBrowserUuid()}` + const previous = args.previousEnvironment + const match = previous + ? classifyRuntimeHostRePair( + { + publicKeys: previous.endpoints.map((endpoint) => endpoint.publicKeyB64), + pin: previous.hostDescriptor + }, + args.offer + ) + : { kind: 'different-host' as const } + // Why keep the id: it is this server's execution-host identity; a new one strands its sessions (#11574). + const sameHost = match.kind === 'same-host' ? previous : null + const id = sameHost?.id ?? `web-${createBrowserUuid()}` const now = Date.now() - const compatibleEnvironmentIds = getCompatibleEnvironmentIds(args.previousEnvironment, args.offer) + const pin = match.kind === 'same-host' ? match.pin : args.offer.hostDescriptor + const compatibleEnvironmentIds = sameHost?.compatibleEnvironmentIds ?? [] return { id, name: args.name.trim() || 'Orca Server', - createdAt: now, + createdAt: sameHost?.createdAt ?? now, updatedAt: now, + // Why bump: request fences compare revisions, and the device token they captured is gone. + ...(sameHost + ? { pairingRevision: Math.max(now, (sameHost.pairingRevision ?? sameHost.createdAt) + 1) } + : {}), lastUsedAt: null, runtimeId: null, ...(args.offer.pairedDeviceId ? { pairedDeviceId: args.offer.pairedDeviceId } : {}), ...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}), ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}), + ...(pin ? { hostDescriptor: pin } : {}), preferredEndpointId: `ws-${id}`, endpoints: [ { @@ -97,20 +126,14 @@ export function createStoredWebRuntimeEnvironment(args: { } } -function getCompatibleEnvironmentIds( - previous: StoredWebRuntimeEnvironment | null | undefined, - offer: WebPairingOffer -): string[] { - if (!previous?.endpoints.some((endpoint) => endpoint.publicKeyB64 === offer.publicKeyB64)) { - return [] - } - return [...new Set([...(previous.compatibleEnvironmentIds ?? []), previous.id])] -} - export function redactStoredWebRuntimeEnvironment( environment: StoredWebRuntimeEnvironment ): PublicKnownRuntimeEnvironment { - const { compatibleEnvironmentIds: _compatibleEnvironmentIds, ...publicEnvironment } = environment + const { + compatibleEnvironmentIds: _compatibleEnvironmentIds, + hostDescriptor: _hostDescriptor, + ...publicEnvironment + } = environment return { ...publicEnvironment, endpoints: environment.endpoints.map( diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 0eac4ec6f6c3..9439a91646be 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -1,11 +1,11 @@ import type { AgentProcessPresence } from './agent-process-presence' // Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification -// the relay sends to Orca. Consumed by `src/relay/agent-hook-server.ts` (which +// the relay sends to Orca. Consumed by `src/wsl-guest/agent-hook-server.ts` (which // produces it after the shared listener parses an HTTP POST) and by // `src/main/agent-hooks/server.ts` (which ingests it via `ingestRemote`). // // Lives in `shared/` because the relay deliberately has no Electron dependency -// (cf. `src/relay/protocol.ts` header). `agent-hook-types.ts` is reserved for +// (cf. `src/wsl-guest/protocol.ts` header). `agent-hook-types.ts` is reserved for // the renderer-bound IPC + installer contract; this module is the wire envelope // between Orca's main process and the remote relay. // @@ -149,7 +149,7 @@ export type AgentHookUnavailableEnvelope = { export const AGENT_HOOK_NOTIFICATION_METHOD = 'agent.hook' as const /** Identifies optional payload fields the relay dropped to fit an oversized frame - * (see `src/relay/agent-hook-envelope-publication.ts`), so `ingestRemote` can tell + * (see `src/wsl-guest/agent-hook-envelope-publication.ts`), so `ingestRemote` can tell * "shed in transit" from "the agent cleared it"; rosters include their digest. */ export const AGENT_HOOK_SHED_FIELDS_KEY = 'shedFields' as const const AGENT_HOOK_SHED_SUBAGENTS_DIGEST_PREFIX = 'subagents:sha256:' diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 0083293cdbd1..0fbf36995620 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -337,12 +337,6 @@ function isAgentLaunchPromptDelivery(value: unknown): value is AgentLaunchPrompt return value === 'submit' || value === 'draft' } -export function agentLaunchTargetIsCreate( - target: AgentLaunchTarget -): target is Extract { - return target.kind === 'create-worktree' -} - /** The agent fields a create payload must not carry: the launch owns placement, and a caller that * sets one of these would route itself around the host's decision. */ export const AGENT_LAUNCH_RESERVED_CREATE_FIELDS = [ diff --git a/src/shared/agent-status-child-work-alias.ts b/src/shared/agent-status-child-work-alias.ts index c7ff29a5c6a4..0442b4310a16 100644 --- a/src/shared/agent-status-child-work-alias.ts +++ b/src/shared/agent-status-child-work-alias.ts @@ -1,8 +1,7 @@ -import { - agentChildWorkFencesEqual, - type AgentChildWorkId, - type AgentChildWorkInvocationFence, - type AgentChildWorkKind +import type { + AgentChildWorkId, + AgentChildWorkInvocationFence, + AgentChildWorkKind } from './agent-status-child-work' import { parseAgentChildWorkInvocationFence } from './agent-status-child-work-codec' import { @@ -218,14 +217,3 @@ export function deserializeAgentChildWorkAliasKey( } return serializeAgentChildWorkAliasKey(identity) === value ? identity : null } - -export function agentChildWorkAliasesMatch( - left: AgentChildWorkAliasInput, - right: AgentChildWorkAliasInput -): boolean { - return ( - serializeAgentChildWorkAliasKey(left) === serializeAgentChildWorkAliasKey(right) && - left.childWorkId === right.childWorkId && - agentChildWorkFencesEqual(left.fence, right.fence) - ) -} diff --git a/src/shared/agent-status-run-capability.ts b/src/shared/agent-status-run-capability.ts index 6fe42f9b403c..61b18ba38ec4 100644 --- a/src/shared/agent-status-run-capability.ts +++ b/src/shared/agent-status-run-capability.ts @@ -2,8 +2,6 @@ export const AGENT_STATUS_RUNS_RUNTIME_CAPABILITY = 'agent-status.runs.v1' as co export const AGENT_STATUS_CAPABILITIES = [AGENT_STATUS_RUNS_RUNTIME_CAPABILITY] as const export type AgentStatusCapability = (typeof AGENT_STATUS_CAPABILITIES)[number] - -const AGENT_STATUS_CAPABILITY_SET: ReadonlySet = new Set(AGENT_STATUS_CAPABILITIES) const MAX_CAPABILITIES = 256 const MAX_CAPABILITY_LENGTH = 128 @@ -44,7 +42,3 @@ export function hasAgentStatusRunCapability(value: unknown): boolean { deserializeAgentStatusCapabilities(value)?.has(AGENT_STATUS_RUNS_RUNTIME_CAPABILITY) === true ) } - -export function isAgentStatusCapability(value: unknown): value is AgentStatusCapability { - return typeof value === 'string' && AGENT_STATUS_CAPABILITY_SET.has(value) -} diff --git a/src/shared/agent-status-store-state.ts b/src/shared/agent-status-store-state.ts index ca05745ad3ba..20c6d9ea8759 100644 --- a/src/shared/agent-status-store-state.ts +++ b/src/shared/agent-status-store-state.ts @@ -78,18 +78,6 @@ export function createEmptyAgentStatusStoreState(epoch: string): AgentStatusStor } } -export function cloneAgentStatusStoreState(state: AgentStatusStoreState): AgentStatusStoreState { - return { - epoch: state.epoch, - revision: state.revision, - parents: new Map(state.parents), - children: new Map(state.children), - aliases: new Map(state.aliases), - facts: new Map(state.facts), - tombstones: new Map(state.tombstones) - } -} - function snapshotCandidateFromAgentStatusStoreState(state: AgentStatusStoreState) { return { version: AGENT_STATUS_STORE_SNAPSHOT_VERSION, diff --git a/src/shared/agent-status-subject.ts b/src/shared/agent-status-subject.ts index 794ecc4452f5..c79b48baa1cc 100644 --- a/src/shared/agent-status-subject.ts +++ b/src/shared/agent-status-subject.ts @@ -153,10 +153,6 @@ export function parseAgentStatusSubject(value: unknown): AgentStatusSubject | nu return null } -export function isAgentStatusSubject(value: unknown): value is AgentStatusSubject { - return parseAgentStatusSubject(value) !== null -} - function subjectKeyTuple(subject: AgentStatusSubject): AgentStatusSubjectKeyTuple { const identity = subject.kind === 'pty-run' diff --git a/src/shared/agent-turn-outcome.ts b/src/shared/agent-turn-outcome.ts index 2d8926d4cb42..ca0f1335afc8 100644 --- a/src/shared/agent-turn-outcome.ts +++ b/src/shared/agent-turn-outcome.ts @@ -17,10 +17,6 @@ export const AGENT_JOURNAL_TURN_OUTCOMES = [ ] as const export type AgentJournalTurnOutcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] -export function isAgentJournalTurnOutcome(value: unknown): value is AgentJournalTurnOutcome { - return AGENT_JOURNAL_TURN_OUTCOMES.some((known) => known === value) -} - /** The verdict an agent-status row's `mainAgent.outcome` reports: the provider's, a `cancellation` * Orca inferred from the user's own interrupt keystroke, or what the host observed of a turn's end * when the provider gave no verdict. `interruption` is a death mid-turn the host proved and nobody diff --git a/src/shared/ai-vault-search-agent-compatibility.ts b/src/shared/ai-vault-search-agent-compatibility.ts index ecda310a04ee..b548ab771816 100644 --- a/src/shared/ai-vault-search-agent-compatibility.ts +++ b/src/shared/ai-vault-search-agent-compatibility.ts @@ -25,10 +25,6 @@ const LEGACY_SEARCH_AGENTS: readonly AiVaultAgent[] = [ 'muse' ] -export function needsSearchAgentNegotiation(agents: readonly AiVaultAgent[]): boolean { - return agents.some((agent) => !LEGACY_SEARCH_AGENTS.includes(agent)) -} - export function compatibleSearchAgents( agents: readonly AiVaultAgent[], status: Pick< diff --git a/src/shared/automation-runtime-capabilities.ts b/src/shared/automation-runtime-capabilities.ts index 4c4d3cf76a6d..2e5720d1c981 100644 --- a/src/shared/automation-runtime-capabilities.ts +++ b/src/shared/automation-runtime-capabilities.ts @@ -12,12 +12,15 @@ export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = // Older hosts strip extraAgentArgs and would run without the user's arguments. export const AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY = 'automations.extra-agent-args.v1' as const +// Fork: automation create/update take `agentFlags` (renamed from the fork's `extraArgs`). +export const AUTOMATION_AGENT_FLAGS_RUNTIME_CAPABILITY = 'automation.agent-flags.v1' as const export const AUTOMATION_RUNTIME_CAPABILITIES = [ AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, - AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY + AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY, + AUTOMATION_AGENT_FLAGS_RUNTIME_CAPABILITY ] as const export const AUTOMATION_RUNTIME_CLIENT_CAPABILITIES = [ diff --git a/src/shared/browser-page-doc-location.ts b/src/shared/browser-page-doc-location.ts index 97a9dcc6e273..ad08cf24a637 100644 --- a/src/shared/browser-page-doc-location.ts +++ b/src/shared/browser-page-doc-location.ts @@ -1,8 +1,4 @@ -import type { - BrowserPage, - BrowserPageDocLocation, - BrowserWorkspace -} from './browser-workspace-types' +import type { BrowserPageDocLocation } from './browser-workspace-types' import { relativePathInsideRoot, resolveRuntimePath } from './cross-platform-path' /** @@ -20,13 +16,6 @@ export function browserPageDocLocationsEqual( return left.worktreeId === right.worktreeId && left.filePath === right.filePath } -/** True for a page or tab that shows a workspace document rather than a URL. */ -export function isWorkspaceDocSurface( - surface: Pick -): boolean { - return Boolean(surface.docLocation) -} - export function remapBrowserPageDocLocation( location: BrowserPageDocLocation, oldWorktreeId: string, diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index e200bff55751..0a3e87963aaf 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -212,7 +212,7 @@ export function getSshTargetIdForExecutionHost( // `contradictory`. Answering with it hands out an SSH connection for a row that declares // itself local. // - `runtime:` is a different machine with its own SSH targets, and a nested one appears -// only in this field (`repoWithFetchedOwner` spreads it through). It is not dialable on its +// only in this field (`adoptFromEndpoint` spreads it through). It is not dialable on its // own, but it is addressable as the pair (environmentId, targetId) — which is how the // renderer reads it, recovering the environment from the worktree and looking the target up // inside it (`selectRuntimeAwareSshStatus`). Dropping it makes a nested-SSH workspace read diff --git a/src/shared/external-automation-scope.ts b/src/shared/external-automation-scope.ts index 0e9fe753a5c1..e7663dbd16e3 100644 --- a/src/shared/external-automation-scope.ts +++ b/src/shared/external-automation-scope.ts @@ -73,18 +73,6 @@ export class ExternalAutomationScopeError extends Error { } } -const SCOPE_CODE_VALUES: readonly ExternalAutomationScopeCode[] = Object.values( - EXTERNAL_AUTOMATION_SCOPE_CODES -) - -export function isExternalAutomationScopeCode( - value: unknown -): value is ExternalAutomationScopeCode { - return ( - typeof value === 'string' && SCOPE_CODE_VALUES.includes(value as ExternalAutomationScopeCode) - ) -} - /** The captured incarnation no longer matches the current SSH registration. */ export function externalAutomationHostChangedError(): AutomationOwnerConflictError { return new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 139990cd5821..69fa1ec3e3a2 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -30,6 +30,7 @@ import { fastForwardLocalBaseBranch } from './worktree/local-base-branch-fast-fo import { gitChangeListArgs, parseGitChangeList } from './git-change-list' import { encodeGitPathspecs } from './git-pathspec-stdin' import { endSubprocessStdin } from './subprocess-stdin-write' +import { registerGitRepositoryRegistrationBinaryCompatibilityCase } from './git-repository-registration-binary-compatibility.test-cases' import { registerGitResolutionBinaryCompatibilityCases } from './git-resolution-binary-compatibility.test-cases' const execFileAsync = promisify(execFile) @@ -138,6 +139,8 @@ describeBinaryCompatibility('real Git binary compatibility', () => { } }) + registerGitRepositoryRegistrationBinaryCompatibilityCase(runGit, () => repoPath, Boolean(image)) + it('stages, unstages and restores NUL-delimited literal pathspecs from stdin', async () => { const fixturePath = join(repoPath, 'stdin-pathspec') await mkdir(fixturePath) diff --git a/src/shared/git-history-graph.test.ts b/src/shared/git-history-graph.test.ts index dce6e7899372..79bd226f5a94 100644 --- a/src/shared/git-history-graph.test.ts +++ b/src/shared/git-history-graph.test.ts @@ -116,6 +116,42 @@ describe('git history graph model', () => { expect(viewModels[0]!.outputSwimlanes[1]!.color).toBe(GIT_HISTORY_REMOTE_REF_COLOR) }) + it.each([ + ['A', 'V'], + ['V', 'A'] + ])('keeps other lanes when parents are %s then %s', (...parents) => { + const rootRef = branch('unrelated', 'V') + const viewModels = buildGitHistoryViewModels( + [item('M', parents), item('V', [], [rootRef]), item('A', ['B']), item('B', [])], + new Map([[rootRef.id, undefined]]) + ) + const root = viewModels[1]! + const rootLane = root.inputSwimlanes.find((lane) => lane.id === 'V')! + + expect(root.inputSwimlanes.map((lane) => lane.id)).toEqual(parents) + expect(root.outputSwimlanes.map((lane) => lane.id)).toEqual(['A']) + expect(root.historyItem.references?.[0]?.color).toBe(rootLane.color) + expect(viewModels[2]!.inputSwimlanes.map((lane) => lane.id)).toEqual(['A']) + expect(viewModels[2]!.outputSwimlanes.map((lane) => lane.id)).toEqual(['B']) + expect(viewModels[3]!.outputSwimlanes).toEqual([]) + }) + + it('ends every converging root lane while retaining another history', () => { + const rootRef = branch('root', 'V') + const rows = buildGitHistoryViewModels( + [item('M', ['A', 'C', 'V']), item('A', ['V']), item('V', [], [rootRef]), item('C', [])], + new Map([[rootRef.id, undefined]]) + ) + const root = rows[2]! + const survivingLane = root.inputSwimlanes[1]! + + expect(root.inputSwimlanes.map((lane) => lane.id)).toEqual(['V', 'C', 'V']) + expect(root.outputSwimlanes).toEqual([survivingLane]) + expect(root.historyItem.references?.[0]?.color).toBe(root.inputSwimlanes[0]!.color) + expect(rows[3]!.inputSwimlanes).toEqual([survivingLane]) + expect(rows[3]!.outputSwimlanes).toEqual([]) + }) + it('avoids building a parent index for linear history', () => { const count = 64 const reads = { count: 0 } @@ -136,7 +172,7 @@ describe('git history graph model', () => { const historyItems: GitHistoryItem[] = [] for (let index = 0; index < mergeCount; index += 1) { historyItems.push(trackedItem(`merge-${index}`, [`missing-${index}`, targetId], reads)) - // Reset swimlanes between merges so this measures parent lookup, not lane growth. + // Roots retain other lanes; growing width must not add history-item reads. historyItems.push(trackedItem(`leaf-${index}`, [], reads)) } historyItems.push(trackedItem(targetId, [], reads)) diff --git a/src/shared/git-history-graph.ts b/src/shared/git-history-graph.ts index f3c6c24ad49d..89c230b5fe63 100644 --- a/src/shared/git-history-graph.ts +++ b/src/shared/git-history-graph.ts @@ -33,6 +33,25 @@ function cloneNode(node: GitHistoryGraphNode): GitHistoryGraphNode { return { id: node.id, color: node.color } } +function resolveNodeColor( + id: string, + hasParents: boolean, + inputSwimlanes: GitHistoryGraphNode[], + outputSwimlanes: GitHistoryGraphNode[] +): GitHistoryGraphColorId { + const inputIndex = inputSwimlanes.findIndex((node) => node.id === id) + const circleIndex = inputIndex !== -1 ? inputIndex : inputSwimlanes.length + // A root ends its own lane, so its output slot may now belong to another history. + if (!hasParents && inputIndex !== -1) { + return inputSwimlanes[inputIndex]!.color + } + return ( + outputSwimlanes[circleIndex]?.color ?? + inputSwimlanes[circleIndex]?.color ?? + GIT_HISTORY_REF_COLOR + ) +} + function findLastIndex(items: readonly T[], predicate: (item: T) => boolean): number { for (let index = items.length - 1; index >= 0; index -= 1) { if (predicate(items[index] as T)) { @@ -106,25 +125,25 @@ export function buildGitHistoryViewModels( let historyItemsById: Map | undefined for (const historyItem of historyItems) { - const kind = historyItem.id === currentRef?.revision ? 'HEAD' : 'node' + const historyItemId = historyItem.id + const firstParentId = historyItem.parentIds[0] + const kind = historyItemId === currentRef?.revision ? 'HEAD' : 'node' const inputSwimlanes = (viewModels.at(-1)?.outputSwimlanes ?? []).map(cloneNode) const outputSwimlanes: GitHistoryGraphNode[] = [] let firstParentAdded = false - if (historyItem.parentIds.length > 0) { - for (const node of inputSwimlanes) { - if (node.id === historyItem.id) { - if (!firstParentAdded) { - outputSwimlanes.push({ - id: historyItem.parentIds[0]!, - color: getLabelColorIdentifier(historyItem, colorMap) ?? node.color - }) - firstParentAdded = true - } - continue + for (const node of inputSwimlanes) { + if (node.id === historyItemId) { + if (!firstParentAdded && firstParentId !== undefined) { + outputSwimlanes.push({ + id: firstParentId, + color: getLabelColorIdentifier(historyItem, colorMap) ?? node.color + }) + firstParentAdded = true } - outputSwimlanes.push(cloneNode(node)) + continue } + outputSwimlanes.push(cloneNode(node)) } for (let index = firstParentAdded ? 1 : 0; index < historyItem.parentIds.length; index += 1) { @@ -162,14 +181,12 @@ export function buildGitHistoryViewModels( .map((ref) => { let color = colorMap.get(ref.id) if (colorMap.has(ref.id) && color === undefined) { - const inputIndex = inputSwimlanes.findIndex((node) => node.id === historyItem.id) - const circleIndex = inputIndex !== -1 ? inputIndex : inputSwimlanes.length - color = - circleIndex < outputSwimlanes.length - ? outputSwimlanes[circleIndex]!.color - : circleIndex < inputSwimlanes.length - ? inputSwimlanes[circleIndex]!.color - : GIT_HISTORY_REF_COLOR + color = resolveNodeColor( + historyItemId, + firstParentId !== undefined, + inputSwimlanes, + outputSwimlanes + ) } return { ...ref, color } }) @@ -195,6 +212,15 @@ export function buildGitHistoryViewModels( return viewModels } +export function getGitHistoryItemColor(viewModel: GitHistoryItemViewModel): GitHistoryGraphColorId { + return resolveNodeColor( + viewModel.historyItem.id, + viewModel.historyItem.parentIds.length > 0, + viewModel.inputSwimlanes, + viewModel.outputSwimlanes + ) +} + export function getGitHistoryItemLaneIndex(viewModel: GitHistoryItemViewModel): number { const inputIndex = viewModel.inputSwimlanes.findIndex( (node) => node.id === viewModel.historyItem.id diff --git a/src/shared/git-history-real-repository.test.ts b/src/shared/git-history-real-repository.test.ts new file mode 100644 index 000000000000..f627e34178df --- /dev/null +++ b/src/shared/git-history-real-repository.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from '@orca/process-host' +import { resolveExecutableCommand } from './node-cli-command-resolution' +import { loadGitHistoryFromExecutor } from './git-history' +import { buildDefaultGitHistoryColorMap, buildGitHistoryViewModels } from './git-history-graph' + +const repositories: string[] = [] +const gitBinary = resolveExecutableCommand(process.env.ORCA_TEST_GIT_BINARY ?? 'git') + +afterEach(async () => { + await Promise.all( + repositories.splice(0).map((repo) => rm(repo, { recursive: true, force: true })) + ) +}) + +async function createRepository(inheritedEnv: NodeJS.ProcessEnv = process.env) { + if (!gitBinary) { + throw new Error('Git is required for the real history regression') + } + const repo = await mkdtemp(join(tmpdir(), 'orca-history-roots-')) + repositories.push(repo) + const emptyConfig = join(repo, 'empty-git-config') + const emptyHooks = join(repo, 'empty-hooks') + await writeFile(emptyConfig, '') + await mkdir(emptyHooks) + const fixtureEnv = Object.fromEntries( + Object.entries(inheritedEnv).filter(([key]) => !key.toUpperCase().startsWith('GIT_')) + ) + const git = async (args: string[], cwd = repo, date = '2000-01-04T00:00:00Z') => { + const result = await runProcess({ + program: gitBinary, + args, + cwd, + env: { + ...fixtureEnv, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_AUTHOR_DATE: date, + GIT_COMMITTER_DATE: date + }, + timeoutMs: 10000, + maxOutputBytes: 1024 * 1024 + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`) + } + return { stdout: result.stdout } + } + await git(['init', '--quiet']) + await git(['config', 'user.name', 'Graph Fixture']) + await git(['config', 'user.email', 'graph-fixture@example.test']) + await git(['config', 'commit.gpgsign', 'false']) + await git(['config', 'core.hooksPath', emptyHooks]) + await git(['checkout', '-b', 'first']) + await git(['commit', '--quiet', '--allow-empty', '-m', 'B'], repo, '2000-01-01T00:00:00Z') + await git(['commit', '--quiet', '--allow-empty', '-m', 'A'], repo, '2000-01-02T00:00:00Z') + await git(['checkout', '--orphan', 'unrelated']) + await git(['commit', '--quiet', '--allow-empty', '-m', 'V'], repo, '2000-01-03T00:00:00Z') + return { repo, git } +} + +describe('real unrelated Git histories', () => { + it.each(['repository pointers', 'config overrides'] as const)( + 'ignores inherited Git %s when creating a fixture', + async (contamination) => { + const cleanEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')) + ) + const caller = await createRepository(cleanEnv) + await caller.git(['config', 'user.name', 'Caller Repository']) + const protectedPaths = ['HEAD', 'config', 'index'].map((name) => + join(caller.repo, '.git', name) + ) + const before = await Promise.all(protectedPaths.map((path) => readFile(path))) + const refsBefore = (await caller.git(['show-ref'])).stdout + const inheritedGitEnv = + contamination === 'repository pointers' + ? { + GIT_DIR: join(caller.repo, '.git'), + GIT_WORK_TREE: caller.repo, + GIT_COMMON_DIR: join(caller.repo, '.git'), + GIT_INDEX_FILE: join(caller.repo, '.git', 'index'), + GIT_OBJECT_DIRECTORY: join(caller.repo, '.git', 'objects'), + GIT_ALTERNATE_OBJECT_DIRECTORIES: join(caller.repo, '.git', 'objects') + } + : { + GIT_CONFIG: join(caller.repo, '.git', 'config'), + GIT_CONFIG_PARAMETERS: "'user.name=Injected Author'", + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.bare', + GIT_CONFIG_VALUE_0: 'true' + } + const fixture = await createRepository({ ...cleanEnv, ...inheritedGitEnv }) + + expect( + await realpath((await fixture.git(['rev-parse', '--show-toplevel'])).stdout.trim()) + ).toBe(await realpath(fixture.repo)) + expect((await fixture.git(['log', '-1', '--format=%an <%ae>'])).stdout.trim()).toBe( + 'Graph Fixture ' + ) + expect(await Promise.all(protectedPaths.map((path) => readFile(path)))).toEqual(before) + expect((await caller.git(['show-ref'])).stdout).toBe(refsBefore) + } + ) + + it.each([false, true])( + 'keeps the other history when first parent is a root: %s', + async (firstParentIsRoot) => { + const { repo, git } = await createRepository() + if (!firstParentIsRoot) { + await git(['checkout', 'first']) + } + await git([ + 'merge', + '--quiet', + '--no-ff', + '--allow-unrelated-histories', + '-m', + 'M', + firstParentIsRoot ? 'first' : 'unrelated' + ]) + const history = await loadGitHistoryFromExecutor(git, repo, { limit: 10 }) + const rows = buildGitHistoryViewModels( + history.items, + buildDefaultGitHistoryColorMap(history), + history.currentRef + ) + const terminatingRoot = rows.find( + (row) => row.historyItem.subject === (firstParentIsRoot ? 'B' : 'V') + )! + const surviving = history.items.find( + (item) => item.subject === (firstParentIsRoot ? 'V' : 'A') + )! + + expect(history.items).toHaveLength(4) + expect(history.items[0]!.parentIds).toHaveLength(2) + expect(terminatingRoot.historyItem.parentIds).toEqual([]) + expect(terminatingRoot.inputSwimlanes).toHaveLength(2) + expect(terminatingRoot.outputSwimlanes.map((lane) => lane.id)).toEqual([surviving.id]) + const nextRow = rows[rows.indexOf(terminatingRoot) + 1]! + expect(nextRow.inputSwimlanes.map((lane) => lane.id)).toEqual([surviving.id]) + expect(rows.at(-1)!.outputSwimlanes).toEqual([]) + + const truncated = await loadGitHistoryFromExecutor(git, repo, { limit: 2 }) + expect(truncated.hasMore).toBe(true) + expect(truncated.items).toHaveLength(2) + } + ) +}) diff --git a/src/shared/git-repository-registration-binary-compatibility.test-cases.ts b/src/shared/git-repository-registration-binary-compatibility.test-cases.ts new file mode 100644 index 000000000000..ff7811e6c46b --- /dev/null +++ b/src/shared/git-repository-registration-binary-compatibility.test-cases.ts @@ -0,0 +1,60 @@ +import { mkdir, rm } from 'node:fs/promises' +import { join, posix, resolve } from 'node:path' +import { expect, it } from 'vitest' + +export function registerGitRepositoryRegistrationBinaryCompatibilityCase( + runGit: (args: string[]) => Promise<{ stdout: string; stderr: string }>, + getRepoPath: () => string, + image: boolean +): void { + it('supports repository registration probes for nested, linked and bare repositories', async () => { + const repoPath = getRepoPath() + const executionRoot = image ? '/repo' : repoPath + const nested = join(repoPath, 'registration nested') + const linked = image ? '/repo/registration linked' : join(repoPath, 'registration linked') + const bare = image ? '/repo/registration bare' : join(repoPath, 'registration bare') + await mkdir(nested) + await runGit(['worktree', 'add', '-b', 'registration-linked', linked]) + await runGit(['init', '--bare', bare]) + try { + const root = (await runGit(['rev-parse', '--show-toplevel'])).stdout.trim() + const linkedRoot = ( + await runGit(['-C', linked, 'rev-parse', '--show-toplevel']) + ).stdout.trim() + for (const cwd of [executionRoot, image ? '/repo/registration nested' : nested, linked]) { + expect( + (await runGit(['-C', cwd, 'rev-parse', '--is-inside-work-tree', '--is-bare-repository'])) + .stdout + ).toBe('true\nfalse\n') + const records = ( + await runGit([ + '-C', + cwd, + 'rev-parse', + '--is-inside-work-tree', + '--show-toplevel', + '--git-dir', + '--git-common-dir' + ]) + ).stdout + .trim() + .split('\n') + expect(records).toHaveLength(4) + expect(records[0]).toBe('true') + expect(records[1]).toBe(cwd === linked ? linkedRoot : root) + const resolveDirectory = image ? posix.resolve : resolve + expect(resolveDirectory(cwd, records[2]) === resolveDirectory(cwd, records[3])).toBe( + cwd !== linked + ) + } + expect( + (await runGit(['-C', bare, 'rev-parse', '--is-inside-work-tree', '--is-bare-repository'])) + .stdout + ).toBe('false\ntrue\n') + } finally { + await runGit(['worktree', 'remove', '--force', linked]) + await rm(nested, { recursive: true, force: true }) + await rm(join(repoPath, 'registration bare'), { recursive: true, force: true }) + } + }) +} diff --git a/src/shared/host-authority.test.ts b/src/shared/host-authority.test.ts new file mode 100644 index 000000000000..b896a7285548 --- /dev/null +++ b/src/shared/host-authority.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + getExecutionHostIdFromWorktreeHostIdentity, + composeWorktreeHostIdentity +} from './worktree/host-qualified-identity' +import { parseExecutionHostId, UNRESOLVED_OWNER_HOST_ID } from './execution-host' +import { + authorityKey, + hostAuthorityFromOperationRoute, + parseHostAuthorityKey, + type HostAuthority +} from './host-authority' + +const self = { kind: 'self' } as const +const env = (environmentId: string) => ({ kind: 'environment', environmentId }) as const + +describe('authorityKey', () => { + it.each<[HostAuthority, string]>([ + [{ endpoint: self, at: 'local' }, 'local'], + [{ endpoint: self, at: 'ssh:box' }, 'ssh:box'], + [{ endpoint: env('env/a'), at: 'local' }, 'runtime:env%2Fa'], + [{ endpoint: env('env/a'), at: 'ssh:my%2Fbox' }, 'nested:env%2Fa/my%2Fbox'] + ])('keeps existing spellings and round-trips %j', (authority, key) => { + expect(authorityKey(authority)).toBe(key) + expect(parseHostAuthorityKey(key)).toEqual(authority) + }) + + it('gives nested authorities a key no host-id parser accepts', () => { + const key = authorityKey({ endpoint: env('env-a'), at: 'ssh:box' }) + expect(parseExecutionHostId(key)).toBeNull() + expect( + getExecutionHostIdFromWorktreeHostIdentity(composeWorktreeHostIdentity(undefined, key)) + ).toBeUndefined() + }) + + it('never parses the unresolved-owner sentinel, plain or nested', () => { + expect(parseHostAuthorityKey(UNRESOLVED_OWNER_HOST_ID)).toBeNull() + expect(parseHostAuthorityKey('nested:unresolved-owner/box')).toBeNull() + expect(parseHostAuthorityKey('nested:env-a')).toBeNull() + expect(parseHostAuthorityKey('nested:env-a/box/extra')).toBeNull() + expect(parseHostAuthorityKey('bogus')).toBeNull() + }) +}) + +describe('hostAuthorityFromOperationRoute', () => { + it('reads the transport as the endpoint and the row host as the place on it', () => { + expect( + hostAuthorityFromOperationRoute({ executionHostId: 'ssh:t', runtimeEnvironmentId: 'env-a' }) + ).toEqual({ endpoint: env('env-a'), at: 'ssh:t' }) + expect( + hostAuthorityFromOperationRoute({ executionHostId: 'local', runtimeEnvironmentId: 'env-a' }) + ).toEqual({ endpoint: env('env-a'), at: 'local' }) + expect( + hostAuthorityFromOperationRoute({ executionHostId: null, runtimeEnvironmentId: 'env-a' }) + ).toEqual({ endpoint: env('env-a'), at: 'local' }) + expect( + hostAuthorityFromOperationRoute({ executionHostId: 'ssh:t', runtimeEnvironmentId: null }) + ).toEqual({ endpoint: self, at: 'ssh:t' }) + }) + + it('reports disagreeing hosts and refuses unusable ones', () => { + expect( + hostAuthorityFromOperationRoute({ + executionHostId: 'runtime:env-b', + runtimeEnvironmentId: 'env-a' + }) + ).toBe('contradictory') + expect( + hostAuthorityFromOperationRoute({ + executionHostId: UNRESOLVED_OWNER_HOST_ID, + runtimeEnvironmentId: 'unresolved-owner' + }) + ).toBeNull() + expect( + hostAuthorityFromOperationRoute({ executionHostId: 'garbage', runtimeEnvironmentId: null }) + ).toBeNull() + expect( + hostAuthorityFromOperationRoute({ executionHostId: null, runtimeEnvironmentId: null }) + ).toBeNull() + }) +}) diff --git a/src/shared/host-authority.ts b/src/shared/host-authority.ts new file mode 100644 index 000000000000..7e214ba67e3e --- /dev/null +++ b/src/shared/host-authority.ts @@ -0,0 +1,116 @@ +/** + * Which host a resource belongs to, as the pair the client needs to reach it: the Orca endpoint that + * serves it (this app, or a paired server) and the place on that endpoint where it executes (the + * endpoint's own machine, or one of its SSH targets). + * + * An `ExecutionHostId` alone cannot say "SSH target t behind server E": `ssh:t` reads as a target + * this client dials itself. The pair can, and {@link authorityKey} keeps every spelling that already + * exists so no persisted key changes. + */ +import { + LOCAL_EXECUTION_HOST_ID, + isUnresolvedOwnerHostId, + parseRoutableExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from './execution-host' + +export type HostAuthorityEndpoint = + | { kind: 'self' } + | { kind: 'environment'; environmentId: string } + +export type HostAuthority = { + endpoint: HostAuthorityEndpoint + /** Relative to `endpoint`: its own machine, or one of its SSH targets. */ + at: typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` +} + +/** A resource named by its owner, so adapters derive params and route from one value. */ +export type HostRef = { authority: HostAuthority; id: string } + +const NESTED_PREFIX = 'nested:' + +/** + * In-memory only, never persisted. The nested spelling is deliberately not an `ExecutionHostId`: + * every host-id parser rejects it, so a leaked key fails closed instead of collapsing to `runtime:E`. + */ +export type HostAuthorityKey = ExecutionHostId | `${typeof NESTED_PREFIX}${string}` + +export const SELF_LOCAL_AUTHORITY: HostAuthority = { + endpoint: { kind: 'self' }, + at: LOCAL_EXECUTION_HOST_ID +} + +export function authorityKey(authority: HostAuthority): HostAuthorityKey { + if (authority.endpoint.kind === 'self') { + return authority.at + } + const runtimeHostId = toRuntimeExecutionHostId(authority.endpoint.environmentId) + if (authority.at === LOCAL_EXECUTION_HOST_ID) { + return runtimeHostId + } + return `${NESTED_PREFIX}${runtimeHostId.slice('runtime:'.length)}/${authority.at.slice('ssh:'.length)}` +} + +function environmentEndpoint(environmentId: string): HostAuthorityEndpoint | null { + return environmentId.trim() && !isUnresolvedOwnerHostId(toRuntimeExecutionHostId(environmentId)) + ? { kind: 'environment', environmentId } + : null +} + +/** Inverse of {@link authorityKey}; null for anything unparseable or the unresolved sentinel. */ +export function parseHostAuthorityKey(key: string | null | undefined): HostAuthority | null { + if (key?.startsWith(NESTED_PREFIX)) { + const parts = key.slice(NESTED_PREFIX.length).split('/') + if (parts.length !== 2) { + return null + } + const runtime = parseRoutableExecutionHostId(`runtime:${parts[0]}`) + const ssh = parseRoutableExecutionHostId(`ssh:${parts[1]}`) + const endpoint = runtime?.kind === 'runtime' ? environmentEndpoint(runtime.environmentId) : null + return endpoint && ssh?.kind === 'ssh' ? { endpoint, at: ssh.id } : null + } + const authority = hostAuthorityFromOperationRoute({ + executionHostId: key ?? null, + runtimeEnvironmentId: null + }) + return authority === 'contradictory' ? null : authority +} + +/** + * Reads the renderer's existing `{ executionHostId, runtimeEnvironmentId }` route as an authority. + * `runtimeEnvironmentId` is the transport; `executionHostId` is the host as the row names it. + * Returns null when no usable owner is named, and `'contradictory'` when the two disagree. + */ +export function hostAuthorityFromOperationRoute(route: { + executionHostId: string | null + runtimeEnvironmentId: string | null +}): HostAuthority | null | 'contradictory' { + const host = route.executionHostId ? parseRoutableExecutionHostId(route.executionHostId) : null + if (route.executionHostId && !host) { + return null + } + const transportId = route.runtimeEnvironmentId?.trim() + if (!transportId) { + if (!host) { + return null + } + if (host.kind === 'runtime') { + const endpoint = environmentEndpoint(host.environmentId) + return endpoint ? { endpoint, at: LOCAL_EXECUTION_HOST_ID } : null + } + return { endpoint: { kind: 'self' }, at: host.id } + } + const endpoint = environmentEndpoint(transportId) + if (!endpoint) { + return null + } + if (host?.kind === 'ssh') { + return { endpoint, at: host.id } + } + if (host?.kind === 'runtime' && host.environmentId !== transportId) { + return 'contradictory' + } + // A `local` host on a paired server's row is local to that server. + return { endpoint, at: LOCAL_EXECUTION_HOST_ID } +} diff --git a/src/shared/host-operation-ownership.test.ts b/src/shared/host-operation-ownership.test.ts new file mode 100644 index 000000000000..6e485063e83d --- /dev/null +++ b/src/shared/host-operation-ownership.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + RPC_METHODS_WITHOUT_SHARED_PARAMS, + RPC_PARAMS_BY_METHOD +} from './rpc-contract/rpc-params-catalog.generated' +import { getHostOperationOwnership } from './host-operation-ownership' + +describe('getHostOperationOwnership', () => { + it('classifies every runtime RPC method', () => { + const methods = [...Object.keys(RPC_PARAMS_BY_METHOD), ...RPC_METHODS_WITHOUT_SHARED_PARAMS] + expect(methods.filter((method) => getHostOperationOwnership(method) === null)).toEqual([]) + }) + + it('reads subscriptions as streams and forge namespaces as credential-sourced', () => { + expect(getHostOperationOwnership('terminal.subscribe')).toBe('stream') + expect(getHostOperationOwnership('files.unwatch')).toBe('stream') + expect(getHostOperationOwnership('session.tabs.subscribeAll')).toBe('stream') + expect(getHostOperationOwnership('accounts.list')).toBe('host') + expect(getHostOperationOwnership('github.prChecks')).toBe('credential') + expect(getHostOperationOwnership('jira.getIssueStream')).toBe('scope-picked') + }) + + it('reads row-less GitHub Projects board methods as scope-picked', () => { + expect(getHostOperationOwnership('github.project.viewTable')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.listAccessible')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.listViews')).toBe('scope-picked') + expect(getHostOperationOwnership('github.project.resolveRef')).toBe('scope-picked') + // Row edits route to the matched repo's owner first, so they stay credential-sourced. + expect(getHostOperationOwnership('github.project.updateItemField')).toBe('credential') + }) + + it('keeps client effects client and returns null for unknown names', () => { + expect(getHostOperationOwnership('shell.openPath')).toBe('client') + expect(getHostOperationOwnership('nope.call')).toBeNull() + expect(getHostOperationOwnership('accounts')).toBeNull() + expect(getHostOperationOwnership('.list')).toBeNull() + }) +}) diff --git a/src/shared/host-operation-ownership.ts b/src/shared/host-operation-ownership.ts new file mode 100644 index 000000000000..264231f1bdf7 --- /dev/null +++ b/src/shared/host-operation-ownership.ts @@ -0,0 +1,97 @@ +/** + * Who decides where an operation runs. + * - `host`: the host that owns the resource (a workspace, terminal, session or account). + * - `credential`: forge APIs. A runtime-owned repo goes to its owner runtime; a local or SSH repo + * uses this desktop's credentials. Never chosen by focus. + * - `client`: an effect on this client (reveal, open in editor); never sent to a host. + * - `stream`: a subscription that lives on the host owning the subscribed resource. + * - `scope-picked`: row-less work whose host comes from an explicit picker, never from focus. + */ +export type HostOperationOwnership = 'host' | 'credential' | 'client' | 'stream' | 'scope-picked' + +/** By runtime RPC namespace; client effects by their preload API name. */ +export const HOST_OPERATION_OWNERSHIP = { + accounts: 'host', + agent: 'host', + agentHooks: 'host', + agentSession: 'host', + agentSessionAttachment: 'host', + agentTeams: 'host', + aiVault: 'host', + artifacts: 'host', + automation: 'host', + browser: 'host', + clipboard: 'host', + computer: 'host', + diagnostics: 'host', + emulator: 'host', + files: 'host', + folderWorkspace: 'host', + git: 'host', + github: 'credential', + gitlab: 'credential', + host: 'host', + hostedReview: 'credential', + jira: 'scope-picked', + layout: 'host', + linear: 'scope-picked', + managedServer: 'host', + markdown: 'host', + mobileWeb: 'host', + nativeChat: 'host', + network: 'host', + notifications: 'host', + orcad: 'host', + orchestration: 'host', + pairing: 'host', + pet: 'host', + plugins: 'host', + preflight: 'host', + project: 'host', + projectGroup: 'host', + projectHostSetup: 'host', + reference: 'host', + repo: 'host', + runtime: 'host', + session: 'host', + settings: 'scope-picked', + shell: 'client', + skills: 'host', + speech: 'host', + ssh: 'host', + stats: 'host', + status: 'host', + terminal: 'host', + ui: 'host', + updater: 'host', + workspacePorts: 'host', + worktree: 'host' +} as const satisfies Record + +/** Methods whose owner differs from their namespace: row-less forge reads use the source picker. */ +const HOST_OPERATION_METHOD_OWNERSHIP = { + 'github.project.listAccessible': 'scope-picked', + 'github.project.listViews': 'scope-picked', + 'github.project.resolveRef': 'scope-picked', + 'github.project.viewTable': 'scope-picked' +} as const satisfies Record + +const OWNERSHIP_BY_METHOD: ReadonlyMap = new Map( + Object.entries(HOST_OPERATION_METHOD_OWNERSHIP) +) +const OWNERSHIP_BY_NAMESPACE: ReadonlyMap = new Map( + Object.entries(HOST_OPERATION_OWNERSHIP) +) +const STREAM_METHOD = /^(un)?(subscribe|watch)/i + +/** Null for a namespace nobody classified, so callers fail closed. */ +export function getHostOperationOwnership(method: string): HostOperationOwnership | null { + const ownership = + OWNERSHIP_BY_METHOD.get(method) ?? + OWNERSHIP_BY_NAMESPACE.get(method.slice(0, method.indexOf('.'))) + if (!ownership || !method.includes('.')) { + return null + } + const leaf = method.slice(method.lastIndexOf('.') + 1) + return ownership !== 'client' && STREAM_METHOD.test(leaf) ? 'stream' : ownership +} diff --git a/src/shared/issue-link-input.ts b/src/shared/issue-link-input.ts index 2f2890a633f2..1ff6c4ed3903 100644 --- a/src/shared/issue-link-input.ts +++ b/src/shared/issue-link-input.ts @@ -11,10 +11,6 @@ export const ISSUE_LINK_PROVIDERS = [ export type IssueLinkProvider = (typeof ISSUE_LINK_PROVIDERS)[number] -export function isIssueLinkProvider(value: unknown): value is IssueLinkProvider { - return ISSUE_LINK_PROVIDERS.includes(value as IssueLinkProvider) -} - /** URL input only. Linear and Jira issue keys are byte-identical in shape, so a * bare `STA-335` can never decide a provider — it would override the chip. */ export function getIssueLinkProviderFromUrl(input: string): IssueLinkProvider | null { diff --git a/src/shared/mobile-relay-pairing-offer.ts b/src/shared/mobile-relay-pairing-offer.ts index 4f5f2cc3d8c2..97c24f54c143 100644 --- a/src/shared/mobile-relay-pairing-offer.ts +++ b/src/shared/mobile-relay-pairing-offer.ts @@ -5,6 +5,7 @@ import { PAIRING_PUBLIC_KEY_MAX_CHARACTERS, PAIRING_RELAY_URL_MAX_CHARACTERS } from './mobile-pairing-protocol-limits' +import { RuntimeHostDescriptorSchema } from './runtime-host-descriptor' export const PAIRING_OFFER_VERSION = 2 const PairingScopeSchema = z.enum(['mobile', 'runtime']) @@ -77,7 +78,9 @@ export function createPairingOfferSchema(now: () => number = () => Date.now()) { publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), pairedDeviceId: z.string().min(1).max(128).optional(), scope: PairingScopeSchema.optional(), - relay: relaySchema.optional() + relay: relaySchema.optional(), + // Why catch: a descriptor this build cannot read is dropped, never a reason to refuse pairing. + hostDescriptor: RuntimeHostDescriptorSchema.optional().catch(undefined) }) .superRefine((offer, ctx) => { if (offer.relay && offer.scope === 'runtime') { diff --git a/src/shared/preflight-other-runtime-refusal-capability.ts b/src/shared/preflight-other-runtime-refusal-capability.ts new file mode 100644 index 000000000000..763b69bf94bb --- /dev/null +++ b/src/shared/preflight-other-runtime-refusal-capability.ts @@ -0,0 +1,11 @@ +// Split out of protocol-version.ts, which lists the capability in RUNTIME_CAPABILITIES. +// Import these names from here: pulling protocol-version into the mobile agent loader +// splits a web-bundle chunk. + +// Why: an older host answers its own agents for a workspace another runtime owns; one with this +// refuses with WORKSPACE_ON_OTHER_RUNTIME, so a client may let it decide for a shared repo id. +export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY = + 'preflight.other-runtime-refusal.v1' as const + +/** What such a host answers, instead of probing itself, for another runtime's workspace. */ +export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime' diff --git a/src/shared/process-table-snapshot.ts b/src/shared/process-table-snapshot.ts index 81a669e6d97c..116180be8ce4 100644 --- a/src/shared/process-table-snapshot.ts +++ b/src/shared/process-table-snapshot.ts @@ -272,11 +272,3 @@ export function parseStrictProcessTableRows(stdout: string): ProcessTableRow[] { } return rows } - -/** - * Rank a descendant row as a foreground candidate: a `+` (foreground process - * group) row always outranks a background one, then the deepest wins. - */ -export function scoreForegroundCandidateRow(row: ProcessTableRow & { depth: number }): number { - return (row.stat.includes('+') ? 10_000 : 0) + row.depth -} diff --git a/src/shared/project-types.ts b/src/shared/project-types.ts index f5704c504ef3..4ef6e522c850 100644 --- a/src/shared/project-types.ts +++ b/src/shared/project-types.ts @@ -116,6 +116,8 @@ export type ProjectHostSetupUpdateArgs = { export type ProjectHostSetupDeleteArgs = { setupId: string + /** `false` refuses while the project has live terminals or workspace metadata; absent skips the check. */ + force?: boolean } export type ProjectHostSetupResult = { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 99e66b7d16d1..d2f815386730 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -9,7 +9,8 @@ export { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, - AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY + AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY, + AUTOMATION_AGENT_FLAGS_RUNTIME_CAPABILITY } from './automation-runtime-capabilities' import { STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES } from './structured-agent-session-surface-capabilities' export { @@ -43,6 +44,7 @@ export { import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities' 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 { PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY } from './preflight-other-runtime-refusal-capability' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' export { AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, @@ -322,6 +324,13 @@ export const WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY = // must not read that answer as the workspace's (a WSL project on a Windows host differs). export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY = 'preflight.workspace-scoped.v1' as const +// Hosts without this capability have no workspacePorts.scanHost/killHost; their scan and Stop act +// only on the endpoint itself, never on a workspace's SSH host. +export const WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY = + 'workspace-ports.host-scoped.v1' as const +// Hosts without this capability ignore a request's expectedRuntimeSource; the CLI's pre-send probe +// is then the only fence. +export const RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY = 'runtime-source-fence.v1' as const // Hosts without this capability have no notifications.registerPush RPC. export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const @@ -348,6 +357,7 @@ export const RUNTIME_CAPABILITIES = [ ...AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, 'files.pathsExist', + 'files.resolve-terminal-path.cross-workspace.v1', 'runtime.status.compat.v1', 'runtime.environments.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, @@ -428,6 +438,9 @@ export const RUNTIME_CAPABILITIES = [ ...AUTOMATION_RUNTIME_CAPABILITIES, NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, + PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, + WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY, + RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY, ...FORK_TERMINAL_AND_AGENT_LAUNCH_RUNTIME_CAPABILITIES ] as const diff --git a/src/shared/remote-runtime-shared-control-connection-actions.ts b/src/shared/remote-runtime-shared-control-connection-actions.ts index 301018c47da0..83f6b6bc5699 100644 --- a/src/shared/remote-runtime-shared-control-connection-actions.ts +++ b/src/shared/remote-runtime-shared-control-connection-actions.ts @@ -79,15 +79,3 @@ export function closeSharedControlSubscription(args: { }): void { closeSharedControlConnectionSubscription(args) } - -export function closeRuntimeControlSubscription(args: { - subscriptions: Map> - retiredRequestIds: SharedControlRetiredRequestIds - requestId: string - deviceToken: string - send: (payload: unknown) => boolean - clearWhenIdle: (isIdle: boolean) => void -}): void { - closeSharedControlSubscription(args) - args.clearWhenIdle(args.subscriptions.size === 0) -} diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index 633abb3d4eff..c482de6f6ef1 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -3,8 +3,6 @@ import { releaseRemoteRuntimePreparedRequest } from './remote-runtime-prepared-r import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import type { - RemoteRuntimeSharedConnectionDiagnostics, - SharedControlConnectionState, SharedControlLogicalSubscription, SharedControlPendingRequest, SharedControlReadyWaiter @@ -13,26 +11,6 @@ import { getSubscriptionId, isEndResult } from './remote-runtime-shared-control- import { withReconnectJitter } from './reconnect-jitter' import { tagRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' -export function buildSharedControlDiagnostics(args: { - state: SharedControlConnectionState - reconnecting: boolean - pendingRequestCount: number - subscriptionCount: number - reconnectAttempt: number - diag: Pick< - RemoteRuntimeSharedConnectionDiagnostics, - 'lastConnectedAt' | 'lastClose' | 'lastError' - > -}): RemoteRuntimeSharedConnectionDiagnostics { - return { - state: args.reconnecting ? 'reconnecting' : args.state, - pendingRequestCount: args.pendingRequestCount, - subscriptionCount: args.subscriptionCount, - reconnectAttempt: args.reconnectAttempt, - ...args.diag - } -} - export function rejectSharedControlPendingRequest( pendingRequests: Map>, requestId: string, diff --git a/src/shared/review-refresh-policy.ts b/src/shared/review-refresh-policy.ts index db9ed5111403..39ca4427ee5e 100644 --- a/src/shared/review-refresh-policy.ts +++ b/src/shared/review-refresh-policy.ts @@ -22,12 +22,3 @@ export function reviewRefreshIntervalMs(input: { } return input.selected ? 60_000 : 120_000 } - -export function finishedReviewRefreshIntervalMs( - state: HostedReviewState | null | undefined, - status: CheckStatus | null | undefined -): number | null { - return state === 'merged' || state === 'closed' - ? reviewRefreshIntervalMs({ state, checksStatus: status }) - : null -} diff --git a/src/shared/rpc-contract/project-runtime-params.ts b/src/shared/rpc-contract/project-runtime-params.ts index 38d9ebff0ba0..94fd5d399153 100644 --- a/src/shared/rpc-contract/project-runtime-params.ts +++ b/src/shared/rpc-contract/project-runtime-params.ts @@ -91,5 +91,6 @@ export const ProjectHostSetupUpdate = z.object({ }) export const ProjectHostSetupDelete = z.object({ - setupId: requiredString('Missing setup ID') + setupId: requiredString('Missing setup ID'), + force: z.boolean().optional() }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 8294558d2b39..b9ae66466b17 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -563,7 +563,12 @@ import { } from './terminal-viewport-schemas-params' import { UpdaterCheckParams } from './updater-params' import { LayoutSubscribeParams, LayoutUnsubscribeParams } from './workspace-layout-params' -import { WorkspacePortKillParams, WorkspacePortScanParams } from './workspace-ports-params' +import { + WorkspacePortKillHostParams, + WorkspacePortKillParams, + WorkspacePortScanHostParams, + WorkspacePortScanParams +} from './workspace-ports-params' import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-params' import { WorktreeActivate, @@ -1258,7 +1263,9 @@ export const RPC_PARAMS_BY_METHOD = { 'updater.getStatus': null, 'updater.install': null, 'workspacePorts.kill': WorkspacePortKillParams, + 'workspacePorts.killHost': WorkspacePortKillHostParams, 'workspacePorts.scan': WorkspacePortScanParams, + 'workspacePorts.scanHost': WorkspacePortScanHostParams, 'worktree.activate': WorktreeActivate, 'worktree.create': WorktreeCreate, 'worktree.detectedList': WorktreeDetectedListParams, diff --git a/src/shared/rpc-contract/workspace-ports-params.ts b/src/shared/rpc-contract/workspace-ports-params.ts index 300f0e84fb17..1583505af5ce 100644 --- a/src/shared/rpc-contract/workspace-ports-params.ts +++ b/src/shared/rpc-contract/workspace-ports-params.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { OptionalString, requiredNumber } from './rpc-param-primitives' +import { OptionalString, requiredNumber, requiredString } from './rpc-param-primitives' export const WorkspacePortScanParams = z.object({ repoId: OptionalString @@ -10,3 +10,14 @@ export const WorkspacePortKillParams = z.object({ pid: requiredNumber('Missing process id'), port: requiredNumber('Missing port') }) + +export const WorkspacePortScanHostParams = z.object({ + worktree: requiredString('Missing worktree selector') +}) + +export const WorkspacePortKillHostParams = z.object({ + worktree: requiredString('Missing worktree selector'), + executionHostId: requiredString('Missing execution host'), + pid: requiredNumber('Missing process id'), + port: requiredNumber('Missing port') +}) diff --git a/src/shared/runtime-file-contracts.ts b/src/shared/runtime-file-contracts.ts index 17ffe6d2c3af..d490ae95e314 100644 --- a/src/shared/runtime-file-contracts.ts +++ b/src/shared/runtime-file-contracts.ts @@ -51,6 +51,10 @@ export type RuntimeNativeChatFileContext = { sessionId: string } +// Why: older hosts ignore crossWorkspace, so their refusal is no proof that no host workspace holds the path. +export const TERMINAL_PATH_CROSS_WORKSPACE_RUNTIME_CAPABILITY = + 'files.resolve-terminal-path.cross-workspace.v1' as const + export type RuntimeTerminalPathResolution = { worktree: string relativePath: string | null diff --git a/src/shared/runtime-host-descriptor.ts b/src/shared/runtime-host-descriptor.ts new file mode 100644 index 000000000000..e17b49721fbb --- /dev/null +++ b/src/shared/runtime-host-descriptor.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +/** + * What a runtime host says about its own installation. `installationId` is generated once per + * profile; `machineBinding` is HMAC(installationId, machine id + profile path) and is only a hint + * that the profile moved or was copied, never an identity on its own. + */ +export const RuntimeHostDescriptorSchema = z.object({ + installationId: z + .string() + .regex(/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/), + machineBinding: z + .string() + .regex(/^[A-Za-z0-9_-]{43}$/) + .optional() +}) + +export type RuntimeHostDescriptor = z.infer diff --git a/src/shared/runtime-host-pairing-identity.test.ts b/src/shared/runtime-host-pairing-identity.test.ts new file mode 100644 index 000000000000..d91c289a3311 --- /dev/null +++ b/src/shared/runtime-host-pairing-identity.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { classifyRuntimeHostRePair } from './runtime-host-pairing-identity' + +const INSTALL_A = '11111111-1111-4111-8111-111111111111' +const INSTALL_B = '22222222-2222-4222-8222-222222222222' +const BIND_1 = 'a'.repeat(43) +const BIND_2 = 'b'.repeat(43) + +describe('classifyRuntimeHostRePair', () => { + it('treats a different host key as a different server, whatever the descriptor says', () => { + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'], pin: { installationId: INSTALL_A } }, + { publicKeyB64: 'key-b', hostDescriptor: { installationId: INSTALL_A } } + ) + ).toEqual({ kind: 'different-host' }) + }) + + it('keeps an unpinned server matched by key and pins its first descriptor', () => { + expect(classifyRuntimeHostRePair({ publicKeys: ['key-a'] }, { publicKeyB64: 'key-a' })).toEqual( + { kind: 'same-host', pin: undefined, descriptor: 'unpinned' } + ) + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'] }, + { publicKeyB64: 'key-a', hostDescriptor: { installationId: INSTALL_A } } + ) + ).toEqual({ kind: 'same-host', pin: { installationId: INSTALL_A }, descriptor: 'pinned' }) + }) + + it('separates a same-key server whose installation changed', () => { + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'], pin: { installationId: INSTALL_A } }, + { publicKeyB64: 'key-a', hostDescriptor: { installationId: INSTALL_B } } + ) + ).toEqual({ kind: 'different-host' }) + }) + + it('keeps a moved or reinstalled server and re-pins its new binding', () => { + const next = { installationId: INSTALL_A, machineBinding: BIND_2 } + expect( + classifyRuntimeHostRePair( + { publicKeys: ['key-a'], pin: { installationId: INSTALL_A, machineBinding: BIND_1 } }, + { publicKeyB64: 'key-a', hostDescriptor: next } + ) + ).toEqual({ kind: 'same-host', pin: next, descriptor: 'moved' }) + }) + + it('never learns from a downgraded server that stopped sending a descriptor', () => { + const pin = { installationId: INSTALL_A, machineBinding: BIND_1 } + expect( + classifyRuntimeHostRePair({ publicKeys: ['key-a'], pin }, { publicKeyB64: 'key-a' }) + ).toEqual({ kind: 'same-host', pin, descriptor: 'unverifiable' }) + }) +}) diff --git a/src/shared/runtime-host-pairing-identity.ts b/src/shared/runtime-host-pairing-identity.ts new file mode 100644 index 000000000000..bd0075f7ac7d --- /dev/null +++ b/src/shared/runtime-host-pairing-identity.ts @@ -0,0 +1,47 @@ +import type { RuntimeHostDescriptor } from './runtime-host-descriptor' + +/** + * How a new pairing offer relates to a server this client already pairs with. + * `pin` is the descriptor to keep pinned. `descriptor`: + * - `pinned`: the offer's descriptor matches (or is now pinned for the first time). + * - `moved`: same installation, different machine binding: the profile moved or was reinstalled. + * - `unverifiable`: a pinned server now sends no descriptor (downgraded); the old pin is kept. + * - `unpinned`: neither side has a descriptor yet. + */ +export type RuntimeHostRePairMatch = + | { + kind: 'same-host' + pin: RuntimeHostDescriptor | undefined + descriptor: 'pinned' | 'moved' | 'unverifiable' | 'unpinned' + } + | { kind: 'different-host' } + +/** + * The host key, which the pairing handshake proves, is the trust root: a different key is a + * different server. The descriptor only separates a reinstall or a deliberate clone that kept the key. + */ +export function classifyRuntimeHostRePair( + previous: { publicKeys: readonly string[]; pin?: RuntimeHostDescriptor }, + offer: { publicKeyB64: string; hostDescriptor?: RuntimeHostDescriptor } +): RuntimeHostRePairMatch { + if (!previous.publicKeys.includes(offer.publicKeyB64)) { + return { kind: 'different-host' } + } + const { pin } = previous + const next = offer.hostDescriptor + if (!next) { + // Why keep the old pin: an older server must never let this client learn a replacement. + return { kind: 'same-host', pin, descriptor: pin ? 'unverifiable' : 'unpinned' } + } + if (!pin) { + return { kind: 'same-host', pin: next, descriptor: 'pinned' } + } + if (pin.installationId !== next.installationId) { + return { kind: 'different-host' } + } + const moved = + pin.machineBinding !== undefined && + next.machineBinding !== undefined && + pin.machineBinding !== next.machineBinding + return { kind: 'same-host', pin: next, descriptor: moved ? 'moved' : 'pinned' } +} diff --git a/src/shared/runtime-rpc-envelope.ts b/src/shared/runtime-rpc-envelope.ts index d407de72cb94..8e9f88847850 100644 --- a/src/shared/runtime-rpc-envelope.ts +++ b/src/shared/runtime-rpc-envelope.ts @@ -83,6 +83,14 @@ export type RuntimeOrchestrationEnvelope = { orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence } +/** Which runtime a request is meant for; a host that is not that runtime refuses it before any handler. */ +export type ExpectedRuntimeSource = { + /** The launching profile's installation id. */ + sourceId: string + /** The runtime the client verified for this send. */ + runtimeId: string +} + export type RuntimeRpcKeepaliveFrame = z.infer export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame { diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9780ff1487a1..09106e78d0fd 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -15,6 +15,7 @@ import type { RuntimeMobileSessionTerminalClientTab } from './runtime-mobile-session-tab-contracts' import type { CliStatusCaller } from './orchestration-caller-status' +import type { RuntimeHostDescriptor } from './runtime-host-descriptor' export type * from './runtime-mobile-session-tab-contracts' @@ -93,6 +94,8 @@ export type RuntimeStatus = { hostPlatform?: NodeJS.Platform /** Optional display name reported by the answering runtime. */ machineName?: string + /** Absent from older hosts and from hosts whose profile could not persist an installation id. */ + hostDescriptor?: RuntimeHostDescriptor terminalWindowsShell?: string | null deviceScope?: DeviceScope floatingWorkspaceEnabled?: boolean diff --git a/src/shared/runtime-source-env.test.ts b/src/shared/runtime-source-env.test.ts new file mode 100644 index 000000000000..435b33072b60 --- /dev/null +++ b/src/shared/runtime-source-env.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { readRuntimeSourceStamp, stampRuntimeSourceEnv } from './runtime-source-env' + +describe('runtime source env', () => { + it('round-trips a stamp and replaces an inherited one', () => { + const env: Record = { + ORCA_RUNTIME_SOURCE_ID: 'parent', + ORCA_RUNTIME_SOURCE_INCARNATION: 'parent-runtime' + } + stampRuntimeSourceEnv(env, { sourceId: 'child', incarnation: 'child-runtime' }) + expect(readRuntimeSourceStamp(env)).toEqual({ sourceId: 'child', incarnation: 'child-runtime' }) + }) + + it('clears an inherited stamp when this runtime has none', () => { + const env: Record = { + ORCA_RUNTIME_SOURCE_ID: 'parent', + ORCA_RUNTIME_SOURCE_INCARNATION: 'parent-runtime' + } + stampRuntimeSourceEnv(env, null) + expect(env).toEqual({}) + }) + + it('treats a partial or oversized stamp as no stamp', () => { + expect(readRuntimeSourceStamp({ ORCA_RUNTIME_SOURCE_ID: 'only-id' })).toBeNull() + expect( + readRuntimeSourceStamp({ + ORCA_RUNTIME_SOURCE_ID: 'x'.repeat(4_097), + ORCA_RUNTIME_SOURCE_INCARNATION: 'runtime' + }) + ).toBeNull() + }) +}) diff --git a/src/shared/runtime-source-env.ts b/src/shared/runtime-source-env.ts new file mode 100644 index 000000000000..b58bbf68849b --- /dev/null +++ b/src/shared/runtime-source-env.ts @@ -0,0 +1,50 @@ +// Which Orca runtime launched a process. Kept separate from the orchestration compatibility stamp, +// whose WSL/SSH union carries caller attestation and must stay untouched. +export const RUNTIME_SOURCE_ID_ENV = 'ORCA_RUNTIME_SOURCE_ID' +export const RUNTIME_SOURCE_INCARNATION_ENV = 'ORCA_RUNTIME_SOURCE_INCARNATION' +export const RUNTIME_SOURCE_PROFILE_PATH_ENV = 'ORCA_RUNTIME_SOURCE_PROFILE_PATH' + +export type RuntimeSourceStamp = { + /** The launching profile's installation id; survives restarts. */ + sourceId: string + /** The launching runtime's per-process id; only a hint that skips the probe. */ + incarnation: string + /** The launching runtime's userData path, so an explicit switch to another profile is not refused. */ + profilePath?: string +} + +const MAX_SOURCE_FIELD_LENGTH = 4_096 + +function boundedValue(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed && trimmed.length <= MAX_SOURCE_FIELD_LENGTH ? trimmed : undefined +} + +export function readRuntimeSourceStamp( + env: Readonly> +): RuntimeSourceStamp | null { + const sourceId = boundedValue(env[RUNTIME_SOURCE_ID_ENV]) + const incarnation = boundedValue(env[RUNTIME_SOURCE_INCARNATION_ENV]) + const profilePath = boundedValue(env[RUNTIME_SOURCE_PROFILE_PATH_ENV]) + if (!sourceId || !incarnation) { + return null + } + return profilePath ? { sourceId, incarnation, profilePath } : { sourceId, incarnation } +} + +/** Replaces any inherited stamp, so a nested Orca never passes on its parent's source. */ +export function stampRuntimeSourceEnv( + env: Record, + stamp: RuntimeSourceStamp | null +): void { + delete env[RUNTIME_SOURCE_ID_ENV] + delete env[RUNTIME_SOURCE_INCARNATION_ENV] + delete env[RUNTIME_SOURCE_PROFILE_PATH_ENV] + if (stamp) { + env[RUNTIME_SOURCE_ID_ENV] = stamp.sourceId + env[RUNTIME_SOURCE_INCARNATION_ENV] = stamp.incarnation + if (stamp.profilePath) { + env[RUNTIME_SOURCE_PROFILE_PATH_ENV] = stamp.profilePath + } + } +} diff --git a/src/shared/skill-install-providers.ts b/src/shared/skill-install-providers.ts index 8220f1df6c9b..ebd7112d1820 100644 --- a/src/shared/skill-install-providers.ts +++ b/src/shared/skill-install-providers.ts @@ -106,20 +106,6 @@ export function isSkillInstallProviderId(value: string): value is SkillInstallPr return PROVIDERS_BY_ID.has(value as SkillInstallProviderId) } -export function skillInstallProvider( - id: SkillInstallProviderId -): SkillInstallProviderDefinition | undefined { - return PROVIDERS_BY_ID.get(id) -} - -/** Detected agents Orca can actually place skills for, in registry order. */ -export function installableSkillProviders( - detectedProviders: readonly string[] -): SkillInstallProviderDefinition[] { - const detected = new Set(detectedProviders) - return SKILL_INSTALL_PROVIDERS.filter((provider) => detected.has(provider.id)) -} - /** * An explicit choice is authoritative, including agents the target may install * later. Removal passes no choice so it can clean every previously used root. diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 993f3bcabb98..2c7729a411a0 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -28,18 +28,6 @@ import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-fi /** Ms of title-less output after a working title before it is cleared. */ export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000 -// Braille spinner glyphs (U+2800–U+28FF); mirrors the range clearWorkingIndicators strips in agent-detection.ts. -// eslint-disable-next-line no-control-regex -- intentional unicode range -const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g - -/** - * Strip decorative braille spinner frame glyphs so titles differing only by the animation frame - * compare equal — the gate consumers use to avoid fan-out churn on spinner ticks. - */ -export function stripBrailleSpinnerGlyphs(title: string): string { - return title.replace(BRAILLE_SPINNER_RE, '').trim() -} - /** Provenance for title/idle facts; `staleWorkingTitleClear` marks facts synthesized by the 3s stale timer — not genuine task completions. */ export type TerminalTitleFactMeta = { staleWorkingTitleClear?: boolean diff --git a/src/shared/workspace-layout/workspace-layout-command-steps.ts b/src/shared/workspace-layout/workspace-layout-command-steps.ts index 64ebf7b77e06..41c77ce0f568 100644 --- a/src/shared/workspace-layout/workspace-layout-command-steps.ts +++ b/src/shared/workspace-layout/workspace-layout-command-steps.ts @@ -89,10 +89,6 @@ export function setLeaf( return { ...workspace, leaves } } -export function paneKeysOf(tab: LayoutTerminalTab): string[] { - return leafIdsOf(tab).map((leafId) => paneKeyOf(tab.entityId, leafId)) -} - /** The terminal tab and pane a pane key names in this workspace. */ export function locatePane( workspace: WorkspaceLayout, diff --git a/src/shared/workspace-ports.ts b/src/shared/workspace-ports.ts index 17a21e42eb49..b474003c7f65 100644 --- a/src/shared/workspace-ports.ts +++ b/src/shared/workspace-ports.ts @@ -73,3 +73,16 @@ export type WorkspacePortScanResult = { ports: WorkspacePort[] unavailableReason?: string } + +/** A scan of one execution host, named endpoint-relative (`local` or `ssh:` as the host sees it). */ +export type WorkspacePortHostScanResult = WorkspacePortScanResult & { + executionHostId: string +} + +export type WorkspacePortHostKillRequest = { + worktree: string + /** The host the row was scanned on; Stop is refused when the workspace now resolves elsewhere. */ + executionHostId: string + pid: number + port: number +} diff --git a/src/shared/worktree/id.test.ts b/src/shared/worktree/id.test.ts index 156c78e9959d..5cb9ffb479e2 100644 --- a/src/shared/worktree/id.test.ts +++ b/src/shared/worktree/id.test.ts @@ -9,12 +9,6 @@ import { worktreeIdComparisonKey } from './id' -describe('WORKTREE_ID_SEPARATOR', () => { - it('is the literal "::" separator', () => { - expect(WORKTREE_ID_SEPARATOR).toBe('::') - }) -}) - describe('getRepoMainWorktreeId', () => { it('round-trips through the id parsers on posix and Windows paths', () => { for (const repo of [ diff --git a/src/shared/zcode-plan-sites.ts b/src/shared/zcode-plan-sites.ts index ba1770cf9562..ac8808b02d3c 100644 --- a/src/shared/zcode-plan-sites.ts +++ b/src/shared/zcode-plan-sites.ts @@ -10,10 +10,6 @@ export type ZcodePlanSite = 'zai' | 'bigmodel' export const ZCODE_PLAN_SITES: readonly ZcodePlanSite[] = ['zai', 'bigmodel'] -export function isZcodePlanSite(value: unknown): value is ZcodePlanSite { - return value === 'zai' || value === 'bigmodel' -} - export const ZCODE_PLAN_SITE_BASE_URLS: Record = { zai: 'https://api.z.ai', bigmodel: 'https://open.bigmodel.cn' diff --git a/src/relay/agent-hook-cached-pane-status.ts b/src/wsl-guest/agent-hook-cached-pane-status.ts similarity index 77% rename from src/relay/agent-hook-cached-pane-status.ts rename to src/wsl-guest/agent-hook-cached-pane-status.ts index 2302dae587f8..9093e0bfa9e2 100644 --- a/src/relay/agent-hook-cached-pane-status.ts +++ b/src/wsl-guest/agent-hook-cached-pane-status.ts @@ -42,19 +42,3 @@ export function selectReplayableCachedPanes(input: { /** How many panes keep a cached status. Bounds a long-lived relay's per-pane state. */ export const MAX_CACHED_PANES = 256 - -/** Drop the longest-idle panes until the cache is back under its cap. Map insertion order is - * recency (writers delete-then-set), so the first key is always the oldest. */ -export function evictCachedPanesOverCap( - cachedByPaneKey: ReadonlyMap, - dropPane: (paneKey: string) => void, - maxPanes: number = MAX_CACHED_PANES -): void { - while (cachedByPaneKey.size > maxPanes) { - const oldest = cachedByPaneKey.keys().next().value - if (oldest === undefined) { - return - } - dropPane(oldest) - } -} diff --git a/src/relay/agent-hook-canonical-status.test.ts b/src/wsl-guest/agent-hook-canonical-status.test.ts similarity index 100% rename from src/relay/agent-hook-canonical-status.test.ts rename to src/wsl-guest/agent-hook-canonical-status.test.ts diff --git a/src/relay/agent-hook-canonical-status.ts b/src/wsl-guest/agent-hook-canonical-status.ts similarity index 100% rename from src/relay/agent-hook-canonical-status.ts rename to src/wsl-guest/agent-hook-canonical-status.ts diff --git a/src/relay/agent-hook-endpoint-coordinates.ts b/src/wsl-guest/agent-hook-endpoint-coordinates.ts similarity index 100% rename from src/relay/agent-hook-endpoint-coordinates.ts rename to src/wsl-guest/agent-hook-endpoint-coordinates.ts diff --git a/src/relay/agent-hook-envelope-build.ts b/src/wsl-guest/agent-hook-envelope-build.ts similarity index 100% rename from src/relay/agent-hook-envelope-build.ts rename to src/wsl-guest/agent-hook-envelope-build.ts diff --git a/src/relay/agent-hook-envelope-publication.test.ts b/src/wsl-guest/agent-hook-envelope-publication.test.ts similarity index 100% rename from src/relay/agent-hook-envelope-publication.test.ts rename to src/wsl-guest/agent-hook-envelope-publication.test.ts diff --git a/src/relay/agent-hook-envelope-publication.ts b/src/wsl-guest/agent-hook-envelope-publication.ts similarity index 100% rename from src/relay/agent-hook-envelope-publication.ts rename to src/wsl-guest/agent-hook-envelope-publication.ts diff --git a/src/relay/agent-hook-event-admission.ts b/src/wsl-guest/agent-hook-event-admission.ts similarity index 100% rename from src/relay/agent-hook-event-admission.ts rename to src/wsl-guest/agent-hook-event-admission.ts diff --git a/src/relay/agent-hook-integration.test.ts b/src/wsl-guest/agent-hook-integration.test.ts similarity index 100% rename from src/relay/agent-hook-integration.test.ts rename to src/wsl-guest/agent-hook-integration.test.ts diff --git a/src/relay/agent-hook-interrupt-reconciliation.test.ts b/src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts similarity index 100% rename from src/relay/agent-hook-interrupt-reconciliation.test.ts rename to src/wsl-guest/agent-hook-interrupt-reconciliation.test.ts diff --git a/src/relay/agent-hook-interrupt-reconciliation.ts b/src/wsl-guest/agent-hook-interrupt-reconciliation.ts similarity index 100% rename from src/relay/agent-hook-interrupt-reconciliation.ts rename to src/wsl-guest/agent-hook-interrupt-reconciliation.ts diff --git a/src/relay/agent-hook-opencode-tui-identity.test.ts b/src/wsl-guest/agent-hook-opencode-tui-identity.test.ts similarity index 100% rename from src/relay/agent-hook-opencode-tui-identity.test.ts rename to src/wsl-guest/agent-hook-opencode-tui-identity.test.ts diff --git a/src/relay/agent-hook-request.ts b/src/wsl-guest/agent-hook-request.ts similarity index 100% rename from src/relay/agent-hook-request.ts rename to src/wsl-guest/agent-hook-request.ts diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/wsl-guest/agent-hook-result-retry-scheduler.ts similarity index 100% rename from src/relay/agent-hook-result-retry-scheduler.ts rename to src/wsl-guest/agent-hook-result-retry-scheduler.ts diff --git a/src/relay/agent-hook-server-claude-owed-notification-expiry.test.ts b/src/wsl-guest/agent-hook-server-claude-owed-notification-expiry.test.ts similarity index 100% rename from src/relay/agent-hook-server-claude-owed-notification-expiry.test.ts rename to src/wsl-guest/agent-hook-server-claude-owed-notification-expiry.test.ts diff --git a/src/relay/agent-hook-server-codex-subagent-transcript.test.ts b/src/wsl-guest/agent-hook-server-codex-subagent-transcript.test.ts similarity index 100% rename from src/relay/agent-hook-server-codex-subagent-transcript.test.ts rename to src/wsl-guest/agent-hook-server-codex-subagent-transcript.test.ts diff --git a/src/relay/agent-hook-server-codex-turn-interruption.test.ts b/src/wsl-guest/agent-hook-server-codex-turn-interruption.test.ts similarity index 100% rename from src/relay/agent-hook-server-codex-turn-interruption.test.ts rename to src/wsl-guest/agent-hook-server-codex-turn-interruption.test.ts diff --git a/src/relay/agent-hook-server-contract.ts b/src/wsl-guest/agent-hook-server-contract.ts similarity index 100% rename from src/relay/agent-hook-server-contract.ts rename to src/wsl-guest/agent-hook-server-contract.ts diff --git a/src/relay/agent-hook-server.test.ts b/src/wsl-guest/agent-hook-server.test.ts similarity index 100% rename from src/relay/agent-hook-server.test.ts rename to src/wsl-guest/agent-hook-server.test.ts diff --git a/src/relay/agent-hook-server.ts b/src/wsl-guest/agent-hook-server.ts similarity index 100% rename from src/relay/agent-hook-server.ts rename to src/wsl-guest/agent-hook-server.ts diff --git a/src/relay/agent-hook-spool-ingest.ts b/src/wsl-guest/agent-hook-spool-ingest.ts similarity index 100% rename from src/relay/agent-hook-spool-ingest.ts rename to src/wsl-guest/agent-hook-spool-ingest.ts diff --git a/src/relay/claude-terminal-interrupt-status.ts b/src/wsl-guest/claude-terminal-interrupt-status.ts similarity index 100% rename from src/relay/claude-terminal-interrupt-status.ts rename to src/wsl-guest/claude-terminal-interrupt-status.ts diff --git a/src/relay/client-request-aborts.ts b/src/wsl-guest/client-request-aborts.ts similarity index 100% rename from src/relay/client-request-aborts.ts rename to src/wsl-guest/client-request-aborts.ts diff --git a/src/relay/dispatcher-bulk-admission.test.ts b/src/wsl-guest/dispatcher-bulk-admission.test.ts similarity index 100% rename from src/relay/dispatcher-bulk-admission.test.ts rename to src/wsl-guest/dispatcher-bulk-admission.test.ts diff --git a/src/relay/dispatcher-bulk-failed-admission.test.ts b/src/wsl-guest/dispatcher-bulk-failed-admission.test.ts similarity index 100% rename from src/relay/dispatcher-bulk-failed-admission.test.ts rename to src/wsl-guest/dispatcher-bulk-failed-admission.test.ts diff --git a/src/relay/dispatcher-bulk-retirement-admission.test.ts b/src/wsl-guest/dispatcher-bulk-retirement-admission.test.ts similarity index 100% rename from src/relay/dispatcher-bulk-retirement-admission.test.ts rename to src/wsl-guest/dispatcher-bulk-retirement-admission.test.ts diff --git a/src/relay/dispatcher-bulk-write-test-harness.ts b/src/wsl-guest/dispatcher-bulk-write-test-harness.ts similarity index 100% rename from src/relay/dispatcher-bulk-write-test-harness.ts rename to src/wsl-guest/dispatcher-bulk-write-test-harness.ts diff --git a/src/relay/dispatcher-capacity-degradation.test.ts b/src/wsl-guest/dispatcher-capacity-degradation.test.ts similarity index 100% rename from src/relay/dispatcher-capacity-degradation.test.ts rename to src/wsl-guest/dispatcher-capacity-degradation.test.ts diff --git a/src/relay/dispatcher-capacity-signals.ts b/src/wsl-guest/dispatcher-capacity-signals.ts similarity index 100% rename from src/relay/dispatcher-capacity-signals.ts rename to src/wsl-guest/dispatcher-capacity-signals.ts diff --git a/src/relay/dispatcher-client-close-cause.test.ts b/src/wsl-guest/dispatcher-client-close-cause.test.ts similarity index 100% rename from src/relay/dispatcher-client-close-cause.test.ts rename to src/wsl-guest/dispatcher-client-close-cause.test.ts diff --git a/src/relay/dispatcher-client-lifecycle.ts b/src/wsl-guest/dispatcher-client-lifecycle.ts similarity index 100% rename from src/relay/dispatcher-client-lifecycle.ts rename to src/wsl-guest/dispatcher-client-lifecycle.ts diff --git a/src/relay/dispatcher-client-state.ts b/src/wsl-guest/dispatcher-client-state.ts similarity index 100% rename from src/relay/dispatcher-client-state.ts rename to src/wsl-guest/dispatcher-client-state.ts diff --git a/src/relay/dispatcher-client-writer.test.ts b/src/wsl-guest/dispatcher-client-writer.test.ts similarity index 100% rename from src/relay/dispatcher-client-writer.test.ts rename to src/wsl-guest/dispatcher-client-writer.test.ts diff --git a/src/relay/dispatcher-client-writer.ts b/src/wsl-guest/dispatcher-client-writer.ts similarity index 100% rename from src/relay/dispatcher-client-writer.ts rename to src/wsl-guest/dispatcher-client-writer.ts diff --git a/src/relay/dispatcher-contract.ts b/src/wsl-guest/dispatcher-contract.ts similarity index 100% rename from src/relay/dispatcher-contract.ts rename to src/wsl-guest/dispatcher-contract.ts diff --git a/src/relay/dispatcher-frame-codec.ts b/src/wsl-guest/dispatcher-frame-codec.ts similarity index 100% rename from src/relay/dispatcher-frame-codec.ts rename to src/wsl-guest/dispatcher-frame-codec.ts diff --git a/src/relay/dispatcher-frame-guard-regressions.test.ts b/src/wsl-guest/dispatcher-frame-guard-regressions.test.ts similarity index 100% rename from src/relay/dispatcher-frame-guard-regressions.test.ts rename to src/wsl-guest/dispatcher-frame-guard-regressions.test.ts diff --git a/src/relay/dispatcher-json-payload.test.ts b/src/wsl-guest/dispatcher-json-payload.test.ts similarity index 100% rename from src/relay/dispatcher-json-payload.test.ts rename to src/wsl-guest/dispatcher-json-payload.test.ts diff --git a/src/relay/dispatcher-notification-ownership.test.ts b/src/wsl-guest/dispatcher-notification-ownership.test.ts similarity index 100% rename from src/relay/dispatcher-notification-ownership.test.ts rename to src/wsl-guest/dispatcher-notification-ownership.test.ts diff --git a/src/relay/dispatcher-notification-publication.ts b/src/wsl-guest/dispatcher-notification-publication.ts similarity index 100% rename from src/relay/dispatcher-notification-publication.ts rename to src/wsl-guest/dispatcher-notification-publication.ts diff --git a/src/relay/dispatcher-per-connection-state-baseline.test.ts b/src/wsl-guest/dispatcher-per-connection-state-baseline.test.ts similarity index 100% rename from src/relay/dispatcher-per-connection-state-baseline.test.ts rename to src/wsl-guest/dispatcher-per-connection-state-baseline.test.ts diff --git a/src/relay/dispatcher-producer-capacity.ts b/src/wsl-guest/dispatcher-producer-capacity.ts similarity index 100% rename from src/relay/dispatcher-producer-capacity.ts rename to src/wsl-guest/dispatcher-producer-capacity.ts diff --git a/src/relay/dispatcher-producer-transport.ts b/src/wsl-guest/dispatcher-producer-transport.ts similarity index 100% rename from src/relay/dispatcher-producer-transport.ts rename to src/wsl-guest/dispatcher-producer-transport.ts diff --git a/src/relay/dispatcher-pty-publication.ts b/src/wsl-guest/dispatcher-pty-publication.ts similarity index 100% rename from src/relay/dispatcher-pty-publication.ts rename to src/wsl-guest/dispatcher-pty-publication.ts diff --git a/src/relay/dispatcher-rpc-cancel-id-coercion.test.ts b/src/wsl-guest/dispatcher-rpc-cancel-id-coercion.test.ts similarity index 100% rename from src/relay/dispatcher-rpc-cancel-id-coercion.test.ts rename to src/wsl-guest/dispatcher-rpc-cancel-id-coercion.test.ts diff --git a/src/relay/dispatcher-rpc-routing.ts b/src/wsl-guest/dispatcher-rpc-routing.ts similarity index 100% rename from src/relay/dispatcher-rpc-routing.ts rename to src/wsl-guest/dispatcher-rpc-routing.ts diff --git a/src/relay/dispatcher-silent-client-reaper.test.ts b/src/wsl-guest/dispatcher-silent-client-reaper.test.ts similarity index 100% rename from src/relay/dispatcher-silent-client-reaper.test.ts rename to src/wsl-guest/dispatcher-silent-client-reaper.test.ts diff --git a/src/relay/dispatcher-structured-error.test.ts b/src/wsl-guest/dispatcher-structured-error.test.ts similarity index 100% rename from src/relay/dispatcher-structured-error.test.ts rename to src/wsl-guest/dispatcher-structured-error.test.ts diff --git a/src/relay/dispatcher-timeout.test.ts b/src/wsl-guest/dispatcher-timeout.test.ts similarity index 100% rename from src/relay/dispatcher-timeout.test.ts rename to src/wsl-guest/dispatcher-timeout.test.ts diff --git a/src/relay/dispatcher-writer-admission.test.ts b/src/wsl-guest/dispatcher-writer-admission.test.ts similarity index 100% rename from src/relay/dispatcher-writer-admission.test.ts rename to src/wsl-guest/dispatcher-writer-admission.test.ts diff --git a/src/relay/dispatcher-writer-admission.ts b/src/wsl-guest/dispatcher-writer-admission.ts similarity index 100% rename from src/relay/dispatcher-writer-admission.ts rename to src/wsl-guest/dispatcher-writer-admission.ts diff --git a/src/relay/dispatcher-writer-drain-arm.ts b/src/wsl-guest/dispatcher-writer-drain-arm.ts similarity index 100% rename from src/relay/dispatcher-writer-drain-arm.ts rename to src/wsl-guest/dispatcher-writer-drain-arm.ts diff --git a/src/relay/dispatcher-writer-lane-scheduler.ts b/src/wsl-guest/dispatcher-writer-lane-scheduler.ts similarity index 100% rename from src/relay/dispatcher-writer-lane-scheduler.ts rename to src/wsl-guest/dispatcher-writer-lane-scheduler.ts diff --git a/src/relay/dispatcher-writer-sink.ts b/src/wsl-guest/dispatcher-writer-sink.ts similarity index 100% rename from src/relay/dispatcher-writer-sink.ts rename to src/wsl-guest/dispatcher-writer-sink.ts diff --git a/src/relay/dispatcher.test.ts b/src/wsl-guest/dispatcher.test.ts similarity index 100% rename from src/relay/dispatcher.test.ts rename to src/wsl-guest/dispatcher.test.ts diff --git a/src/relay/dispatcher.ts b/src/wsl-guest/dispatcher.ts similarity index 100% rename from src/relay/dispatcher.ts rename to src/wsl-guest/dispatcher.ts diff --git a/src/relay/filesystem-capacity-error-wire.test.ts b/src/wsl-guest/filesystem-capacity-error-wire.test.ts similarity index 100% rename from src/relay/filesystem-capacity-error-wire.test.ts rename to src/wsl-guest/filesystem-capacity-error-wire.test.ts diff --git a/src/relay/legacy-relay-publication-ledger.test.ts b/src/wsl-guest/legacy-relay-publication-ledger.test.ts similarity index 100% rename from src/relay/legacy-relay-publication-ledger.test.ts rename to src/wsl-guest/legacy-relay-publication-ledger.test.ts diff --git a/src/relay/legacy-relay-publication-ledger.ts b/src/wsl-guest/legacy-relay-publication-ledger.ts similarity index 100% rename from src/relay/legacy-relay-publication-ledger.ts rename to src/wsl-guest/legacy-relay-publication-ledger.ts diff --git a/src/relay/omp-config-root.test.ts b/src/wsl-guest/omp-config-root.test.ts similarity index 100% rename from src/relay/omp-config-root.test.ts rename to src/wsl-guest/omp-config-root.test.ts diff --git a/src/relay/omp-status-extension.ts b/src/wsl-guest/omp-status-extension.ts similarity index 100% rename from src/relay/omp-status-extension.ts rename to src/wsl-guest/omp-status-extension.ts diff --git a/src/relay/opencode-canonical-config.ts b/src/wsl-guest/opencode-canonical-config.ts similarity index 100% rename from src/relay/opencode-canonical-config.ts rename to src/wsl-guest/opencode-canonical-config.ts diff --git a/src/relay/opencode-overlay-mirror.ts b/src/wsl-guest/opencode-overlay-mirror.ts similarity index 100% rename from src/relay/opencode-overlay-mirror.ts rename to src/wsl-guest/opencode-overlay-mirror.ts diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/wsl-guest/opencode-startup-prompt-install.test.ts similarity index 100% rename from src/relay/opencode-startup-prompt-install.test.ts rename to src/wsl-guest/opencode-startup-prompt-install.test.ts diff --git a/src/relay/plugin-overlay-env.test.ts b/src/wsl-guest/plugin-overlay-env.test.ts similarity index 100% rename from src/relay/plugin-overlay-env.test.ts rename to src/wsl-guest/plugin-overlay-env.test.ts diff --git a/src/relay/plugin-overlay-env.ts b/src/wsl-guest/plugin-overlay-env.ts similarity index 100% rename from src/relay/plugin-overlay-env.ts rename to src/wsl-guest/plugin-overlay-env.ts diff --git a/src/relay/plugin-overlay.test.ts b/src/wsl-guest/plugin-overlay.test.ts similarity index 100% rename from src/relay/plugin-overlay.test.ts rename to src/wsl-guest/plugin-overlay.test.ts diff --git a/src/relay/plugin-overlay.ts b/src/wsl-guest/plugin-overlay.ts similarity index 100% rename from src/relay/plugin-overlay.ts rename to src/wsl-guest/plugin-overlay.ts diff --git a/src/relay/plugin-source-limit.test.ts b/src/wsl-guest/plugin-source-limit.test.ts similarity index 100% rename from src/relay/plugin-source-limit.test.ts rename to src/wsl-guest/plugin-source-limit.test.ts diff --git a/src/relay/plugin-source-limit.ts b/src/wsl-guest/plugin-source-limit.ts similarity index 100% rename from src/relay/plugin-source-limit.ts rename to src/wsl-guest/plugin-source-limit.ts diff --git a/src/relay/preflight-handler.test.ts b/src/wsl-guest/preflight-handler.test.ts similarity index 100% rename from src/relay/preflight-handler.test.ts rename to src/wsl-guest/preflight-handler.test.ts diff --git a/src/relay/preflight-handler.ts b/src/wsl-guest/preflight-handler.ts similarity index 100% rename from src/relay/preflight-handler.ts rename to src/wsl-guest/preflight-handler.ts diff --git a/src/relay/protocol-backpressure.test.ts b/src/wsl-guest/protocol-backpressure.test.ts similarity index 100% rename from src/relay/protocol-backpressure.test.ts rename to src/wsl-guest/protocol-backpressure.test.ts diff --git a/src/relay/protocol-handshake.test.ts b/src/wsl-guest/protocol-handshake.test.ts similarity index 100% rename from src/relay/protocol-handshake.test.ts rename to src/wsl-guest/protocol-handshake.test.ts diff --git a/src/relay/protocol-json-payload.test.ts b/src/wsl-guest/protocol-json-payload.test.ts similarity index 100% rename from src/relay/protocol-json-payload.test.ts rename to src/wsl-guest/protocol-json-payload.test.ts diff --git a/src/relay/protocol.ts b/src/wsl-guest/protocol.ts similarity index 100% rename from src/relay/protocol.ts rename to src/wsl-guest/protocol.ts diff --git a/src/relay/relay-agent-presence.test.ts b/src/wsl-guest/relay-agent-presence.test.ts similarity index 100% rename from src/relay/relay-agent-presence.test.ts rename to src/wsl-guest/relay-agent-presence.test.ts diff --git a/src/relay/relay-agent-presence.ts b/src/wsl-guest/relay-agent-presence.ts similarity index 100% rename from src/relay/relay-agent-presence.ts rename to src/wsl-guest/relay-agent-presence.ts diff --git a/src/relay/relay-command-env.test.ts b/src/wsl-guest/relay-command-env.test.ts similarity index 100% rename from src/relay/relay-command-env.test.ts rename to src/wsl-guest/relay-command-env.test.ts diff --git a/src/relay/relay-command-env.ts b/src/wsl-guest/relay-command-env.ts similarity index 100% rename from src/relay/relay-command-env.ts rename to src/wsl-guest/relay-command-env.ts diff --git a/src/relay/relay-frame-decoder.ts b/src/wsl-guest/relay-frame-decoder.ts similarity index 100% rename from src/relay/relay-frame-decoder.ts rename to src/wsl-guest/relay-frame-decoder.ts diff --git a/src/relay/relay-hot-path-operation-counts.test.ts b/src/wsl-guest/relay-hot-path-operation-counts.test.ts similarity index 100% rename from src/relay/relay-hot-path-operation-counts.test.ts rename to src/wsl-guest/relay-hot-path-operation-counts.test.ts diff --git a/src/relay/relay-tmux-hook-owner.ts b/src/wsl-guest/relay-tmux-hook-owner.ts similarity index 100% rename from src/relay/relay-tmux-hook-owner.ts rename to src/wsl-guest/relay-tmux-hook-owner.ts diff --git a/src/relay/wsl-agent-hook-relay.test.ts b/src/wsl-guest/wsl-agent-hook-relay.test.ts similarity index 100% rename from src/relay/wsl-agent-hook-relay.test.ts rename to src/wsl-guest/wsl-agent-hook-relay.test.ts diff --git a/src/relay/wsl-agent-hook-relay.ts b/src/wsl-guest/wsl-agent-hook-relay.ts similarity index 100% rename from src/relay/wsl-agent-hook-relay.ts rename to src/wsl-guest/wsl-agent-hook-relay.ts diff --git a/src/relay/wsl-browser-network-relay.ts b/src/wsl-guest/wsl-browser-network-relay.ts similarity index 100% rename from src/relay/wsl-browser-network-relay.ts rename to src/wsl-guest/wsl-browser-network-relay.ts diff --git a/src/relay/wsl-hook-fs-bridge.test.ts b/src/wsl-guest/wsl-hook-fs-bridge.test.ts similarity index 100% rename from src/relay/wsl-hook-fs-bridge.test.ts rename to src/wsl-guest/wsl-hook-fs-bridge.test.ts diff --git a/src/relay/wsl-hook-fs-bridge.ts b/src/wsl-guest/wsl-hook-fs-bridge.ts similarity index 100% rename from src/relay/wsl-hook-fs-bridge.ts rename to src/wsl-guest/wsl-hook-fs-bridge.ts diff --git a/src/relay/wsl-install-plugins-handler.test.ts b/src/wsl-guest/wsl-install-plugins-handler.test.ts similarity index 100% rename from src/relay/wsl-install-plugins-handler.test.ts rename to src/wsl-guest/wsl-install-plugins-handler.test.ts diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/wsl-guest/wsl-install-plugins-handler.ts similarity index 100% rename from src/relay/wsl-install-plugins-handler.ts rename to src/wsl-guest/wsl-install-plugins-handler.ts diff --git a/tests/e2e/cross-version-wire/cross-version-host-descriptor.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-host-descriptor.unit.test.ts new file mode 100644 index 000000000000..756db721f5e6 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-host-descriptor.unit.test.ts @@ -0,0 +1,84 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +/** + * The host descriptor rides inside the pairing offer, which both sides parse with a schema. An old + * client must still accept an offer from a new host (dropping the field), and a new client must + * accept an offer from an old host that has none. + */ +const RELEASE_REF = 'v1.4.224' +const SUITE_TIMEOUT_MS = 180_000 + +type PairingModule = { + encodePairingOffer(offer: Record): string + parsePairingCode(input: string): Record | null +} + +function pairingModuleOf(module: Record): PairingModule { + const { encodePairingOffer, parsePairingCode } = module + if (typeof encodePairingOffer !== 'function' || typeof parsePairingCode !== 'function') { + throw new Error('module does not export the pairing codec') + } + return { + encodePairingOffer: (offer) => String(encodePairingOffer(offer)), + parsePairingCode: (input) => parsePairingCode(input) + } +} + +const BASE_OFFER = { + v: 2, + endpoint: 'ws://100.64.1.20:6768', + deviceToken: 'device-token', + publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + pairedDeviceId: 'device-1', + scope: 'runtime' +} +const DESCRIPTOR = { + installationId: '0b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e', + machineBinding: 'A'.repeat(43) +} + +let released: PairingModule +let current: PairingModule + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(RELEASE_REF) + const [oldModule, newModule] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/shared/pairing.ts'), + import('../../../src/shared/pairing') + ]) + released = pairingModuleOf(oldModule) + current = pairingModuleOf(newModule) +}, SUITE_TIMEOUT_MS) + +describe('cross-version host descriptor in the pairing offer', () => { + it('pairs two real builds', () => { + expect(released.parsePairingCode).not.toBe(current.parsePairingCode) + }) + + it('an old client pairs with a new host and drops the descriptor', () => { + const url = current.encodePairingOffer({ ...BASE_OFFER, hostDescriptor: DESCRIPTOR }) + expect(released.parsePairingCode(url)).toEqual(BASE_OFFER) + expect(current.parsePairingCode(url)).toEqual({ ...BASE_OFFER, hostDescriptor: DESCRIPTOR }) + }) + + it('a new client pairs with an old host that sends no descriptor', () => { + const url = released.encodePairingOffer(BASE_OFFER) + expect(current.parsePairingCode(url)).toEqual(BASE_OFFER) + }) + + it('a descriptor from a newer host in a shape this build cannot read never blocks pairing', () => { + const futureDescriptor = { installationId: 'some-future-format', machineBinding: 42 } + const url = released.encodePairingOffer(BASE_OFFER).replace( + /code=(.*)$/, + (_match, code: string) => + `code=${Buffer.from( + JSON.stringify({ + ...JSON.parse(Buffer.from(code, 'base64url').toString('utf8')), + hostDescriptor: futureDescriptor + }) + ).toString('base64url')}` + ) + expect(current.parsePairingCode(url)).toEqual(BASE_OFFER) + }) +}) diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts index bfda5598cdde..f2f046ea9ffe 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -25,6 +25,8 @@ const ARCHIVE_PATHS = [ 'src/types', 'mobile/src/worktree/agent-row-display.ts' ] +// Why optional: only releases from the move onward have it, and `git archive` refuses a missing pathspec. +const OPTIONAL_ARCHIVE_PATHS = ['src/wsl-guest'] const WORKSPACE_POLICY = 'pnpm-workspace.yaml' @@ -187,23 +189,32 @@ function workspacePatternRoot(pattern: string): string { return segments.join('/') } +async function listReleasePaths( + repoRoot: string, + commit: string, + paths: string[], + deadline: number +): Promise { + return ( + await runCheckoutProcess( + repoRoot, + 'git', + ['ls-tree', '-z', '--name-only', commit, '--', ...paths], + deadline + ) + ) + .split('\0') + .filter(Boolean) +} + /** The release's workspace policy and declared package directories; none before workspaces. */ async function releaseWorkspaceArchivePaths( repoRoot: string, commit: string, deadline: number ): Promise { - const listed = async (paths: string[]): Promise => - ( - await runCheckoutProcess( - repoRoot, - 'git', - ['ls-tree', '-z', '--name-only', commit, '--', ...paths], - deadline - ) - ) - .split('\0') - .filter(Boolean) + const listed = (paths: string[]): Promise => + listReleasePaths(repoRoot, commit, paths, deadline) if ((await listed([WORKSPACE_POLICY])).length === 0) { return [] } @@ -235,6 +246,7 @@ export async function extractReleaseCheckoutTree( const archive = join(staging, '.release-checkout.tar') const deadline = Date.now() + CHECKOUT_PROCESS_TIMEOUT_MS const workspacePaths = await releaseWorkspaceArchivePaths(repoRoot, commit, deadline) + const optionalPaths = await listReleasePaths(repoRoot, commit, OPTIONAL_ARCHIVE_PATHS, deadline) try { await runCheckoutProcess( repoRoot, @@ -246,6 +258,7 @@ export async function extractReleaseCheckoutTree( commit, '--', ...ARCHIVE_PATHS, + ...optionalPaths, ...workspacePaths ], deadline diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts index 0d24c0939783..e79ea9be8abe 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.unit.test.ts @@ -202,5 +202,20 @@ describe('release checkout workspace packages', () => { expect(readFileSync(join(staging, 'src/main/old.ts'), 'utf8')).toBe(source) expect(existsSync(join(staging, 'pnpm-workspace.yaml'))).toBe(false) + expect(existsSync(join(staging, 'src/wsl-guest'))).toBe(false) + }) + + it('extracts the WSL guest sources a release relay imports when the release has them', async () => { + const source = "export { RELAY_SENTINEL } from '../wsl-guest/protocol'\n" + const { repo, commit } = commitRelease({ + 'src/relay/relay.ts': source, + 'src/wsl-guest/protocol.ts': "export const RELAY_SENTINEL = 'sentinel'\n" + }) + const staging = temporaryDirectory('orca-cross-version-release-staging-') + + await extractReleaseCheckoutTree(repo, staging, commit) + + expect(readFileSync(join(staging, 'src/relay/relay.ts'), 'utf8')).toBe(source) + expect(existsSync(join(staging, 'src/wsl-guest/protocol.ts'))).toBe(true) }) }) diff --git a/tests/e2e/cross-version-wire/runtime-source-fence-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/runtime-source-fence-downgrade.unit.test.ts new file mode 100644 index 000000000000..44e1bb551219 --- /dev/null +++ b/tests/e2e/cross-version-wire/runtime-source-fence-downgrade.unit.test.ts @@ -0,0 +1,193 @@ +// The host-side runtime-source fence, paired across two builds over a real socket: current code +// against the newest published release's host. Release checkouts carry no CLI sources, and an old +// CLI's frame is exactly the current one without the field, so the old client is the current +// transport sending none. +// +// A new CLI adds an optional `expectedRuntimeSource` to its request frame. Each skew must hold: +// +// - an old CLI sends no expectation, and a new host runs its request as before; +// - an old host ignores the field and runs the request (the CLI's pre-send probe is then the only +// fence), so a new CLI never breaks against it; +// - a new host refuses a request meant for another runtime before its handler runs. + +import { createServer, type Server } from 'node:net' +import { randomUUID } from 'node:crypto' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + RUNTIME_CAPABILITIES, + RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' +import { publishHostDescriptor } from '../../../src/main/runtime/host-descriptor' +import type { RuntimeMetadata } from '../../../src/shared/runtime-bootstrap' +import type { ExpectedRuntimeSource } from '../../../src/shared/runtime-rpc-envelope' +import { sendRequest } from '../../../src/cli/runtime/transport' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 +const RUNTIME_ID = 'runtime-a' +const SOURCE_A = '11111111-1111-4111-8111-111111111111' +const SOURCE_B = '22222222-2222-4222-8222-222222222222' + +type Dispatcher = { dispatch(request: unknown): Promise } +type HostBuild = (effect: () => unknown) => Dispatcher + +function member(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the module exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a release module is typed unknown; the name is the same export this build calls, and a missing one throws above. + return value as T +} + +function hostBuildOf( + dispatcher: Record, + core: Record +): HostBuild { + const RpcDispatcher = member< + new (options: { runtime: unknown; methods: unknown[] }) => Dispatcher + >(dispatcher, 'RpcDispatcher') + const defineMethod = member<(declaration: Record) => unknown>( + core, + 'defineMethod' + ) + return (effect) => + new RpcDispatcher({ + runtime: { getRuntimeId: () => RUNTIME_ID }, + methods: [ + defineMethod({ + name: 'terminal.send', + permission: 'workspace', + params: z.object({ text: z.string() }), + handler: effect + }) + ] + }) +} + +const servers: Server[] = [] + +async function serve(host: Dispatcher): Promise { + const dir = mkdtempSync(join(tmpdir(), 'orca-source-fence-wire-')) + const isWindows = process.platform === 'win32' + const endpoint = isWindows + ? `\\\\.\\pipe\\orca-source-fence-wire-${randomUUID()}` + : join(dir, 'runtime.sock') + const server = createServer((socket) => { + let buffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline !== -1) { + void host + .dispatch(JSON.parse(buffer.slice(0, newline))) + .then((response) => socket.end(`${JSON.stringify(response)}\n`)) + } + }) + }) + servers.push(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + return { + runtimeId: RUNTIME_ID, + pid: 1, + transports: [{ kind: isWindows ? 'named-pipe' : 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + } +} + +let releasedHost: HostBuild +let currentHost: HostBuild + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const load = (path: string) => importReleaseCheckoutModule(checkout, path) + const [oldDispatcher, oldCore, newDispatcher, newCore] = await Promise.all([ + load('src/main/runtime/rpc/dispatcher.ts'), + load('src/main/runtime/rpc/core.ts'), + import('../../../src/main/runtime/rpc/dispatcher'), + import('../../../src/main/runtime/rpc/core') + ]) + releasedHost = hostBuildOf(oldDispatcher, oldCore) + currentHost = hostBuildOf(newDispatcher, newCore) + publishHostDescriptor(RUNTIME_ID, { installationId: SOURCE_A }) +}, SUITE_TIMEOUT_MS) + +afterAll(async () => { + publishHostDescriptor(RUNTIME_ID, null) + await Promise.all( + servers.map((server) => new Promise((resolve) => server.close(() => resolve()))) + ) +}) + +async function call( + host: HostBuild, + expectedRuntimeSource?: ExpectedRuntimeSource +): Promise<{ response: { ok: boolean; error?: { code: string } }; ran: number }> { + let ran = 0 + const metadata = await serve( + host(() => { + ran += 1 + return { sent: true } + }) + ) + const response = await sendRequest( + metadata, + 'terminal.send', + { text: 'hi' }, + 5_000, + undefined, + expectedRuntimeSource + ) + return { response, ran } +} + +describe('runtime-source fence across versions', () => { + it('pairs two real host builds', () => { + expect(releasedHost).not.toBe(currentHost) + }) + + it('an old CLI sends no expectation, and a new host runs its request', async () => { + const { response, ran } = await call(currentHost) + expect(response).toMatchObject({ ok: true }) + expect(ran).toBe(1) + }) + + it('an old host ignores a new CLI’s expectation rather than refusing', async () => { + const { response, ran } = await call(releasedHost, { + sourceId: SOURCE_A, + runtimeId: RUNTIME_ID + }) + expect(response).toMatchObject({ ok: true }) + expect(ran).toBe(1) + }) + + it('a new host refuses a new CLI’s request meant for another runtime before running it', async () => { + const { response, ran } = await call(currentHost, { + sourceId: SOURCE_B, + runtimeId: RUNTIME_ID + }) + expect(response).toMatchObject({ ok: false, error: { code: 'runtime_source_mismatch' } }) + expect(ran).toBe(0) + }) + + it('a new host runs a new CLI’s request meant for it and advertises the fence', async () => { + const { response, ran } = await call(currentHost, { + sourceId: SOURCE_A, + runtimeId: RUNTIME_ID + }) + expect(response).toMatchObject({ ok: true }) + expect(ran).toBe(1) + expect(RUNTIME_CAPABILITIES).toContain(RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY) + }) +}) diff --git a/tests/e2e/cross-version-wire/workspace-ports-host-scoped-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/workspace-ports-host-scoped-downgrade.unit.test.ts new file mode 100644 index 000000000000..28eb505c0205 --- /dev/null +++ b/tests/e2e/cross-version-wire/workspace-ports-host-scoped-downgrade.unit.test.ts @@ -0,0 +1,140 @@ +// Host-scoped workspace port scan and Stop, paired across two builds: current code against the +// newest published release. +// +// `workspacePorts.scanHost` / `killHost` are new methods, not new fields on `scan` / `kill`, because +// an old host would strip a host field and scan or signal on itself. Each skew must hold: +// +// - an old client keeps calling `scan` / `kill` with `{ repoId }`, which a new host parses as before; +// - an old host neither advertises the capability nor knows the methods, so a new client can only +// get "update server" from it, never a scan or Stop of the wrong machine. +// +// The old side's capability list and catalog have the new entries removed, so this stays exercised +// after a release ships them. + +import { beforeAll, describe, expect, it } from 'vitest' +import { + RUNTIME_CAPABILITIES, + WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' +import { RPC_PARAMS_BY_METHOD } from '../../../src/shared/rpc-contract/rpc-params-catalog.generated' +import { parseRpcRequestParams } from '../../../src/main/runtime/rpc/dispatcher-request-parsing' +import type { RpcAnyMethodDeclaration, RpcRequest } from '../../../src/main/runtime/rpc/core' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 +const HOST_SCOPED_METHODS = ['workspacePorts.scanHost', 'workspacePorts.killHost'] + +type ParseParams = ( + request: RpcRequest, + method: Pick, + meta: unknown +) => { value?: unknown; error?: unknown } + +type HostBuild = { + capabilities: readonly string[] + knows: (method: string) => boolean + parse: (method: string, params: unknown) => unknown +} + +function member(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the baseline release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a release module is typed unknown; the name is the same export this build calls, and a missing one throws above. + return value as T +} + +function hostBuild( + capabilities: readonly string[], + catalog: Record, + parse: ParseParams +): HostBuild { + return { + capabilities, + knows: (method) => Object.hasOwn(catalog, method), + parse: (method, params) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: catalog entries are the Zod schema or null each build's dispatcher reads. + const schema = (catalog[method] ?? null) as RpcAnyMethodDeclaration['params'] + const parsed = parse({ id: '1', authToken: 't', method, params }, { params: schema }, {}) + if (parsed.error) { + throw new Error(`${method} refused ${JSON.stringify(params)}`) + } + return parsed.value + } + } +} + +let oldHost: HostBuild +let newHost: HostBuild + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const [protocol, catalog, parsing] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/shared/protocol-version.ts'), + importReleaseCheckoutModule( + checkout, + 'src/shared/rpc-contract/rpc-params-catalog.generated.ts' + ), + importReleaseCheckoutModule(checkout, 'src/main/runtime/rpc/dispatcher-request-parsing.ts') + ]) + const oldCatalog = Object.fromEntries( + Object.entries(member>(catalog, 'RPC_PARAMS_BY_METHOD')).filter( + ([method]) => !HOST_SCOPED_METHODS.includes(method) + ) + ) + oldHost = hostBuild( + member(protocol, 'RUNTIME_CAPABILITIES').filter( + (capability) => capability !== WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY + ), + oldCatalog, + member(parsing, 'parseRpcRequestParams') + ) + newHost = hostBuild(RUNTIME_CAPABILITIES, RPC_PARAMS_BY_METHOD, (request, method, meta) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the parser only reads `params` from the declaration. + parseRpcRequestParams(request, method as RpcAnyMethodDeclaration, meta as never) + ) +}, SUITE_TIMEOUT_MS) + +describe('host-scoped workspace ports across versions', () => { + it("an old client's endpoint scan and Stop parse the same on a new host", () => { + for (const host of [oldHost, newHost]) { + expect(host.parse('workspacePorts.scan', { repoId: 'repo-1' })).toEqual({ repoId: 'repo-1' }) + expect(host.parse('workspacePorts.kill', { repoId: 'repo-1', pid: 7, port: 3020 })).toEqual({ + repoId: 'repo-1', + pid: 7, + port: 3020 + }) + } + }) + + it('an old host offers a new client nothing to scan or stop another machine with', () => { + expect(oldHost.capabilities).not.toContain(WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY) + for (const method of HOST_SCOPED_METHODS) { + expect(oldHost.knows(method)).toBe(false) + } + }) + + it('a new host advertises the methods and requires the host a Stop was scanned on', () => { + expect(newHost.capabilities).toContain(WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY) + expect(newHost.parse('workspacePorts.scanHost', { worktree: 'id:repo-1::/srv/app' })).toEqual({ + worktree: 'id:repo-1::/srv/app' + }) + expect(() => + newHost.parse('workspacePorts.killHost', { worktree: 'id:w', pid: 7, port: 3020 }) + ).toThrow(/refused/) + expect( + newHost.parse('workspacePorts.killHost', { + worktree: 'id:w', + executionHostId: 'ssh:box', + pid: 7, + port: 3020 + }) + ).toEqual({ worktree: 'id:w', executionHostId: 'ssh:box', pid: 7, port: 3020 }) + }) +}) diff --git a/tests/e2e/folder-repo-git-upgrade.spec.ts b/tests/e2e/folder-repo-git-upgrade.spec.ts new file mode 100644 index 000000000000..aafe658ed790 --- /dev/null +++ b/tests/e2e/folder-repo-git-upgrade.spec.ts @@ -0,0 +1,73 @@ +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog' +import { chooseFolderInAppBrowserIfShown } from './helpers/in-app-folder-browser' +import { runProcess } from '@orca/process-host' + +test.use({ seedTestRepo: false }) + +async function runFixtureGit(spec: Parameters[0]): Promise { + const result = await runProcess(spec) + expect(result.code, result.stderr).toBe(0) +} + +test('Add Project opens a folder and preserves its workspace when Git appears', async ({ + electronApp, + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + const folderPath = realpathSync(mkdtempSync(join(tmpdir(), 'orca-folder-upgrade-'))) + registerPostElectronShutdownCleanup(async () => { + const { rm } = await import('node:fs/promises') + await rm(folderPath, { recursive: true, force: true }) + }) + await waitForSessionReady(orcaPage) + await electronApp.evaluate(({ dialog }, path) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [path], bookmarks: [] }) + }, folderPath) + await openSidebarProjectDialog(orcaPage) + await orcaPage.getByRole('button', { name: /Browse folder/i }).click() + await chooseFolderInAppBrowserIfShown(orcaPage, folderPath) + const dialog = orcaPage.getByRole('dialog', { name: 'Open as Folder' }) + await expect(dialog).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('open-folder.png') }) + await dialog.getByRole('button', { name: 'Open as Folder', exact: true }).click() + await expect + .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos[0]?.kind)) + .toBe('folder') + await expect + .poll(() => + orcaPage.evaluate(() => { + const state = window.__store!.getState() + return state.worktreesByRepo[state.repos[0].id]?.[0]?.id + }) + ) + .toBeTruthy() + const before = await orcaPage.evaluate(() => { + const state = window.__store!.getState() + const repo = state.repos[0] + return { repoId: repo.id, worktreeId: state.worktreesByRepo[repo.id]?.[0]?.id } + }) + await orcaPage.screenshot({ path: testInfo.outputPath('folder-workspace.png') }) + await runFixtureGit({ program: 'git', args: ['init', folderPath] }) + await expect + .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos[0]?.kind), { + timeout: 30_000 + }) + .toBe('git') + await expect + .poll( + () => + orcaPage.evaluate(() => { + const state = window.__store!.getState() + const repo = state.repos[0] + return { repoId: repo.id, worktreeId: state.worktreesByRepo[repo.id]?.[0]?.id } + }), + { timeout: 30_000 } + ) + .toEqual(before) + await orcaPage.screenshot({ path: testInfo.outputPath('git-workspace.png') }) +}) diff --git a/tests/e2e/golden-core-flows.spec.ts b/tests/e2e/golden-core-flows.spec.ts index 669e3afa49aa..84a7f0490b43 100644 --- a/tests/e2e/golden-core-flows.spec.ts +++ b/tests/e2e/golden-core-flows.spec.ts @@ -2,6 +2,7 @@ import { openSidebarProjectDialog, openSidebarWorkspaceComposer } from './helpers/sidebar-project-dialog' +import { chooseFolderInAppBrowserIfShown } from './helpers/in-app-folder-browser' import { execFileSync } from 'node:child_process' import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' @@ -209,35 +210,6 @@ async function expectProjectVisible(page: Page, repoPath: string): Promise await expect(page.getByText(repoName, { exact: true }).first()).toBeVisible({ timeout: 15_000 }) } -// Why: this fork browses Add project folders in-app on Linux instead of GTK showOpenDialog, -// so the native-dialog stub never runs there. Enter the path and select it in that browser. -async function chooseFolderInAppBrowserIfShown(page: Page, folderPath: string): Promise { - const browser = page.getByRole('dialog', { name: /Browse host filesystem/i }) - const shown = await browser - .waitFor({ state: 'visible', timeout: 3_000 }) - .then(() => true) - .catch(() => false) - if (!shown) { - return - } - const pathInput = browser.getByPlaceholder(/Type to filter or enter a path/i) - const target = folderPath.endsWith('/') ? folderPath : `${folderPath}/` - await expect - .poll( - async () => { - if ((await browser.textContent())?.includes(`· ${folderPath}`)) { - return true - } - await pathInput.fill(target) - await pathInput.press('Enter') - return false - }, - { timeout: 15_000, message: `in-app folder browser did not open ${folderPath}` } - ) - .toBe(true) - await browser.getByRole('button', { name: 'Select folder', exact: true }).click() -} - async function addProjectFromSidebar( page: Page, electronApp: ElectronApplication, diff --git a/tests/e2e/helpers/in-app-folder-browser.ts b/tests/e2e/helpers/in-app-folder-browser.ts new file mode 100644 index 000000000000..ef9f697fe561 --- /dev/null +++ b/tests/e2e/helpers/in-app-folder-browser.ts @@ -0,0 +1,33 @@ +import { expect, type Page } from '@stablyai/playwright-test' + +// Why: this fork browses Add project folders in-app on Linux instead of GTK showOpenDialog, +// so a native-dialog stub never runs there. Enter the path and select it in that browser. +export async function chooseFolderInAppBrowserIfShown( + page: Page, + folderPath: string +): Promise { + const browser = page.getByRole('dialog', { name: /Browse host filesystem/i }) + const shown = await browser + .waitFor({ state: 'visible', timeout: 3_000 }) + .then(() => true) + .catch(() => false) + if (!shown) { + return + } + const pathInput = browser.getByPlaceholder(/Type to filter or enter a path/i) + const target = folderPath.endsWith('/') ? folderPath : `${folderPath}/` + await expect + .poll( + async () => { + if ((await browser.textContent())?.includes(`· ${folderPath}`)) { + return true + } + await pathInput.fill(target) + await pathInput.press('Enter') + return false + }, + { timeout: 15_000, message: `in-app folder browser did not open ${folderPath}` } + ) + .toBe(true) + await browser.getByRole('button', { name: 'Select folder', exact: true }).click() +} diff --git a/tests/e2e/repo-registration-async.spec.ts b/tests/e2e/repo-registration-async.spec.ts new file mode 100644 index 000000000000..01fa0bf276bf --- /dev/null +++ b/tests/e2e/repo-registration-async.spec.ts @@ -0,0 +1,180 @@ +import { mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { runProcess } from '@orca/process-host' + +test.use({ seedTestRepo: false }) + +async function runFixtureGit(spec: Parameters[0]): Promise { + const result = await runProcess(spec) + expect(result.code, result.stderr).toBe(0) +} + +test('registration resolves nested and linked paths to one existing project', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}) => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-registration-'))) + const repoPath = join(root, 'repo') + const nestedPath = join(repoPath, 'nested') + const linkedPath = join(root, 'linked') + mkdirSync(nestedPath, { recursive: true }) + registerPostElectronShutdownCleanup(async () => { + const { rm } = await import('node:fs/promises') + await rm(root, { recursive: true, force: true }) + }) + await runFixtureGit({ program: 'git', args: ['init', repoPath] }) + await runFixtureGit({ + program: 'git', + args: [ + '-c', + 'user.name=E2E', + '-c', + 'user.email=e2e@test.local', + 'commit', + '--allow-empty', + '-m', + 'fixture' + ], + cwd: repoPath + }) + await runFixtureGit({ + program: 'git', + args: ['worktree', 'add', '-b', 'linked', linkedPath], + cwd: repoPath + }) + await waitForSessionReady(orcaPage) + const result = await orcaPage.evaluate( + async ({ repoPath, nestedPath }) => { + const results = [] + for (const path of [repoPath, nestedPath]) { + results.push(await window.api.repos.add({ path })) + } + if (results.some((result) => 'error' in result)) { + throw new Error(JSON.stringify(results)) + } + await window.__store!.getState().fetchRepos() + await window.__store!.getState().awaitLocalRepoCatalogSettlement() + return { + ids: results.map((result) => ('repo' in result ? result.repo.id : null)), + persistedIds: (await window.api.repos.list()).map((repo) => repo.id) + } + }, + { repoPath, nestedPath } + ) + expect(new Set(result.ids).size).toBe(1) + expect(result.persistedIds).toEqual([result.ids[0]]) + // A repos:changed refresh can supersede the explicitly awaited catalog fetch. + await expect + .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos.map((repo) => repo.id))) + .toEqual(result.persistedIds) + const linked = await orcaPage.evaluate(async (path) => window.api.repos.add({ path }), linkedPath) + expect('repo' in linked && linked.repo.id).toBe(result.ids[0]) + const missing = await orcaPage.evaluate( + async (path) => window.api.repos.add({ path }), + join(root, 'missing') + ) + expect('error' in missing).toBe(true) + expect( + await orcaPage.evaluate(async () => (await window.api.repos.list()).map((repo) => repo.id)) + ).toEqual(result.persistedIds) +}) + +test('SSH registration reads the repository on its execution host', async ({ + orcaPage +}, testInfo) => { + test.skip(process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Requires the existing Docker SSH fixture') + test.setTimeout(180_000) + const { + startDockerSshRelayTarget, + cleanupDockerSshRelayTarget, + execDockerSshRelayTargetCommand, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH + } = await import('./helpers/docker-ssh-relay-target') + const { connectDockerSshRelayTarget, disconnectDockerSshRelayTarget } = + await import('./helpers/docker-ssh-relay-connection') + const target = startDockerSshRelayTarget(testInfo) + let targetId: string | null = null + try { + await waitForSessionReady(orcaPage) + const connected = await connectDockerSshRelayTarget(orcaPage, target, { seedInitialTab: false }) + targetId = connected.targetId + execDockerSshRelayTargetCommand(target, `mkdir -p ${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/nested`) + const nested = await orcaPage.evaluate( + async ({ connectionId, remotePath }) => + window.api.repos.addRemote({ connectionId, remotePath }), + { connectionId: targetId, remotePath: `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/nested` } + ) + expect('repo' in nested && nested.repo.id).toBe(connected.repoId) + expect('repo' in nested && nested.repo.connectionId).toBe(targetId) + expect('repo' in nested && nested.repo.executionHostId).toBe(`ssh:${targetId}`) + const missing = await orcaPage.evaluate( + async (connectionId) => + window.api.repos.addRemote({ connectionId, remotePath: '/tmp/or60risk-missing' }), + targetId + ) + expect('error' in missing).toBe(true) + await orcaPage.screenshot({ path: testInfo.outputPath('ssh-project.png') }) + } finally { + if (targetId) { + await disconnectDockerSshRelayTarget(orcaPage, targetId) + } + cleanupDockerSshRelayTarget(target) + } +}) + +test('real WSL registration preserves literal paths and fences login output', async ({ + orcaPage +}, testInfo) => { + const distro = process.env.ORCA_REAL_WSL_DISTRO + test.skip(process.platform !== 'win32' || !distro, 'Requires a real Windows WSL distro') + const { buildWslExecArgs, buildWslCapturedLoginShellCommand, quotePosixShell } = + await import('../../src/shared/wsl-login-shell-command') + const root = `/tmp/or60risk/run-${Date.now()}` + const repoPath = `${root}/repo-$literal space` + const uncPath = `\\\\wsl.localhost\\${distro}${repoPath.replaceAll('/', '\\')}` + const setup = await runProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, [ + '/bin/sh', + '-c', + `mkdir -p ${quotePosixShell(`${repoPath}/nested`)} && git init ${quotePosixShell(repoPath)}` + ]), + timeoutMs: 30_000 + }) + expect(setup.code, setup.stderr).toBe(0) + try { + const capture = buildWslCapturedLoginShellCommand( + `git -C ${quotePosixShell(repoPath)} rev-parse --is-inside-work-tree` + ) + const raw = await runProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, [ + '/bin/sh', + '-c', + `printf 'OR60_LOGIN_NOISE\\n'; ${capture.command}` + ]), + timeoutMs: 30_000 + }) + expect(raw.code, raw.stderr).toBe(0) + expect(raw.stdout).toContain('OR60_LOGIN_NOISE') + expect(capture.readStdout(raw.stdout)).toBe('true\n') + await waitForSessionReady(orcaPage) + const added = await orcaPage.evaluate( + async (path) => window.api.repos.add({ path }), + `${uncPath}\\nested` + ) + expect('repo' in added).toBe(true) + expect('repo' in added && added.repo.path).toBe(uncPath) + await orcaPage.screenshot({ path: testInfo.outputPath('wsl-project.png') }) + } finally { + const cleanup = await runProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, ['/bin/sh', '-c', `rm -rf ${quotePosixShell(root)}`]), + timeoutMs: 30_000 + }) + expect(cleanup.code, cleanup.stderr).toBe(0) + } +}) diff --git a/tests/tools/omp-config-root-relay-shell.test.mjs b/tests/tools/omp-config-root-relay-shell.test.mjs index 423443e98ca8..7c9fb8cb7cf5 100644 --- a/tests/tools/omp-config-root-relay-shell.test.mjs +++ b/tests/tools/omp-config-root-relay-shell.test.mjs @@ -4,8 +4,8 @@ import { delimiter, join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' import { resetLoginShellEnvironmentCacheForTests } from '../../src/main/startup/login-shell-environment' -import { PluginOverlayManager } from '../../src/relay/plugin-overlay' -import { resolveOmpConfigDirName } from '../../src/relay/plugin-overlay-env' +import { PluginOverlayManager } from '../../src/wsl-guest/plugin-overlay' +import { resolveOmpConfigDirName } from '../../src/wsl-guest/plugin-overlay-env' import { __resetShellStartupEnvCache } from '../../src/main/pty/shell-startup-env' const fixture = { home: '', shell: '' } const shells = ['bash', 'zsh', 'fish'].map((name) => {